技术文章

诊断 Pascal PDF 库中的静默存根失败

当一个 Delphi 库长出不带可视化框架的构建配置时,替代类就是 bug 的栖身之所。不是平台,不是编译器,而是这些替身。PDFlibPas 有一个图形层,为不带 VCL 的构建提供位图、画布、字体、图元文件与打印机的等价物;把它移植到 Free Pascal 时,替身可能有的每种失败模式都暴露了出来。它们按诊断成本整齐排序,而顺序与直觉相反

抛异常的替身最容易找,异常会点名方法。返回空数据的替身代价高,因为故障出现在离成因好几层的地方。返回成功的替身最糟糕:返回码有效、错误码为零、没有异常,唯一能说明出问题的证据藏在输出的字节里

Pascal PDF 库静默存根失败的三种形态按诊断成本排序,从抛异常的存根到空产物上的成功返回
抛异常的替身诊断便宜,空数据代价高,而空产物上的成功返回最难发现

形态三:空 XObject 之上的有效图像标识符

在非 VCL 配置里,矢量图元文件转换器是一个空的过程体。它之上的每层都继续工作。EMF 导入入口与画布捕获入口一路跑完,返回合法的图像标识符,调用方把它放上了页面。落进文件的是一个内容长度为零的 form XObject。页面渲染成一片白

没有任何东西报告问题,其中包括库自带的该功能演示程序——它画出一页空白而毫无察觉。没有可检查的失败返回值,因为调用序列确实全部成功;唯一不对的是产出流的大小。诊断这类缺陷意味着换一个问题:不是"调用失败了吗",而是"产物看起来合理吗"。长度为零的 form XObject、零像素的图像、零内容字节的页面,这些就是能抓住它的断言

修复有两半,后半容易忘。第一,让空实现抛异常,使故障至少有通道。第二,在图像工厂把该异常转换成 null 结果,并在两处消费图像标识符的地方加 null 检查,否则"干净的失败"会直接变成访问违例——页面树解引用了空。只有当调用方已经准备好接收它们以前从未收到过的失败时,抛异常的存根才算改进

形态二:空数据,距崩溃三层

图元文件画布替身没有填自己的物理尺寸。该值被除进页面几何计算,于是算出零,于是包围盒计算除以零。一个裸异常处理器吞掉了它,图像工厂返回 null 结果,访问违例最终在页面树使用 null 时发生。成因与症状之间隔着三层,中间还有个异常处理器在销毁证据

PDFlibPas 中空图元文件画布替身的失败链:除以零之后隔三层才出现访问违例
空的尺寸把几何计算除成零,裸处理器抹掉异常,null 标识符让页面树崩溃

同一单元还有两处同型实例。字体类的 Assign 与构造器是空体,这比看上去更重要,因为画布的 font 属性是只读的:向它赋值是交付字体的唯一途径,空实现让字体选择静默失效,文本以默认字体输出。而每英寸像素值为零,使每个按字体度量确定画布尺寸的调用者产出零乘零画布,换来一页空白与一个成功返回

// 在替身单元里要找的形状:一个既不抛异常
// 也不做任何事的方法。下面两个都能编译,且都
// 产出"成功"而无输出
procedure TMetafileCanvasStandIn.Create(...);
begin
  // 无 inherited 调用,无字段初始化
end;

function TBitmapStandIn.LoadFromStream(Stream: TStream): Boolean;
begin
  Result := True;    // 而位图依然是空的
end;

只留下第一个字符的宽字符结构

这个根本不是替身问题,但症状同样远离成因,所以归入同一目录。打印机枚举结构声明时,全部十二个字符串成员都写成了单字节字符指针,而填充它的函数是该枚举 API 的宽字符变体

指针大小相同,所以结构布局正确,什么都不崩。实际发生的是:把 UTF-16 字符串当单字节字符串读,会在第一个零字节处停止——对任何 ASCII 打印机名,那就是第二个字符的高半部。每个打印机名都恰好返回一个字符。下游,名称验证失败、打印机创建失败、机器上每台真实打印机都打印失败,而这些症状没有一个指向结构声明

宽字符 Win32 结构用 PAnsiChar 而非 PWideChar 声明成员后,UTF-16 打印机名被截成一个字符
单字节成员读 UTF-16 名称只到第一个零字节,于是每个打印机名都只剩一个字符
// 错误:大小正确,元素类型错误。无编译错误,不崩溃,
// 每个字符串都截成一个字符
type
  TPrinterInfo2Wrong = record
    pServerName: PAnsiChar;
    pPrinterName: PAnsiChar;
    // ... 其余十个
  end;

// 正确:*W 结构全程使用宽字符成员
type
  TPrinterInfo2W = record
    pServerName: PWideChar;
    pPrinterName: PWideChar;
    // ... 其余十个
  end;

由此得出的规则是机械的,值得不假思索地执行:任何名字以 W 结尾的 Win32 结构,逐字段核实每个字符串成员都是宽字符变体。ANSI 与宽字符两个世界混用,既没有编译诊断也没有崩溃,只有静默截断;反过来对 ANSI 变体同理

裸异常处理器才是真正的对手

以上每起排查都被同一个构造拖慢:一个捕获一切并转成 false 返回值的处理器。围绕图像解码器这么写是合理的,损坏的图像不该拖垮整个文档作业。但它也是一台删除你所需要的那条信息的装置

务实的应对是让处理器暂时变吵。在调试条件包裹下,从裸处理器内部倾泻异常类、消息与回溯,把一个无法解释的 null 返回变成一个带位置的具名异常。上述三例中有两例,单凭这一步就结束了排查,因为异常是除以零,或是替身方法里的访问违例——方法名说明了一切

采用替身路径的检查清单

四项,按见效顺序。调用替代类之前,读一遍将要用到的方法,确认每个都有真实方法体;空方法体不是实现细节,而是缺失的功能。优先选择抛异常的替身而不是返回中性值的替身,并在工厂现在可以合法返回空的那些位置配上 null 检查。验证功能靠检查产物而不是返回码,因为这里的整个失败模式就是空产物配干净返回码;对文档实际内容的字节级分解是看清它最快的办法,文件体积审计一文介绍了相关工具。当某功能没有可行的替代实现时,把受影响的示例改道到能用的路径并在注释里说明原因,而不是留下一个悄悄产出空白输出的演示

更广的道理远远超出一个库。任何带条件编译的第二实现、mock 层、无头模式、平台垫片的代码库,都暴露在形态三之下。它藏得这么深的原因是:团队平常依赖的每道质量闸门——返回码、错误码、异常、退出状态——都是状态通道,而形态三把它们全部保持干净。唯有输出会出卖它。这也是处理不可信输入时检查产物而非状态的缘由,不可信 PDF 解析一文有所论述;也是跨引擎比较渲染输出而非信任单一引擎的缘由,见多引擎渲染

PDFlibPas 是面向 Delphi、C++Builder 与 Free Pascal 的原生 Object Pascal PDF 库,其非 VCL 配置正是无头与跨工具链构建成为可能的凭借;当前配置覆盖情况列在 losLab PDF Developer Library 产品页