技術記事

DelphiでのOfficeアプリケーションからのハイブリッド参照PDFの処理

Microsoft WordやExcelから「PDFとして保存」でドキュメントをエクスポートすると、ディスク上のファイルは高い確率でハイブリッド参照ファイルになります。これは相互参照情報を2回保持しています。1つはバージョン1.4までのすべてのPDFの末尾にある古典的な固定幅のテーブルとして、もう1つはドキュメントの大部分が実際に依存する圧縮された相互参照ストリームとしてです。1つのトレイラーキー /XRefStm がこれら2つのビューを繋ぎ合わせており、ツールがドキュメント全体を認識できるかどうかは、このキーをたどるかどうかにかかっています

この記事では、消費側の視点からハイブリッドファイルについて見ていきます。ファイルの末尾のバイトがどのようになっているか、編集によって2つのビューがどのように乖離していくか、そしてDelphiのパイプラインがハイブリッドの入力をどのように検出してルーティングできるかです。ローダーがビューをどのようにマージするか、そしてなぜその順序が交渉の余地がないのかについては、ハイブリッド参照ファイルのロードに関するHotPDFの記事の主題です。この記事は、そもそもそのレイアウトを認識することについてです

Officeのエクスポートがインデックスを2回書き込む理由

PDF 1.5では、ファイルの形を変える2つの機能が導入されました。オブジェクトインデックスをプレーンテキストのテーブルではなく圧縮されたバイナリデータとして保存する相互参照ストリームと、多くの小さなオブジェクトを1つのFlate圧縮コンテナに詰め込むオブジェクトストリームです。これらを使用するライターはより小さなファイルを生成しますが、PDF 1.4のリーダーはその結果を開くことができません。なぜなら、それが手がかりとする構造である xref キーワードと trailer 辞書がなくなっているからです

ISO 32000-1 §7.5.8.4 はその妥協案を定義しています。ハイブリッド参照ファイルは両方を書き込みます。古いリーダーが到達しなければならないオブジェクト(カタログやページツリーなど)をアドレス指定する古典的な相互参照テーブルと、それ以外のすべてをインデックス化する相互参照ストリームです。オブジェクトストリームに折りたたまれたオブジェクトは、古典的なテーブルでは空き(free)としてマークされるため、1.4のリーダーは文句なしにそれらをスキップします。それらの実際の場所はストリーム内にのみ存在します。そして、古典的なトレイラーには、そのストリームのバイトオフセットを保持する /XRefStm キーが置かれます。古いビューアはこのキーを読み取ることはなく、テーブルビューからファイルをレンダリングします。モダンなビューアはそれをたどり、ドキュメント全体を認識します。WordとExcelは何年もの間、まさにこのレイアウトを出力してきました。これが、ハイブリッドファイルが風変わりなエッジケースではなく、ビジネスパイプラインが受け取るものの大きな割合を占めている理由です

ハイブリッドファイルの末尾の様子

このレイアウトは、バイト列から理解するのが最も簡単です。以下は、オフセットを短くした小さなハイブリッドファイルの末尾です。実際のOfficeのエクスポートでは、/XRefStm の値は通常、ファイル末尾近くの大きなオフセットになります。読み取り順序は、PDFファイル構造の概要で説明した末尾から先頭への探索です。%%EOF を見つけ、startxref を読み、テーブルにジャンプします

% ... オブジェクトストリームと、バイト116にある相互参照ストリーム(/Type /XRef を持つストリームオブジェクト)を含むボディオブジェクト ...

xref                    % 古典的なセクション: startxrefが指している場所
0 4
0000000000 65535 f      % スロット0: フリーリストの先頭。常に存在する
0000000017 00000 n      % オブジェクト1: カタログ。すべてのリーダーに見える
0000000000 65535 f      % オブジェクト2: freeとしてマークされている -- オブジェクトストリーム内に存在する
0000000000 65535 f      % オブジェクト3: 同上。ストリームビューのみがこれを見つける
trailer
<<
  /Size 4
  /Root 1 0 R
  /XRefStm 116          % 相互参照ストリームのバイトオフセット
>>
startxref
7164                    % 上記の 'xref' キーワードのバイトオフセット
%%EOF

このダンプ内の2つの詳細がメカニズム全体を担っています。1つ目は、startxref が古典的なセクションを指していることです。これは意図的なものであり、古いリーダーが着地しなければならないアドレスだからです。相互参照ストリームは、トレイラー辞書内の /XRefStm キーを経由してのみ到達可能であるため、このキーを探さないパーサーはストリームが存在することを知ることはありません。2つ目は、オブジェクト2と3が良性の嘘であることです。古典的なテーブルはこれらを空き(free)であると宣言していますが、それらは圧縮されたコンテナ内に存在する実際のオブジェクトです。空きのマーキングは、1.4のリーダーが使用できないエントリにつまずくのを防ぐためのものです。古典的なビューだけを信頼するコンシューマーは、このドキュメントの大部分が存在しないと結論付けます

2つのビューがどのように乖離していくか

Wordから出力されたばかりのハイブリッドファイルは内部的に一貫しています。つまり、両方のビューがそれぞれの宣言された範囲内で同じドキュメントを記述しています。問題は、どちらか一方のビューしか理解できないツールによってファイルが編集されたときに始まります。新しいオブジェクト、新しい xref セクション、前のセクションへの /Prev チェーン、そして新しいトレイラーを追加する、クラシックスタイルの増分更新を追加するスタンプユーティリティを考えてみましょう。そのトレイラーが /XRefStm キーを欠落させると、ストリームビューは孤立します。古い値を前方にコピーすると、ストリームビューは編集前のドキュメントを記述したままになります。いずれにせよ、2つのインデックスはファイルの内容について一致しなくなります

結果として生じるファイルには、特徴的な障害の兆候があります。つまり、一方のビューで表示されるオブジェクトが、もう一方のビューでは見つからないか、古くなっているのです。ストリームビューを通じて解決するリーダーは、更新されたオブジェクトの編集前のバージョンを見つけるか、追加されたオブジェクトのエントリを全く見つけられません。テーブルビュー上のリーダーは、編集を認識しますが、ストリームだけが見つける圧縮オブジェクトの行方を追えなくなります。実際には、これはあるビューアでは残り、別のビューアでは消えるフォームフィールドや、スタンプ処理によって削除されたように見える注釈、または全く間違ったオブジェクトに着地する検索として表面化します

これらのファイルのデバッグにコストがかかる理由は、Adobe Acrobatが通常これらを文句なしに開いてしまうためです。インデックスがバイト列と一致しない場合、Acrobatはオブジェクトヘッダーをスキャンして相互参照データを静かに再構築するため、壊れたファイルを生成した人は何も異常に気づきません。障害が表面化するのは後になってからです。宣言された構造を信頼する厳格なコンシューマー、プリフライトバリデーター、署名サービス、アーカイブ取り込みジョブにファイルが届き、オブジェクトの欠落や相互参照の不一致が報告されたときです。「Acrobatでは正常に開きます」というのは、ほぼすべてのハイブリッド非同期チケットの始まりの言葉です

純粋なDelphiでのハイブリッドファイルの検出

入力の分類にPDFライブラリは必要ありません。/XRefStm キーは古典的なトレイラー辞書内にのみ出現する可能性があり、アクティブなトレイラーはファイルの最後の数キロバイト内にあります。仕様では %%EOF が物理的な末尾の近くに出現することが求められているためです。境界を定めた末尾のウィンドウを読み取り、そこを検索するだけでトリアージには十分です

uses
  System.SysUtils, System.Classes, System.StrUtils, System.Math;

function IsHybridReferencePdf(const FileName: string): Boolean;
const
  TailWindow = 2048;
var
  Stream: TFileStream;
  Buf: TBytes;
  Tail: string;
  Len, TrailerPos, NextPos, KeyPos, StartXrefPos: Integer;
begin
  Result := False;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Stream.Size < 48 then
      Exit;
    Len := Min(TailWindow, Integer(Stream.Size));
    SetLength(Buf, Len);
    Stream.Position := Stream.Size - Len;
    Stream.ReadBuffer(Buf[0], Len);
  finally
    Stream.Free;
  end;

  // 関連するすべてのキーワードは7ビットASCIIなので、バイト単位のデコードは安全です
  Tail := TEncoding.ANSI.GetString(Buf);

  // 最後の 'trailer' キーワードを見つける: 増分更新がある場合、
  // 最も新しいトレイラーがファイルを管理するトレイラーになります
  TrailerPos := 0;
  NextPos := Pos('trailer', Tail);
  while NextPos > 0 do
  begin
    TrailerPos := NextPos;
    NextPos := PosEx('trailer', Tail, NextPos + 1);
  end;
  if TrailerPos = 0 then
    Exit;  // 古典的なトレイラーがない: ハイブリッドではない純粋なxref-streamファイル

  // ハイブリッドトレイラーは 'trailer' と 'startxref' の間に /XRefStm を保持します
  KeyPos := PosEx('/XRefStm', Tail, TrailerPos);
  StartXrefPos := PosEx('startxref', Tail, TrailerPos);
  Result := (KeyPos > 0) and
    ((StartXrefPos = 0) or (KeyPos < StartXrefPos));
end;

3つの結果は3つのレイアウトと一致します。クラシックのみのファイルにはトレイラーがありますが /XRefStm はありません。したがってFalseです。相互参照ストリームを完全に採用したファイルには trailer キーワードが全くなく、トレイラーキーはストリーム辞書内に存在します。このようなファイルはハイブリッドではなく圧縮されているため、これも正しくFalseになります。二重にインデックス化されたレイアウトのみがTrueを返します

本番環境での使用には、追加のコード行に見合う2つの強化策があります。/XRefStm の後の整数を解析し、そのオフセットにシークして、/Type /XRef を持つストリームオブジェクトが実際にそこに存在することを確認します。切り詰められたファイルはキーを保持したままストリームがなくなっている可能性があり、これは正常なハイブリッドとは別のバケットに分類されるべきです。また、ウィンドウサイズをパラメータとして扱います。2KBあれば通常のOfficeの出力はカバーできますが、異常に大きなトレイラー辞書によってキーワードが範囲外に押し出される可能性があり、ウィンドウを広げることは、誤ってファイルをクラシックと宣言してしまうのを防ぐのに役立ちます

Delphiパイプラインを通じたハイブリッドファイルのルーティング

検出により、ルーティングの決定が可能になります。読み取り、レンダリング、または検証のみを行うファイルの場合は、両方のビューを解決するローダーを使用し、バイト列ではなく振る舞いを検証します。PDFiumコンポーネントはロード時に /XRefStm チェーンを解析するため、コードが参照するオブジェクトテーブルはマージされたものであり、オブジェクトと相互参照ストリームの検証に関する私たちの記事で説明されているチェックがそのまま適用されます。同期ずれしたハイブリッドファイルがロードを拒否するほどひどく損傷している場合、エンジンはエラーセット(FPDF_ERR_SUCCESS, FPDF_ERR_UNKNOWN, FPDF_ERR_FILE, FPDF_ERR_FORMAT, FPDF_ERR_PASSWORD, FPDF_ERR_SECURITY, FPDF_ERR_PAGE)を通じて報告し、構造的な損傷により FPDF_ERR_FORMAT が発生します。ただし、そのシグナルに依存しないでください。PDFiumは意図的に寛容であり、ほとんどの矛盾したファイルを静かに再構築するため、ロードの成功はファイルが復元可能であったことを証明するものであり、2つのビューが一致していることを証明するものではありません。意味のある一貫性チェックは、完全なオブジェクトの探索で見つかったものと、トレイラーの /Size が宣言しているものとを比較することです

パイプラインが変更を加えるファイルの場合、最も安全なポリシーは、それらがハイブリッドである状態を完全に止めることです。HotPDFを通じてロードし、完全に保存し直すことで、ドキュメントは単一の一貫した相互参照を持つ1つの形式に書き換えられます。/XRefStm はなく、同期から外れる2つ目のビューもなく、すべてのオブジェクトは正確に1つのインデックスエントリによって所有されます。この正規化は、アーカイブの取り込み前、厳格なダウンストリームのRIPや署名サービスの前、およびハイブリッド入力に適用されたあらゆる編集の後に必要なものです。これは、HotPDFのハイブリッド参照に関する記事で詳しく解説されているメカニズムである、入力時にローダーがビューを正しくマージしたために機能します

そのままにしておくべきファイルのクラスが1つあります。それはデジタル署名されたドキュメントです。完全な書き換えはすべてのバイトを移動させるため、元の範囲に対して計算された署名は無効になります。署名されたハイブリッドファイルへの変更は、両方のビューを維持する適切な増分更新として行う必要があります。読み取りのみが必要なファイルは、手つかずのまま通過させる必要があります。正規化はあなたが所有するファイルのためのものであり、署名されたファイルは追加のみを行うべきです

ハイブリッド参照PDFは不正な形式ではありません。それらはフォーマット自身の互換性の架け橋であり、OfficeアプリケーションはインストールベースにPDF 1.4のリーダーが存続する限り、これらを生成し続けるでしょう。/XRefStm キーを見つけ、PDFium Component でマージされたドキュメントを検証し、HotPDF Component でクリーンな単一インデックス出力を再生成できるパイプラインは、それらをあるがままのものとして扱います。それはトレイラーに1つの追加の道しるべがある通常の入力です