技术文章

PDFlibPas 内容流状态:CTM 与裁剪跟踪

PDFlibPas 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件库,它通过 TPDFContentStateTracker 类重放页面内容流,完全不接触渲染画布。每次向跟踪器输入一个已解析的操作符,就能持续维护一份图形状态记录,包括当前变换矩阵、文本矩阵、裁剪边界以及 q/Q 保存栈,并可在每个操作符执行前或执行后获取快照

如果要回答一段文字实际落在打印页面的什么位置,仅凭内容流中的原始数字每次都会误导你。TPDFContentProgram.GetTextRuns 已经通过 TPDFTextRun 的 OriginX 和 OriginY 字段报告每条显示文本指令的锚点,字段注释明确说明该点位于文本空间中,并且已经经过 Tm、Td、TD 和 T* 的综合变换。仍然缺少的部分,也是这些注释所说调用方必须提供的部分,是该指令执行时刻处于活动状态的 CTM,也就是截至该位置所有 cm 串联后的乘积,并嵌套在内容流当时打开的任意数量 q/Q 对中

为什么要重放内容流,而不是渲染它

PDFlibPas 为两种不同任务保留了两套独立的图形状态概念,这种拆分是有意的。渲染器的内部状态记录包含实时设备画布句柄、裁剪区域句柄和字体光栅化缓存,这些是真正绑定到当前绘制表面的资源,表面消失后它们也就失去意义。TPDFContentGraphicsState 不包含这些内容,它是一个普通记录,仅限于 ISO 32000-1 §8.4 定义的、单靠内容流操作符即可访问的值,包括 CTM、线条样式、颜色、文本状态以及派生出的裁剪和路径边界。由于该记录不持有画布引用,也不持有打开的文件句柄,调用方可以解析内容流,使用 TPDFContentStateTracker 遍历它,并在产生这些字节的对象消失很久之后继续使用所得快照

TPDFContentStateTracker 如何构建 CTM

TPDFContentStateTracker.Apply 使用 PDF 规定的相同前乘方式,将 cm 操作符的六个操作数串联到跟踪器的 CTM 中:新矩阵 M2 与当前 CTM 按 M2 × CTM 组合,并采用点按行向量变换为 P′ = P × M 的约定(ISO 32000-1 §8.4)。容易出错的部分不是线性部分,而是平移项:M2 自身的平移必须先经过当前 CTM 的旋转与缩放部分,再将当前 CTM 的平移叠加到结果上。跳过这一步,改用朴素的分量组合硬编码,那么你测试的第一个独立 cm 看起来会正确,而第二或第三个嵌套 cm 之后的所有坐标都会悄悄漂移,这正是容易通过代码审查的错误,因为能捕获它的单元测试至少需要两个串联变换才能失败

var
  Prog: TPDFContentProgram;
  Runs: TPDFTextRunArray;
  States: TPDFContentGraphicsStateArray;
  DeviceX, DeviceY: Double;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  try
    Prog.Parse(ContentBytes);
    Runs := Prog.GetTextRuns;
    // One before-instruction snapshot per operator, computed in a single pass
    States := Prog.TraceGraphicsStates(nil, False);
    for I := 0 to High(Runs) do
    begin
      // OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
      // at this instruction is still missing (ISO 32000-1 8.4)
      with States[Runs[I].InstructionIndex].CTM do
      begin
        DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
        DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
      end;
      LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
    end;
  finally
    Prog.Free;
  end;
end;

上面的循环回答了开头提出的难题:TPDFContentProgram.GetTextRuns 返回的 OriginX 和 OriginY 已经经过 Tm、Td、TD 和 T* 的综合变换,而 TraceGraphicsStates(nil, False) 在对整个程序进行一次线性遍历时,提供了剩下的部分,也就是每个文本运行所对应精确指令索引处的指令前 CTM。传入 nil 后,方法会为本次调用创建私有跟踪器并在内部释放它,这适合一次性扫描;如果传入已有的 TPDFContentStateTracker,就能让状态跨越由多个内容流组成的页面持续保持,因为 ISO 32000-1 将页面的 /Contents 数组视为一个逻辑流,q/Q 栈必须保持一致

文本矩阵会跨越 Q 保留,图形状态不会

ISO 32000-1 §9.4.2 将 Td、TD、Tm 和 T* 定义为 BT/ET 块中构建文本矩阵与文本行矩阵的操作符,PDFlibPas 严格保持这种区别:Td 和 TD 将纯平移串联到文本行矩阵上,T* 使用当前行距的负值执行相同操作,而只有 Tm 会用给定的六个数字直接替换两个矩阵。BT 会在文本对象开始时将两个矩阵重置为单位矩阵,并且只重置一次,但 q 和 Q 完全不会影响它们。TPDFContentStateTracker.Apply 正是因此对 coRestoreState 做了特殊处理:从栈中弹出保存状态之前,它会先保存当前文本矩阵、文本行矩阵和 BT/ET 标志,再将它们覆盖回弹出的状态,因为包围文本运行的 q/Q 对不应让文本位置退回

var
  Tracker: TPDFContentStateTracker;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  Tracker := TPDFContentStateTracker.Create;
  try
    Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
    for I := 0 to Prog.Count - 1 do
    begin
      Tracker.Apply(Prog[I]);
      if Prog[I].Op in [coShowText, coRestoreState] then
        LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
    end;
  finally
    Tracker.Free;
    Prog.Free;
  end;
end;

运行这段序列后,第二个 Tj 处报告的 CTM 已恢复为 q 之前的单位缩放状态,保存与恢复对中的 2 0 0 2 0 0 cm 已按 q/Q 的要求消失。不过同一指令处的 TextMatrix.DX 仍然是 100:设置它的 Td 在 q 之前执行,因此它从来不属于 Q 有权修改的图形状态,而错误地做出相反假设的工具会报告第二个字形运行从页面上的错误水平位置开始

裁剪路径操作符运行时会发生什么

W 或 W* 操作符不会立即缩小裁剪区域,它只记录要使用的填充规则,实际相交会等到后续路径绘制操作符执行时才发生,其中包括无操作绘制符 n,PDF 作者经常正是利用它在不绘制任何内容的情况下执行裁剪。TPDFContentStateTracker 精确复现了这一分两步的时序:coClip 和 coClipEvenOdd 只设置待处理的裁剪规则标志,而每个路径绘制操作符都会调用的 EndCurrentPath 才会将待处理路径的边界实际相交到 ClipMinX、ClipMinY、ClipMaxX 和 ClipMaxY 中。正确处理这一暂存过程也关系到前后状态快照契约本身:恰好在 W 指令处取得的前快照仍必须显示旧的、更宽的裁剪区域,因为此时裁剪尚未在流中生效;将两个步骤合并会悄悄破坏所有依赖前状态语义的调用方

ClipBoundsExact 会告诉调用方当前属于两种情况中的哪一种,而且它只有在其他路径为空、由 re 构建出单个轴对齐矩形时才会为 True,这是 PDFlibPas 能用四个数字精确表示的唯一形状。其他所有情况,包括旋转矩形、曲线轮廓、包含多个子路径的复合路径,或由文本渲染模式构成的裁剪,仍然会产生 ClipMinX 到 ClipMaxY,但 ClipBoundsExact 会被清除为 False,明确表示这四个数字是安全的外接边界,而不是真实裁剪形状;只需要该边界的调用方,例如在 将 PDF 页面渲染为 1 位单色图像 中介绍的 GDI 半色调降转换之前隔离矩形子区域时,可以直接读取它,而不必根据页面几何重新推导

贝塞尔曲线:精确边界还是安全边界

界定三次贝塞尔线段最省事的方法是取四个控制点的凸包,这始终安全,因为曲线不会离开凸包;但一条浅而宽的曲线可能会报告一个远大于实际占用范围的包围盒,恰恰在大面积装饰路径最需要它时削弱基于裁剪的过滤。PDFlibPas 解决的是更严格的问题:对于每个轴,它会求出三次曲线导数在开区间(0, 1)内的根,并将曲线在找到的每个根以及两个端点处求值,这是获得曲线真实轴对齐范围而不是过大估计值的标准闭式方法。不过,每条曲线的精度不会延续到裁剪本身:曲线轮廓成为裁剪路径后,ClipBoundsExact 仍会被置为 False,因为无论包围盒多么紧,仍然不是它所包围曲线的同一形状,状态跟踪器宁可明确说明这一点,也不让调用方在实际是曲线的地方误以为是矩形

读取每个操作符执行前后的状态

调用方要读取前状态还是后状态,完全取决于操作符的作用:关于路径或文本运行的绘制或命中测试问题,需要操作符运行前瞬间的状态,因为那正是决定其如何绘制的状态;而关于 gs 这类状态设置操作符的诊断问题,通常需要查看它刚刚改变的结果。TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) 通过一个 Boolean 参数直接暴露这一选择,无论请求哪一时刻,都会对整个程序进行一次线性遍历,为每条指令计算一个 TPDFContentGraphicsState。GetGraphicsState(InstructionIndex, AfterInstruction, State) 为单条指令提供相同的前后选择,而不是处理整个程序,但它每次都会从指令零开始重放才能到达目标,因此在循环中扫描多个索引的成本是 O(n²),而对同一程序调用一次 TraceGraphicsStates 的成本是 O(n)

var
  Before, After: TPDFContentGraphicsState;
begin
  // Same instruction index, two different instants: before vs. after it runs
  Prog.GetGraphicsState(CmIndex, False, Before);
  Prog.GetGraphicsState(CmIndex, True, After);
  // Before.CTM reflects every earlier cm; After.CTM already folds in
  // this instruction's own concatenation as well
end;

如何处理格式错误的内容流

真实 PDF 生成器产生的两类格式错误输入十分常见,因此 TPDFContentStateTracker 必须容忍它们,而不能因它们失败。第一类是跨越 q/Q 边界的路径:当前路径、当前点和子路径计数不是图形状态参数,ISO 32000-1 §8.4 说明了 q 和 Q 保存、恢复的内容,而正在构建的当前路径不在其中,因此 TPDFContentStateTracker 完全在保存状态之外跟踪这些数据,q 之前开始的子路径在匹配的 Q 之后仍然存在,并且尚未绘制。第二类是没有任何匹配 q 的单独 Q,这在通过拼接组装内容流片段且记录出错的生成器输出中并不罕见。TPDFContentStateTracker.RestoreUnderflowCount 会统计每次此类事件,而不会引发异常或破坏状态:不匹配的 Q 只会让当前图形状态保持原样,就像该指令是无操作一样,因此流的其余部分会继续在正常状态上重放,调用方随后仍可根据该计数决定是否需要向生成输入的对象报告问题

CTM 组合、文本矩阵独立于 q/Q,以及裁剪路径的分阶段实现,都不取决于内容流是否最终被绘制,这正是关键所在:无论页面从未渲染,还是即将交给 PDFlibPas 为该文件选择的某个后端,同一个 TPDFContentStateTracker 快照都是正确的,其中也包括 PDFlibPas 多引擎 PDF 渲染指南 所介绍的运行时引擎切换。内容分析、坐标映射和涂黑工具都可以完全基于跟踪器输出运行,早在请求渲染器介入之前,甚至完全不需要渲染器介入

通过 TPDFContentStateTracker 重放内容流,是 PDFlibPas 内置的结构化内容编辑框架的一部分,它是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件库