技術記事

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

あなたは小さなバリデーターを作成します。それはPDFを開き、最後までシークし、startxrefを見つけ、オフセットを読み取り、その下に固定幅の相互参照テーブルがあるxrefキーワードにたどり着くことを期待します。そのテーブルからオブジェクトのオフセットを収集し、後方へスキャンしてtrailerキーワードを探し、/Root/Sizeを学習します。これは、テスト用に生成したすべてのファイルで完璧に機能します。その後、Wordの現在のバージョン、またはPDF 1.5をターゲットとするライブラリによって生成されたファイルが到着し、バリデーターはそれが壊れていると宣言します。オフセットが指す場所にxrefキーワードはなく、trailer辞書はどこにもなく、バリデーターが構築したオブジェクトテーブルはほぼ空です。ファイルは有効です。バリデーターは15年前のレンズを通してそれを読んでいます

これは、クラシックなレイアウトに対して書かれたバイトレベルのPDFチェックが、現代のドキュメントで失敗する最も一般的な理由です。それが依存している構造である、プレーンテキストの相互参照テーブルとtrailerキーワードは、PDF 1.5でオプションとなり、頻繁に欠落しています。それに代わる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が相互参照ストリームを指している場合、オブジェクト番号の直後のバイトは通常の辞書のように見え、/Root/Size、および/IDが、streamキーワードとFlateデータが始まる前に、クリアな状態でそこに存在します

これは、バリデーターが最も必要とする3つの事実、つまりカタログの場所、ファイルが主張するオブジェクトの数、およびファイル識別子を、ストリーム辞書のみを解析することで学習できることを意味します。相互参照データを解凍する必要はなく、その内部のバイナリエントリを解釈する必要もありません。単純なパーサーを打ち負かす作業はトレーラーを読み取ることではなく、オブジェクトを見つけることです。これらは2つの分離可能な問題であり、最初の問題を解決するのは安価です

オブジェクトストリーム:ヘッダー、そしてFlate blob

オブジェクトストリームはコンテナーです。その辞書には、/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のエントリ、つまりこのオブジェクトはインデックスiのオブジェクトストリームN内に存在するというエントリは、完全なオフセットマップを構築するためにデコードするものです。任意のオブジェクトを番号で解決するには、そのマップが必要です。プレーンテキスト辞書にある/Root/Size、および/IDを読み取るためにそれらは必要なく、また各/ObjStm/N/Firstを通じて独自の内容をアナウンスするため、オブジェクトストリームを展開するためにも必要ありません

また、トレーラーキーを取得する目的だけで、相互参照ストリームが/DecodeParmsを通じて適用する可能性のあるPNGおよびTIFF予測関数を処理する必要もありません。予測子はバイナリの相互参照行をフィルタリングして圧縮を向上させます;それらはストリームの前にある辞書とは何の関係もありません。したがって、クラシックなバリデーターを最新のPDFに対応させる最小限のアップグレードは小規模です:startxrefxrefキーワードではなくストリームに着地した場合は、ストリーム辞書でトレーラーキーを解析し、遭遇したすべての/ObjStmオブジェクトを展開して、その内容がオブジェクトテーブルに入るようにします。タイプ2のエントリと予測子のデコードは別の大きなタスクであり、純粋にランダムなオブジェクトの解決が必要になるまで延期できます

コンプライアンスチェックで最初にストリームを展開しなければならない理由

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

順序が重要です。すべてのチェックは番号によってオブジェクトに到達できると想定しているため、展開はチェックの実行と並行してではなく、その前に実行する必要があります。プロファイルチェックを生のバイトスキャンに直接配線すると、クラシックなパーサーの盲点を継承し、そもそも相互参照ストリームを書き込めるほど新しいツールチェーンから出力された、最も適切な形式である可能性が高い現代のファイルに対してまさに誤った違反を生成します

PDFiumに解析を任せる

PDFiumコンポーネントは、ドキュメントの読み込みの一部として相互参照ストリームとオブジェクトストリームを解析します。これは、インフレートおよび展開の手順を手動で作成することを避けるための実用的な方法です。TPdfコンポーネントでファイルをロードすると、/ObjStmコンテナーにパックされたオブジェクトはすでに解決されており、検証エントリポイントは完全に展開されたドキュメントを見ます。ValidatePdfAは、Conformanceフィールドがpac1bpacNoneなどのTPdfAConformance値であり、Issuesフィールドが見つかった特定の問題のセットであり、IsCompliantメソッドがコンフォーマンスレベルが検出され、問題のセットが空である場合にのみtrueとなるTPdfAValidationResultレコードを返します。オブジェクトはロード中に展開されたため、オブジェクトストリーム内に存在する/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;

これは、同じ形状のTPdfXValidationResultを返すValidatePdfXにも当てはまります。PDFiumを経由するルーティングのポイントは、上記で説明した構造的解凍がローダー内で1回正確に実行されるため、検証コードはクラシックなファイルと完全に圧縮されたファイルの違いを見ることがないということです。どちらも、解決されたオブジェクトのセットとしてバリデーターに到着します

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

ディスク上ではなくメモリ上にすでにバイトがある場合、生のファイルコンテンツを受け取り、ファイルパスと同じように相互参照およびオブジェクトストリームを解析するLoadDocument(const Data: TBytes)オーバーロードを通じて、同じロードして検証するシーケンスが機能します。手書きのバリデーターから得られるのは、APIではなく構造上の規則です:プレーンテキストのストリーム辞書からトレーラーキーを読み取り、ドキュメントをたどる前にFlateデコーダーですべての/ObjStmを展開し、バイナリの相互参照エントリのデコードは、それが大きなオプションのジョブとして扱うことです

構造が展開されると、バリデーターはワークフローの残りの部分をその上で駆動できます。入力のフォルダー全体のコンフォーマンスを報告するコマンドラインプリフライトハーネスについては、バッチプリフライトレポートCLIの構築に関するウォークスルーを参照してください。大きなドキュメントを分割する前に検証がゲートとなる場合、PDFドキュメントを複数のファイルに分割するためのガイドの技術は、ここで示すロードアンドチェックパターンと自然にペアになります。どちらも、DelphiおよびC++Builder用のPDFiumコンポーネントのロードおよび検証サーフェスに基づいています