技术文章

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

内容流先设 /F1 1 Tf,再设 12 0 0 12 72 700 Tm,然后显示 (Hello world) Tj,完全合法。手写的渲染器却常常把十一个字符全部画在五磅半之内—本该是一个 66 磅宽单词的地方,只剩左页边上一团黑渍。这团黑渍背后的算术只有一行。ISO 32000-1 §9.4.4 把每个字形的步进定义在文本空间里,所以位移必须先穿过文本矩阵才能落到页面上;而 q ... Q 内设置的裁剪和线宽一样,都是图形状态的一部分

为什么 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,把整行正文渲染成每个字形只错开十二分之一字符的黑渍
把字号编码进文本矩阵的生产者让每个字形容身后一个只错开十二分之一个字符——这个缺陷在库自己的输出上看不见

这个捷径能存活很久,因为人们拿来测试的大多数生成器写的都是 /F1 12 Tf 配一个未缩放的 Tm,这种情况下文本空间和用户空间确实同尺寸。下面是完整的天真版本,把会击穿它的内容流写在顶部注释里:

// 来自把尺寸编码进 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 那一行是最响的 bug,其余的更安静,而且在不同文件上才现形。词间距 Tw 本来就以未缩放的文本空间单位表示,再乘 FontSize / 1000 之后,Tf 12 下的 3 Tw 变成 0.036 个单位——两端对齐的段落几乎丢光词间距。水平缩放 Th 必须同样作用于 Tc、Tw 和 TJ 的 kerning 数字。Code = 32 的测试还会在双字节码内任何值为 32 的字节上触发,给 CJK 文本撒下随机的空隙。这类文本状态错误很少响亮地失败:正如那些能在画出第一个字形之前就把 Tc、Tw、Tz 归零的操作数索引与资源名陷阱,它们对一个生产者产出看似正常的页面,换下一个生产者就坏

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

ISO 32000-1 §9.4.4 完全在文本空间里计算步进,并把它作为平移矩阵作用到文本矩阵上,缩放、旋转和斜切全部来自 Tm,与步进本身无关。横排时 tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,其中 w0 是以千分之一 em 计的字形宽度,Tj 是 TJ 调整量,Th 是 Tz 除以 100。新矩阵是 [1 0 0 1 tx 0] × Tm。把乘积展开:它给 e 加上 a·tx、给 f 加上 b·tx——这正是为什么文本空间里的平移永远不能直接往 e 上加,除非 a 为 1 且 b 为 0。按 §9.3.3,Tw 只作用于单字节字符码 32

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

HotPDF Delphi Component 把这个公式收拢在一处。HPDFContentStream 单元导出 HPDFTextGlyphDisplacement(逐字形步进)和 HPDFTextAdjustmentDisplacement(TJ 数字),页面渲染器和文本提取解释器都调用它们,于是渲染正确的页面,报告的字形位置也与你看到的一致。如果你在库的解析器之上写自己的内容流消费器,可以调用同样这两个例程,自己补上矩阵那一步:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // 你自己的文本矩阵记录,不是 HotPDF 类型
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// 你自己的辅助例程:[1 0 0 1 Tx Ty] × Tm,经 a、b、c、d 移动
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 是以 1/1000 em 计的字形宽度;Tz 是 Tz 操作数(100 = 不缩放)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw 只作用于单字节空格
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1、Tm 12 0 0 12 72 700、W0 = 500 时:Tx = 0.5,Tm.e 增长 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th 同样作用于 kerning
end;

在渲染器内部,同一个矩阵步骤驱动每个移动文本位置的操作符。Td、TD、T*、' 和 " 让行矩阵沿其系数平移,所以 12 倍文本矩阵下的 0 -1.2 Td 会把基线下移 14.4 磅而不是 1.2。从不绘制的字形走完全相同的路径。渲染模式 3——OCR 文本层使用的不可见模式——和隐藏可选内容里的文本,都与绘制的文本一样推进 Tm。给它们一份算术的私有副本是经典错误:此后同一 BT 块里跟在不可见文本之后的可见文字,起点就会差之毫厘

字形本身的摆放

字形摆放遵循同样的逻辑。没有内嵌轮廓可用、渲染器回落到 GDI TextOutW 时,它从 CTM × Tm × rise × em 缩放构建完整的字形矩阵,Th 包含在内。矩阵经 SetWorldTransform 以 GM_ADVANCED 模式装入,包在 SaveDC / RestoreDC 对里。GDI 字体以固定的 1000 单位高度创建,尺寸全部由世界变换负责。于是旋转和斜切的文本保住朝向,而不是在一个被变换过的原点上直立画出。描边渲染模式在描边前先复位世界变换,否则以 1000 单位设计尺寸度量出的笔宽会缩小到几乎看不见。竖排是唯一刻意的非对称:WMode 1 字体按其纵向度量沿 y 轴向下步进,Tc 和 Tw 沿该轴作用,Th 则完全不参与

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

ISO 32000-1 §8.4.2 把当前裁剪路径列为图形状态的一部分,所以 Q 必须把裁剪恢复成匹配 q 时的原样。这听上去理所当然,在 GDI 支撑的渲染器里却容易漏掉:CTM、颜色、线条参数和文本状态都住在渲染器自己的状态栈里,而裁剪住在设备上下文里—在这个栈之外。复制数值状态恢复了除裁剪之外的一切,于是 q ... Q 块内的一次 W n 会持续裁掉页面上后面每个操作,症状看起来像是缺内容,而且离肇事的操作符很远

Form XObject 为同一种失败开了第二条路。§8.10 给 form 的内容周围配了隐式的 save 和 restore,并把内容裁剪到 /BBox;现实中的 form 内容有时把自己的 q 和 Q 留得不配对,尽管规范要求它们成对。HotPDF 在运行 form 之前记录裁剪并保存设备上下文,form 结束后丢弃它遗留的任何未闭合裁剪帧并恢复设备上下文,每个保存的区域恰好释放一次。form 内部一条会弹出调用方状态的流浪 Q 会被拒绝而不是执行。RenderFallbackPolicy 设为 rfpReport 时,这次拒绝会出现在诊断里,粗心的生产者和渲染问题由此一眼可辨:

// 诊断:THPDFRenderFallbackDiagnostics(声明于 HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // 对 nil 调 Free 是安全的
end;

只在帧真的改动裁剪时才捕获它

直截了当的修法是每次 q 读一次 GDI 裁剪区域,每次 Q 放回去。这是对的,但在恰好最爱用 q 的那些页面上代价也最高,因为生成器喜欢把每个小图形包进 q 1 0 0 1 x y cm ... Q,纯粹为了挪原点。每一对都要创建一个 GDI 区域、把裁剪拷进去、再删掉。多页并行渲染时,线程们最终在争抢 GDI 区域对象而不是光栅化,并行渲染管线的提速随之缩水

HotPDF 渲染器中的惰性 GDI 裁剪捕获:DevPushState 在每个 q 只记录深度和 DC,CaptureClipBeforeChange 恰在 W、n 或 form 进入改变裁剪之前读取区域,DevPopState 在 Q 恢复并删除它;每个 q 都捕获的急切版本把并行吞吐拖到单线程的约 1.15 倍
每个 q 都创建 GDI 区域饿死了渲染线程,所以捕获现在只发生在操作符即将改变裁剪时,1.5 倍提速闸门重新通过

HotPDF 把昂贵的部分推迟。q 只记录栈深度和所属表面,放在一个从 16 起倍增扩容、整页复用的帧数组里,于是一连串数值变换一个 GDI 对象都不分配。裁剪在该帧内第一次有操作要改动它时才捕获:带挂起 W 或 W* 的路径绘制或 n、pattern 填充、文本渲染模式 4 到 7 留下的裁剪、或进入 form。Q 时,捕获过的帧放回并释放自己的区域,从没动过裁剪的帧只弹出数值状态。常见页面于是零区域分配,需要裁剪的页面为每次实际裁剪变化恰好付一个区域。如果你为 PDF 解释器自建 GDI 设备,同样的形状几十行就装得下:

// 给你自己的 GDI 支撑设备的草图;这些都不是 HotPDF API
// uses 子句需要 Windows、SysUtils 和 Math
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q:一次 GDI 调用都没有
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // 在 W n、W f、文本裁剪、form BBox 之前调用
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 已拷贝,0 无裁剪,-1 出错
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // 不配对的 Q:忽略
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 移除裁剪
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

那段草图里有两个细节承重最大。GetClipRgn 返回 0 不是错误:它表示设备上下文当时根本没有裁剪。Q 时把零区域传给 SelectClipRgn,恰恰就是移除一个在匹配 q 处并不存在的裁剪的做法;把 0 当失败处理,内层裁剪就会一直留着。另一个细节是 Captured 标志,它防止同一帧内的第二次裁剪改动用已收窄的区域覆盖已保存的区域

这套方案的边界在哪里?

保存的裁剪是设备像素里的 GDI 区域。它对正在渲染的位图精确,对任何其他表面毫无意义,所以每个帧还记录自己取自哪个设备上下文;上下文变了就跳过恢复,比如透明组渲染进自己的图层位图时

文本侧,步进规则按拿到的宽度摆放每个字形。简单字体省略 /Widths 数组、又没有内嵌字体程序可用时,步进只能好到宽度兜底的程度,而 §9.4.4 对这个宽度从哪来只字未提。这方面的回归测试寄存在简单生成文档几乎不会包含的文件里:一份 Tf 1 配缩放 Tm 的,一份在两端对齐文本上带非零 Tz 和 Tw 的,还有一份在 q ... Q 内放了 W n 裁剪、随后又画裁剪区之外内容的。第三份渲染正确,意味着外面的内容完整画出

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