※ これは自分が個人開発しているサービス(DofuPDF)の実装メモです。自作の技術的な解説を主目的とした記事です。
PDFの暗号化をブラウザ内だけで完結させる Web アプリを作りました。C/C++ 製の PDF エンジン qpdf を WebAssembly として動かす構成で、ネットワークへのファイル送信は一切ありません。
本記事では、その実装で実際にハマった点と解決策を、できるだけ再利用可能な形で残します。Next.js のバージョン特有の話は避け、WASM まわりの話に絞ります。
全体構成
ユーザーのPDF (File オブジェクト)
│
▼
Web Worker 内の qpdf (WASM)
│ ├─ Emscripten の仮想ファイルシステム(MEMFS)に input.pdf として書き込み
│ ├─ ネイティブと同じ argv で暗号化/復号を実行
│ └─ 出来上がりを output.pdf として読み出し
▼
Blob → ユーザーがローカルに保存
PDF 処理そのものはすべてこの Worker の中で完結します。メインスレッドは進捗表示だけ。
ハマった点①:Emscripten の locateFile
@jspawn/qpdf-wasm のような Emscripten ビルドには、最初に大きな罠があります。Module に wasmBinary を渡しても無視されて、必ず locateFile で解決した URL から fetch しようとします。ホスト側でバイトを用意していても、結局 URL 経由の取得が走ってしまうのです。
対策は、WASM バイナリを自分でバイト列として取得し、Blob URL を作って locateFile に渡すことです。
async function engineBlobUrl(path: string): Promise<string> {
const res = await fetch(`/wasm/${path}`);
if (!res.ok) throw new Error(`wasm fetch failed: ${path}`);
const bytes = await res.arrayBuffer();
const head = new Uint8Array(bytes, 0, 4);
// キャッシュ破損やデプロイミスを拾うため、WASM の魔数を検証する
if (!(head[0] === 0x00 && head[1] === 0x61 && head[2] === 0x73 && head[3] === 0x6d)) {
throw new Error(`invalid wasm binary: ${path}`);
}
return URL.createObjectURL(new Blob([bytes], { type: 'application/wasm' }));
}
const mod = await createQpdf({
locateFile: (path: string) => engineBlobUrl(path),
});
なお locateFile の戻り値は非同期でも問題ありません(Promise を返せます)。
MEMFS との入出力
Emscripten の仮想ファイルシステムは起動時に空っぽです。処理したいファイルを書き込み、実行後に読み出すだけの単純な使い方ですが、argv の組み立てにいくつか注意点があります。
復号は --password= のキーバリュー形式を使います。パスワードがそのまま位置引数になると、- や -- で始まるパスワードをオプションと誤解釈される可能性があるためです(なお暗号化側は qpdf --encrypt の仕様上、ユーザーパスワードとオーナーパスワードが位置引数なので、そちらは UI 側で - 始まりを拒否しています)。
mod.FS.writeFile('input.pdf', new Uint8Array(pdfBytes));
const code = mod.callMain([
'--decrypt',
`--password=${password}`,
'input.pdf',
'output.pdf',
]);
// qpdf は exit code 3 を「警告付き成功」と返す。これをエラーと誤認しない
if (code !== 0 && code !== 3) {
throw new Error(`qpdf exited with ${code}`);
}
const out = mod.FS.readFile('output.pdf');
暗号化は AES-256(256)に加えて、PDF の権限制限ビットをフラグで渡します。フォーム入力の権限は --annotate とは別に --form も同期させる必要があります。注釈だけ禁止してもフォーム入力が許可されたままになる罠です。
const code = mod.callMain([
'--encrypt', userPassword, ownerPassword, '256',
`--print=${permissions.print}`,
`--modify=${permissions.modify}`,
`--extract=${yn(permissions.extract)}`,
`--annotate=${yn(permissions.annotate)}`,
`--form=${yn(permissions.annotate)}`, // ← これを忘れるとフォーム入力が開いたまま
`--assemble=${yn(permissions.assemble)}`,
`--accessibility=${yn(permissions.accessibility)}`,
'--',
'input.pdf',
'output.pdf',
]);
バイナリのキャッシュ戦略
WASM エンジンは数 MB あるので毎回ダウンロードは避けたい。一方で、キャッシュの stale は Engine 起動失敗に直結するので慎重に設計しました。
結論として、こうしています。
-
manifest.json(エンジン一覧の数十分の1KB):キャッシュしない。毎回取り直す。 stale しても被害ゼロなので - WASM バイナリ:Cache Storage にキャッシュ。ただし使用時に魔数検証し、壊れていれば削除して再取得する
「とにかくキャッシュする」より「キャッシュしても壊れていれば自己修復する」のほうが運用で幸せになれます。
HTTP キャッシュも _headers で immutable を効かせていますが、Emscripten が URL 経由で取りに来る都合上、Service Worker / Cache Storage 側で最終防衛線を持っておくのが安心です。
ハマった点②:CSP と Blob URL
connect-src に blob: がないと、Blob URL 経由の WASM ロードが CSP にブロックされます。今回のような「ファイルを外部に送らない」設計では、connect-src 'self' blob: に留めるのが要点です。ここに外部ドメインを足すたびにプライバシー設計を見直す、という運用にしています。
ハマった点③:TypeScript 5.7 の Uint8Array
TS 5.7 以降、Uint8Array はそのまま BlobPart に代入できなくなりました。
// これは型エラーになる
new Blob([uint8]);
// こうする
new Blob([uint8 as unknown as ArrayBuffer]); // または .buffer を正しく扱う
FS.readFile の戻り値は Uint8Array なので、ダウンロード用 Blob を作る箇所で一度踏みます。utility 化しておくと二度と悩みません。
まとめ
- Emscripten 製ライブラリは
wasmBinaryを無視する。locateFileに Blob URL を渡す - MEMFS は「書き込み → callMain → 読み出し」の単純構造。exit code 3 に注意
-
--formを--annotateと同期させる - キャッシュは「小さいものはキャッシュしない、大きいものは壊れても自己修復する」
- CSP の
connect-srcにblob:が必要
WebAssembly が成熟したおかげで、「サーバーにアップロードして処理してもらう」前提のネイティブツールを、ほぼそのままユーザーの端末に持ち込める時代になりました。機密性の高い文書ほどこの構成が向いていると思っています。
これを製品化したのが DofuPDF(GitHub、AGPL-3.0)です。ブラウザの DevTools で Network タブを見ながら使うと、通信ゼロで処理が完結しているのが確認できます。関連実装は src/workers/pdf-heavy.worker.ts にあります。