PDFlibPas のビジュアル比較は、2 つの PDF を同じ DPI でレンダリングし、リーダーが実際に見るものを diff します。TPDFlib.CompareRenderedPagesWith は 1 組のページを比較し、変化したピクセル、リージョン、マスク、オーバーレイを返します。TPDFlib.CompareDocumentVisualsWith と TPDFlib.CompareFileVisuals は文書全体をページ単位でペアリングし、挿入・削除されたページを検出して、すべてを JSON として報告します
ユースケースはビジュアルリグレッションテストです。誰かが請求書テンプレートを書き直し、フォントパッケージを更新し、レンダリングエンジンを交換する。そのとき問うべきは、バイト列が変わったかどうか(常に変わります)ではなく、顧客の誰かが違いに気づくかどうかです。構造 diff は別の問いに答えます。すべてのコンテンツストリームが異なりながら同一のページを描く 2 つのファイルもあり得ますし、オペランドが 1 つずれただけで、テキスト抽出器がフラグを立てる何にも触れずに合計が 3 ポイント動くこともあります。"見た目が同じ"が検証できる唯一のレイヤーはピクセルです
ビジュアル PDF diff は実際に何を比較するのか
PDFlibPas が比較するのは白いページ上の可視結果であって、生のバッファ内容ではありません。各ページは RenderPageToString で非圧縮 BMP としてレンダリングされ、上から下向きのビットマップにデコードされ、アルファを持つピクセルはチャネル比較の前に白の上へ合成されます。したがって画面上で同一に見える RGB ビットマップと RGBA ビットマップは同一と判定されます。両側が異なるレンダリング経路から来たときに、まさに望む挙動です
BMP デコーダには、知っておく価値のある意図的な癖が 1 つあります。32 ビットの BI_RGB ビットマップはピクセルごとに第 4 のバイトを運びますが、フォーマットはこのバイトを未定義のままにしています。0 を埋めるライターもあれば、255 を埋めるもの、残りカスを埋めるものもあります。PLDecodeVisualBMP はこれを無視して色を保持します。このバイトを不透明度として扱うと、0 を書くレンダラーがページ上の全ピクセルを消し去り、diff は 2 つの白紙で同一の文書を平然と報告することになります。下位レベルの API に自分のビットマップを流し込む場合は、フォーマットを自分で vpfRGBA か vpfBGRA と宣言したときに限りアルファが尊重されます
赤、緑、青のいずれかのチャネルが ColorTolerance を超えて異なれば、そのピクセルは変化と数えます。許容差 0 はビット一致を意味します。実務では 1 桁の値を選んでおけば、レンダラービルド間のアンチエイリアスの揺らぎを吸収しつつ、変化したグリフを隠しません。本物のテキスト変化はチャネルをそれよりはるかに大きく動かすからです。数値は勘で決めず、既知の正常なベースライン実行から選びます
CompareRenderedPagesWith で 1 組のページを比較する
どのページ同士が対応するか既に分かっていて、詳細が欲しいときのツールが TPDFlib.CompareRenderedPagesWith です。バウンディングボックス、連結した変化リージョン、任意のピクセル単位マスク、任意の赤 tint のオーバーレイが得られます。オプションレコードと結果型は PDFlibVisualCompare ユニットにあり、PLDefaultVisualCompareOptions は許容差 0、32 ピクセルのタイル、無視リージョンなし、マスクとオーバーレイなしの設定を返します
uses
PDFlibrary, PDFlibVisualCompare;
var
Baseline, Candidate: TPDFlib;
Options: TPDFVisualCompareOptions;
Comparison: TPDFVisualCompareResult;
ErrorText: WideString;
begin
Baseline := TPDFlib.Create;
Candidate := TPDFlib.Create;
try
if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
(Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
begin
Options := PLDefaultVisualCompareOptions;
Options.ColorTolerance := 8;
Options.IncludeOverlay := True;
// 印刷タイムスタンプをマスクする。ピクセル単位(下の DPI 基準)
SetLength(Options.IgnoreRects, 1);
Options.IgnoreRects[0].Left := 600;
Options.IgnoreRects[0].Top := 20;
Options.IgnoreRects[0].Right := 790;
Options.IgnoreRects[0].Bottom := 50;
if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
Comparison, ErrorText) then
begin
if Comparison.ChangedPixels > 0 then
Writeln(PLVisualCompareResultToJSON(Comparison));
end
else
Writeln('Compare failed: ', ErrorText);
end;
finally
Candidate.Free;
Baseline.Free;
end;
end;
ここには人を咬む細部が 3 つあります。無視矩形はレンダリングされたピクセル単位で、原点は左上、区間は半開です。Right と Bottom は排他であり、DPI を変えれば同じ矩形がページの別の部分を覆います。重なる無視矩形は 1 回だけ数えられ、無視されたピクセルは消えるのではなく IgnoredPixels に現れます。これで ChangedRatio が正直に保たれます。比較ピクセルだけで割るからです。最後に、1 ページの呼び出しは両側のレンダリングが同じ寸法であることを要求します。サイズが変わったページは引き伸ばして合わせる代わりに、エラーで失敗します
リージョンはタイルパスから来ます。変化したピクセルを TileSize の正方形に仕分けし、隣接するタイルを、固有の境界とピクセル数を持つ 1 つのリージョンへ統合します。タイルを大きくするとリージョンは少なく粗くなります。テストレポートが普通は求めるものです。オーバーレイは 2 ページ目の RGB コピーで、変化したピクセルが赤く色付けされ、生バイト列として Comparison.Overlay に入って返ります。CI の成果物として PNG に書き出すのは自分のコードの仕事で、ライブラリの仕事ではありません
文書レベルのページペアリングは挿入ページをどう扱うのか
TPDFlib.CompareDocumentVisualsWith は、正確なピクセルフィンガープリント上の最長共通部分列(LCS)でページをペアリングします。挿入ページが 1 枚あっても、その後の全ページが偽の変化に化けません。ページ 1 対ページ 1、ページ 2 対ページ 2 という素朴なループは、テンプレートに表紙が 1 枚加わった瞬間に役立たずになります。挿入より後の全ページが 1 枚ずれ、レポートは上から下まで赤く点灯します
各ページはレンダリングされた後、ピクセル幅、高さ、可視ピクセルの SHA-256 ダイジェストからなるキーへ要約されます。寸法が思ったより重要なのはここです。同じ面積の全白ポートレートページと全白ランドスケープページは、ピクセルバッファがバイト単位で同一であり、キーに寸法がなければ互いにアンカーとして結び付いてしまいます。両側で同一のキーがアンカーになり、LCS がその最長の順序付きチェーンを選び、連続する 2 つのアンカーの間のページが順番にペアリングされます。どちらかの隙間が片側で多くのページを抱えている場合、余剰は added か removed として報告されます
var
Options: TPDFVisualCompareOptions;
Report: WideString;
begin
// Baseline と Candidate を以前と同じく、一致するレンダリング設定で読み込み済み
Options := PLDefaultVisualCompareOptions;
Options.ColorTolerance := 8;
Options.TileSize := 64;
Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
if Baseline.LastErrorCode <> 0 then
Writeln('Visual diff failed: ', Report)
else
Writeln(Report);
end;
アンカーはビット一致で、これは見落としではなく設計上の選択です。ColorTolerance はペアになったページの比較にのみ適用され、フィンガープリントには決して適用されません。アンチエイリアスのノイズしかないページはアンカーにはなりませんが、それでもどこかの隙間に落ちて、順番に相手とペアになり、許容差の下で unchanged と判定されます。曖昧なアンカーのほうは寛容な代わりに、はるかに予測不能になります。近重複ページ(白紙の区切りページ、繰り返しの約款ページ)が互いにマッチし始めるからです
このアラインメントが何であるかは、はっきり理解しておくべきです。JSON が exact-raster-lcs-gap-order と名付けているのは意図的です。これはラスターのアラインメントであって、意味論的なページ認識ではありません。隙間に書き直されたページと挿入されたページが両方入っている場合、順序通りのペアリングは書き直しを挿入ページと比較し、オリジナルを removed として報告することがあります。同一ページだらけの文書も同様に曖昧です。ペアリングが判定に効く場合は、カウンタだけでなくペアそのものを見てください
JSON レポートの読み方
文書レポートは、テストランナーでアサートできる固定の形をしています。成功時には "status":"complete"、"complete":true、アラインメント名、firstPageCount、secondPageCount、そしてカウンタの changedPages、addedPages、removedPages、unchangedPages を運びます。pairs 配列は続いてすべての行を firstPage、secondPage(欠けている側は 0)、unchanged、changed、size_changed、added、removed のいずれかの kind 付きで列挙します
kind が changed と unchanged の行は comparison オブジェクトを埋め込んでいます。中身は width、height、comparedPixels、changedPixels、ignoredPixels、changedRatio、boundingBox(変化がなければ null)、そして bounds と changedPixels からなる regions リストです。size_changed の行には comparison が一切なく、firstWidth、firstHeight、secondWidth、secondHeight を報告して changedPages に数えられます。サイズが変わったページを引き伸ばして比較すれば、無意味な変化ピクセルの壁ができるだけです。ライブラリは幾何の変化を報告してそこで止めます
文書レポートにマスクとオーバーレイは一切入りません。CompareDocumentVisualsWith は、オプションが求めていても IncludeMask と IncludeOverlay を強制的にオフにします。ページ数が増えてもメモリが平らに保たれ、ペアごとに全ページのプレビューを抱え込まないためです。レポートで気になるページを見つけ、そのページだけに CompareRenderedPagesWith を呼んでオーバーレイを取ります。失敗時は "status":"error"、error 文字列付きの "complete":false で返り、LastErrorCode が 401 に設定されます
CompareFileVisuals でファイルと比較する
TPDFlib.CompareFileVisuals は CI 向けの 1 回呼び出し形です。相手の PDF を読み込み、現在のレンダラー設定をそのままコピーし、同じ文書 JSON を返します。シグネチャは CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance) で、許容差は 0 から 255、DPI は有限の正の値でなければなりません
var
Lib: TPDFlib;
Report: WideString;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
begin
Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
Writeln(Report);
end;
finally
Lib.Free;
end;
end;
2 つ目のファイルを自分で読み込む代わりにこの呼び出しを使うべき理由は、このレンダラー継承にあります。読み込まれた文書は、現在のインスタンスのレンダースケール、クロップタイプ、グラフィックス設定を引き継ぎ、コンパイルに含まれていれば Cairo か PDFium のエンジン選択も引き継ぎます。CompareDocumentVisualsWith のために 2 つの TPDFlib インスタンスを手作業で構築するなら、両方を同一に設定するのは自分の責任で、食い違いは全ページが変化したという形で表面化します。無視リージョンは受け付けません。必要なら型付き API に手を伸ばしてください
上限、コスト、構造 diff の居場所
PDFlibPas は作業量を明示的に上限で切り、悪意のある入力や巨大な入力がメモリを使い果たす代わりにエラーで失敗するようにしています。レンダリングされたページは 64 Mi ピクセルを超えられず、タイルグリッドは 4 Mi タイルで頭打ちになり、ページアラインメントは 2 文書のページ数の合計が 8192 を超える入力や、LCS マトリクスが 4 Mi セルを超える入力を拒否します。文書比較はさらに、各ページを 2 回レンダリングします。フィンガープリント用に 1 回、ペア比較用にもう 1 回です。150 DPI 以上ではこれを見込んで予算を組んでください。ほとんどのリグレッションスイートなら 72 から 96 DPI で、レイアウトとテキストの変化を何分の一もの時間で捕まえられます
ビジュアル diff と構造 diff は別々の問いに答え、本気のパイプラインはたいてい両方を欲しがります。ピクセル比較が教えてくれるのは、リーダーが何かに気づくかどうかです。署名済み文書がページの外にレンダリングされる注釈を追加したかどうか、あるいはインクリメンタル更新が見た目同じテキストでフォームフィールドの値を静かに置き換えたかどうかは教えてくれません。その層については、署名後の PDF で何が変わったかを分類する記事がリビジョンレベルの diff をたどります。そしてレンダラーの選択がピクセルを決める以上、ベースラインを 1 つのエンジンに固定する前にDelphi でのマルチエンジン PDF レンダリングを読む価値があります
FAQ
PDFlibPas のビジュアル比較はサイズが変わったページを引き伸ばすのか
いいえ。CompareRenderedPagesWith はピクセルサイズの異なるページに対してエラーを返し、文書レポートはそのようなペアを比較する代わりに、両方の寸法付きで size_changed とマークします
近似の同一ページがアンカーに使われないのはなぜか
アンカーは可視ピクセルとページサイズの正確な SHA-256 フィンガープリントを使うため、アラインメントは予測可能に保たれます。許容差はその後、ペアのページを比較するときに適用されるので、ノイズしかないページも結局 unchanged になります
文書の全ページのオーバーレイ画像は取得できるのか
文書呼び出しからは取れません。メモリを平らに保つため、意図的にマスクとオーバーレイを省いています。JSON で変化ページを見つけ、そのページに IncludeOverlay 付きで CompareRenderedPagesWith を呼んでください
ビジュアル比較、ページペアリング、その他のレンダリングと文書 API は、Delphi 向け PDFlibPas PDF ライブラリに同梱されています