PDF Library for Delphi 通过跟踪活动调用链,而不是使用全局已访问集合,来解析 Delphi PDF 内容流中的递归 Form XObject 调用,因此 TPDFlib.EnumPageContentStatesEx 可以遍历同一页面上多次调用的同一个 Form,而不会把合法复用误判为循环。发票模板中的印章 Form XObject 是典型案例:同一个对象会在一页中分别从页眉、页脚和水印层调用,只有回到自身的调用链才是真正的循环
ISO 32000-1 §8.10 将 Form XObject 定义为自包含的内容流,页面或另一个 Form 通过 Do 运算符调用它;它在 /Matrix 中拥有自己的坐标系,在该坐标系中通过 /BBox 定义裁剪边界,还可以拥有自己的资源字典。规范没有限制一个 Form 可被调用的次数,也没有限制 Form 之间相互调用的深度,因此符合规范的解析器必须接受合法复用和合法嵌套,同时防御规范明确禁止的唯一安排:某个 Form 的内容流直接或传递地调用自身。PDF Library for Delphi 通过附加到每个 Do 快照的 TPDFlibContentFormTraversalStatus 值报告这种区别,其中最重要的是成功下降时的 ftsEnumerated,以及唯一真正表示循环的情况 ftsCycle
重复使用同一个 Form XObject 为什么不会触发误报循环
重复的 Form XObject 引用本身并不能证明存在问题。ISO 32000-1 允许同一个 Form 对象在内容流中被作者从任意多个位置调用,这正是徽标印章、信头模板或页码页脚可以在一页中复用而不必多次复制内容流的方式。防止无限递归的朴素保护措施,是使用以对象编号为键的单一已访问集合:遍历器第一次看到 Form 对象 12 时,将 12 标记为已见,并拒绝在树的其他位置再次进入它。当同一个印章出现在一页中两个互不相关的角落时,这种方法立刻失效,因为第二次完全合法的调用到达时对象编号已经被标记为已见,于是被当作循环拒绝
PDF Library for Delphi 将循环检测限定在当前调用链,而不是整个文档,从而避免这种误报。EnumPageContentStatesEx 在进入 Form 之前立即将解析出的 Form 流压入活动调用链,并在下降返回时立即弹出同一项,无论下降成功还是失败。相同流的兄弟调用只有在第一次调用已经弹出后才会开始,因此兄弟调用检查时调用链中已经没有该流,遍历器会像处理其他 Form 一样准确地枚举它。真正的循环在同一调用链上呈现出不同形态:Form A 调用 Form B,B 仍在调用链上时,其内容又回调 A,而 A 仍因外层调用尚未返回而位于调用链中——这正是 ftsCycle 报告的唯一形态,即某个 Form 流仍在当前调用链更早的位置保持打开,而不是仅仅存在于页面的其他位置
Form XObject 递归在 PDF Library for Delphi 停止前可以深入多少层
循环检测和深度限制解决的是两个不同的问题,PDF Library for Delphi 因此将它们保留为两个不同的 TPDFlibContentFormTraversalStatus 结果。由 20 个互不相同的 Form 组成的链,每个 Form 调用下一个且没有任何重复,按任何定义都不是循环——活动链检查永远找不到重复流——但 20 层真实嵌套仍然意味着 20 层解析、矩阵串联和资源解析,如果没有其他机制阻止,格式错误或恶意 PDF 可能将其任意推高。EnumPageContentStatesEx 正是为此接受 MaxFormDepth 参数,并将传入值限制为最大 64,无论调用者要求多少。深度为零是一个值得单独了解的特殊情况:它完全禁用 Form 递归,恢复旧版 EnumPageContentStates 方法的扁平页面级行为,因此该模式下每个 Do 快照都会报告 ftsNotRequested,而不会尝试执行任何操作
var
Lib: TPDFlib;
States: array of TPDFlibContentGraphicsState;
Count, I: Integer;
begin
Lib:= TPDFlib.Create;
try
if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
Exit;
Lib.SelectPage(1);
Count:= Lib.EnumPageContentStatesEx(True, 8, States); // count only
SetLength(States, Count);
Lib.EnumPageContentStatesEx(True, 8, States); // fill
for I:= 0 to Count- 1 do
if States[I].FormTraversalStatus= ftsCycle then
LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
finally
Lib.Free;
end;
end;
每次调用使用一个子跟踪器:隔离图形状态
每次下降进入 Form XObject 都会获得自己的图形状态跟踪器,而不是与正在遍历页面的跟踪器共享,因为 Form 的内容流必须保持图形状态与进入时完全一致,而 PDF Library for Delphi 不能假定它打开的每个 PDF 都确实遵守这一要求。子跟踪器从调用 Do 指令时处于活动状态的 CTM、颜色状态和文本参数快照开始,然后在执行 Form 的单条指令前,将自己的保存恢复栈和当前路径跟踪重置为空。粗糙或损坏的 Form 内没有匹配 Q 的不平衡 q(在旧工具生成的 PDF 中并不罕见)会被限制在这一次调用的跟踪器中,绝不会泄漏到页面跟踪器,也不会泄漏到内容流下一行调用的同一印章的兄弟调用中
Form 的 /Matrix 与 Do 处生效的 CTM 组合,其方式与 cm 运算符相同:相对于当前变换进行左乘,而不是替换当前变换。PDF Library for Delphi 有意复用同一条代码路径,而不是维护第二套公式,因为同一矩阵代数的两个独立实现,正是经过几轮缩放、旋转和剪切组合后容易悄然产生偏差的重复。随后,/BBox 会在矩阵已经应用后于 Form 自身的坐标空间中进行裁剪,边界框的四个角也会分别变换,而不只是变换两个对角点,因为旋转或剪切的 Form 否则可能报告一个遗漏真实内容的边界框,真实内容原本位于极值角,变换后却移动到了其他位置。将前一个示例中的循环扩展到同一个 States 数组即可直接读取这些字段
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
States[I].FormBBoxKnown then
Writeln('Form ', States[I].XObjectResource, ' matrix ',
States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);
两个具有相同资源名称的 Form 是否共享同一个字体
不共享。像 /F1 这样的资源名称,只有相对于使用位置处活动的资源字典才有意义,两个不同的 Form XObject 可以在相同名称下定义完全不同的字体。PDF Library for Delphi 通过在每个资源名称旁跟踪资源作用域来解析这一点:当 Form 携带自己的 /Resources 字典时,该字典就成为其中所有内容的完整资源作用域,Form 自己的字典省略了某个项目时,也不会按键回退到页面或调用者字典。只有完全没有 /Resources 键的 Form(某些旧版 PDF 生成器仍会生成这种模式)才会整体继承调用方字典,这是有意提供的兼容性例外,而不是新输出值得依赖的通用规则。因此,TPDFlibContentGraphicsState 快照中的字体身份是 FontResource 与 FontResourceScope 的组合,而不只是名称;使用 FontObjectNumber 可以确认特定作用域中的 /F1 到底解析为了哪个间接对象
同样的作用域规则适用于 Form 可以携带的其他命名资源,包括 ExtGState 项和嵌套的 XObject 项,因为底层解析机制不会对字体特殊处理——字体案例之所以最重要,只是因为字体身份不匹配会静默地产生错误字形,而不会明显失败。只按字体名称分组文本运行、而不同时按资源作用域分组的提取代码,会合并两个碰巧共享名称但视觉上不同的字体,直到有人发现错误字体中的数字出现在本应使用一致字体的文本中时,这个错误才会暴露
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
States[I].FontObjectNumber, States[I].ContentDepth);
在自己的处理管线中读取 FormTraversalStatus
FormTraversalStatus 让每个 Do 快照都自带一份小型诊断报告,忽略它的管线等于丢弃了正好可以解释提取不完整原因的信息。ftsNotApplicable 表示该指令一开始就不是已解析的 Form 调用;ftsNotRequested 表示本次调用关闭了递归;ftsEnumerated 表示 Form 已成功解析和遍历;ftsDepthLimit 与 ftsCycle 标记了下降过程被主动截断的两种方式;ftsMalformed 则涵盖其他所有导致遍历停止的情况——无法解析的流引用、解析失败的 /Matrix 或 /BBox,或者执行 Form 自身内容时引发的异常。最后一种情况在实际运行中很重要,因为失败的嵌套遍历会回滚该分支已经产生的部分输出,因此调用者无需猜测 Form 是确实为空,还是在其内容流的第二条指令处发生了错误
var
Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
Status: TPDFlibContentFormTraversalStatus;
begin
for Status:= Low(Tally) to High(Tally) do
Tally[Status]:= 0;
for I:= 0 to Count- 1 do
Inc(Tally[States[I].FormTraversalStatus]);
if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;
边界、成本以及它在整体中的位置
无论 Form 被调用多少次,每次枚举调用都会对其内容流解码并解析恰好一次,因为 PDF Library for Delphi 会针对底层流对象缓存已解析的指令列表,而不是在每次兄弟调用时重新解析——开头示例中的三角印章只解码一次、遍历三次,而不是解码三次。每次调用都会重新构建的,是调用点之间确实可能不同的内容:子跟踪器、串联后的 CTM、相交后的裁剪区域和资源作用域。每次调用的 CTM 和裁剪记录使用的正是 PDF Library for Delphi 内容流 CTM 与裁剪状态跟踪器 背后的同一套机制,对于任何超出 Form 递归本身的内容流遍历,值得与本文一并阅读
在将此 API 纳入更大的管线前,有两个限制值得先明确预期。64 层深度上限不是用于合法深文档的调节旋钮,因为真实的发票、报表和报告模板几乎从不嵌套超过三四层——实际触发 ftsDepthLimit 的文档,更可能是格式错误或恶意文档,而不是异常复杂,值得作为数据质量信号记录,而不是静默地用更大数字重试。EnumPageContentStatesEx 也是一个读取侧分析 API:它报告内容流做了什么,而不判断 Form 是否应当可见;当印章或水印 Form 位于查看器可能已关闭的图层后方时,这是由 可选内容组可见性状态 回答的独立问题。调用链循环检测、每次调用隔离和资源作用域共同构成 PDF Library for Delphi Delphi 和 C++Builder 组件内容流检查能力的一部分