ダウンロードの途中で切れた PDF は、ほぼ全ページが届いていても開きを拒むことがあります。その一方で、2,000 ページのマニュアルは 1,500 ページ目へ一瞬で跳びます。この 2 つはどちらも、HotPDF Delphi Component が書き出すあの PDF ファイル構造、つまりヘッダー、番号付きオブジェクトのボディ、バイトオフセットの相互参照(xref)テーブル、トレイラーから帰結します。最後の 2 つはファイル末尾に置かれます
ISO 32000-2 §7.5.1 は、1 ページのレシートからスキャンアーカイブまで、すべての PDF に同じ 4 部構成のアナトミーを与えています。パートは常にファイル中で同じ順序に並び、固定の鎖で結ばれます。トレイラーがカタログを名指しし xref テーブルの始点を告げ、xref テーブルが各オブジェクトの始点を告げ、オブジェクト同士は番号で互いを指します。以下の抜粋は、オフセットを 1 バイトずつ検証した 610 バイトの Hello World ファイルから来ています。キーワードごとに注釈を付けた完全なファイルはPDF の中身の注釈付き実例に、ビューアがこれらのパートをピクセルに至るまでどう扱うかはリーダーが PDF を開くときの仕組みにあります
PDF のヘッダーは 2 行あり、2 行目が肝心
最初の行、たとえば %PDF-1.7 は、構文上はコメントにすぎませんが、リーダーはこれをファイルシグネチャとして扱い、バージョンをここから取り出します。バージョンの扱いは実務では寛容なので、この番号はどの機能を期待すべきかのヒントと捉えてください。2 行目のほうが、うっかり削除してその後の午後をデバッグに費やすはめになる行です。中身は ASCII 127 を超える 4 バイト以上(ISO 32000-1 §7.5.2)で、テキストモードでファイルを運ぶものがバイナリと認識し、行末の書き換えを止めるために存在します。圧縮ストリームには、偶然キャリッジリターンやラインフィードと一致するバイトが含まれます。これを書き換えると、辞書に記録されたストリーム長がディスク上のバイトと一致しなくなります。それが防ぐ障害は静かで全面的なものなので、まじめなライターは今でもこのコメントを出力し続けます
ボディ:番号付きオブジェクトと 8 つのオブジェクト型
ボディは間接オブジェクトのフラットな並びで、それぞれがオブジェクト番号と世代番号を伴う N G obj ... endobj というラッパーに包まれます。このフラットなリストを PDF の内部構造へ変えているのが参照トークン N G R です。2 0 R は「オブジェクト 2、世代 0、ファイル内のどこに在ろうと」という意味です。任意の値はその場に書かれるダイレクトオブジェクトにもできます。抜粋では、ページの /Resources 辞書はダイレクトで、そこに名指されたフォントはインダイレクトです。つまり 100 ページがコピーを 100 個担う代わりに、オブジェクト 4 を共有できるのです
1 0 obj
<< /Type /Catalog /Pages 2 0 R >> % catalog はページツリーを指す
endobj
...
3 0 obj
<< /Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources << /Font << /F1 4 0 R >> >> % ダイレクト辞書、インダイレクトフォント
/Contents 5 0 R
>>
endobj
ISO 32000-1 §7.3 は語彙を短く保っています。PDF のすべての値は 8 つの基本型のいずれかです。Boolean と数値がスカラーで、PDF に指数表記はないため、適合ファイルに 1e6 が現れることはありません。文字列はバイトを保持し、(Hello, World!) または 16 進の <48656C6C6F> と書きます。/Type や /Helvetica のような名前はバイト単位で比較されるため、/BaseFont が名前を期待する場所に (Helvetica) と書くと、文字は一致していても型の誤りです。配列は MediaBox のように、何でも順序付きで保持します。辞書は名前から値へのマップで、構造の大部分を担います。ストリームは辞書と生バイトの組み合わせで、ページコンテンツ、フォント、画像の住処です。8 番目の型は null です。存在しないオブジェクトへの参照は、エラーを上げる代わりに null へ解決されます。壊れたファイルが即死せずに劣化して動き続けられるのはこのためです
世代番号は再利用のためにあります。編集がオブジェクトを削除すると、その番号はフリーリストへ入り、後の編集がその番号を再利用すれば世代が 1 つ上がります。そのため、古くなった 12 0 R が新しい 12 1 obj へ静かに解決されることはありません。目にするオブジェクトはほぼすべて世代 0 です。解決はカタログから始まります。抜粋ではオブジェクト 1 がそれで、その /Pages エントリがページツリーへ続いています。内部ノードはページを /Kids に集め、/Count を保持し、/MediaBox のような属性を子孫へ渡せます。物理的なオブジェクト順ではなくこのツリーがなぜページ順を決めるのかは、PDF ページツリーとページ順序で扱っています
xref テーブルはどうオブジェクトを探し当てるのか
xref テーブルは算術でオブジェクト番号をバイトオフセットへ変換します。2,000 ページのマニュアルが、それより前のページを解析せずに 1,500 ページ目で開けるのはこのためです。ISO 32000-1 §7.5.4 はすべてのエントリをちょうど 20 バイトに固定します。ファイル先頭から数えた 10 桁のオフセット、スペース、5 桁の世代、スペース、タイプ文字、そして 2 バイトの行末(LF ならスペースとラインフィード)です。行が均一であるため、リーダーはオブジェクト n の行へ直接インデックスできます。0 6 行はサブセクションヘッダーで、エントリ 0 は常に世代 65535 のフリーとして、フリーオブジェクト番号の連結リストの先頭を務めます。それ以外のオフセットはすべて、対応する N G obj ヘッダーの最初の桁に着地しなければなりません。610 バイトのファイルでは次のとおりです
xref
0 6 % 6 エントリ、オブジェクト 0 から開始
0000000000 65535 f % エントリ 0:フリーリストの先頭
0000000019 00000 n % オブジェクト 1(catalog)はバイト 19 から開始
0000000068 00000 n % オブジェクト 2(ページツリー)はバイト 68 から開始
0000000125 00000 n % オブジェクト 3(ページ)はバイト 125 から開始
0000000263 00000 n % オブジェクト 4(フォント)はバイト 263 から開始
0000000333 00000 n % オブジェクト 5(コンテンツストリーム)はバイト 333 から開始
切り詰められた PDF が開かないのはなぜか
切り詰められた PDF が開かないのは、すべてを結び付ける 2 つのパート、xref テーブルとトレイラーが、最後に書かれたバイトだからです。トレイラー辞書は、カタログを指す /Root と、xref エントリ数を表す /Size を運びます。スロット 0 がフリーエントリを保持するぶん、この数は最大オブジェクト番号より 1 つ多いです。その下に、xref キーワードのバイトオフセットを与える startxref と、ファイルを閉じる %%EOF が置かれます
trailer
<< /Size 6 /Root 1 0 R >> % エントリ数、ドキュメントカタログ
startxref
427 % xref キーワードのバイトオフセット
%%EOF
最後の数百バイトを失うと、ファイルはオブジェクトを持っていても、それへの地図を持ちません。厳格なリーダーはそこで止まり、寛容なリーダーはファイル全体を N G obj ヘッダーで走査してテーブルを再構築します。xref が存在して避けたかった遅い経路であり、再構築された地図はそれでもオブジェクトを見逃したり、編集済みオブジェクトの間違ったコピーを拾ったりし得ます。テキストモードの転送は、行末の書き換えがすべてのオブジェクトをずらすため、もっと静かな被害をもたらします。実際に 610 バイトのファイルを CRLF 行末へ変換して qpdf --check(qpdf 12.3.2)を実行したところ、オフセット 427 で「file is damaged」「xref not found」と報告され、何かを検査する前に走査でテーブルを再構築しました
古典的な xref テーブルか、クロスリファレンスストリームか
PDF 1.5 以降、xref はクロスリファレンスストリーム(ISO 32000-1 §7.5.8)にもなれます。/Type /XRef の間接オブジェクトで、バイナリ行がテキストテーブルを置き換え、ストリーム辞書がトレイラーのエントリを吸収します。trailer キーワードは消え、startxref はそのオブジェクトを指します。行タイプは数値になり(0 がフリー、1 がバイトオフセット、2 がオブジェクトストリーム内)、/W が各列のバイト幅を与えます。HotPDF は行を Flate と PNG Up プレディクタで圧縮します。Version が pdf15 以降のときストリーム形式を書くため、例では明示的に設定し、UseObjectStreams は同じスイッチの上に成り立ちます。PDF 1.4 時代のツールで開く必要があるファイルには xref ストリームをまったくパースできないため UseXRefStream をオフのままにし、コンパクトなインデックスが報酬を払う大文書ではオンにします。変わるのはファイルの末尾だけです
uses
System.SysUtils, HPDFDoc;
procedure SaveWithXRef(const FileName: string; AsStream: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.FileName := FileName;
PDF.Version := pdf17; // xref ストリームには PDF 1.5 以降が必要
PDF.UseXRefStream := AsStream; // 既定は False:テキストテーブルとトレイラー
PDF.BeginDoc;
PDF.CurrentPage.TextOut(72, 72, 0, 'Same page, different index');
PDF.EndDoc;
finally
PDF.Free;
end;
end;
// SaveWithXRef('table.pdf', False);
// 期待される末尾:xref、0 N、20 バイトの行、
// trailer << /Size /Root /Info /ID >>、startxref、%%EOF
// SaveWithXRef('stream.pdf', True);
// 期待される末尾:N 0 obj << /Type /XRef /Size /Root /Info /ID /W ...
// /Filter /FlateDecode >> stream ... endstream endobj、startxref、%%EOF
増分更新は書き直す代わりに 2 つ目の xref を追記する
PDF は、ディスク上にすでにあるバイトを 1 つも触れずに編集できます。変更・新規のオブジェクトが追記され、それらだけを列挙する xref セクションと、前のテーブルのオフセットを /Prev に持つトレイラーが続きます。リーダーは startxref で最新セクションへ行き、/Prev チェーンを後ろへたどり、各オブジェクト番号の最新エントリを勝たせます。以下の編集は 610 バイトのファイルへノートアノテーションを追加します。ページオブジェクト 3 は同じ番号で /Annots 配列付きに書き直され、オブジェクト 6 が新規です。コメントを除き LF 行末ならオフセットは正確で、qpdf --check はエラーなしで結果を通りました
% ... 元の 610 バイト、無変更、ここまで ...
3 0 obj % ページ 3 を同じオブジェクト番号で置き換える
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R] >>
endobj
6 0 obj % 新しいアノテーション
<< /Type /Annot /Subtype /Text /Rect [72 650 92 670] /Contents (Checked) >>
endobj
xref % 1 エントリのサブセクションが 2 つ
3 1
0000000610 00000 n
6 1
0000000752 00000 n
trailer
<< /Size 7 /Root 1 0 R /Prev 427 >>
startxref
843
%%EOF
追記こそが、署名付き PDF を検証可能に保つ仕組みです。署名はバイト範囲をカバーし、増分更新はそのバイトを決して動かしません。PDF が復元可能な履歴を運ぶ理由でもあります。古いページ 3 はバイト 125 に、到達不能のまま居座っているのです。何度も改訂されたファイルは長い /Prev チェーンを育てます。qpdf revised.pdf flat.pdf は到達可能なオブジェクトを単一の xref の下で再シリアライズし、古いリビジョンを捨てます。上のファイルでは 1 つの xref テーブルだけが残り /Prev はなくなりました。元のバイトへの署名を失うのがその代償です。HotPDF では、増分リビジョンは次のようになります
procedure ReviseInPlace(const Original, Revised: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.BeginIncrementalUpdate(Original); // 元のバイトを 1 バイトも変えずに保持する
PDF.SetLoadedTitle('Quarterly report, revised');
PDF.SetLoadedAuthor('Finance');
PDF.SaveIncrementalUpdate(Revised); // 変更オブジェクト、新しい xref、/Prev 付きトレイラー
finally
PDF.Free;
end;
end;
4 部構成のレイアウトは、調子の悪い PDF への手早いトリアージ順も与えてくれます。ファイルが %%EOF で終わり、もっともらしい startxref を持つことを確認し、xref オフセットの 1 つか 2 つが N G obj ヘッダーに着地することを確かめ、それから /Root をたどってカタログとページツリーへ入ります。Delphi と C++Builder 向けのHotPDF Delphi Component は、要求に応じて古典的な xref テーブルかクロスリファレンスストリームを書き分け、以前のバイトと、その上の署名を無傷のまま残す増分リビジョンを追記します