技術記事

DelphiでのWordおよびExcelからのハイブリッド参照PDFの読み込み

Microsoft WordまたはExcelが生成したPDFを開き、ページをめくってみても、特に異常はありません。それをDelphiプログラムに読み込み、ページ数を読み取っても、数字は正しいです。しかし、暗号化をオンにして再保存しようとすると、ジョブはEListErrorで失敗するか、出力されたファイルを開くと破損した相互参照の警告が表示されます。ファイルが破損していたわけではありません。それはハイブリッド参照ファイルであり、15年前のビューアで開くことを可能にしている構造そのものが、早すぎる段階で読み取りを停止するローダーを打ち負かす構造なのです

これは、すべての内部テストに合格したPDFパイプラインが、ラウンドトリップできないファイルに遭遇する最も一般的な方法の1つです。入力はすべて社内で生成されたため、ハイブリッドになることはありませんでした。最初のハイブリッドファイルは、顧客がスプレッドシートからエクスポートされた請求書を転送してきた日に到着します

WordとExcelが実際に書き込むもの

ISO 32000-1は、§7.5.8.4でハイブリッド参照レイアウトについて説明しています。PDF 1.4リーダーでファイルを開けるようにしつつ、オブジェクトストリームなどのPDF 1.5機能を必要とするアプリケーションは、相互参照情報を2回書き込みます。古典的な相互参照テーブル(バージョン1.4までのすべてのPDFの終わりにある固定幅のASCII行)と、残りのインデックスを作成する相互参照ストリームがあります。古典的なセクションのトレーラーには/XRefStmエントリがあり、その値はそのストリームのバイトオフセットです

役割の分担は意図的です。カタログやページツリーなど、古いリーダーが到達する必要があるオブジェクトは、古典的なテーブルからアドレス指定可能です。圧縮されたオブジェクトストリームに折り込まれたオブジェクトは、古典的なテーブルではタイプfのエントリで空きとしてマークされるため、1.4リーダーはそれらをスキップし、解析できない構造でつまずくことはありません。それらの実際の場所は、相互参照ストリームにのみ存在します。そのようなファイルのシグネチャはその末尾にあります:短い古典的なセクション、しばしばxrefの後に0 0のサブセクションヘッダーが続く程度のもので、そのトレーラーは実際の復旧データが存在する/XRefStmを指し示しています

正しいページ数が何も証明しない理由

カタログとページツリーは古典的なテーブルから意図的に到達可能であるため、そのテーブルのみを読み取るローダーは/Rootを見つけ、ページツリーを歩き、正しいページ数を報告します。古いリーダーが必要とするものはすべて揃っているため、ファイルは正常に見えます。行方不明になったオブジェクトは、オブジェクトストリームにパックされたものです:AcroFormフィールド辞書、タグ付きPDF構造要素、レガシービューアに表示される必要がなかった小さな辞書の長い尾などです

何かがそれらのオブジェクトに触れるまでギャップに気付くことはありません。そして完全な再保存はそれらすべてに触れます。再暗号化または再書き込みのためにドキュメントを歩くことは、まさに順番にすべてのオブジェクト番号を要求する操作であるため、症状はロード時ではなく保存時に、その原因から遠く離れた場所で表面化します

罠はxrefを見て停止する検出器です

ファイルがどのようにインデックス付けされているかを決定する安価な方法は、startxrefをたどり、それが指し示す最初のバイトを検査することです。キーワードxrefは古典的なテーブルを意味します。ストリームオブジェクトは相互参照ストリームを意味します。このテストは、1つのスキームをコミットするファイルに対しては正しいです。しかし、ハイブリッドファイルに対しては間違っています。ハイブリッドファイルの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をたどることです

マージの順序は交渉不可能

両方のインデックスが読み取られると、それらは一方向にのみ結合できます。相互参照ストリームを最初にマージし、その周囲に古典的なエントリを入力する必要があります。その理由は、フォーマットの核心にある小さな欺瞞にあります。ハイブリッドファイルは、古いリーダーがそれらを無視するように、古典的なテーブルで圧縮されたオブジェクトを空きとしてマークします。最初に見たものが勝つというポリシーを尊重し、古典的なテーブルを最初に読み取るローダーは、それらのオブジェクト番号を空きとして記録し、スロットがすでに取得されているため、実際にそれらを配置するストリームエントリを破棄します。順序を逆にすると、ストリームからのタイプ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つの検査で質問はすぐに解決します。ファイルを16進ビューで開き、最後のstartxrefの後のバイトを読み取ります。ハイブリッドファイルは、トレーラー辞書に/XRefStmを含む短い古典的なセクションを示します。または、完全な解析が報告するオブジェクト数と、/Sizeがトレーラーで宣言する最高のオブジェクト番号を比較します。大きなギャップは、ローダーが開いていないストリームにオブジェクトが隠れていることを意味します。これは、後で保存時の失敗に変わるのと同じ不足です

典型的なExcelエクスポートの末尾は、最初のチェックを具体的にします。最後のxrefキーワードの後のすべてはプレーンなASCIIであるため、シグネチャは16進ビューから直接読み取ることができます(オフセットは説明用であり、注釈が追加されています)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

0 0のサブセクションが証拠です:ゼロエントリの古典的なテーブルはトレーラーを運ぶためだけに存在し、トレーラーは主に/XRefStm 87325と言うために存在します。xrefキーワードで停止する検出器は、この時点で何のインデックスも見ていません。目視するよりもチェックをスクリプト化したい場合、マーカーは常にファイルの最後の数キロバイト以内に配置されるため、境界のある後方読み取りで十分です

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

プローブはパーサーではなくトリアージとして扱ってください:再保存ジョブが実行される前に、バッチ内のどのファイルに注意を払うべきかを伝えるだけで、それ以上のことはありません。見つけたオフセットでローダーが次に何をしなければならないか、セクションチェーンをたどり、ストリームエントリを古典的なエントリの前にマージし、空きエントリのセンチネルを尊重する方法については、Officeアプリケーションからのハイブリッド参照PDFの処理に関する関連記事で段階的に説明されています

この話のライター側、つまりオブジェクトストリームと圧縮された相互参照がそもそもどのように生成されるかについては、オブジェクトストリームと増分更新に関する記事で説明されています。問題のハイブリッドファイルが非常に大きい場合、大規模なPDFワークフローのためのDirect File APIのウォークスルーの読み込み手法を使用すると、すべてをメモリに読み込むことなく検査できます。どちらも、このブログの他の場所で説明されているロード、編集、暗号化、および署名APIと並んで、DelphiおよびC++Builder用のHotPDF Componentの一部として出荷される、ここで説明したリカバリと自然にペアになります