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 NPDFオブジェクト
オブジェクトは番号付き(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
endobj2 0 RのRは「オブジェクト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(バイト9)
0000000058 00000 n ← オブジェクト2(バイト58)
0000000115 00000 n ← オブジェクト3(バイト115)
0000000258 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
endobjWasmでは、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(塗りつぶし色)コマンドを使ってテキストに色を追加する