技術文章

Delphi 的視覺化 PDF Diff:像素比較與頁面配對

PDFlibPas 的視覺化比較以相同 DPI 算繪兩份 PDF,比較的是讀者實際看到的東西。TPDFlib.CompareRenderedPagesWith 比較一組頁面對,回報變更像素、變更區域、遮罩與疊加層。TPDFlib.CompareDocumentVisualsWith 與 TPDFlib.CompareFileVisuals 則逐頁配對整份文件,偵測插入與移除的頁面,並把一切以 JSON 回報

用途是視覺化迴歸測試。有人改寫了發票範本、升級了字型套件,或換掉了算繪引擎,問題不在位元組有沒有變(一定會變),而在客戶會不會看出差別。結構式 diff 回答的是另一個問題。兩份檔案可以在每一條 content stream 上都不同、卻畫出一模一樣的頁面;而一個位移的運算元,可以讓總額移動三個點,卻不碰任何文字擷取器會標記的東西。像素是唯一一層"看起來一樣"可以被測試的地方

視覺化 PDF diff 實際比較的是什麼?

PDFlibPas 比較的是白底頁面上的可見結果,不是原始緩衝區內容。每一頁都透過 RenderPageToString 算繪成未壓縮的 BMP,解碼成 top-down 點陣圖,任何帶 alpha 的像素在通道比較之前都先合成到白色上。因此,螢幕上看起來一模一樣的 RGB 點陣圖與 RGBA 點陣圖會被判為相同——當兩側來自不同的算繪路徑時,這正是您要的行為

BMP 解碼器有一個故意為之、值得知道的怪癖。32 位元的 BI_RGB 點陣圖每個像素帶第四個位元組,但格式並未定義這個位元組:有的寫入端填 0,有的填 255,有的留殘值。PLDecodeVisualBMP 忽略它、保留顏色。把這個位元組當成不透明度,會讓一個填零的算繪器把整頁像素全部擦掉,而 diff 還會欣然回報兩份空白且相同的文件。如果您把自己的點陣圖餵進較底層的 API,只有您自己把格式宣告成 vpfRGBA 或 vpfBGRA 時,alpha 才會被採用

任何一個紅、綠、藍通道的差異超過 ColorTolerance,該像素就算變更。容差 0 表示逐位元完全一致。實務上,個位數的值可以吸收不同算繪器版本之間的抗鋸齒抖動,又不會遮住真正變了的字形,因為真正的文字變動造成的通道偏移遠大於此。這個數字請從一次已知良好的基準執行裡挑,不要靠猜

用 CompareRenderedPagesWith 比較單一頁面對

當您已經知道哪些頁面該配在一起、又想要細節時,TPDFlib.CompareRenderedPagesWith 就是那個工具:一個包圍框、相連的變更區域、可選的逐像素遮罩,以及可選的紅色調疊加層。選項記錄與結果型別位於 PDFlibVisualCompare 單元,PLDefaultVisualCompareOptions 給您容差 0、32 像素的 tile、無忽略區域、無遮罩與疊加層

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;

這裡有三個細節常咬人。忽略矩形以算繪像素為單位,原點在左上、區間半開,所以 Right 與 Bottom 是排除值,同一個矩形在改了 DPI 之後蓋住的就是頁面另一塊地方。重疊的忽略矩形只計一次;被忽略的像素會計入 IgnoredPixels 而不是憑空消失,這讓 ChangedRatio 保持誠實,因為它的分母只算被比較的像素。最後,單頁呼叫要求兩次算繪尺寸相同;尺寸變了的頁面會以錯誤失敗,而不是被拉伸湊合

區域來自一次 tile 掃描:變更像素被裝進 TileSize 大小的方格,相鄰的 tile 合併成一個帶自己邊界與像素數的區域。tile 越大,區域越少越粗,這通常正是測試報告想要的。疊加層是第二頁的 RGB 複本、變更像素染紅,以原始位元組的形式放在 Comparison.Overlay;把它寫成 PNG 放進 CI 產物是您的程式碼的事,不是函式庫的

文件層級的頁面配對如何處理插入的頁面?

TPDFlib.CompareDocumentVisualsWith 以精確像素指紋上的最長共同子序列(LCS)配對頁面,所以插入一頁不會把之後每一頁都變成假變更。天真的第 1 頁對第 1 頁、第 2 頁對第 2 頁迴圈,在範本多了一張封面時就廢了:插入點之後每一頁都偏移一頁,報告從頭紅到尾

每一頁都會被算繪,然後化約成一把鑰匙:像素寬、像素高,加上可見像素的 SHA-256 摘要。尺寸比看起來更重要:一張全白的直式頁與一張同面積的全白橫式頁,像素緩衝區逐位元組相同,鑰匙裡若沒有尺寸,兩者就會互相錨定。兩側相同的鑰匙成為錨點,LCS 挑出它們之間最長的有序鏈,兩個相鄰錨點之間的頁面則按順序配對。當某一側的間隔裡頁面比較多,多出來的就回報為 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)與 kind,kind 的值是 unchanged、changed、size_changed、added 或 removed

kind 為 changed 與 unchanged 的列內嵌一個 comparison 物件,含 width、height、comparedPixels、changedPixels、ignoredPixels、changedRatio、boundingBox(無變更時為 null)以及 regions 清單,每個區域是 bounds 加 changedPixels。size_changed 的列完全沒有 comparison,只回報 firstWidth、firstHeight、secondWidth 與 secondHeight,並計入 changedPages。把尺寸變了的頁面拉伸去比,只會產生一面毫無意義的變更像素牆,所以函式庫回報幾何變更就停手

文件報告絕不包含遮罩與疊加層。CompareDocumentVisualsWith 會強制關掉 IncludeMask 與 IncludeOverlay,就算您的選項開了也一樣,這樣頁數成長時記憶體保持平坦,不會替每一組配對捧著一張整頁預覽。用報告找出有意思的頁面,再對那幾頁呼叫 CompareRenderedPagesWith 拿疊加層。失敗以 "status":"error"、"complete":false 加一個 error 字串回傳,LastErrorCode 則設為 401

用 CompareFileVisuals 與檔案比較

TPDFlib.CompareFileVisuals 是給 CI 用的單次呼叫形式:它載入另一份 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;

算繪器的繼承正是這個呼叫比自己載入第二份檔案更好的原因。載入的文件會承接目前實例的 render scale、crop type 與圖形設定,編譯有進來的話還包括 Cairo 或 PDFium 引擎選擇。如果您為 CompareDocumentVisualsWith 手動建兩個 TPDFlib 實例,把兩邊設成一致就是您自己的責任,設得不一致會表現成每一頁都變了。它也不能收忽略區域;需要那些就用有型別的 API

限制、成本,以及結構式 diff 的位置

PDFlibPas 明確框住工作量,讓懷有惡意或巨大的輸入以錯誤收場,而不是把記憶體吃光。算繪後的頁面不得超過 64 Mi 像素,tile 網格上限 4 Mi 個 tile,頁面對齊則拒絕兩側頁數總和超過 8192、或 LCS 矩陣會超過 4 Mi 格的輸入。文件比較還會把每一頁算繪兩次,一次取指紋、一次做配對比較,150 DPI 以上請把這筆帳算進去。對大多數迴歸測試集,72 到 96 DPI 就能用幾分之一的時間抓住版面與文字變更

視覺式 diff 與結構式 diff 回答的是不同的問題,認真的管線通常兩個都要。像素比較告訴您讀者會不會注意到什麼。它說不了一個簽署過的文件是否多了一個算繪到頁面外的註解,也說不了一次增量更新是否悄悄把表單欄位值換成了同樣可見的文字。那一層的事,分類 PDF 簽署後改了什麼一文會帶您走修訂層級的 diff。而算繪引擎的選擇決定像素,在把基準釘在某個引擎之前,值得先讀Delphi 的多引擎 PDF 算繪

FAQ

PDFlibPas 的視覺化比較會拉伸尺寸變了的頁面嗎?

不會。CompareRenderedPagesWith 對像素尺寸不同的頁面回傳錯誤,文件報告則把這類配對標成 size_changed 並附上兩側尺寸,而不是硬比

為什麼不用近乎相同的頁面當錨點?

錨點用的是可見像素加頁面尺寸的精確 SHA-256 指紋,這樣對齊才可預測。容差是在之後、配對頁面互相比較時才套用的,所以只有雜訊差異的頁面最後仍是 unchanged

能為文件的每一頁取得疊加圖嗎?

文件層級呼叫給不了,它刻意省略遮罩與疊加層來讓記憶體保持平坦。用 JSON 找出變更的頁面,再對那幾頁帶著 IncludeOverlay 呼叫 CompareRenderedPagesWith

視覺化比較、頁面配對與其餘算繪及文件 API,都隨 PDFlibPas PDF library for Delphi 出貨