Microsoft WordやExcelで作成されたPDFを開き、ページをめくっても、変わったところは何も見つかりません。それをDelphiプログラムに読み込み、ページ数を取得すると、正しい数値が返されます。しかし、暗号化を有効にして再保存しようとすると、処理がEListErrorで失敗するか、出力ファイルを開いたときに相互参照テーブル(cross-reference)の破損警告が表示されます。ファイル自体は破損していたわけではありません。それはハイブリッド参照ファイルであり、15年前のビューアでも開くことができるまさにその構造が、読み込みを途中で打ち切ってしまうローダーを失敗させる原因となっているのです。
これは、すべての内部テストに合格したPDFパイプラインが、ラウンドトリップできないファイルに遭遇する最も一般的な状況の1つです。テスト用入力ファイルはすべて自社で生成されたものであるため、ハイブリッド参照ではありませんでした。最初のハイブリッド参照ファイルは、顧客からスプレッドシートからエクスポートされた請求書が転送されてきた日にやってきます。
WordとExcelが実際に出力しているもの
ISO 32000-1は、§7.5.8.4でハイブリッド参照レイアウトについて説明しています。オブジェクトストリームなどのPDF 1.5機能を使用しつつ、PDF 1.4リーダーでもファイルを開けるようにしたいアプリケーションは、相互参照情報を2回書き込みます。すなわち、バージョン1.4までのすべてのPDFの末尾にあった固定幅ASCII行である従来の相互参照テーブルと、残りのデータをインデックス化する相互参照ストリームの2つです。従来のセクションのトレイラーには、そのストリームのバイトオフセットを示す/XRefStmエントリが含まれています。
役割の分担は意図的なものです。古いリーダーが到達する必要があるオブジェクト(カタログやページツリーなど)は、従来のテーブルからアドレス指定できます。圧縮されたオブジェクトストリームに折りたたまれたオブジェクトは、従来のテーブル内でタイプfエントリとして「空き(free)」マークされているため、1.4リーダーはそれらをスキップし、解析できない構造でクラッシュすることはありません。それらの実際の場所は、相互参照ストリーム内のみに存在します。このようなファイルのシグネチャはその末尾にあります。すなわち、xrefとそれに続く0 0サブセクションヘッダーだけのことが多い短い従来のセクションであり、そのトレイラーが実際のリカバリデータが配置されている/XRefStmを指しています。
正しいページ数が何も証明しない理由
カタログとページツリーは意図的に従来のテーブルから到達可能になっているため、そのテーブルだけを読み込むローダーでも/Rootを見つけ、ページツリーをたどり、正しいページ数を報告します。古いリーダーに必要なものはすべて揃っているため、ファイルは健全であるように見えます。失われているオブジェクトは、オブジェクトストリームにパックされたオブジェクト群です。すなわち、AcroFormフィールドディクショナリ、タグ付きPDF構造要素、および従来のビューアに見せる必要のなかった小さなディクショナリのロングテールなどです。
これらのオブジェクトに何かが接触するまでは、その欠落に気づきません。そして、完全な再保存はそれらすべてに接触します。再暗号化または書き換えのためにドキュメントを走査する処理こそが、すべてのオブジェクト番号を順番に要求する操作そのものであり、これがロード時ではなく、原因から遠く離れた保存時に症状が表面化する理由です。
罠はxrefを見つけて停止する検出器
ファイルがどのようにインデックス化されているかを判定する簡単な方法は、startxrefに従ってそれが指し示す最初の数バイトを検査することです。キーワードxrefは従来のテーブルを意味し、ストリームオブジェクトは相互参照ストリームを意味します。このテストは、どちらか一方の方式に専念しているファイルに対しては正解です。しかし、ハイブリッドファイルに対しては誤りです。ハイブリッドファイルでは、古いリーダーを満足させるためだけにstartxrefが従来のセクションを指しており、実際にはそのセクションのトレイラーにある/XRefStmでドキュメントの大部分がインデックス化されています。最初に出会ったxrefに対して「クラシック」を返す検出器は、/XRefStmを読み取ることがなく、ストリーム内のみに存在するすべてのオブジェクトが見えなくなってしまいます。
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // count is correct
// inspect or edit the loaded document here
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // walks every object
finally
Pdf.Free;
end;
end;
早期終了型の検出器が存在する場合、ロードは問題なく見え、再保存時に欠落しているオブジェクトがエラーとして現れます。修正方法は、最初に読み取るバイト数を増やすことではありません。ハイブリッドトレイラーを認識し、ファイル処理完了と判断する前に/XRefStmに従うことです。
マージ順序は譲れない要件
両方のインデックスを読み取った後は、それらを一方向のみで結合する必要があります。相互参照ストリームを最初にマージし、その周囲に従来のエントリを埋め込む必要があります。理由は、このフォーマットの核心にある「小さな欺瞞」にあります。ハイブリッドファイルは、古いリーダーが無視するように、従来のテーブル内で圧縮オブジェクトを空き(free)としてマークします。「最初に見つかったものを優先する」ポリシーを採用し、従来のテーブルを最初に読み取るローダーは、それらのオブジェクト番号を空きとして記録し、スロットがすでに占有されているため、実際にそれらの場所を示すストリームエントリを破棄してしまいます。順序を逆にすると、ストリームからのタイプ2エントリ(オブジェクトストリーム番号とインデックスの組み合わせ)が、本来所有すべきスロットを獲得し、従来のエントリがその周囲に配置されます。
同様のルールは、古いリビジョンが削除されたオブジェクトを復活させるのを防ぎます。インクリメンタルアップデートは/Prevを介して逆方向にリンクされ、タイプ0の空きエントリは、より新しいセクションがそのオブジェクト番号を破棄したことを示すセンチネルです。チェーン内の後続(=履歴的に古い)セクションが、そのセンチネルを古い位置情報で上書きすることを許してはなりません。「最初に見つかったもの」を空きマークの決定版として扱えば、削除されたオブジェクトは削除されたままになります。不注意に扱うと、ファイル自身の履歴によって、最新リビジョンが削除したコンテンツが復活してしまいます。
HotPDFにおける対応
エンジンはハイブリッド参照ファイルを自動的に解決します。これは、相互参照データを解析する必要があるすべてのパスで行われます。LoadFromFileまたはLoadFromStreamでドキュメントをロードし、変更を加えてからSaveLoadedDocumentを呼び出すか、入力を読み込んで出力を作成するEncryptFileのようなワンショット操作を実行します。いずれの場合も、リカバリ処理は/XRefStmを読み取り、従来のテーブルエントリよりも前にストリームセクションをマージし、書き込み処理がそれらを列挙する前にストリーム内に存在するオブジェクトを解決します。AES-256暗号化パスでこの問題が最初に顕在化した理由は、ドキュメントの暗号化がすべてのオブジェクトを書き換えるため、事前にすべてのオブジェクトが配置されている必要があるからです。
// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
覚えておくべき重要な詳細は、APIの上流にあります。Word、Excel、PowerPoint、および数多くの「PDFとして保存」機能から生成されるファイルは日常的にハイブリッドです。そのため、自前のジェネレータ出力に対してのみ検証しているローダーは、テスト中にこの問題に遭遇しない可能性があります。独自のコードで生成したファイルだけでなく、実際のOfficeアプリケーションからエクスポートされたドキュメントをテスト用のデータセットに追加してください。
疑わしいファイルの確認方法
2つの検査によって、この疑問はすぐに解決します。ファイルをバイナリ(Hex)ビューで開き、最後のstartxrefの後のバイトを読み取ります。ハイブリッドファイルは、トレイラーディクショナリに/XRefStmを含む短い従来のセクションを表示します。あるいは、完全な解析が報告するオブジェクト数と、トレイラーの/Sizeが宣言する最大のオブジェクト番号を比較します。大きな乖離がある場合は、ローダーが開いていないストリーム内にオブジェクトが隠れていることを意味し、これが後に保存時の失敗へと変わる不足そのものです。
オブジェクトストリームや圧縮された相互参照がそもそもどのように生成されるかという書き込み側のストーリーは、オブジェクトストリームとインクリメンタルアップデートに関する記事でカバーされています。対象となるハイブリッドファイルが非常に大きい場合、大規模PDFワークフロー向けのDirect File APIチュートリアルに記載されているロード手法を使用すれば、ファイル全体をメモリに読み込むことなく検査できます。どちらも、このブログの他の場所で扱われている読み込み、編集、暗号化、および署名APIと並んで、DelphiおよびC++Builder向けのHotPDF Componentの一部として提供されている、ここで解説した修復処理と深く結びついています。