Technical Article

圧縮されたPDFの検証:オブジェクトおよびXRefストリーム

小さな検証ツールを作成したとします。PDFを開き、末尾をシークし、startxrefを見つけ、オフセットを読み取り、固定幅の相互参照テーブルを伴ってキーワードxrefに到達することを想定します。そのテーブルからオブジェクトのオフセットを収集し、逆方向にスキャンしてtrailerキーワードを見つけ、/Root/Sizeを特定します。テスト用に生成したすべてのファイルで完璧に動作します。しかしその後、現在のバージョンのWordや、PDF 1.5をターゲットとするライブラリで作成されたファイルが届くと、検証ツールはそれを破損していると判断します。オフセットが指す場所にxrefキーワードはなく、どこにもtrailer辞書はなく、検証ツールが構築したオブジェクトテーブルはほぼ空です。ファイルは有効です。検証ツールが15年前のレンズを通してファイルを読み取っているだけなのです。

これが、クラシックなレイアウトを前提に書かれたバイトレベルのPDFチェックが、現代のドキュメントで失敗する最も一般的な理由です。それが依存している構造(プレーンテキストの相互参照テーブルとtrailerキーワード)は、PDF 1.5でオプションになり、頻繁に欠落しています。代わりに、クロスリファレンスストリーム(cross-reference stream)と圧縮オブジェクトストリーム(compressed object stream)という2つの機能がそれに取って代わりました。どちらもISO 32000-1で定義されており、それらを知らない検証ツールは、健全なファイルを失われたオブジェクトの山として認識してしまいます。

PDF 1.5がファイルの末尾にもたらした変更

ISO 32000-1 §7.5.8はクロスリファレンスストリームを定義し、§7.5.7は/ObjStmタイプのオブジェクトストリームを定義しています。これらにより、ライターは従来のパーサーがキーとしていた2つの構造をドロップできます。PDF 1.5ファイルは、xrefテーブルを一切持たずに終わることがあります。代わりに、startxrefが指すオブジェクトは/Type /XRefを持つ通常のストリームオブジェクトであり、そのストリームはコンパクトなバイナリ形式で相互参照データを保持します。trailerキーワードも存在しません。トレーラーは今やそのストリーム自体の辞書だからです。クラシックなパーサーが捜索していた/Root/Size、および/IDなどのキーは、その辞書内に存在します。

第2の変更は、オブジェクト自体の配置を移動させます。すべての間接オブジェクトを独自のバイトオフセットに書き出す代わりに、ライターは多数の小さなオブジェクト(ページ辞書、注釈辞書、構造ツリーなど)を単一のオブジェクトストリームにパックし、そのコンテナ全体をFlateで圧縮できます。個々のオブジェクトは、ファイル内の物理的なバイトオフセットを持たなくなります。それらは圧縮されたブロブ(blob)内の相対位置を持ちます。生のバイトに対して1 0 objを走査する検証ツールはそれらを見つけることができません。そのテキストは解凍(インフレート)した後にのみ出現するためです。クラシックなパーサーから見れば、ドキュメントの半分が消え去ったように見えます。

圧縮ファイルでもトレーラーキーはプレーンテキストである

安心できる点は、クロスリファレンスストリームのトレーラーを読み取るために何も解凍する必要はないということです。ストリームオブジェクトは、辞書の後にstreamキーワードが続き、その後に圧縮バイトが続く構造です。辞書部分はプレーンテキストです。したがって、startxrefがクロスリファレンスストリームを指している場合、オブジェクト番号の直後のバイトは通常の辞書のように見え、streamキーワードとFlateデータが始まる前に、/Root/Size、および/IDがクリアな状態で配置されています。

これは、検証ツールがカタログの場所、ファイルが主張するオブジェクト数、ファイル識別子という最も必要な3つの事実を、ストリーム辞書のみをパースすることで把握できることを意味します。バイナリデータを解凍したり解釈したりする必要はありません。ナイーブなパーサーを悩ませる作業は、トレーラーを読み取ることではなく、オブジェクトを見つけることです。これらは2つの分離可能な問題であり、最初の問題を解決するのは低コストです。

オブジェクトストリーム:ヘッダーとFlateブロブ

オブジェクトストリームはコンテナです。その辞書は/Type /ObjStm、パックされたオブジェクト数を示す/Nエントリ、および解凍されたデータ内で最初のオブジェクトの本体が開始するバイトオフセットを示す/Firstエントリを保持します。圧縮されたペイロードは、解凍されると、/N個の整数ペアの小さなヘッダーで始まります。各ペアはオブジェクト番号と、そのオブジェクト本体の/Firstに対する相対オフセットです。ヘッダーに続いて、オブジェクトの本体が連結された状態で並んでいます。

バイトデータが解凍されれば、展開処理は機械的です。辞書を読み取って/N/Firstを取得し、Flateデコーダーでストリームを解凍し、先頭の/Nペアを辿ってどのオブジェクト番号がどのオフセットにあるかを学習し、各本体を通常の間接オブジェクトであるかのように抽出します。唯一のリアルな依存関係はFlateデコーダーですが、すでに手元にあります。DelphiはSystem.ZLibを同梱しており、Free Pascalはzstreamユニットを提供しており、どちらもzlibをラップして、サードパーティのコードなしで生のFlateストリームを解凍します。抽出されたすべてのオブジェクトを検証ツールのオブジェクトテーブルに追加するルーチンがあれば、検証ツールの残りの部分(/Rootを辿ってページツリーをチェックする処理)は、クラシックなファイルに対する場合とまったく同様に動作します。

実装しなくてもよい部分

処理を過大評価しがちです。圧縮されたファイルからトレーラーキーを読み取るために、クロスリファレンスストリームのバイナリエントリをデコードする必要はありません。§7.5.8のクロスリファレンスストリームは3つのエントリタイプを使用しており、タイプ2のエントリ(「このオブジェクトはオブジェクトストリームNのインデックスiに存在する」というもの)は、完全なオフセットマップを構築するためにデコードするものです。番号で任意のオブジェクトを解決するためにはそのマップが必要ですが、プレーンテキスト辞書に存在する/Root/Size/IDを読み取るためには不要であり、各/ObjStm/N/Firstを通じて自らのコンテンツを公表するため、オブジェクトストリームを展開するためにも不要です。

また、トレーラーキーを取得するだけのために、クロスリファレンスストリームが/DecodeParmsを介して適用している可能性のあるPNGやTIFFの予測子(predictor)関数を処理する必要もありません。予測子は、バイナリの相互参照行をフィルタリングして圧縮率を高めるためのものであり、ストリームに先行する辞書とは無関係です。したがって、クラシックな検証ツールを現代のPDFに対応させるための最小限のアップグレードは小規模です。startxrefxrefキーワードではなくストリームに着地した場合、ストリーム辞書からトレーラーキーをパースし、遭遇したすべての/ObjStmオブジェクトを展開してコンテンツをオブジェクトテーブルに追加します。タイプ2エントリや予測子のデコードは、任意のオブジェクトのランダムアクセス解決が本当に必要になるまで延期できる、より大きな別個のタスクです。

適合性チェックが最初にストリームを展開しなければならない理由

これは、プロファイルチェックを実行した瞬間に学術的な話ではなくなります。PDF/AやPDF/Xの検証ツールは特定のオブジェクトを検査します。/OutputIntents配列用のドキュメントカタログ、適切な識別子を持つXMPパケット用の/Metadataストリーム、埋め込みフォントファイル用の各フォント記述子、および/ID用のトレーラーです。圧縮されたファイルでは、これらのオブジェクトのほとんどはオブジェクトストリームの内部にあります。オブジェクトストリームを展開していない検証ツールは、カタログのキーを見つけられず、メタデータを見つけられず、フォントを列挙できません。適合しているドキュメントであっても、出力インテント、XMP、および構造の半分が欠落していると報告してしまいます。必要とする証拠が、解凍されたことのないFlateブロブの中に眠っているためです。

順序が重要です。展開はチェックが実行される前に行われなければなりません。すべてのチェックはオブジェクト番号でアクセスできることを前提としているためです。プロファイルチェックを生のバイトスキャンに直接接続してしまうと、クラシックなパーサーの盲目を引き継ぎ、そもそもクロスリファレンスストリームを書き出せる新しいツールチェーンで作成された、十分に適合している現代のファイルに対して誤った違反を報告することになります。

PDFiumによるパースの処理

PDFium Componentは、ドキュメントのロードの一部としてクロスリファレンスストリームおよびオブジェクトストリームをパースします。これが、インフレートおよび展開ステップを手作業で実装するのを回避する実用的な方法です。TPdfコンポーネントでファイルをロードすると、/ObjStmコンテナにパックされたオブジェクトは既に解決されており、検証エントリーポイントは完全に展開されたドキュメントを認識します。ValidatePdfATPdfAValidationResultレコードを返します。そのConformanceフィールドはpac1bpacNoneなどのTPdfAConformance値であり、Issuesフィールドは検出された特定の問題のセットであり、IsCompliantメソッドは適合レベルが検出され、問題のセットが空である場合にのみTrueになります。オブジェクトはロード時に展開されたため、オブジェクトストリーム内に存在した/OutputIntents配列や埋め込みフォントも欠落と報告されずに発見されます。

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

同じことがValidatePdfXにも適用され、同様の構成を持つTPdfXValidationResultを返します。PDFiumを経由するポイントは、前述した構造的なデコンプレッションがロード処理の内部で一度だけ正確に実行されるため、検証コードが従来のファイルと完全圧縮されたファイルの違いを意識しなくて済むという点です。どちらも解決済みのオブジェクトセットとして検証ツールに到達します。

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

バイトデータがディスク上ではなくメモリ上にある場合でも、同じロード・検証シーケンスが、生のファイルコンテンツを受け取ってファイルパスと同様に相互参照およびオブジェクトストリームをパースするLoadDocument(const Data: TBytes)オーバーロードを介して動作します。手書きの検証ツールとして持ち帰るべきは、APIではなく構造的なルールです。ストリーム辞書からトレーラーキーをプレーンテキストで読み取り、ドキュメントを辿る前にFlateデコーダーですべての/ObjStmオブジェクトを展開し、バイナリ相互参照エントリのデコードは必要になるまで延期すべきより大きなタスクとして扱うというルールです。

構造が展開されれば、検証ツールはその上でワークフローを実行できます。フォルダー内の入力全体に対して適合性を報告するコマンドラインのプリフライトハーネスについては、バッチプリフライトレポートCLIの構築に関するチュートリアルを参照してください。検証が、巨大なドキュメントを分割する前段のゲートである場合、PDFドキュメントを複数ファイルに分割するためのガイドのテクニックが、ここで示したロードおよびチェックのパターンと自然に結合します。どちらも、DelphiおよびC++Builder向けのPDFium Componentのロードおよび検証インターフェース上に構築されています。