[Astro] #162 PDF Inspector — PDFの中身を覗くツールを作成

[Astro] #162 PDF Inspector — PDFの中身を覗くツールを作成

はじめに

前回(#161)は、過去作品のギャラリーページでした。

今回は、PDF の中身を覗くツール(/pdf-inspector) です。

AST Inspector、WASM Inspector に続く「中身を覗く」シリーズの 3 作目です。PDF の内部を見るツールは、デスクトップアプリには良いものがあります(UniDoc PDF Debugger、iText RUPS など)。ブラウザだけで動いて、同じことができるものを目標にしました。

正直に書くと、作る前は PDF に興味がありませんでした。作ってみて一番驚いたのは、黒塗りの下の文字が見えた ことです。

要点は次の 3 つです。

  1. オブジェクト・描画命令・16 進ダンプを、バイト範囲で連動させる
  2. ページを自前で描いて、命令を 1 つずつ進められるようにする
  3. 壊れた PDF・追記された PDF・署名された PDF を、原因の位置つきで調べる

以降は、実装の全体を簡単に残しておきます。量が多いので、細かいところは省いています。

完成画面

PDF Inspector — PAGE タブでステップ実行

黒塗りの下が見える

スライダーを戻すと、黒塗りの下の金額が見える スライダーを戻すと、黒塗りの下の金額が見える

追記の差分

REVISIONS タブ — 後から足された 2 行が緑で出る

操作動画

操作動画

タブは 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 つです。

  1. 署名範囲のハッシュが、署名の中の値と一致するか(署名後に書き換えられていないか)
  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 を知らない人向けのガイド記事にまとめる予定です。