HotPDF 透過 THPDFDocComparison 在 Delphi 中比對兩份 PDF 文件,從目錄開始向外走訪兩份檔案的物件圖,並在需要時轉譯每一對頁面、量測有差異的像素。結果是一份 JSON 報告,列出找到的每一項差異、消耗的預算,以及比對是否完整跑完。這兩個步驟都很重要,因為結構化差異與視覺化差異回答的是不同的問題
這項功能背後的問題通常是發版問題。報表引擎收到一項變更、輸出被重新產生,接著就得有人判斷是否有任何東西動過。把兩份檔案並排開啟,大約在三頁以內還撐得住注意力。逐位元組比對原始資料則從一開始就沒用,因為同一個產生器跑兩次,會因為與讀者所見毫無關係的原因而產生不同的位元組
為什麼 PDF 可以位元組不同卻視覺上一模一樣?
兩份獨立產生、列印出來一模一樣的 PDF,其位元組經常有差異,而原因是結構性的而非表面上的。物件編號是依物件實際寫入的順序指派。字型子集依字符首次出現的順序分配 CID,因此稍微不同走訪順序所建置的子集,即使呈現的文字相同,也會產生不同的內容串流位元組。交叉參照偏移量只要上游任何東西的長度一變,就會跟著位移
這正是為什麼物件編號無法用作跨文件的身分識別。HotPDF 改為從目錄開始走訪來建立每份快照,並依鍵值的位元組順序展開字典、依索引展開陣列,因此每個物件都是以到達它的路徑來命名。走訪從根節點無法到達的物件,會退回一個帶有物件編號與世代的合成 $Unreachable[...] 路徑,讓孤立的內容保持在報告中可見,而不是悄悄消失
串流不是靠複製來比對的。每個串流貢獻一個以累加方式計算的 SHA-256 簽章,計算完畢後會還原原始的串流位置,因此比對兩份一百百萬位元組的檔案,並不代表要把兩百百萬位元組的資料實體化兩次
當某份文件插入了頁面時,如何對齊頁面?
把第 1 頁對第 1 頁、第 2 頁對第 2 頁如此逐一比對,只有在沒有插入任何內容時才是正確的。若插入一張封面頁,天真的比對會回報每一頁都變了,這在技術上正確,操作上卻毫無用處
HotPDF 會先對齊頁面再進行比對。它從可擷取的文字為每一頁建立一份簽章,對沒有文字的頁面則退回結構化簽章,接著在已配對的目標索引上計算最長遞增子序列。落在該子序列內的頁面只是位置有偏移,落在子序列外的頁面則是真正的移動。這個區別讓一份 400 頁手冊的差異報告能夠讀懂,因為報告會說「插入了一頁」,而不是「四百頁都變了」
執行結構化比對
最簡單的呼叫方式接受兩份已載入的文件與一種模式。cmStructural 執行物件圖走訪,cmRenderedImage 執行像素比對,cmFull 兩者都做,而較輕量的模式 cmPageCount、cmPageText 與 cmObjectCount 則是為了低成本的快速檢查而存在:
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;
這份報告區分出布林值無法表達的三種狀態。identical 說明是否有任何差異,comparisonComplete 說明走訪是否完整跑完,comparisonBudget 則點名如果被中止是被哪個上限攔下。當比對耗盡預算時,會同時回報 comparisonComplete=false 與 identical=false,因為一次被截斷的走訪沒有依據能宣稱兩者相等。任何只讀取 identical 的自動化流程,遲早會把預算中止誤判為真正的差異,因此請三個欄位都讀取
哪些上限讓走訪保持有界?
THPDFStructuralCompareLimits.Default 中的預設值是針對真實文件而非刻意刁難的文件設計的,每個具語意重要性的預算都有自己的上限:250,000 個物件、2,000,000 條邊、深度 128、10,000 項回報差異、每個串流 64 MB 與串流位元組總量 512 MB、每個值 1 MB、每條路徑 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 會以平方級數提高精確度與成本:150 DPI 的像素數是 72 的四倍,每頁與總量的像素上限之所以存在,正是因為一個以 300 DPI 執行的批次作業,若無上限就會一路配置記憶體到出問題為止
相似到什麼程度才算相似?
兩頁只有在兩項條件同時成立時才算相似:改變的像素比例小於或等於 MaxChangedPixelRatio,且相似度大於或等於 MinimumSimilarity。之所以用兩個門檻而非一個,是因為少數幾個嚴重錯誤的像素跟大範圍細微的色偏,是兩種不同的失敗,而在某種工作流程中其中一種可以接受,換到另一種工作流程可能就無法接受。門檻檢定使用未四捨五入的數值;JSON 中的六位小數是為了讓報告穩定且可比對,而非用來定義比對本身
改變的像素會以固定大小的方塊格作為節點、以四向鄰接方式分群成區域,而非逐像素泛洪填充。這讓記憶體用量保持有界,也讓每次執行的區域列表保持穩定。截斷保留的區域細節只會影響清單本身,不會影響回報的區域數量,因此變更區域數超過 MaxChangedRegions 的頁面,仍然會回報實際有多少個區域
有一項行為值得明白說出來,因為它與一般直覺相反。轉譯失敗、配置失敗與疊圖失敗絕不會被吞掉。這類失敗一律記錄為 renderError 或 renderBudget,並強制把 renderComparisonComplete 設為 false,因為一頁轉譯失敗的頁面,等於沒有人比對過它,把它回報為相同,比什麼都不報告更糟
每種模式在流程中各自的定位
結構化比對回答的是「改了什麼」,是回歸測試套件的正確預設選擇:它會點名路徑、頁面索引與相關的物件編號,因此一次失敗能直接指向產生該問題的程式碼。轉譯比對回答的是「有沒有人會注意到」,這是核准流程與驗證一次最佳化真的沒有損失品質時該問的問題
兩者搭配得很好。每次建置都跑 cmStructural,讓它對非預期的物件層級變更大聲抗議;發版前跑帶疊圖的 cmFull,這時有人力可以審視疊圖。若你的流程已經因其他原因輸出頁面標記,匯出 PDF 頁面為 SVG 一文所述的文字輸出提供第三種、可供人工比對的檢視方式,而 飛航前檢查報告自動化 一文所述的自動化檢查,則涵蓋這兩種比對模式都不打算回答的規範合規問題
比對、飛航前檢查與轉譯共用同一套已載入文件物件模型,因此對一份檔案只需掃描一次,就能同時供這三者使用。適用 Delphi 與 C++Builder 的完整功能清單,列於 HotPDF Delphi PDF 元件頁面