オブジェクト番号1はページ1ではありません。その単一の事実が、フォーマットの他のどの側面よりも多くのPDF処理コードをつまずかせます。その理由を理解するには、ビューアが表示するものだけでなく、ビューアが実際に読み取るオブジェクトグラフを調べる必要があります
PDFファイルは、番号付けされた間接オブジェクトのコレクションです。各オブジェクトはオブジェクト番号と生成番号を持ち、他のオブジェクトは N G R と書かれた参照でそれを指し示します。たとえば、3 0 R はオブジェクト3の現在のバージョンを意味します。ページはそれらのオブジェクトの中に含まれていますが、その表示順序は、ファイル内のどこにあるかや、どのような番号を持っているかとは関係ありません。表示順序は、ドキュメントカタログをルートとするリンク構造である /Pages ツリーによって完全に決定されます。ツリーを無視してオブジェクトを数値順にスキャンすると、実際のファイルの大部分で間違った順序でページを組み立てることになります
ページツリー:実際に順序を設定するもの
すべてのPDFはドキュメントカタログ(ISO 32000-2 §7.7.2)から始まります。カタログは、ページツリーのルートノードを指す /Pages エントリを保持します。そのルートノードは、/Type /Pages、間接参照の /Kids 配列、およびその下にあるリーフ(葉)ページの総数を示す /Count を持つ辞書です。表示順序は、そのツリーの深さ優先の左から右へのトラバーサル(走査)であり、それだけです
最小限の3ページのファイルでこれを具体化します
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [20 0 R 4 0 R 9 0 R] /Count 3 >>
endobj
% Object 4 is stored third in the file but is page 2 in display order
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 9 is stored fourth but is page 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 20 is stored last but is page 1; Kids[0] decides, not object number
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
/Kids 配列は [20 0 R 4 0 R 9 0 R] と読み取られるため、オブジェクト20はページ1、オブジェクト4はページ2、オブジェクト9はページ3です。オブジェクトの番号付けは無関係です。オブジェクトを数値順に反復処理し、/Type /Page を持つものを収集するコードは、このファイルで間違った順序を生成します
なぜジェネレーターは非連続的なレイアウトを生成するのでしょうか?いくつかの理由があります。コンテンツを書き込む前にすべてのページのオブジェクト番号を事前に割り当てるライブラリは、作成順に番号を付け、その後シリアライザーに合った順序で実際のバイトを書き込みます。ドキュメントをつなぎ合わせるマージツールは、衝突を避けるために各ソースドキュメントのオブジェクト番号を付け直します。番号が付け直されたページオブジェクトは結合されたオブジェクトテーブル全体に散らばりますが、新しいルートの /Kids 配列は正しい表示順序を保持します。増分更新(Incremental updates)は、新しい番号の新しいオブジェクトをファイルの末尾に追加するため、リビジョンとして追加されたページは、表示順序の1番目に属していても、バイトストリームの最後の方に存在します
フラットなツリーとネストされたサブツリー
仕様では、ページツリーの2つの形状が許可されています。単純なジェネレーターはフラットな構造を生成します。1つのルート /Pages ノードがあり、その /Kids 配列には /Page リーフオブジェクトのみが含まれます。これは簡単にトラバースできます。1レベルの深さで、1回のパスです
大規模なドキュメントでは、代わりにバランスの取れたツリー(平衡木)が日常的に使用されます。ルートの /Pages ノードの /Kids 配列には中間の /Pages ノードが含まれ、それぞれが独自の /Kids 配列を保持します。各中間ノードの /Count は、そのサブツリー内のリーフページの総数を報告するため、インデックスでページにジャンプするときに、すべてのオブジェクトを解析しなくても、ビューアはサブツリー全体をスキップできます。リーフノードごとに10ページを持つバランスツリーとして構造化された1,000ページのドキュメントは、750個の /Kids エントリをスキャンするのではなく、3〜4回の辞書ルックアップによる二分探索でページ750を特定できます
処理コードに対する結果:/Kids の最初のレベルに /Page オブジェクトが含まれていると想定することはできません。各子をチェックする必要があります。その /Type が /Pages の場合は、再帰的に処理します。その /Type が /Page の場合は、リーフです。最初のレベルで停止すると、ジェネレーターがネストすることを選択したドキュメントでサブツリー全体が静かに欠落します。なぜライターがそもそも深いツリーを選択するのか、フラット化ツールが何を諦めているのか、そして /Count の破損が実際にはどのように影響するかについては、ページツリーの形状、ファンアウト、および /Count の整合性に関する姉妹記事で説明されています
継承されたページ属性
ページツリーには、リソース共有のメカニズムも備わっています。/MediaBox、/CropBox、/Resources、および /Rotate などの特定のページ属性は継承可能です(ISO 32000-2 §7.7.3.4)。/Page 辞書がそのうちの1つを省略している場合、リーダーはその属性を見つけるかルートに到達するまで /Parent チェーンを遡ります。すべてのリーフページにコピーするのではなく、ルートの /Pages ノードに共有フォント辞書を配置することで、同じ書体を全体で使用するドキュメントのファイルサイズを著しく減らすことができます
この継承規則は、ページプロパティを読み取るコードに微妙な問題をもたらします。/Page オブジェクトから /MediaBox を直接読み取り、欠落しているキーをエラーとして扱うのは間違いです。キーは単に継承されている可能性があります。ページのジオメトリを正しく解決するコードは、親チェーンをたどる必要があります。また、サイクルガードも必要です。破損したファイルでは、すでにアクセスしたノードを指す /Parent 参照がある可能性があり、アクセス済みオブジェクトのチェックがないと無限ループに陥ります
xrefテーブルと相互参照ストリーム
間接オブジェクトの検索は、相互参照テーブル(またはPDF 1.5で導入されたその後継である相互参照ストリーム)を介して行われます。xrefは、各オブジェクト番号をファイル内のバイトオフセットにマッピングします。準拠するリーダーはxrefを使用して任意のオブジェクトに直接ジャンプし、ファイルを順番にスキャンすることはありません。そのランダムアクセス設計により、高速なページジャンプが可能になります。ビューアはカタログを読み取り、xrefを介して /Pages 参照を解決し、ルートの /Pages ノードを読み取り、/Kids エントリを解決する、というように、必要なオブジェクトのみに触れます
増分更新(Incremental updates)では、前のセクションにチェーンでつながるトレーラーとともに、ファイルの最後に新しいxrefセクションが追加されます。リビジョンで更新されたオブジェクトは、追加されたxrefセクションに新しいエントリを取得します。元のバイトはそのまま残りますが、置き換えられます。これが、注釈やフォーム入力のリビジョンが追加された後でもデジタル署名されたPDFが検証可能なままである方法です。署名されたバイト範囲には触れられず、新しいコンテンツは追加されたセクションに存在します。ページツリーも更新できるため、リビジョンでのページの追加や削除により、改訂された /Kids 配列を持つ新しい /Pages ルートが生成されますが、古いルートオブジェクトは引き続きファイル内の元の位置を占めます。リニアライズド(Web最適化)ファイルでは、バイトレイアウトに工夫が加えられています。ページ1のオブジェクトが物理的にファイルの先頭に移動されるため、残りの部分のダウンロード中であってもビューアは最初のページを表示できますが、ページツリーが順序に関する唯一の権威であることに変わりはありません。変更されるのはxrefに記録されたオフセットだけです
ツリートラバーサルがないと何がうまくいかないのか
オブジェクトスキャンアプローチの障害モードは静かです。出力ドキュメントはもっともらしく見えます。正しいページ数があり、各ページに認識可能なコンテンツが含まれています。単に順序が間違っているだけであり、その間違い方は、ジェネレーター、リビジョンの数、および外部ソースからマージされたページがあるかどうかによって異なります。単一のツールで作成されたファイルのテストコーパスは完全にパスする可能性がありますが、別のツールやマージワークフローからのファイルは失敗します。この不整合が、ヒューリスティックな修正が決して持続しない理由です。実際の顧客ドキュメントでのまさにこの障害のチュートリアル(症状、誤診、トラバーサルによる修正)については、ページ順序デバッグのケーススタディをご覧ください
増分更新ファイルは特にこれに陥りやすい傾向があります。後のリビジョンで追加または再配置されたページは高いオブジェクト番号を持つ一方で、表示順序は更新された /Kids 配列によって制御されるためです。オブジェクトを数値順に処理するスキャンでは、ツリーで指定された場所に関係なく、後から番号付けされたそれらのページを最後に配置してしまいます
修正は複雑ではありません。カタログから開始し、/Pages 参照を解決し、/Kids 配列を再帰的にたどり、遭遇した順序でリーフを出力します。オブジェクト番号、バイトオフセット、またはファイル構造に関係なく、それが定義上表示順序です。最も成熟したPDFライブラリは、これをすでに正しく実行するページ数とインデックス付きページアクセサーを公開しています。リスクは、ライブラリのページモデルをバイパスしてオブジェクトレイヤーに直接触れるコードにあります
明示的に処理する価値のある構造的な異常が1つあります。中間 /Pages ノードの /Count 値は、不正な形式のファイルでは間違っている可能性があります。境界チェックのために /Count を信頼し、完全なトラバーサルの手前で停止すると、カウントが少なく見積もられている場合にページが静かに欠落します。/Count を容量の事前割り当てや二分探索のパフォーマンスのヒントとしてのみ使用し、実際のカウントはトラバーサルから導出することが、重要なドキュメントにおけるより安全なパターンです