Delphi PDF 库中的范围检查错误因为它们不遵循一致的输入模式而素有难以被揪出的名声。同一个文档在一台机器上产生错误,而在另一台机器上却没有;在 3 页的文件上执行相同的代码路径会触发异常,但在 12 页的文件上却运行正常。这种不一致几乎总是追溯到一个单一的根本原因:PDF 页面对象不是按文件顺序存储的。如果该库通过顺序扫描对象而不是遍历目录声明的页面树来构建其内部页面数组,它就会构建一个其有效范围与调用者期望不符的索引,而范围检查就在最糟糕的时刻捕获了这种不匹配。
Delphi 中的范围检查如何工作
在激活了 {$R+} 编译器指令(Debug 配置中的默认值)的情况下,Delphi 运行时库在运行时验证每个数组索引、字符串下标和枚举赋值。越界访问会引发 ERangeError 而不是默默地读取相邻的内存。这种行为很有价值:它能在早期暴露出潜在的漏洞,而不是让它们破坏一个在一百行代码之后才失败的数据结构。令人沮丧的是,异常是在访问点触发的,而不是在不正确计算索引的点触发的。当调用栈显示 PDF 单元中一个深层嵌套的方法时,真正的错误通常在几个栈帧之前。
复合布尔条件使情况变得更糟。Delphi 以短路语义从左到右评估 and 表达式,但短路计算仅在左侧为 False 时才跳过评估。像这样的表达式:
if FDocStarted and (DestIndex < Length(PageArr)) and
(PageArr[DestIndex].PageObj <> nil) then
看起来很安全,但它仅在 FDocStarted 为 True 且 DestIndex 为非负数时才能防止越界索引。当 DestIndex 为负数时,检查 DestIndex < Length(PageArr) 不起任何作用,因为在有符号算术中将负整数与非负长度进行比较返回 True,随后的数组访问仍然会触发范围错误。将边界检查移到最外层才是正确的修复方法:
if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
Result := PageArr[DestIndex].PageObj
else
Result := nil;
end
else
raise ERangeError.CreateFmt(
'Page index %d is out of range (0..%d)',
[DestIndex, Length(PageArr) - 1]);
这只是机制上的修复。它阻止了崩溃。它并没有解释为什么 DestIndex 一开始会接收到一个在有效范围之外的值。
真正的原因:对象顺序与页面顺序
ISO 32000-1 §7.7.3 将页面树定义为 Pages 节点的树,其 Kids 数组列出处于显示顺序的页面对象。文件将那些对象存储在编写器恰好选择的任何偏移量处;对象编号 20 可以在字节流中在物理上位于对象编号 3 之前。如果一个库通过按对象编号顺序迭代交叉引用表而不是顺着 Kids 链来构建其页面列表,它将产生与用户期望有所偏差的序列。在生成器恰好按顺序写入页面的文档上,一切正常。在没有按顺序写入的文档上,该库的页面编号和调用者的页面编号之间的差异产生了落在 PageArr 之外的索引。
正确的方法是从目录开始,解析 /Pages 间接引用,并递归遍历 Kids 数组。对于没有中间 Pages 节点的平铺文档,遍历很简单:
procedure BuildPageIndexFromTree(
const KidsArray: THPDFArray;
var PageArr: TPageObjArray);
var
i, Idx: Integer;
Child: THPDFObject;
ChildType: string;
begin
for i := 0 to KidsArray.Count - 1 do
begin
Child := KidsArray.GetIndirectObject(i);
if Child = nil then
Continue;
ChildType := Child.GetNameValue('/Type');
if ChildType = 'Page' then
begin
Idx := Length(PageArr);
SetLength(PageArr, Idx + 1);
PageArr[Idx].PageObj := Child;
end
else if ChildType = 'Pages' then
begin
// 中间节点:递归进入其 Kids
BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
end;
end;
end;
在这个代码运行之后,PageArr[0] 就是阅读器会显示的第一个页面,不管该对象在字节流中的位置在哪里。调用者传递的假定为显示顺序的索引现在正确映射,范围错误停止。
硬编码的变通方法使问题恶化
在从未查明根本原因的代码库中,通常会发现启发式补丁:如果总数为 3 则交换首页和末页、针对特定生成器生成的文档旋转索引、在第一个对象编号超过某个阈值时应用偏移量。每一个这样的补丁都恰好符合在编写它时手头的那些测试文件。加上一个不同的 PDF 来源,其中的一个补丁就会在错误的时候触发,产生一个现在双重错误的索引:由于从一个失序数组计算而错,又因为在它上面运用了不适用的映射再次出错。范围检查器在下游某处捕获到它,而且堆栈追踪指向了没有用的地方。
唯一有成效的途径是移除所有启发式映射,并用适当的树遍历来替换页面数组结构。一旦索引在构建上正确了,就不需要任何补丁,而范围检查器成为了一项资产,不再是障碍。
如果你正在维护一个展现出这种模式的库,在 Release 构建中暂时开启范围检查,并用一组多样的 PDF 进行测试:由 Word 产生的文档、由 LaTeX 产生的文档、由扫描仪固件产生的文档、由 PDF 到 PDF 的拆分实用工具产生的文档。那些触发异常的文件是页面对象顺序与你代码假设的遍历顺序出现偏离的文件。每个文件是一个数据点,而不是一个独立的漏洞。
对于调用 Delphi PDF 库的新代码,实用的建议是将库的页面计数视为权威,绝不传递一个由外部数据通过算术计算出的索引,除非首先确认它在 0..PageCount - 1 之内。HotPDF 组件在 BeginDoc 之后或加载文档之后通过 THotPDF.PageCount 暴露解析后的页面总数;该值始终反映了页面树的遍历,可安全用作任何索引算术的上限。