ObsidianでPDF仕様書を作成する:CSSスニペット活用術

これまでAsciidocやNotionでPDF仕様書を作成していましたが、バージョン管理との相性やカスタマイズ性の面で課題を感じていました。そこで、ローカルファイルベースのObsidianを採用し、CSSスニペットを使った高度なPDFエクスポート環境を構築したので、その手法を共有します。

本記事で実現できること:

1. Obsidianが技術文書作成に適している理由

Obsidianは、ローカルファイルベースの強力なナレッジ管理ツールです。単なるメモアプリではなく、プログラマブルなドキュメント管理システムとして機能します。

1.1. 技術仕様書作成における強み

機能 メリット
ローカルファースト Markdown形式でPC内に保存。Gitとの統合が容易で、オフライン動作可能
Mermaid対応 システム構成図やシーケンス図をコードで記述・管理
プラグインエコシステム 1,000種以上のプラグインで機能拡張。Better Export PDFで高度なPDF生成が可能
CSSカスタマイズ スニペットでPDFレイアウトを完全制御。表紙や改ページを自由に設計
双方向リンク ドキュメント間の依存関係を可視化し、仕様の整合性を保ちやすい

2. Gitによるバージョン管理の統合

Markdownファイルの特性を活かし、仕様書の変更履歴をGitで管理します。

メリット:

3. PDFエクスポート用CSSスニペットの設計

ObsidianのPDFエクスポートは、@media printクエリを使ったCSSで制御できます。ここでは、実務で使える3つの重要機能を実装します。

3.1. 1. 表紙レイアウト(pdf-cover.css

Markdownには「ページ」の概念がありませんが、CSSで疑似的に表紙ページを作成します。

設計ポイント:

Markdownでの使用例:

<div class="cover-page"> 

# IoTプロトタイプ構築ガイド

<div class="cover-footer">
第1.0版  
2026.01.01  
![](../logo.svg)
</div>

</div>

3.2. 2. セクション別横向きレイアウト(pdf-fix.css

システム構成図などの横長コンテンツを、特定セクションだけA4横向きで出力します。

技術的な仕組み:

@page landscape-page {
  size: A4 landscape;
  margin: 15mm 5mm 20mm 5mm;
}

.landscape-section {
  page: landscape-page;
  width: 277mm; /* A4横幅297mm - 余白20mm */
  page-break-before: always;
  page-break-after: always;
}

重要な工夫:

使用例:

<div class="landscape-section">

## システム構成図

```mermaid
graph LR
  A[デバイス] --> B[ゲートウェイ] --> C[クラウド]
```

</div>

3.3. 3. 印刷最適化の細部調整

実装した最適化:

目次記号の除去:

li:has(.internal-link) {
  list-style-type: none;
}

Obsidianの内部リンクを含むリスト項目から黒丸を削除し、目次らしい見た目に。

4. 完全なCSSスニペット

pdf-cover.css
@media print {
  .cover-page {
    height: 100vh !important;
    min-height: 290mm !important;
    position: relative !important;
    display: flex !important;
    flex-direction: column !important;
    align-items: center !important;
    margin: 0 !important;
    padding: 0 !important;
  }

  .cover-page h1 {
    margin-top: 35vh !important;
    font-size: 28pt !important;
    line-height: 1.4 !important;
    text-align: center !important;
    border-bottom: none !important;
    width: 80% !important;
  }

  .cover-footer {
    position: absolute !important;
    bottom: 60mm !important;
    left: 0 !important;
    width: 100% !important;
    display: flex !important;
    flex-direction: column !important;
    align-items: center !important;
    text-align: center !important;
  }

  .cover-footer p {
    font-size: 14pt !important;
    margin: 0.2rem 0 !important;
    color: #444 !important;
  }

  .cover-footer img {
    margin-top: 2rem !important;
    width: 300px !important;
    height: auto !important;
  }
}
pdf-fix.css
@media print {
  @page {
    size: A4 portrait;
    margin: 15mm 5mm 20mm 5mm !important;
  }

  @page landscape-page {
    size: A4 landscape;
    margin: 15mm 5mm 20mm 5mm !important;
  }

  body,
  .markdown-rendered,
  .markdown-preview-view,
  .markdown-preview-sizer {
    width: 100%;
    overflow: visible !important;
  }

  /* 横向きセクション */
  .landscape-section {
    page: landscape-page;
    width: 277mm !important;
    max-width: 277mm !important;
    box-sizing: border-box !important;
    position: relative !important;
    page-break-before: always;
    page-break-after: always;
    page-break-inside: avoid;
  }

  .landscape-section * {
    max-width: none !important;
  }

  .landscape-section img,
  .landscape-section svg,
  .landscape-section .mermaid,
  .landscape-section table {
    width: 100% !important;
    max-width: 100% !important;
  }

  /* 図表の調整 */
  .mermaid {
    display: flex !important;
    justify-content: center !important;
    background-color: transparent !important;
  }

  table {
    width: 100% !important;
    table-layout: fixed !important;
  }

  /* 改ページ設定 */
  h2 {
    page-break-before: always !important;
    margin-top: 0 !important;
  }

  h3, h4 {
    margin-top: 2em !important;
  }

  /* 上位見出し直後のマージン削除 */
  h1 + * h3:first-child,
  h1 + h3,
  h2 + * h3:first-child,
  h2 + h3,
  h3 + * h4:first-child,
  h3 + h4,
  h1 + * h4:first-child,
  h1 + h4,
  h2 + * h4:first-child,
  h2 + h4 {
    margin-top: 0em !important;
  }

  /* テキストの折り返し */
  p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th {
    overflow-wrap: break-word !important;
  }

  pre, code {
    white-space: pre-wrap !important;
    word-break: break-all !important;
  }

  pre code {
    font-size: 0.75em !important;
    line-height: 1.4 !important;
  }

  code {
    font-size: 0.85em !important;
  }

  /* リンクと目次 */
  a, a:visited, .internal-link {
    color: #000000 !important;
    text-decoration: none !important;
  }

  .toc li,
  li:has(a[href^="#"]),
  li:has(.internal-link) {
    list-style-type: none !important;
  }

  .toc ul,
  ul:has(a[href^="#"]) {
    padding-left: 0.25em !important;
    margin-left: 0 !important;
  }

  li::marker {
    color: #000000 !important;
  }

  .task-list-item-checkbox {
    filter: grayscale(1) brightness(0);
  }
}

5. 推奨プラグイン構成

5.1. ドキュメント作成用

  1. Better Export PDF (better-export-pdf)
    標準エクスポート機能の拡張版。プレビュー表示、ブックマーク(しおり)生成、ヘッダー/フッター設定、複数ファイルの一括エクスポートに対応 github
  2. Number Headings (number-headings-obsidian)
    見出しに自動採番(1.1、1.2...)。Front matterでnumber headings: auto, first-level 2と指定すると、h2から番号付与
  3. Table of Contents (obsidian-plugin-toc)
    [[_TOC_]]と記述するだけで目次を自動生成。手動管理の手間を削減
  4. Git (obsidian-git)
    Obsidian内からコミット・プッシュが可能。自動バックアップ機能も搭載 norilanda.github

5.2. 高度な連携用(オプション)

  1. Local REST API (obsidian-local-rest-api)
    REST APIでノートの読み書きを外部から制御。CI/CDパイプラインでのPDF自動生成などに活用
  2. MCP Tools (mcp-tools)
    Claude DesktopとObsidian Vaultを接続。AIによるドキュメント生成支援が可能

6. 実践:サンプルドキュメントの作成

6.1. Front matterの設定

これは自動設定されます。

---
number headings: auto, first-level 2, max 6, start-at 1, _.1.1
---

この設定で、h2レベルから自動的に「1.1」「1.2」形式の番号が付与されます。

6.2. 表紙の作成

<div class="cover-page"> 

# IoTプロトタイプ構築ガイド

<div class="cover-footer">
第1.0版  
2026.01.01  
![](../logo.svg)
</div>

</div>

6.3. 目次の挿入

プラグインで挿入可能ですが <div class="toc">で囲むことで、目次専用のスタイルが適用されます。

<div class="toc">

**目次**
- [[#1 プロジェクト概要]]
  - [[#1.1 システム構成図]]
  - [[#1.2 ハードウェア要件]]
- [[#2 データ構造設計]]

</div>

6.4. 横向きセクションの活用

<div class="landscape-section">

## システム構成図

```mermaid
graph LR
  Device[エッジデバイス] --> Gateway[ゲートウェイ]
  Gateway --> Cloud[クラウド]
```

</div>
サンプル Markdown
---
number headings: auto, first-level 2, max 6, start-at 1, _.1.1
---
<div class="cover-page"> 

# Sidewalk-Rust-Flutter<br>IoT プロトタイプ構築ガイド

<div class="cover-footer">

第1.0版 
2026.01.01 

![](../wohllogo.svg)

</div>

</div>
<div class="toc">

**目次**
- [[#1 プロジェクト概要|1 プロジェクト概要]]
    - [[#1 プロジェクト概要#1.1 システム構成図|1.1 システム構成図]]
    - [[#1 プロジェクト概要#1.2 ハードウェア要件 (BOM)|1.2 ハードウェア要件 (BOM)]]
- [[#2 データ構造設計 (Payload Protocol)|2 データ構造設計 (Payload Protocol)]]
- [[#3 実装フェーズ|3 実装フェーズ]]
    - [[#3 実装フェーズ#3.1 Phase 1: AWS クラウド基盤構築 (Rust Backend)|3.1 Phase 1: AWS クラウド基盤構築 (Rust Backend)]]
    - [[#3 実装フェーズ#3.2 Phase 2: エッジデバイス開発 (XIAO nRF52840)|3.2 Phase 2: エッジデバイス開発 (XIAO nRF52840)]]
    - [[#3 実装フェーズ#3.3 Phase 3: ゲートウェイ構築 (Android)|3.3 Phase 3: ゲートウェイ構築 (Android)]]
    - [[#3 実装フェーズ#3.4 Phase 4: 可視化アプリ開発 (Flutter)|3.4 Phase 4: 可視化アプリ開発 (Flutter)]]
- [[#4 開発ロードマップとチェックリスト|4 開発ロードマップとチェックリスト]]
- [[#5 Tips & トラブルシューティング|5 Tips & トラブルシューティング]]

</div>


<div class="landscape-section">

## 1 プロジェクト概要

本プロジェクトは、Amazon Sidewalk の広域ネットワーク特性を模倣した BLE 通信環境を構築し、温度データをクラウドで収集・可視化するシステムです。

### 1.1 システム構成図

```mermaid
graph LR
    subgraph "Edge Device (Rust)"
        S[ENV III Unit] -- I2C --> M[XIAO nRF52840]
        M -- "BLE Adv (19byte)" --> G[Android Gateway]
    end

    subgraph "Gateway (Kotlin/Java)"
        G -- "Sidewalk Mobile SDK" --> I[AWS IoT Core]
    end

    subgraph "Cloud (Rust)"
        I -- "Rule Engine" --> L[Lambda Function]
        L -- "Decode & Put" --> D[(DynamoDB)]
    end

    subgraph "Mobile App (Flutter)"
        F[Flutter App] -- "REST API" --> A[API Gateway]
        A --> L2[Lambda Function]
        L2 -- "Query" --> D
    end
```

</div>

### 1.2 ハードウェア要件 (BOM)

1. **MCU:** Seeed Studio XIAO nRF52840 (Sense または Standard)
2. **Base:** Seeed Studio XIAO Expansion Base
3. **Sensor:** M5Stack ENV III Unit (SHT30 + QMP6988)
4. **Gateway:** Android スマートフォン (Amazon Sidewalk Mobile SDK 対応)
5. **PC:** Rust 開発環境 (VS Code 推奨)

## 2 データ構造設計 (Payload Protocol)

LoRa (Sub-GHz) のペイロード制限(最大19byte)に適合するよう、`postcard` クレートを用いてバイナリ化します。

**データレイアウト (Total: 11 bytes)**

|**Byte Offset**|**Field**|**Type (Rust)**|**Description**|
|---|---|---|---|
|0-3|`device_id`|`u32`|デバイス固有ID|
|4-5|`temperature`|`i16`|温度 (℃) × 100|
|6|`humidity`|`u8`|湿度 (%)|
|7-10|`pressure`|`u32`|気圧 (Pa)|

_残りの8byteは、将来的にタイムスタンプやステータスフラグに使用可能。_


## 3 実装フェーズ

### 3.1 Phase 1: AWS クラウド基盤構築 (Rust Backend)

データの受け皿を先に作ります。

**手順:**

1. **DynamoDB テーブル作成:**
    - Table Name: `SidewalkTemperatures`
    - Partition Key: `device_id` (String)
    - Sort Key: `timestamp` (Number)
2. **Rust Lambda (Receiver) 実装:**
    - Sidewalk から届く Base64 エンコードされたバイナリをデコードし、DBへ保存します。


```rust
// Cargo.toml
// dependencies: lambda_runtime, serde, postcard, aws-sdk-dynamodb, base64

#[derive(Deserialize, Serialize)]
struct Payload {
    device_id: u32,
    temperature: i16, // real_temp = temperature / 100.0
    humidity: u8,
    pressure: u32,
}

async fn function_handler(event: LambdaEvent<IoTEvent>) -> Result<(), Error> {
    let payload_bytes = base64::decode(&event.payload.data)?;
    // 19byte以下のバイナリを構造体に復元
    let data: Payload = postcard::from_bytes(&payload_bytes)?;

    // DynamoDBへ保存 (aws-sdk-dynamodb使用)
    client.put_item()
        .table_name("SidewalkTemperatures")
        .item("device_id", AttributeValue::S(data.device_id.to_string()))
        .item("timestamp", AttributeValue::N(Utc::now().timestamp().to_string()))
        .item("temperature", AttributeValue::N((data.temperature as f64 / 100.0).to_string()))
        .send().await?;

    Ok(())
}
```


### 3.2 Phase 2: エッジデバイス開発 (XIAO nRF52840)

センサー値を読み取り、BLEアドバタイズパケットに乗せます。

**I2C 接続情報:**

- **SDA:** Pin D4
- **SCL:** Pin D5
- **ENV III Address:** `0x44` (SHT30), `0x70` (QMP6988)

**Rust 実装のポイント:**

`embassy-nrf` (非同期ランタイム) を使用し、省電力動作を実装します。


```rust
// main.rs (抜粋)

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_nrf::init(Default::default());

    // I2C 初期化 (Expansion Baseの仕様に合わせる)
    let config = twim::Config::default();
    let i2c = twim::Twim::new(p.TWIM0, p.P1_08, p.P0_07, config); // ピン番号は要確認(XIAO D4/D5)

    // センサー初期化
    let mut sht30 = sht3x::Sht3x::new(i2c, sht3x::Address::Low);

    loop {
        // 1. 計測
        let measurement = sht30.measure().await.unwrap();
        let temp_int = (measurement.temperature.as_degrees_celsius() * 100.0) as i16;

        // 2. パッキング (postcard)
        let payload = Payload {
            device_id: 1001,
            temperature: temp_int,
            humidity: measurement.humidity.as_percent() as u8,
            pressure: 0, // 今回は省略
        };
        let mut buf = [0u8; 19];
        let bytes = postcard::to_slice(&payload, &mut buf).unwrap();

        // 3. 送信 (BLE Advertise)
        // Sidewalk Mobile SDKが検知できる特定のUUIDまたはManufacturer Dataに設定
        let mut adv_data = LegacyAdvertisement::new();
        adv_data.manufacturer_specific_data(0xFFFF, bytes); // テスト用ID

        // アドバタイズ実行 (例えば5秒間)
        advertiser.advertise(&adv_data).await;

        // 4. Deep Sleep (例えば10分)
        Timer::after(Duration::from_secs(600)).await;
    }
}
```


### 3.3 Phase 3: ゲートウェイ構築 (Android)

スマートフォンをブリッジとして機能させます。

**手順:**

1. **Sidewalk Mobile SDK 導入:**
    - Amazon Developer Portal から SDK を入手し、Android Studio プロジェクトにインポート。
2. **ブリッジ機能の実装:**
    - `SidewalkManager` を初期化。
    - Bluetooth スキャンを実行し、XIAO からのパケットをキャッチ。
    - SDK のメソッド `sendData()` を使用して、受信したペイロードをそのまま AWS へ転送。

> **Note:** 開発段階では、単なるBLEスキャナーアプリを作り、受信データを「AWS IoT CoreのHTTPエンドポイント」にPOSTする簡易実装でも代用可能です(Sidewalk SDKの認証周りが複雑なため)。


### 3.4 Phase 4: 可視化アプリ開発 (Flutter)

ユーザーがデータを見るためのダッシュボードです。

**技術スタック:**

- **State Management:** Riverpod
- **Chart:** `fl_chart`
- **Networking:** `dio` or `http`

**実装フロー:**

1. **API クライアント:**

    Phase 1 で作成したデータを読み出す Lambda (API Gateway経由) を叩くリポジトリクラスを作成。

2. **UI 構築:**



```dart
// Chart Widget 簡易例
LineChart(
  LineChartData(
    lineBarsData: [
      LineChartBarData(
        spots: points.map((e) => FlSpot(e.time, e.temp)).toList(),
        isCurved: true,
        color: Colors.blue,
      ),
    ],
    // ...軸の設定など
  ),
);
```


## 4 開発ロードマップとチェックリスト

- [ ] **Rust環境構築:** `rustup target add thumbv7em-none-eabihf`, `cargo install probe-rs`.
- [ ] **ハードウェアテスト:** XIAO + Expansion Base で OLED に "Hello" を表示する。
- [ ] **センサー疎通:** Rust で I2C 経由の温度取得を成功させる。
- [ ] **クラウド疎通:** テスト用バイナリデータを AWS IoT Core のテスト画面から投げ、DynamoDB に入ることを確認する。
- [ ] **エンドツーエンド (BLE):** デバイス → Android → AWS のパスを通す。
- [ ] **アプリ表示:** Flutter アプリでグラフが描画されることを確認する。

## 5 Tips & トラブルシューティング

- **XIAO nRF52840 ピン配置:** Rust の `bsp` (Board Support Package) クレートを使う場合、`D4`, `D5` という名前でアクセスできるか、`P0_07` などの生ピン番号が必要かを確認してください。XIAO の回路図と照らし合わせるのが確実です。
- **エンディアン:** `postcard` はリトルエンディアンを使用します。デバッグ時にバイナリを手動で読む際は注意してください。
- **権限:** Android アプリには `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION` の権限付与が必要です。

7. エクスポート手順

  1. スニペットの配置
    .obsidian/snippets/フォルダにpdf-cover.csspdf-fix.cssを保存
  2. スニペットの有効化
    設定 > 外観 > CSSスニペット で両方のスニペットを有効化
  3. PDFエクスポート
    Better Export PDF:画面右上のメニューからエクスポート可能。このとき用紙方向をLandscapeに指定すること。
  4. プレビュー確認
    Better Export PDFを使用すると、エクスポート前にプレビュー表示が可能

サンプルPDF(PDF, 1.79 MB)

8. まとめ

ObsidianとCSSスニペットを組み合わせることで、以下を実現できました:

9. FAQ

Obsidianの「CSSスニペット」はどこから設定すればいいですか?
設定 > 外観 > CSSスニペット セクションにあるフォルダアイコンをクリックし、開いたフォルダに .css ファイルを保存してください。その後、画面上で各スニペットを有効化(トグルをオン)すれば反映されます。
Better Export PDFプラグインが標準のエクスポート機能より優れている点は?
標準機能では難しい「しおり(ブックマーク)の自動生成」「ヘッダー・フッターのカスタマイズ」「プレビュー画面での微調整」が可能です。また、エクスポートしたPDF目次のリンクが正常に動作します。