技術記事

Delphiで2つのPDFファイルを比較する:構造とピクセル

HotPDFは、DelphiからTHPDFDocComparisonを通じて2つのPDFドキュメントを比較します。このクラスは、両方のファイルのオブジェクトグラフをカタログから外側へたどり、必要に応じて各ページのペアをレンダリングして異なるピクセルも測定します。結果は、見つかったすべての差分、消費した予算、比較が完了まで走ったかどうかを示すJSONレポートとして得られます。構造的な差分と視覚的な差分は異なる問いに答えるものなので、両方のパスが重要です

この機能の背後にある問いは、たいていリリースに関する問いです。レポート生成エンジンに変更が加わり、出力が再生成され、誰かが何か変わったかどうかを判断しなければなりません。両方のファイルを並べて開いて確認するやり方は、せいぜい3ページ程度までしか通用せず、それ以上になると注意力が続きません。生のバイト列を比較する方法は即座に破綻します。同じ生成器を2回実行しただけでも、読者が目にするものとは無関係な理由で異なるバイト列が生成されるからです

なぜPDFはバイト列としては異なっても見た目は同一になり得るのか?

独立に生成された2つのPDFが印刷結果としてはまったく同一であっても、バイト列は日常的に異なります。その理由は表面的なものではなく構造的なものです。オブジェクト番号は、オブジェクトがたまたま書き出された順序で割り当てられます。フォントサブセットは、グリフが最初に出現した順序でCIDを割り当てるため、わずかに異なる走査順で構築されたサブセットは、同じ見た目のテキストに対しても異なるコンテンツストリームのバイト列を生成します。クロスリファレンスのオフセットは、上流の何かが長さを変えるたびにずれます

だからこそ、オブジェクト番号をドキュメント間の同一性の判定には使えません。HotPDFは代わりに、カタログから走査してスナップショットを構築します。辞書はそのキーのバイト順で、配列はインデックス順で展開するため、すべてのオブジェクトはそこへ到達するパスによって名付けられます。ルートから走査してもたどり着けないオブジェクトは、オブジェクト番号と世代番号を持つ合成の$Unreachable[...]パスにフォールバックし、孤立したコンテンツが黙って消えるのではなくレポート上に見える形で残ります

ストリームはコピーして比較されるわけではありません。各ストリームは、後で元のストリーム位置を復元しながら計算される、逐次的なSHA-256署名を提供します。そのため、200メガバイトのファイル同士を比較しても、200メガバイトのデータを2回実体化するようなことにはなりません

一方のドキュメントに挿入がある場合のページ整合

ページ1とページ1、ページ2とページ2という具合に単純に比較していく方法が正しいのは、何も挿入されていない場合に限られます。表紙ページを1枚挿入すると、素朴な比較はすべてのページが変更されたと報告します。これは技術的には正しいものの、運用上はまったく役に立ちません

HotPDFは、差分を取る前にページの整合を取ります。ページごとに抽出可能なテキストから署名を構築し、テキストを持たないページには構造的な署名にフォールバックしたうえで、一致したターゲットインデックスに対する最長増加部分列を計算します。この部分列の内側にあるページは単に位置がずれただけであり、外側にあるページが本当の変更です。この区別があるからこそ、400ページのマニュアルの差分が読める形になります。レポートは400ページが変更されたのではなく、1ページが挿入されたと報告するからです

構造比較を実行する

最もシンプルな呼び出しは、読み込み済みの2つのドキュメントとモードを受け取ります。cmStructuralはオブジェクトグラフの走査を行い、cmRenderedImageはピクセル比較を行い、cmFullは両方を行います。より軽量なモードとして、簡易的なスモークチェック向けにcmPageCountcmPageTextcmObjectCountも用意されています:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

このレポートは、真偽値では表現できない3つの状態を区別します。identicalは何か差分があったかどうかを示し、comparisonCompleteは走査が完了したかどうかを示し、comparisonBudgetは、もし走査を止めた上限があればその名前を示します。予算を使い果たした比較は、comparisonComplete=falseidentical=falseを同時に報告します。途中で打ち切られた走査には、同一性を主張する根拠がないからです。identicalだけを読む自動化処理は、いずれ予算による停止を本当の差分として扱ってしまうことになるため、3つすべてを読んでください

走査の範囲を制限しているのはどのような上限か?

THPDFStructuralCompareLimits.Defaultの既定値は、悪意ある入力ではなく実際のドキュメントを想定したサイズになっており、意味的に関連するすべての予算に個別の上限があります。オブジェクト数25万、エッジ数200万、深さ128、報告される差分1万件、ストリームあたり64MBかつストリーム全体で512MB、値あたり1MB、パスあたり4,096バイトです。自分のコーパスをよく理解しているのであれば意図的に引き上げ、外部から届いたファイルを比較するのであれば引き下げてください:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // CIで早期に失敗させる
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // デフォルトは72
  Options.ColorTolerance := 2;         // 1〜2レベルの丸め誤差を無視する
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // レビュー用にオーバーレイ画像を書き出す

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

レンダリングパスは、ビットマップを確保する前に、ページ寸法と要求されたDPIからピクセル数を見積もり、その後に実際のビットマップを再チェックします。そのため、不正な形のページジオメトリがサイズを偽ることで予算をすり抜けることはできません。DPIを上げると忠実度もコストも2乗で増加します。150DPIは72DPIの4倍のピクセル数になります。ページごとおよび合計のピクセル上限が存在するのはまさに、300DPIでのバッチジョブが、そうしなければメモリ確保だけでトラブルに陥ってしまうからです

どの程度似ていれば「十分に似ている」と言えるのか?

2つのページが類似していると見なされるのは、変更されたピクセルの比率がMaxChangedPixelRatio以下であり、かつ類似度がMinimumSimilarity以上である、両方の条件を満たす場合だけです。しきい値が1つではなく2つあるのは、ごく一部のピクセルが致命的に間違っている場合と、微小な色のずれが広範囲に及んでいる場合とでは、失敗の性質が異なるからです。どちらか一方だけであれば、あるワークフローでは許容できても、別のワークフローでは失格になり得ます。しきい値の判定には丸めていない値を使用します。JSON内の小数点以下6桁は、レポートを安定させ差分比較しやすくするために存在するのであって、比較そのものを定義するためのものではありません

変更されたピクセルは、ピクセル単位のフラッドフィルではなく、固定サイズのタイルをノードとした4方向の隣接関係を用いて領域にグループ化されます。これによりメモリ使用量が有界に保たれ、実行ごとの領域リストも安定します。保持する領域の詳細情報を切り詰めても、影響を受けるのは一覧表示だけで、報告される領域数には影響しません。したがって、MaxChangedRegionsより多くの変更領域を持つページでも、実際にいくつあったかは正しく報告されます

直感に反する動作なので、はっきり述べておく価値のある挙動が1つあります。レンダラーの失敗、メモリ確保の失敗、オーバーレイ生成の失敗は、決してもみ消されません。この種の問題はすべてrenderErrorまたはrenderBudgetとして記録され、renderComparisonComplete=falseを強制します。レンダリングに失敗したページは、誰も比較していないページであり、それを同一だと報告することは、何も報告しないことよりも悪いことだからです

各モードはパイプラインのどこに位置づけられるのか

構造比較は「何が変わったか」に答えるものであり、回帰テストスイートにとっての適切なデフォルトです。関連するパス、ページインデックス、オブジェクト番号を名指しするため、失敗はそれを生み出したコードを指し示します。レンダリング比較は「誰かが気づくかどうか」に答えるものであり、これは承認作業や、最適化パスが本当に無損失だったかを検証する際の問いです

両者はうまく組み合わせられます。すべてのビルドでcmStructuralを実行し、予期しないオブジェクトレベルの変更があれば派手に失敗させましょう。リリース前には、オーバーレイを確認できる人がいるタイミングで、ヒートマップ付きのcmFullを実行します。他の理由ですでにページマークアップを出力しているパイプラインでは、PDFページのSVGエクスポートで説明したテキスト出力が、人間が差分を読める第三の視点を与えてくれます。また、プリフライトレポートの自動化にある自動チェックは、どちらの差分モードも答えるようには設計されていない適合性の問いをカバーします

比較、プリフライト、レンダリングは同じ「読み込み済みドキュメント」オブジェクトモデルを共有しているため、ファイルに対する1回のパスで3つすべてに情報を供給できます。Delphiおよび C++Builder向けの完全な機能一覧は、HotPDF Delphi PDFコンポーネントページに掲載されています