技術記事

PDFのファイル構造: ヘッダー、本文、Xref、トレイラー

PDFリーダーはファイルの先頭から開始しません。ファイルの最後から開始します。最後の数バイトに他のすべてのもののアドレスが保持されており、その順序を理解していないパーサーは、最初の行からフォーマットを誤読します。そのため、ディスク上のPDFを学習する最も役立つ方法は、リーダーが行う方法で学習することです。つまり、尾部から始め、後ろにジャンプしてマップを参照し、マップが指し示すオブジェクトを解決します

何も圧縮されていない場合、バイト自体はテキストエディタで読むのに十分明確です。「Hello, World!」を描画する最小の1ページのドキュメントは500バイト未満に収まり、フォーマットのすべての構造要素がその中で確認できます。4つの部分がマークされたファイル全体を以下に示します:

%PDF-1.0                          % Header
%âãÏÓ

1 0 obj                           % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

常にファイルの下に向かってこの順序で4つの部分があります。ヘッダー、オブジェクトの本文、相互参照テーブル、およびトレイラーです。落とし穴は、それらをほぼ逆の順序で読むということです。ISO 32000-2 §7.5.1 は同じ4つの部分の解剖学を提示しており、後ろから前にアクセスする理由は純粋に実用的なものです。必要なオブジェクトに直接ジャンプするリーダーは、先頭からすべてのバイトをスキャンするリーダーよりもはるかに高速であり、そのランダムアクセスこそが、トレイラーと相互参照テーブルが提供するために存在しているものなのです

ヘッダーは2行であり、2行目が重要です

最初の行は %PDF-1.0 です。パーセント記号は構文上それをコメントにしますが、リーダーはそれをファイルシグネチャとして扱い、そこからバージョン番号を抽出します。実際のバージョン処理は緩やかです。PDF 2.0用に構築されたリーダーは、1.0を主張するファイルを喜んで開きますし、ほとんどのリーダーは、宣言されたバージョンが間違っているファイルや、バージョン行がバイトゼロではなくファイルの少し奥に埋もれているファイルを試行します。この番号は、どの機能を期待すべきかについてのヒントであり、ゲート(門)ではありません

2行目は、人々が誤って削除してしまい、デバッグに午後を費やしてしまうものです。これもコメントですが、そのペイロードはASCII 127以上の4バイトです。これらは、「テキストモード」でファイルを移動するものがそれをバイナリとして認識し、行末の書き換えを停止するように存在しています。PDFには圧縮されたストリームが含まれており、そのバイトが偶然にキャリッジリターンやラインフィードと一致することがあります。転送ツールがそれらを書き換えると、辞書に記録されているストリームの長さがディスク上のバイトと一致しなくなり、ファイルが破損します。この高バイトのコメントは、ASCIIモードでのFTPに対する40年前の防御策であり、それが防ぐ障害は静かで完全なものであるため、深刻なツールが書き込むすべてのファイルに現在でも存在しています

本文にはオブジェクトが保持され、それぞれに番号が付けられています

ドキュメントを構成するすべてのものは、インダイレクトオブジェクトのフラットなシーケンスとして本文に存在します。それぞれは2つの整数と obj キーワードで始まり、コンテンツを保持し、endobj で閉じます。上記のサンプルのオブジェクト1はページツリーノードです:1 0 obj、次に辞書、そして endobj。最初の整数はオブジェクト番号で、2番目は世代番号です。新しく書き込まれたファイルでは、世代はほぼ常にゼロです。編集全体でオブジェクト番号が再利用された場合にのみ増加しますが、これはまれであるため、ゼロ以外の世代は、ファイルが増分更新を経た兆候として扱うことができます。ここでのキーワード間のコンテンツは辞書であり、<<>> の間に書かれていますが、数値、文字列、配列、またはストリームである可能性もあります

これをリストではなくグラフにしているのは、参照トークン 2 0 R です。これは「ファイル内のどこにあろうとも、オブジェクト2、世代0」を意味します。上記のページツリーノードにはページが含まれていません。オブジェクト2を指しており、オブジェクト2は同じメカニズムによってリソースとコンテンツストリームを指しています。本文は書き手が便利だと感じた順序で配置され、参照がそれをカタログをルートとするツリーに縫い合わせます。ファイル内の位置には意味がありません。アイデンティティはオブジェクト番号から得られ、場所は相互参照テーブルから得られます

相互参照テーブルはバイトオフセットのインデックスです

xrefテーブルは、オブジェクト番号をファイル位置に変換するものです。これが、リーダーが1,000ページのドキュメントを開き、その前の849ページを解析することなく850ページ目をレンダリングできる理由です。各エントリは、ファイルの先頭からのバイト数で数えられ、オブジェクトが正確にどこから始まるかを記録します:

xref
0 6                  % 6 entries, starting at object 0
0000000000 65535 f   % entry 0: head of the free list
0000000015 00000 n   % object 1 begins at byte 15
0000000074 00000 n   % object 2 begins at byte 74
0000000192 00000 n   % object 3 begins at byte 192
0000000291 00000 n   % object 4 begins at byte 291
0000000409 00000 n   % object 5 begins at byte 409

固定幅は意図的なものです。すべてのエントリは正確に20バイトです。10桁のオフセット、スペース、5桁の世代、スペース、1文字のタイプ、および2バイトの行末。行が均一であるため、リーダーはスキャンするのではなく計算によってオブジェクト n のエントリに直接インデックスを付けることができます。そのため、本文へのランダムアクセスを提供するテーブル自体がランダムアクセス可能です。0 6 行はサブセクションヘッダーです。次のエントリが番号0から始まる6つのオブジェクトを記述していることを示しています

オブジェクト0は特別であり、常に存在します。そのタイプはフリーを表す f で、世代は 65535 であり、フリーオブジェクト番号のリンク付きリストの先頭にあります。編集されたことがないファイルでは、フリーリストはこの1つのエントリだけであり、形式的なものです。それは増分更新中にその役割を果たします。オブジェクトを削除するとその番号がそのリストに追加され、後続の編集でそれを再利用できるようになります。他のエントリは使用中を表すタイプ n であり、その10桁の数字は、そのオブジェクトの定義を読み取るためにシークするオフセットです

トレイラーはエントリポイントであり、最後に配置されます

トレイラーは、最後に書かれているにもかかわらず、リーダーが実際に最初に消費するものです。パーサーはファイルを開き、最後までシークし、後ろ向きに歩きながら %%EOF を探します。そのすぐ上に startxref があり、その後に単一の数字が続きます。その数字は xref キーワードのバイトオフセットです。これにより、リーダーは単一のオブジェクトをスキャンすることなく、相互参照テーブルに直接ジャンプします:

trailer
<<
/Root 5 0 R          % the document catalog
/Size 6              % one more than the highest object number
>>
startxref
459                  % byte offset of the xref table
%%EOF

トレイラー辞書には、リーダーが他のことを行う前に必要とする2つの値が含まれています。/Root はドキュメントカタログ(ここではオブジェクト5)を指します。これはオブジェクトグラフのトップであり、ページツリーへのルートです。/Size は相互参照テーブルに含まれるべきエントリの数であり、スロットゼロのフリーエントリのため、最も大きいオブジェクト番号よりも1つ多くなります。%%EOF から読み取りシーケンス全体が始まります。マーカーを見つけ、startxref を読み取ってテーブルを見つけ、テーブルを読み込んで各オブジェクトがどこにあるかを学習し、/Root を読み取ってカタログを見つけ、そこからオンデマンドでオブジェクトを解決します。一番上にあるヘッダーは、後半になるまでほとんど参照されません。一番下にあるマップこそが、リーダーが最初に必要とするものです

増分更新は書き換える代わりに2つ目のマップを追加する

その尾部優先の設計は、ファイルが変更されたときに報われます。PDFは、すでにディスク上にあるバイトを一切書き換えることなく編集できます。新規および変更されたオブジェクトが最後に追加され、その後に新しい相互参照セクションと新しいトレイラーが続き、下にある元のファイルはそのまま残されます。新しいブックキーピングの1つの要素は、新しいトレイラー内の /Prev エントリであり、以前の相互参照テーブルのバイトオフセットを保持します:

% ... original file, unchanged, ends here ...

6 0 obj                          % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % a second xref section, for the new object only
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % byte offset of the earlier xref table
>>
startxref
680                              % offset of this new xref section
%%EOF

リーダーは依然として最後の %%EOF から開始し、依然として startxref をたどって最新のテーブルに到達しますが、今回は /Prev チェーンを後方にたどって古いテーブルに移動し、任意のオブジェクト番号の最新のエントリが優先されるようにそれらをマージします。相互参照セクションはファイル全体にリンクされたリストを形成し、それぞれが触れるオブジェクトについて前のものをオーバーライドします。編集によって置き換えられたオブジェクトは、古いオフセットにまだ物理的に存在しています。後の xref エントリが新しい場所を指しているため、単に到達できなくなっているだけです

これが、署名されたPDFを検証可能にするメカニズムです。デジタル署名はファイルのバイト範囲をカバーしており、増分更新は常に追加のみを行うため、署名されたバイトが移動することはありません。以降のリビジョンが独自の xref とトレイラーを持ってその先に配置されている間も、署名は元の範囲に対して引き続き検証されます。これは、PDFが回復可能な履歴を保持できる理由でもあります。置き換えられたすべてのオブジェクトは、以前の相互参照セクションの下のディスク上にまだ存在します。これはバージョン追跡のための機能ですが、「削除」とはバイトが消え去ったことだと考えていた人にとってはリスクとなります

その代償は増大です。各編集は追加を行い、その場で再利用されるものは何もないため、何度も改訂されたファイルには死んだオブジェクトと xref セクションの長いチェーンが蓄積されます。解決策は完全な書き換えです。ドキュメントを読み込んで新しく保存します。これにより、残ったオブジェクトの番号が付け直され、到達できないオブジェクトが削除され、単一のクリーンな相互参照テーブルが出力されます。2つの戦略は直接トレードオフの関係にあります。追加は高速で、署名と履歴を保持します。書き換えは遅く、コンパクトなファイルと引き換えに両方を破棄します

実際に4つの部分を読む

レイアウトを知っていれば、「このファイルが開かない」という問題のほとんどを手作業でデバッグするのに十分です。リーダーがPDFを拒否する場合、通常の原因は真ん中ではなく、両端にあります。切り捨てられたダウンロードはトレイラーを失うため、startxref または %%EOF が欠落し、リーダーにはエントリポイントがありません。寛容なリーダーはファイル全体をスキャンして xref を再構築するようにフォールバックしますが、これこそがテーブルが回避することを目的としていた遅いパスです。失敗したテキストモードの転送はストリームバイトを破損したり、オフセットが現実と一致しなくなったりして、オブジェクトが間違った位置から読み込まれます。テーブル内のオフセットが実際の obj キーワードを指さなくなった場合、各オブジェクトが個別に問題なくても、ファイルは構造的に壊れています

新しいコードの場合、レイアウトの教訓は、ライブラリにバイトのブックキーピングを任せることです。相互参照テーブル内のオフセットは、すべてのオブジェクトの実際のバイト単位の位置と一致しなければならず、トレイラーは正しいテーブルを指していなければならず、増分更新は /Prev を通じて正しくチェーンしなければなりません。Delphi および C++Builder 向けの HotPDF Component のようなネイティブコンポーネントは、ファイルを書き込むときにこれらすべてを処理し、増分リビジョンを追加するかコンパクトなものを書き換えるかの選択も含みます。解剖するのではなく、同じ構造が無から構築されるのを見たい場合は、ゼロからのPDFドキュメントの構築に関する記事で、ヘッダー、オブジェクト、xref、トレイラーを順に出力する手順を説明しています