技术文章

Delphi 中的可视化 PDF Diff:像素比较与页面配对

PDFlibPas 的可视化比较把两份 PDF 渲染到同一 DPI,再对读者实际看到的东西做 diff。TPDFlib.CompareRenderedPagesWith 比较一对页面,返回变更像素、变更区域、蒙版和叠加图。TPDFlib.CompareDocumentVisualsWith 和 TPDFlib.CompareFileVisuals 则把整份文档逐页配对,检测插入页和移除页,全部结果以 JSON 报告

用例是视觉回归测试。有人重写了发票模板,升级了一个字体包,或者换掉了渲染引擎,此时问题不是字节有没有变(它们总会变),而是有没有客户会看出差别。结构 diff 回答的是另一个问题。两份文件可以每条内容流都不一样,画出来的页面却完全相同;一个移位的操作数能让合计挪动三个点,却不碰任何文本提取器会标记的东西。像素是唯一一层"看起来一样"可以被测试的地方

可视化 PDF diff 到底比较的是什么

PDFlibPas 比较的是白底页面上的可见结果,不是原始缓冲区内容。每页先经 RenderPageToString 渲染成未压缩 BMP,解码成自上而下的位图,任何带 alpha 的像素都会先合成到白色上,然后才比较通道。于是一个 RGB 位图和一个在屏幕上看起来一模一样的 RGBA 位图会判为相同——两边来自不同渲染路径时,这正是你想要的行为

BMP 解码器有一个刻意为之、值得知道的怪癖。32 位 BI_RGB 位图每个像素带第四个字节,但格式并没有定义这个字节:有的写入器填 0,有的填 255,有的填残留值。PLDecodeVisualBMP 无视它,只保留颜色。若把那个字节当透明度,一个写零的渲染器就能擦掉页面上所有像素,diff 还会欢快地报告两份空白且相同的文档。如果你把自己的位图喂给更底层的 API,只有你自己把格式声明为 vpfRGBA 或 vpfBGRA 时,alpha 才会被尊重

像素的红、绿、蓝任一通道差值超过 ColorTolerance,就算变更。容差 0 表示逐位一致。实践中个位数的值能吸收不同渲染器构建之间的抗锯齿抖动,又不会掩盖一个真正变化的字形,因为真实的文本变化带来的通道偏移远不止这点。这个数请从一次已知良好的基线运行里取,别靠猜

用 CompareRenderedPagesWith 比较单对页面

当你已经知道哪些页面互相对应、又想要细节时,TPDFlib.CompareRenderedPagesWith 就是那个工具:边界框、连通的变更区域、可选的逐像素蒙版和可选的红色叠加图。选项记录和结果类型位于 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;

这里有三处细节咬人。忽略矩形用渲染像素表示,原点在左上角且区间半开,所以 Right 和 Bottom 是排他的,同一个矩形一旦改了 DPI,罩住的就是页面另一块地方。重叠的忽略矩形只计一次,被忽略的像素会出现在 IgnoredPixels 里而不是凭空消失,这让 ChangedRatio 保持诚实,因为它只除以参与比较的像素。最后,单页调用要求两边渲染尺寸一致;尺寸变了的页面会报错,而不是被拉伸凑合

区域来自一趟瓦片划分:变更像素被归进 TileSize 见方的瓦片,相邻瓦片再合并成一个带自己边界和像素数的区域。瓦片越大,区域越少越粗,测试报告通常要的就是这个。叠加图是第二页的 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——unchanged、changed、size_changed、added 或 removed

changed 和 unchanged 行内嵌一个 comparison 对象,含 width、height、comparedPixels、changedPixels、ignoredPixels、changedRatio、boundingBox(无变化时为 null)以及 regions 列表——每项是 bounds 加 changedPixels。size_changed 行则完全没有比较对象;它报告 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;

渲染器设置的继承,正是这个调用比你自己加载第二份文件更可取的原因。被加载的文档会沿用当前实例的渲染缩放、裁剪类型和图形设置,编译进来的 Cairo 或 PDFium 引擎选择也同样沿用。如果你为 CompareDocumentVisualsWith 手工构建两个 TPDFlib 实例,两边配置完全一致就是你的责任,一处不一致的表现就是每页都判变更。它也不支持忽略区域;需要时就用强类型的 API

限制、开销与结构 diff 的位置

PDFlibPas 给工作量划了明确的界,让恶意或超大输入以报错收场,而不是耗光内存。渲染后的页面不得超过 64 Mi 像素,瓦片网格上限 4 Mi 个瓦片,页面配对则拒绝两份页数之和超过 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 提供