技术文章

在 Delphi 中比较两份 PDF 文件:结构与像素

HotPDF 通过 THPDFDocComparison 从 Delphi 比较两份 PDF 文档,它会从两个文件的目录出发向外遍历对象图,并在需要时同时渲染每一对页面、测量差异的像素。结果是一份 JSON 报告,列出发现的每一处差异、消耗的预算,以及比对是否运行到了完成状态。两趟比对都很重要,因为结构性差异和视觉性差异回答的是不同的问题

驱动这项功能的通常是一个发布相关的问题。报表引擎收到一次变更,输出被重新生成,总得有人判断有没有东西变了。把两个文件并排打开,大约三页以内还能应付,再多注意力就跟不上了。逐字节比较原始数据则完全无济于事,因为同一个生成器跑两次产生的字节会有差异,而这些差异和阅读者看到的内容毫无关系

为什么 PDF 可以字节不同却视觉上完全一致?

两份独立生成、打印效果完全一致的 PDF,字节层面经常存在差异,原因是结构性的而非表面的。对象编号是按对象实际被写出的顺序分配的。字体子集按字形首次出现的顺序分配 CID,因此在略有不同的遍历过程中构建出的子集,对同一段可见文本也会产生不同的内容流字节。交叉引用偏移量只要上游任何内容发生长度变化就会随之偏移

这正是对象编号不能用作跨文档身份标识的原因。HotPDF 转而从目录出发遍历构建每份快照,按字典键的字节顺序展开字典、按索引展开数组,因此每个对象都以到达它所经过的路径来命名。遍历从根节点触及不到的对象会退回到一个合成的 $Unreachable[...] 路径,携带对象编号和代数,这样孤立的内容会在报告中保持可见,而不是悄悄消失

流不是通过复制来比较的。每个流都会贡献一个增量式的 SHA-256 签名,计算过程中会在之后恢复原始的流位置,因此比较两份上百兆字节的文件并不意味着要把两百兆字节的数据物化两次

当某份文档有插入内容时如何对齐页面

把第 1 页和第 1 页比、第 2 页和第 2 页比,以此类推,这种做法只有在没有任何插入的情况下才是正确的。插入一页封面,朴素的比较就会把每一页都报告为已更改,这在技术上确实如此,但在实际运用中毫无意义

HotPDF 在比对之前会先对齐页面。它会为每一页根据可提取的文本构建一个签名,对没有文本的页面则退回到结构性签名,然后在匹配到的目标索引上计算最长递增子序列。落在该子序列内的页面只是发生了整体位移,落在子序列之外的才是真正的移动。这种区分正是让一份 400 页手册的比对结果变得可读的关键,因为报告会说"插入了一页",而不是"四百页都变了"

运行一次结构比较

最简单的调用接受两份已加载文档和一个模式。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;

该报告区分了一个布尔值无法表达的三种状态。identical 表示是否存在任何差异,comparisonComplete 表示遍历是否完成,comparisonBudget 则在遍历被中止时说明是哪个限制导致了中止。一旦耗尽预算,比对会同时报告 comparisonComplete=falseidentical=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 DPI 的四倍,每页和总量的像素上限之所以存在,正是因为在 300 DPI 下批量运行否则会一路分配内存直到出问题

相似到什么程度才算足够相似?

只有两个条件同时成立,两页才算相似:变化像素占比在 MaxChangedPixelRatio 以内,并且相似度达到 MinimumSimilarity 或更高。用两个阈值而不是一个,是因为少量灾难性错误的像素和大面积微小色差是两类不同的失败,其中任何一类都可能在某个流程中可以接受,而在另一个流程中足以判定不合格。阈值判断使用未经四舍五入的数值;JSON 中保留六位小数是为了让报告稳定、便于比对,而不是用来定义比对本身

变化的像素以固定大小的图块作为节点,采用四向邻接关系分组成区域,而不是逐像素的泛洪填充。这样能让内存占用保持有界,也让区域列表在多次运行之间保持稳定。裁剪保留的区域细节只会影响列表本身,不会影响报告出的区域计数,因此变化区域数量超过 MaxChangedRegions 的页面仍会如实报告其实际数量

有一个行为值得明说,因为它和通常的直觉相反。渲染失败、内存分配失败和叠加图失败绝不会被悄悄吞掉。此类问题一律记录为 renderErrorrenderBudget,并强制 renderComparisonComplete=false,因为一个渲染失败的页面就是一个没人真正比对过的页面,把它报告为相同远比报告为空更糟糕

各种模式在流水线中各自的位置

结构比较回答"改了什么",是回归测试套件的正确默认选择:它会指出路径、页面索引以及涉及的对象编号,因此一次失败会直接指向产生它的代码。渲染比较回答"是否有人会注意到",这是审批流程要问的问题,也是验证一次优化确实无损的问题

两者搭配使用效果很好。在每次构建时运行 cmStructural,让它对意料之外的对象级变化大声报错;在发布前、有人可以查看叠加图时运行带热力图的 cmFull。对于已经因其他原因输出页面标记的流水线,将 PDF 页面导出为 SVG 中描述的文本输出提供了第三种、人眼可比对的视图,而 预检报告自动化 中的自动化检查覆盖的是两种比对模式都不打算回答的合规性问题

比较、预检和渲染共享同一个已加载文档对象模型,因此对一个文件的单次遍历可以同时供三者使用。适用于 Delphi 和 C++Builder 的完整功能列表见 HotPDF Delphi PDF 组件页面