上級 api

Wasm + PDF生成

はじめに

Rust/Wasmでクライアントサイドにてpdfを生成することで、ドキュメント作成のためにサーバーへ機密データを送信する必要がなくなります。請求書、レポート、証明書、領収書をすべてブラウザ内で直接生成できます。このレッスンでは最小限ながら有効なPDFジェネレーターをゼロから構築し、PDFファイルフォーマットの内部構造を解説します。

なぜクライアントサイドでPDF生成するのか?

アプローチ レイテンシ プライバシー オフライン サーバーコスト
サーバーサイド (wkhtmltopdf) 200-500ms + ネットワーク データがサーバーに送信される 不可 高い
サーバーサイド (Puppeteer) 500-2000ms + ネットワーク データがサーバーに送信される 不可 非常に高い
クライアントJS (jsPDF) 50-200ms データはローカルに留まる 可能 なし
クライアントWasm (Rust) 10-50ms データはローカルに留まる 可能 なし

WasmはPDF生成においてJavaScriptより3〜5倍高速です。大きなバイト配列の構築時にGC負荷を回避し、効率的な文字列フォーマットを行うためです。

PDFファイルフォーマットの構造

PDFファイルは4つの主要セクションで構成されています:

┌──────────────────────────────┐
│  ヘッダー                     │  %PDF-1.4
├──────────────────────────────┤
│  ボディ                       │  オブジェクト(ページ、フォント、
│  (番号付きオブジェクト)      │   画像、コンテンツストリーム)
├──────────────────────────────┤
│  相互参照テーブル              │  ランダムアクセスのための
│  (xref)                      │  各オブジェクトのバイトオフセット
├──────────────────────────────┤
│  トレーラー                   │  ルートオブジェクトと
│                              │  xrefテーブルへのポインタ
└──────────────────────────────┘

オブジェクト階層

すべてのPDFはこのツリー構造を持ちます:

Catalog(ルート)
└── Pages(コレクション)
    ├── Page 1
    │   ├── MediaBox [0 0 612 792]  (USレターサイズ、ポイント単位)
    │   ├── Resources
    │   │   └── Font /F1 → Helvetica
    │   └── Contents → Streamオブジェクト
    │       └── "BT /F1 12 Tf (Hello) Tj ET"
    ├── Page 2
    │   └── ...
    └── Page N

PDFオブジェクト

オブジェクトは番号付き(1 0 obj、2 0 objなど)で、辞書、配列、ストリーム、プリミティブ値のいずれかです:

% 辞書オブジェクト
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

% ストリームオブジェクト(描画コマンドを含む)
3 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
1 0 0 1 50 750 Tm
(Hello World) Tj
ET
endstream
endobj

2 0 RRは「オブジェクト2、世代0への参照」を意味します -- これがオブジェクト間のリンク方法です。

コンテンツストリームコマンド

PDFコンテンツストリームはPostScriptに似たスタックベースの描画言語を使用します:

コマンド 意味
BT テキストブロック開始 BT
ET テキストブロック終了 ET
Tf フォントとサイズを設定 /F1 12 Tf
Tm テキスト行列(位置)を設定 1 0 0 1 50 750 Tm
Tj テキスト文字列を表示 (Hello) Tj
Td テキスト位置を移動 0 -14 Td
m 点に移動 50 700 m
l 点まで線を引く 550 700 l
S パスをストローク S
re 矩形 50 50 500 700 re
rg 塗りつぶし色を設定 (RGB) 1 0 0 rg(赤)

座標系は左下隅から始まります。USレターは612 x 792ポイントです(1ポイント = 1/72インチ)。

組み込みフォント

PDFはすべてのPDFリーダーがサポートしなければならない14の標準フォントを定義しています -- 埋め込み不要:

Helvetica              Helvetica-Bold
Helvetica-Oblique      Helvetica-BoldOblique
Times-Roman            Times-Bold
Times-Italic           Times-BoldItalic
Courier                Courier-Bold
Courier-Oblique        Courier-BoldOblique
Symbol                 ZapfDingbats

これらのフォントを使用することでPDFを小さく保てます。カスタムフォントにはフォントファイルの埋め込みが必要で、数百キロバイトが追加される場合があります。

相互参照テーブル

xrefテーブルはファイル全体を読み込まずにオブジェクトへのランダムアクセスを可能にします。各エントリはファイル先頭からのオブジェクトのバイトオフセットを記録します:

xref
0 5
0000000000 65535 f     ← フリーオブジェクト(常に最初)
0000000009 00000 n     ← オブジェクト1(バイト90000000058 00000 n     ← オブジェクト2(バイト580000000115 00000 n     ← オブジェクト3(バイト1150000000258 00000 n     ← オブジェクト4(バイト258

これは大きなPDFにとって重要です -- リーダーはページ1〜499をパースせずに直接ページ500にジャンプできます。

PDFへの画像の追加

PDF内の画像は特定のフィルターを持つストリームオブジェクトとして保存されます:

% 画像オブジェクト(概念図)
5 0 obj
<< /Type /XObject
   /Subtype /Image
   /Width 200
   /Height 150
   /ColorSpace /DeviceRGB
   /BitsPerComponent 8
   /Length 90000
   /Filter /DCTDecode    ← JPEGデータを意味する
>>
stream
[生のJPEGバイト]
endstream
endobj

Wasmでは、JavaScriptからRustに画像バイトを渡し、JPEG(DCTDecode)またはPNG(FlateDecode)ストリームとして直接埋め込めます。

テーブル生成

PDF内のテーブルは線とテキストのコマンドを使って手動で描画します:

シンプルなテーブルのコンテンツストリーム:
% グリッド線を描画
0.5 w                    % 線幅
50 700 m 550 700 l S     % 上罫線
50 680 m 550 680 l S     % ヘッダー区切り
50 660 m 550 660 l S     % 行1
50 700 m 50 660 l S      % 左罫線
300 700 m 300 660 l S    % 列区切り
550 700 m 550 660 l S    % 右罫線

% ヘッダーテキスト
BT /F1 10 Tf
1 0 0 1 55 685 Tm (Name) Tj
1 0 0 1 305 685 Tm (Value) Tj
ET

これがPDFライブラリが価値を持つ理由です -- セル位置の計算、テキスト折り返しの処理、改ページの管理を手動で行うのは非常に手間がかかります。

プロダクション向けクレート:printpdfとgenpdf

実際のアプリケーションでは、以下のRustクレートがWasmにコンパイルできます:

printpdf -- 低レベルPDF生成:

// printpdfクレートが必要
use printpdf::*;

let (doc, page1, layer1) = PdfDocument::new(
    "My Document", Mm(210.0), Mm(297.0), "Layer 1"
);
let font = doc.add_builtin_font(BuiltinFont::Helvetica).unwrap();
let layer = doc.get_page(page1).get_layer(layer1);
layer.use_text("Hello from Rust!", 14.0, Mm(10.0), Mm(280.0), &font);

genpdf -- レイアウトエンジン付きの高レベルAPI:

// genpdfクレートが必要
let font = genpdf::fonts::from_files("./fonts", "Helvetica", None).unwrap();
let mut doc = genpdf::Document::new(font);
doc.push(genpdf::elements::Paragraph::new("Hello from Rust!"));
doc.push(genpdf::elements::Break::new(1));
doc.push(genpdf::elements::Paragraph::new("Second paragraph."));

試してみよう

PDFジェネレーターを拡張してみましょう:

  • ページ全幅の水平線(罫線)の描画コマンドを追加する
  • ストリーム途中で/F2(Helvetica-Bold)に切り替えて太字テキストをサポートする
  • 各ページの下部にページ番号を追加する
  • グリッド線とセルテキストでテーブルを生成する
  • rg(塗りつぶし色)コマンドを使ってテキストにを追加する

試してみる