[Astro] #162 PDF Inspector — PDFの中身を覗くツールを作成
はじめに
前回(#161)は、過去作品のギャラリーページでした。
[Astro] #161 過去作品ギャラリー — Three.jsのカードを扇とリストで並べ、拡大するとサムネが動画になる // PROTOCOL.LAIN
過去に作った作品を一覧する /works ページを、Three.js のカード UI で作りました。14 枚のカードを扇状に並べて回すモードと、グリッドに並べるリストモードがあり、選んだカードをもう一度押すと画面いっぱいに拡大して、サムネイルが mp4 の動画に切り替わります。
lain-lab.com今回は、PDF の中身を覗くツール(/pdf-inspector) です。
AST Inspector、WASM Inspector に続く「中身を覗く」シリーズの 3 作目です。PDF の内部を見るツールは、デスクトップアプリには良いものがあります(UniDoc PDF Debugger、iText RUPS など)。ブラウザだけで動いて、同じことができるものを目標にしました。
正直に書くと、作る前は PDF に興味がありませんでした。作ってみて一番驚いたのは、黒塗りの下の文字が見えた ことです。
要点は次の 3 つです。
- オブジェクト・描画命令・16 進ダンプを、バイト範囲で連動させる
- ページを自前で描いて、命令を 1 つずつ進められるようにする
- 壊れた PDF・追記された PDF・署名された PDF を、原因の位置つきで調べる
以降は、実装の全体を簡単に残しておきます。量が多いので、細かいところは省いています。
完成画面
黒塗りの下が見える
追記の差分
操作動画
タブは 8 つあります。
- OBJECT:オブジェクトの中身。
12 0 Rを押すと参照先へ。ストリームは展開の過程も出る - CONTENT:ページの描画命令を 1 行 1 命令で。演算子の意味と、復元した文字が出る
- PAGE:ページの描画。ステップ実行、クリックで命令を特定、止めた時点の状態
- XREF:クロスリファレンス表。オフセットのずれを赤で表示
- REVISIONS:増分更新の履歴と、変更前後の差分
- DIAG:診断。壊れている場所と原因の一覧
- RESOURCES:フォント、画像、電子署名、メタデータ、添付、フォーム、JavaScript など
- STATS:統計
右には常に 16 進ダンプがあって、どのタブで何を選んでも、対応するバイトが光ります。
PDF は「命令の列」
実装の前に、作っていて分かったことを 1 つだけ書きます。
PDF のページは、絵でも文章でもなく、描画命令の列 です。サンプルのページの一部は、こうなっています。
BT /F2 12 Tf 40 555 Td (Confidential amount: 12,340,000 JPY) Tj ET
…
0 g 36 530 330 16 re f
1 行目は「このフォントで、この座標に、この文字列を置く」。最後の行は「黒で、この四角を塗る」です。数値を先に並べて、最後に命令を書く形(逆ポーランド記法)です。
黒い四角は、文字の上に重ねて描かれているだけです。文字の命令は消えていません。だから、四角を描く手前で止めれば、下の文字が見えます。画像のモザイクとは違って、最初から消えていなかった、ということです。
ちゃんとした墨消しは、文字の命令そのものを削除します。その場合は、このツールでも見えません。逆に言うと、このツールで見えたら、その黒塗りは失敗しています。
構成
src/pages/pdf-inspector.astro ページ本体(マークアップと HELP)
public/pdf-inspector/
├─ pdf-core.js 字句解析・構文解析・xref・暗号・リビジョン
├─ pdf-filters.js Flate / LZW / ASCII85 などの展開
├─ pdf-crypto.js MD5 / RC4 / AES / SHA と、パスワードの検証
├─ pdf-content.js 描画命令の切り分け
├─ pdf-render.js 描画エンジン
├─ pdf-fonts.js フォント辞書 → 文字とグリフ
├─ pdf-fontfile.js TrueType / CFF / Type1 の輪郭の解読
├─ pdf-cmap.js 日本語などの CMap
├─ pdf-color.js 色空間と関数
├─ pdf-image.js 画像
├─ pdf-pattern.js グラデーション
├─ pdf-ccitt.js ファクス圧縮
├─ pdf-sign.js 電子署名(ASN.1 と検証)
├─ pdf-resources.js 文書全体の調査
├─ pdf-model.js 表示用の整形
├─ pdf-worker.js Worker の受付
└─ ui-*.js / pdf-inspector.js 画面
全部で約 12,900 行です。外部ライブラリは使っていません。zlib の展開も、フォントの解読も、暗号も、自前です。
解析はすべて Web Worker で行います。メイン側は「頼む」「結果を置く」だけなので、大きな PDF でも画面は固まりません。
レイアウト(タブ、左パネル、16 進ダンプ、仮想スクロール)は、WASM Inspector と Bz バイナリエディタのコードを下敷きにしています。
すべての値にバイト範囲を持たせる
このツールの土台は、パーサが作る値が、必ずバイト範囲を持つ ことです。
// 値はすべて { t, s, e, ... }。s / e はファイル上の開始・終了位置
{ t: 'name', v: 'Catalog', s: 8970, e: 8978 }
オブジェクトの行にカーソルを乗せると 16 進ダンプが光るのも、16 進ダンプをクリックするとオブジェクトが開くのも、この s と e を突き合わせているだけです。
描画命令も同じです。parseContent は、命令ごとに「最初の数値の先頭から、演算子の末尾まで」の範囲を持たせています。
xref を信用しない
PDF には、各オブジェクトがファイルの何バイト目にあるかの表(xref)があります。ふつうのリーダーは、これを読んでオブジェクトを探します。
このツールは、xref を読む のと、ファイル全体を走査して N G obj を拾う のを、両方やります。
readXrefChain startxref → xref → /Prev → … と辿る
scanObjects ファイル全体から「N G obj」を拾う
checkXrefAgainstScan 2 つを突き合わせて、ずれを診断に出す
2 つを比べること自体が、診断になります。実在のファイルで、「xref が 1 バイトずれている」「/Size が小さい」「endobj が無い」といったものが見つかりました。
壊れたトークンは、例外を投げずに読み飛ばして、警告に記録します。ここで止まると、壊れた PDF が開けなくなるからです。
追記の履歴を追う
PDF は、変更したオブジェクトと新しい xref を、ファイルの末尾に追記して保存できます(増分更新)。古い版は消えずに残ります。
xref の区間ごとに「リビジョン」を作って、どのオブジェクトが足されたか・変わったかを出しています。変わったオブジェクトを押すと、前後の差分が出ます。
ストリーム(圧縮されたデータ)は、そのままでは比べられません。展開した中身が文字として読めるときは、その行も差分に並べています。
if (rec.value.t === 'stream' && looksTextual(dec.data)) {
lines.push('', '── 展開した中身 ──');
for (const l of text.split(/\r\n|\r|\n/)) lines.push(l);
}
サンプルでは、末尾に足された 2 行(黒い四角と、その上の白い文字)が、緑で出ます。
ページを自前で描く
なぜ自前か
ステップ実行と、クリックからの逆引きをするには、「どの命令が、画面のどこを塗ったか」を知る必要があります。既存の描画ライブラリは、そこを外に出してくれません。そのため、描画エンジンを書きました。
描く先は canvas です。PDF の命令(m l c re f)は、canvas の命令(moveTo lineTo bezierCurveTo rect fill)と、ほぼ 1 対 1 で対応します。
行列
PDF は y が上向き、canvas は下向きです。この違いは、最初の行列に入れてしまいます。
// ページ座標 → ピクセル(回転なしの場合)
base = [s, 0, 0, -s, -x0 * s, y1 * s];
現在の変換行列(CTM)には、最初からこの base を掛け込んでおきます。描くときは ctx.setTransform(...st.ctm) するだけです。
待ちが要る命令だけ Promise を返す
命令の実行は大きな switch です。ほとんどの命令は同期で、画像やパターンのように待ちが要るときだけ Promise を返します。
for (let i = 0; i <= upTo; i++) {
this.opIndex = i;
const p = this.exec(ops[i]);
if (p) await p;
}
全部を async にすると、1 命令ごとに待ちが入って遅くなります。
ステップ実行
upTo 番目の命令で止めて、その時点の絵と状態を返すだけです。スライダーを動かすたびに、先頭から描き直しています。1 ページの描画は数 ms〜数十 ms なので、それで足ります。
描画の依頼は、同時に 1 つだけにしています。スライダーを速く動かして依頼が重なったら、最後の 1 つだけを次に出します。
クリックから命令を引く
何かを塗るたびに、「命令番号ごとの外接矩形」を記録しています。
mark(x0, y0, x1, y1) {
const o = this.opIndex * 4, b = this.boxes; // Float32Array
if (Number.isNaN(b[o])) { b[o] = x0; b[o + 1] = y0; b[o + 2] = x1; b[o + 3] = y1; }
else { /* 既にあれば広げる */ }
}
ページ上をクリックしたら、その点を含む矩形のうち、面積が一番小さいもの を選びます。ページ全体の背景より、その上の文字のほうが選ばれます。
透明は「別の canvas に描いてから合成」
canvas には「描いたものにマスクを掛ける」機能がありません。そのため、別の canvas に描いてから、合成しています。
beginLayer() 作業用の canvas に切り替える
(ここで描かれたものは全部レイヤーへ)
endLayer() マスクがあれば掛けて、元の canvas に合成
同じ仕組みを、ソフトマスク・透明グループ・文字での切り抜きの 3 つに使っています。
文字が出るまで
PDF の文字列は、ただのバイト列です。意味はフォント次第です。
バイト列 → コード → (CMap)→ CID → グリフ番号 → 輪郭 → canvas
フォントの本体(TrueType、CFF、Type1 の 3 種類)は、自前で解読して、輪郭を取り出しています。
- TrueType:2 次ベジェ。曲線の制御点が連続したら、中点を補う
- CFF:スタックに数値を積んで実行する小さな機械。最初の命令のとき、引数が 1 個多ければ、それが文字幅
- Type1:
eexec以降が暗号化されている(鍵は 55665 と 4330)
フォントが埋め込まれていないときは、Unicode に直して、システムのフォントで描きます。その場合は、警告と、状態の表示に「代用フォント」と出ます。
日本語の CMap を計算で求める
日本語の PDF には、90ms-RKSJ-H や UniJIS-UCS2-H のように、名前だけで CMap を指定しているものがあります。本来は、Adobe の CMap ファイル(数 MB)が必要です。
同梱したくなかったので、調べたところ、Adobe-Japan1 の CID は、JIS の区点から計算できる ことが分かりました。
if (row >= 16 && row <= 47) return 1125 + (row - 16) * 94 + cell - 1; // 第 1 水準
if (row >= 48 && row <= 83) return 4090 + (row - 48) * 94 + cell - 1; // 第 2 水準
非漢字(1〜7 区)は、未定義の点を詰めた小さな表で持っています。Unicode 系の CMap は、TextDecoder('shift_jis') に全コードを渡して、逆引きの表を作っています。
Adobe の表と突き合わせたところ、Shift_JIS・EUC・JIS 系は全コードが一致しました。Unicode 系は、JIS X 0208 の範囲が一致しています。
電子署名を確かめる
PDF の署名は、ファイルのうち /ByteRange で指定した範囲(ふつうは、署名の値そのものを除いた全体)に対して付けられます。
確かめているのは、次の 2 つです。
- 署名範囲のハッシュが、署名の中の値と一致するか(署名後に書き換えられていないか)
- 署名値が、証明書の公開鍵で検証できるか
署名の中身(CMS)は、小さな ASN.1 パーサで読んで、検証は WebCrypto に任せています。
const key = await subtle.importKey('spki', cert.spki, importAlg, false, ['verify']);
res.signatureOk = await subtle.verify(alg, key, sig, data);
署名の後に追記されているときは、「署名は有効・署名後に追記あり」と出ます。署名が有効なまま、見た目だけを変えられるので、ここは大事な確認です。
証明書そのものが信頼できるか(発行元の連鎖、失効)は、確かめていません。画面にも、そう書いてあります。
検証
描画が合っているかは、自分の目では分かりません。そのため、別の描画ソフト(poppler)の結果と、画素で比べました。
- 実在の PDF 590 ページ:559 ページがほぼ同一。例外は 0
- 生成した PDF 46 ページ(reportlab、matplotlib、TikZ、LibreOffice、Chrome、手書き):43 ページが差 2% 以下
差が大きいのは、埋め込まれていないフォント(代用フォントが違うので当然)と、パターンの細い線です。
解析のほうは、qpdf と突き合わせています。日本語の CMap は Adobe の表と、ファクス圧縮は libtiff の出力と比べました。
node で描画を試せるように、描画エンジンは canvas を外から受け取る作りにしています。
ハマりどころ
1. 引用符とダッシュが化ける(node だけ)
TextDecoder('windows-1252') が、node では 0x80〜0x9F を Latin-1 として返します。“ ” — が、制御文字になっていました。ブラウザでは起きませんが、テストが通らないので、32 個の表で持つことにしました。
2. matplotlib の PDF で、数字が全部違う文字になる
フォントの種類は「TrueType 系の CID フォント」なのに、中身は OpenType(CFF の輪郭)でした。「CFF だから CFF のやり方で引く」としていたのが原因です。
辞書が TrueType 系(CIDFontType2)と言っているなら、中身が何であっても、/CIDToGIDMap でグリフ番号にします。
3. グラデーションが、範囲の外まで塗られる
canvas のグラデーションは、両端の色を外側へ延ばし続けます。PDF で「延ばさない」と指定されているとき、同じ位置に透明の段を重ねても、効きませんでした。
本来の色を、ほんの少し内側(1e-5)にずらして、端に透明を置いています。
4. 細い線だらけの図が、真っ黒になる
「線は最低 1 ピクセル」にしていたら、地図のような細線の多い図が潰れました。幅 0 のときだけ 1 ピクセルにして、それ以外は指定どおりの太さ(下限 0.35 ピクセル)にしています。
5. 関数つきのメッシュで、途中の色が違う
三角形の頂点が色ではなく 1 つの値 t を持っていて、t を関数で色に直す形式があります。頂点で色に直してから補間していたら、途中の色が変わりました。t を補間してから、色に直すのが正しい順番です。
6. 日本語の「文字:」が ���� になる
ページの描画は正しいのに、ステータスに出す文字だけが化けていました。描画とは別の、古い簡易デコードを使っていたのが原因です。描画と同じフォント処理を通すようにしました。
これは、自分で画面を触って見つけました。
7. 別のファイルを開いても、前の差分が残る
REVISIONS の差分欄を、ファイルを開き直したときに閉じていませんでした。これも、触って見つけたものです。
まだ入れていないこと
- JBIG2 と JPEG 2000 の画像(枠と警告を出します)
- ICC プロファイルを使った正確な色
- 中国語・韓国語の、名前だけの CMap で、埋め込みフォントを引くこと
- 2 つの PDF の構造を比べる機能
- 黒塗りの失敗を、自動で見つけて DIAG に出す
- 書き出し(選んだ命令を消して保存する、など)
おわりに
このツールを、自分で使うことは、たぶんありません。PDF を作るソフトを書いている人や、壊れた PDF の原因を探す人のためのものです。
それでも、作ってよかったと思っています。黒塗りが四角を重ねただけだったこと、フォントが埋め込まれていないと見る人の PC のフォントで描かれること、保存のたびに古い版がファイルに残ること。どれも、触って初めて知りました。
調べてみると、PDF は 1993 年に出て、最初は流行らなかったそうです。閲覧ソフトを無料にして、仕様を公開したことで、広がりました。追記で保存する仕組みは、全体を書き直すのが遅かった頃の名残です。
いつもの流れですが、作る → 学ぶ → 歴史 の順で進みました。先に歴史から読んでいたら、たぶん途中でやめていました。
次は、この黒塗りの話を、PDF を知らない人向けのガイド記事にまとめる予定です。