PDFに名前を書いて印鑑を押す。それだけのためにAcrobatへ年23,760円払っていたので、思い切ってClaudeで作りました。
- 動くもの: https://ilove-ai.net/pdf
- ソース: https://github.com/takumi-golf/generic-pdf (MIT・
index.html1ファイル・2,058行・148KB)
ビルド工程はありません。ダウンロードしてダブルクリックでも動きます。
Claudeへの指示は1〜2回で、ほぼ動くものができました。なのでこの記事は作り方ではなく、動かしてみて見つかった8つの落とし穴の話です。
構成
ブラウザだけでPDFを扱うとき、1つのライブラリで全部は無理でした。役割の違うライブラリ4つとフォントを組み合わせています。
| ライブラリ | ライセンス | 担当 |
|---|---|---|
| pdf-lib 1.17.1 | MIT | 書き出し(ページ操作・描画・フォーム) |
| @pdf-lib/fontkit 1.1.1 | MIT | フォント埋め込み |
| pdf.js 4.10.38 | Apache-2.0 | 表示・ラスタライズ |
| qpdf-wasm 0.0.2 | Apache-2.0 | パスワード・劣化なし圧縮(必要なときだけ読み込む) |
| Noto Sans JP | SIL OFL 1.1 | 日本語の字 |
読むのはpdf.js、書くのはpdf-lib、という分担です。同じPDFを両方で開くので、座標の変換が常について回ります。
穴1. 日本語は標準フォントでは1文字も出ない
pdf-libの標準14フォントはWinAnsiエンコードで、日本語を含みません。drawText("請求書") は WinAnsi cannot encode のエラーで落ちます。OTF/TTFを埋め込む必要があります。
そしてNoto Sans JPのフルOTFは4.5MBです。文字を1つ置くために毎回これを取りに行くのは重すぎます。
@fontsource のwoff2を使おうとしましたが、@pdf-lib/fontkit経由の埋め込みはOTF/TTFが前提で、woff2はそのままでは使えませんでした。
やったこと。
-
pyftsubsetでJIS X 0208の範囲に絞ったOTF(7,364字)を作る。1.9MB になります - 文字を初めて置くときに遅延取得する(起動時には取らない)
- サブセットに無い字が来たときだけ、CDNのフル版に切り替える
3の判定は fontkit にやらせます。
const f = fontkit.create(new Uint8Array(subsetBytes));
const missing = [...text].some((ch) => {
const cp = ch.codePointAt(0);
return cp > 0x20 && !f.hasGlyphForCodePoint(cp);
});
出力側は embedFont(bytes, { subset: true }) を付けておけば、実際に使った字だけがPDFに入ります。1.9MBのフォントを埋め込んでも、出来上がるPDFは数十KBです。
なお、pdf-libの subset: true はCFFベースのOTFで字化けする報告が過去にあります。出力したPDFはAcrobat・Preview・Chromeの3つで表示を確認しています。
穴2. AcroFormは copyPages で引き継がれない
複数のPDFを結合するとき、copyPages でページを持ってきて新しいDocumentに足す、というのが定石です。ところがフォームは付いてきません。ページの見た目だけが来て、入力欄は消えます。
フォーム記入を実装するなら、結合とは別の経路が要ります。元のバイト列をそのまま開いて、書いて、バイト列ごと差し替える方式にしました。
const doc = await PDFDocument.load(src.bytes, { ignoreEncryption: true, updateMetadata: false });
doc.registerFontkit(fontkit);
const form = doc.getForm();
// … setText / check / select …
form.updateFieldAppearances(jpFont); // 穴3
form.flatten({ updateFieldAppearances: false });
src.bytes = (await doc.save()).buffer; // ページではなくファイルを置き換える
元のバイト列は別に取っておいて「元に戻す」で戻せるようにしています。flattenは不可逆なので、これは要ります。
穴3. 日本語のフォーム記入は、フォントを渡さないと空欄になる
setText("山田太郎") して flatten() すると、見た目が空のPDFができます。エラーは出ません。
フォームの見た目(appearance stream)は、フィールドに紐づいたフォントで描かれます。元のPDFがHelvetica前提で作られていれば、日本語のグリフが無いので何も描かれません。
updateFieldAppearances に日本語フォントを明示的に渡します。
if (needJp) {
const bytes = await fontFor(values.join(""));
form.updateFieldAppearances(await doc.embedFont(bytes, { subset: true }));
} else {
form.updateFieldAppearances();
}
form.flatten({ updateFieldAppearances: false }); // ここで再計算させない
flatten にも updateFieldAppearances: false を渡すのが大事です。渡さないと、せっかく差し替えた見た目をデフォルトのフォントで上書きされます。
穴4. 同じcanvasに render() が重なると、以後ずっと描画が止まる
並べ替えの直後に回転、のように描画が連続すると出ます。
Cannot use the same canvas during multiple render() operations
一度出ると、それ以降その canvas は描けません。実機でもボタン連打で普通に起きます。
pdf.jsの page.render() は非同期で、前の描画が終わる前に同じcanvasへ次を始めると例外を投げます。直前の RenderTask を持っておいて、新しい描画の前に cancel() します。
if (v.task) { try { v.task.cancel(); } catch (_) {} }
v.task = page.render({ canvasContext: ctx, viewport });
try { await v.task.promise; }
catch (e) { if (e && e.name === "RenderingCancelledException") return; throw e; }
取り消された側の例外は握りつぶして戻ります。連番を持たせて「最後の1回」だけが画面に残るようにしておくと、連打しても破綻しません。
穴5. 墨消しは「黒い箱を上に置く」では消えていない
一番怖い穴です。PDFに黒い矩形を描いても、下のテキストはそのまま残っています。テキスト選択でコピーできますし、pdftotext に通せば出てきます。黒塗りしたはずの公文書から中身が読めてしまった、というニュースの典型パターンです。
確実に消すなら、そのページを画像にしてしまうしかありません。
- pdf.jsでページをscale 2・rotation 0でcanvasに描く
- canvasに黒い矩形を塗る
- JPEGにして、pdf-libで新しいページとして埋め込む
代償として、そのページは文字が検索できなくなります。これは避けられないので、画面に太字で書いています。「そのページは画像に変換され、箱の下にあった文字は完全に消えます(文字の検索・コピーはできなくなります)」。
消えないものを消えたように見せるくらいなら、できないと書くほうがいいです。
穴6. devicePixelRatioを素直に使うとcanvasが落ちる
高解像度で描こうとして devicePixelRatio を掛けると、A3やポスターサイズのPDFでcanvasの上限を超えます。Chrome・Safariで試した範囲では、上限を超えたcanvasは例外を投げずに真っ白を返すので、原因が分かりにくいです。
辺と面積の両方で抑えます。
const MAX_SIDE = 8192, MAX_AREA = 16e6;
const d = Math.min(
window.devicePixelRatio || 1, 2,
MAX_SIDE / vp.width, MAX_SIDE / vp.height,
Math.sqrt(MAX_AREA / (vp.width * vp.height))
);
2 で頭を止めているのは、3倍以上にしても画面上では差が見えないからです。
穴7. qpdfの --encrypt は、owner passwordが空だと exit 2
パスワード保護に qpdf-wasm を使っています。「開くときのパスワードだけ掛けたい、権限パスワードは無し」のつもりで空文字を渡すと、終了コード2で落ちます。
// 落ちる
["--encrypt", user, "", "256", "--"]
// 通る
["--encrypt", user, owner || user, "256", "--"]
ownerが空のときはuserを流用します。
qpdf-wasmは必要になったときだけscriptを注入して読み込んでいます。パスワードと劣化なし圧縮を使わない人には、wasmを一切ダウンロードさせません。
穴8. 「スマホ対応」だけでは足りない。痩せるのはタブレット縦
スマホ(412px)専用レイアウトを作り、PC(1280px)で確認して、出しました。あとからiPad縦で開いたら、PDFが45%の大きさでしか表示されていませんでした。
スマホ判定を max-width: 768px で切っていたので、820pxのiPad縦は「PC」として扱われます。左パネル208px+右パネル296pxが固定で出て、残り約300pxに紙を収めるので0.45になります。横(1180px)では起きないので、横だけ見ると気づきません。
初回訪問時だけ innerWidth <= 1024 でパネルを畳む既定にして、0.90になりました。ブレークポイントを増やして3レイアウトにするより、既定値を変えるほうが壊す範囲が小さいです。
中央のコンテンツが固定幅のパネルに挟まれる画面は、768〜1024pxを必ず実測してください。
おまけ: 「アップロードしていません」を検証可能にする
ブラウザ完結にしたので、本当にアップロードしていないかを誰でも確認できるようにしました。
grep -n 'fetch(\|XMLHttpRequest\|FormData\|sendBeacon\|WebSocket\|POST' index.html
ヒットは2行です。1行は「無い」と書いたコメント、もう1行は日本語フォントの取得。
ただしgrepで見られるのは index.html の中だけで、CDNから読み込むライブラリの中身は対象外です。そこでheadless Chromiumで全機能をひと通り操作して、リクエストも実測しています。
| 実測した項目 | 結果 |
|---|---|
| GET以外のリクエスト | 0件 |
| 本文のあるリクエスト | 0件 |
| 通信先 | 同一オリジン / cdnjs / jsDelivr / Google Fonts のみ |
読み込みは FileReader でメモリに乗せるだけ、保存は Blob を a[download] に渡すだけです。localStorage に残るのは言語とパネルの開閉の2つだけです。
できないこと
宣伝ではないので、できない側も書いておきます。
既存の文字や画像そのものの書き換え(上から重ねるだけ)、Word・Excelへの変換、PDF/A・PDF/X、証明書による電子署名、2つのPDFの比較、OCR、XFAフォーム、オフライン動作(ライブラリとフォントはCDNから取ります)。
OCR以外は対応する予定もありません。ブラウザの中で品質が出ないものを「できます」と書くほうが害が大きいと思っています。
MITなので、好きに持っていってください。