技术文章

Delphi 渲染器中的 PDF 文本步进与 q/Q 裁剪恢复

HotPDF Delphi Component 的页面渲染器现在这样推进文本:按 ISO 32000-1 §9.4.4 的定义在文本空间计算每个字形的位移 tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,然后用 HPDFTranslateTextMatrix 让文本矩阵沿其线性部分移动。裁剪在每个 q 帧保存、在 Q 恢复,但只有那个帧真的改变裁剪时才捕获 GDI 区域。两个修复都落在 HotPDF 2.754.0,也都来自真实页面——要么词挤成一团,要么裁剪区域泄漏过它们的 Q。第一个 bug 是那种直到生产者把字号写进矩阵之前看起来都对的算术;第二个是差点牺牲掉并行渲染提速的正确性修复,而我们把速度拿回来的方式,对任何写 GDI 支撑的 PDF 设备的人都值得知道

为什么 PDF 用 Tf 1 时文本会挤成一团?

因为旧的步进代码把一个文本空间距离直接加到 Tm 的平移分量上,仿佛文本空间和用户空间永远同尺度。大量真实生产者用 Tf 把字号设为 1、把真实尺寸写进文本矩阵。在 /F1 1 Tf 加 12 0 0 12 72 700 Tm 下,一个 500 单位宽的字形在文本空间步进 0.5,经 Tm 缩放后就是页面上的 6 磅。旧渲染器执行 Tm.e := Tm.e + Adv,把笔挪了 0.5 磅。每个字形容身后一个只错开十二分之一个字符,于是整行正文在左边距处渲染成一团黑渍,而同一份文件在其他所有阅读器里都完美

HotPDF 渲染器中 Tf 1 下文本塌缩的原因:在 12 0 0 12 72 700 Tm 之下,500 单位的字形必须步进 0.5 个文本空间单位、由 Tm 缩放成 6 磅,而旧代码把 0.5 直接加进 Tm.e,把整行正文渲染成每个字形只错开十二分之一字符的黑渍
把字号编码进文本矩阵的生产者让每个字形容身后一个只错开十二分之一个字符——这个缺陷在库自己的输出上看不见
// 来自把尺寸编码进 Tm 而不是 Tf 的生产者的内容流:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// 旧步进(简化):距离直接加进 Tm.e,仿佛那是用户空间
Adv := W * FontSize / 1000;                  // 500 单位字形得 0.5
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th 只作用于宽度
Adv := Adv + CharSpace;                      // Tc 不经 Th 缩放
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw 被错误地乘了 Tfs
Tm.e := Tm.e + Adv;                          // 无视 Tm.a、Tm.b、Tm.c、Tm.d

// 旧 TJ 调整:没有 Th,又只动 Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Tm.e 的捷径不是那段代码里唯一的缺陷。词间距 Tw 以未缩放的文本空间单位表示,旧代码却拿它乘 FontSize / 1000,于是在 Tf 12 之下两端对齐的一行几乎丢光词间距。水平缩放 Th 作用于字形宽度却不作用于 Tc 或 Tw,TJ 的 kerning 调整则完全跳过它。推进渲染模式 3 不可见文本(OCR 文本层用的那种)的非绘制路径和隐藏可选内容里的文本,带着同一套算术的私有副本,所以不可见文本运行开始之后画的任何东西都从错误的位置出发。渲染器里的文本状态 bug 很少响亮地失败:就像那批曾把 Tc、Tw 和 Tz 归零而一条错误都不报的操作数索引与资源名缺陷一样,它们在库自己的输出上产出看似合理的页面,只在别的生产者的文件上才坏掉

ISO 32000-1 §9.4.4 怎么定义字形步进?

ISO 32000-1 §9.4.4 完全在文本空间里定义步进,并把它作为平移矩阵作用到文本矩阵上,所以答案是先算出 tx、再让 Tm 去做缩放、旋转和斜切。横排时 tx 等于 ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,其中 w0 是以千分之一 em 计的字形宽度,Tj 是 TJ 调整,Th 是 Tz 除以 100。新的 Tm 是 [1 0 0 1 tx 0] × Tm,在 HotPDF 里就是辅助函数 HPDFTranslateTextMatrix:它经矩阵系数 a、b、c、d 加上 X 和 Y,而不是直接写 e 和 f。按 §9.3.3,Tw 只作用于单字节字符码 32,所以多字节 CID 码在横排路径上永远不沾词间距。同一个辅助函数现在驱动 Td、TD、T*、' 和 " 操作符、TJ 调整和隐藏文本路径——意味着这条规则只有一个函数拥有

HotPDF 渲染器中的 ISO 32000-1 9.4.4 字形步进:tx 在文本空间里由 w0、Tj、Tfs、Tc、Tw 和 Th 算出,再经 HPDFTranslateTextMatrix 应用,位移因此经过 a、b、c、d 矩阵系数,Td、TD、TJ 和隐藏文本路径共用一条规则
把步进直接加进 Tm.e 只在文本空间等于用户空间时才对;让它走过矩阵系数,缩放、旋转和斜切的文本才能保持正确
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// 水平字形步进,ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw 在文本空间,未缩放
Adv := Adv * State.Text.HorizScale / 100;    // Th 作用于整个和
HPDFTranslateTextMatrix(Tm, Adv, 0);

// TJ 数字元素:同一空间,同一 Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

字形摆放也得跟上同一套逻辑。没有内嵌轮廓可用、渲染器回落到 GDI TextOutW 时,它现在从 CTM × Tm × rise × em 缩放构建完整的字形矩阵——包括 Th——并在 SaveDC / RestoreDC 对之内以 GM_ADVANCED 模式经 SetWorldTransform 安装。GDI 字体以固定的 1000 单位高度创建、由变换负责尺寸,于是旋转和斜切的文本保住自己的朝向,而不是在一个被变换过的原点上直立画出。竖排是唯一刻意的非对称:WMode 1 字体按其纵向度量沿 y 轴向下步进,水平缩放不作用于那条轴

q/Q 在 PDF 图形状态里实际保存什么?

ISO 32000-1 §8.4.2 把当前裁剪路径列为图形状态的一部分,所以 Q 必须把裁剪恢复成匹配 q 时的原样,而不只是数值参数。HotPDF 早就维护一个带 CTM、颜色、线条参数和文本状态的图形状态栈,但 GDI 把裁剪放在设备上下文里、在这个栈之外。于是数值状态的副本恢复了除裁剪之外的一切,而一个在 q ... Q 块内用 W n 安装的裁剪会继续裁掉页面上后面每个操作。Form XObject 给同一个失败加了第二条路,因为 §8.10 给 form 的内容周围配了隐式 save 和 restore,而现实中的 form 内容有时把自己的 q 操作符留得不配对,尽管规范要求它们成对。渲染器现在在运行 form 之前调 CaptureClipBeforeChange 和 SaveDC,form 结束后丢弃任何比进入深度更深的已存区域并调 RestoreDC,于是每个保存的 HRGN 都恰好有一条释放路径

用 THPDFSavedClipState 做惰性裁剪捕获

发布的修法为每个 q 保存一条 THPDFSavedClipState 记录,但把昂贵的那部分推迟到该帧第一次修改裁剪时。记录持有区域句柄、它所属的栈深度、取它的设备上下文和一个 Captured 标志。DevPushState 只填深度和 DC,并把帧数组从 16 起倍增扩容,于是一整条满是 q 1 0 0 1 x y cm ... Q 的内容流一个 GDI 对象都不分配。即将改变裁剪的操作符——指带挂起 W 或 W* 的路径绘制、n 操作符、pattern 填充和 form 进入——先调 CaptureClipBeforeChange

HotPDF 渲染器中的惰性 GDI 裁剪捕获:DevPushState 在每个 q 只记录深度和 DC,CaptureClipBeforeChange 恰在 W、n 或 form 进入改变裁剪之前读取区域,DevPopState 在 Q 恢复并删除它;每个 q 都捕获的急切版本把并行吞吐拖到单线程的约 1.15 倍
每个 q 都创建 GDI 区域饿死了渲染线程,所以捕获现在只发生在操作符即将改变裁剪时,1.5 倍提速闸门重新通过
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // 已保存,或不属于这一层
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 表示根本没有裁剪
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 移除裁剪
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

急切版本的实测代价就是这个设计存在的理由。第一个正确实现每个 q 都创建并读取一个 GDI 区域,在主要由数值变换构成的页面上,渲染线程把时间花在争抢 GDI 区域对象而不是光栅化上。并行渲染管线从预期的收益跌到大约 1.13 到 1.20 倍单线程吞吐,没过基准套件里的 1.5 倍提速闸门。换成惰性捕获加复用帧容量之后,同一基准重新通过原来的 1.5 倍闸门。小的 TrueType 字形抗锯齿也在同一版落地、曾是明显的嫌疑对象,但回归最终追到区域分配上——这提醒我们,怪罪最新的功能之前先测量

这套方案的边界在哪里?

保存的裁剪是设备像素里的 GDI 区域,所以它对正在渲染的位图精确、对任何其他目标无意义。这就是每个帧都记录自己的设备上下文、而 DC 变了时 DevPopState 跳过恢复的原因——比如透明组渲染进自己的图层位图时。GetClipRgn 返回零是合法结果、表示没有裁剪,用 SelectClipRgn(FDC, 0) 恢复它正是正确移除一个在匹配 q 处不存在的裁剪的做法。文本侧,修复纠正每个字形去哪儿,但不发明宽度:字体省略 /Widths 数组、内嵌程序又不可用时,步进仍然只有宽度兜底那么好。给这个区域做回归测试时,至少保留一个 Tf 1 加缩放 Tm 的样例、一个非零 Tz 与 Tw 的样例、一个裁剪在 q ... Q 内而内容在其外的样例——因为库自己生成的文档里这些一个都不会出现

如果你从应用代码驱动渲染器,把 PDF 页面渲染成位图里描述的调用模式没有任何变化,以前显示黑渍行或内容被裁的页面在 2.754.0 及之后应当直接渲染正确。组件详情、支持的 Delphi 与 C++Builder 版本以及授权信息见 HotPDF Delphi PDF Component 产品页