PDFのカタログ辞書には、正確に1つの必須ナビゲーションキー /Pages があります。このキーは /Pages 型のインダイレクトオブジェクトを指している必要があり、そのオブジェクトには /Kids 配列とページの合計 /Count が保持されています。そのポインターを取り除くと、準拠しているリーダーはファイル内の単一のページを見つけることができなくなります。ISO 32000-1 §7.7.2 はこの点について明確です。カタログには /Pages エントリが必要であり、参照されるオブジェクトは /Pages 型でなければなりません。この要件に違反するファイルは、単に非準拠であるだけでなく、ほとんどのパーサーが適切に処理できないような形で構造的に壊れています
仕様書が実際に述べていること
最小の準拠PDFには少なくとも3つのオブジェクトがあります。オブジェクト1はカタログ、オブジェクト2はPagesのルート、オブジェクト3以降は個々のPage辞書です。カタログはPagesのルートを指し、Pagesのルートはその子を /Kids にリストします。各ページには /Parent への後方参照が含まれています。チェーン全体は設計上双方向であるため、パーサーはどちらの端からでも開始し、バランスの取れたツリーであれば O(log n) の時間で任意のページに移動できます
% Minimal conforming structure (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << >> >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj
Pagesツリーはネストさせることができます。何千ページもあるドキュメントでは、通常、ページを中間ノードオブジェクトにグループ化し、それらも /Pages 型を持ち、それぞれに独自の子 /Kids と、その下のサブツリーを反映した /Count を持ちます。ルートノードの /Count は常に総ページ数と等しくなります。1ページも解析する前にビューアがページ番号フィールドに表示するのはこのカウントです。オブジェクト2から1つの整数を読み取る方が、ツリー全体をたどるよりもはるかにコストが低いためです
Pagesがないファイルの様子
Pages辞書が欠落しているファイルは、ページをツリーに組み立てることなくページオブジェクトを直接書き込むPDFジェネレータ、またはリーフのPageオブジェクトをそのままにしてルートノードを削除する破損に由来することが一般的です。このようなファイルのカタログには、/Pages キーが完全に欠落しているか、相互参照テーブルに存在しなくなったオブジェクトへの参照が保持されています
% Non-conforming: Catalog with no /Pages reference
1 0 obj
<< /Type /Catalog >>
endobj
% Page objects exist but are unreachable from the Catalog
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj
15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj
25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj
仕様に従うパーサーは、カタログを読み取り、/Pages の解決を試み、何も見つからない(または死んだ参照)場合、エラーを発生させるかゼロページを報告します。してはならないことは、ファイルにページがゼロであるかのように続行し、静かに成功することです。これにより、自動化ツールには正しく見え、それを開くすべての人間には間違って見える空白の出力が生成されます
パーサーがクラッシュする理由
ほとんどのPDFパーサーは、Pagesルートからの /Count 値に基づいて、読み込み時に内部ページテーブルを割り当てます。そのルートが存在しない場合、パーサーはゼロを読み取り、何も割り当てず、コードが最初にページ1を要求したときにヌルポインタを参照解除するか、ゴミを読み取って非常に不正確なバッファを割り当てます。どちらの結果も優雅ではありません。このようなファイルを処理した際のクラッシュログに表示される 0x008E5D78 でのアクセス違反は、まさにこれです。パーサーが常に存在すると想定していた構造が存在しないことによって引き起こされる、ページアクセスパス内のヌルポインタ参照解除です
基礎となる設計の仮定は妥当です。存在するPDFの大部分にはPages辞書があります。少数の命令を節約するために存在チェックをスキップするパーサーは無謀ではありません。それらは一般的なケースに合わせて最適化しています。その最適化を台無しにするファイルは非常にまれであるため、本番コードでは遭遇することがないかもしれませんが、一度遭遇すると、エンジニアが §7.7.2 を読んでいない場合、クラッシュは再現可能であると同時に不可解なものになります
Pagesツリーなしでの回復
パーサーがこれらのファイルを拒否するのではなく処理する必要がある場合、回復は予測可能なパスに従います。相互参照テーブル内のすべてのインダイレクトオブジェクトをスキャンし、/Type /Page を持つものを収集し、オブジェクト番号で並べ替えます。仕様ではオブジェクト番号の順序が読み取り順序と一致することは保証されていませんが、実際には、Pagesツリーを省略するジェネレータはページを順番に出力する傾向があるため、オブジェクト番号の順序が正しいことがほとんどです
チェック自体は低コストです。カタログの /Pages ポインターをたどる前に、ポインターが存在すること、実際のオブジェクトとして解決されること、および解決されたオブジェクトの /Type が /Pages と等しいことを確認します。これら3つの条件のいずれかが満たされない場合は、リニアスキャンにフォールスルーします。大きなドキュメントの場合、バランスの取れたパスをたどるのではなく、すべてのオブジェクトヘッダーを読み取るため、スキャンはツリー探索よりも遅くなりますが、機能します。すでに不正な形式になっているファイルの場合、正確さは速度に勝ります
リニアスキャンでは自動的に解決されないエッジケースの1つが、ページの順序です。順序を定義する /Kids 配列がない場合、「正しい」順序は仕様では未定義です。オブジェクト番号の順序は実用的なデフォルトです。ファイルを慎重に処理するほど重要な場合は、Pageオブジェクトに明示的な /StructParents があるか、読み取り順序を暗示する注釈の参照があるかを確認するだけの価値があります
PDFジェネレータへの影響
パーサーではなくPDFジェネレータを作成する人にとって、教訓は限られています。ファイルを閉じる前に常にPagesルートを出力することです。/Pages エントリのないカタログは、仕様のどのリビジョンにおいても有効なPDFではありません。ページオブジェクトをオンザフライで構築し、ファイナライズ時にツリーを組み立てるジェネレータ(ほとんどのストリーミングライターが使用するアプローチ)は、ファイナライズが実際に実行される限り問題ありません。よくある障害モードは、トレイラーが完了する前に書き込みを中断する例外または早期の戻りであり、回復のヒューリスティクスを持つ一部のビューアでは開くが、持たない他のビューアでは失敗するファイルが残されます
PDF/A および PDF/UA は、ベース仕様の要件を超えてページツリーに追加の制約を課しますが、どちらも /Pages の要件を緩和するものではありません。ISO 19005 または ISO 14289 への準拠をチェックするバリデーターは、プロファイル固有のルールに到達する前に、ベース仕様違反として欠落しているPages辞書を検出します