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 磅。每个字形容身后一个只错开十二分之一个字符,于是整行正文在左边距处渲染成一团黑渍,而同一份文件在其他所有阅读器里都完美
// 来自把尺寸编码进 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 调整和隐藏文本路径——意味着这条规则只有一个函数拥有
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
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 产品页