HotPDF 之所以能正确渲染嵌入字体里的带音标字母,靠的是处理好两种用别的字形拼出一个字形的字体机制:TrueType 组合字形——其分量偏移必须先按字形单位应用、再套字形到设备的矩阵;以及 CFF endchar 的 seac 形式——按 StandardEncoding 码把基础字母和变音符号拼在一起。这两处任何一处弄错,é、Å、ü 或弯双引号就会缺一块,而周围普通的字母看起来完好无损
正是最后这个细节让这类 bug 代价高昂。一份报表渲染出来,正文没问题,偏偏 “München” 中间缺了块,或者一对引号只剩一个单引号加一段空白。没人怀疑渲染器。第一猜测永远是字体损坏或生成器有问题,工单就这么来回踢一个星期。本文逐个机制讲清实际会出什么错、HotPDF Delphi 组件自 v2.768.10 和 v2.768.11 起如何处理,以及 v2.770.83 里相关的子集化修复——HotPDF 写字体而不是读字体时,它保证组合字形完好无损
为什么 PDF 字形里偏偏只有变音符号消失
变音符号消失,是因为它是字形里唯一带非零偏移的部分。TrueType 和 CFF 字体都靠复用轮廓省空间:é 不是画成一个新形状,而是“e 字形,加上向右上平移的 acute 字形”。基础字母几乎总在 (0, 0) 偏移处,所以偏移应用方式上的任何错误都动不了它。变音符号是会动的那部分,于是也是落错位置或干脆没被画出来的那部分
症状模式本身就是一份诊断书。如果带音标字符的基础字母渲染了、附加符号没渲染,或者 “ 和 ” 这样的成对标点显示成一个符号而不是两个,就别再盯着字体文件看了。坏字体通常坏掉整个字形,而且对每个查看器都坏。单个渲染器里组合字形缺零件、Acrobat 却显示正常,矛头指向的是组装分量的代码
TrueType 组合字形如何摆放分量
TrueType 组合字形用每个分量自己的小仿射变换来摆放它,变换以字形单位表示,且必须先作用于分量轮廓,字形到设备的矩阵随后才生效。在 OpenType glyf 表里,组合字形的轮廓数是负数,后面跟着一列分量记录。每条记录含一个标志字、分量字形索引、两个字节或字的参数(ARG_1_AND_2_ARE_WORDS,0x0001),以及可选的缩放:WE_HAVE_A_SCALE(0x0008)带一个 F2Dot14 值,WE_HAVE_AN_X_AND_Y_SCALE(0x0040)带两个,WE_HAVE_A_TWO_BY_TWO(0x0080)则带完整的 2x2 矩阵。设置了 ARGS_ARE_XY_VALUES(0x0002)时,两个参数就是字形单位下的 dx/dy 偏移。MORE_COMPONENTS(0x0020)表示后面还有记录
所以对每个分量,先从缩放部分和偏移构造分量矩阵 CM,再与 M(把字形单位映射到设备像素的矩阵)结合。结合的次序就是整个 bug 所在。HotPDF 的内部矩阵乘法用列向量约定,A 与 B 的乘积先应用 B 再应用 A。正确的组合是先在字形空间应用 CM,再应用 M。反过来写,M 先跑,CM 的偏移随后才加,而且已经是设备像素、从未被缩放
// 仅为示意:列向量约定下的 2D 仿射记录,
// Mul(A, B) 把点先经 B 再经 A 映射
type
TAffine = record
a, b, c, d, e, f: Double;
end;
function Mul(const A, B: TAffine): TAffine;
begin
Result.a := A.a * B.a + A.c * B.b;
Result.b := A.b * B.a + A.d * B.b;
Result.c := A.a * B.c + A.c * B.d;
Result.d := A.b * B.c + A.d * B.d;
Result.e := A.a * B.e + A.c * B.f + A.e;
Result.f := A.b * B.e + A.d * B.f + A.f;
end;
// M :字形单位 -> 设备像素(缩放极小,y 翻转)
// CM :分量变换;e/f 存字形单位下的 dx/dy
Sub := Mul(M, CM); // 正确:偏移随轮廓一起缩小
// Sub := Mul(CM, M); // 错误:dx/dy 落成原始设备像素
数字一摆出来,失败就一目了然。Arial 用 2048 单位的 em,é 的变音分量偏移就记在这些字形单位里。次序正确时,偏移随轮廓一起缩到正常缩放下的几个像素;次序颠倒时,原始字形单位的值被当成设备像素直接加上,HotPDF 测试渲染里 Arial é 的变音符号跑到了离字母 243 像素远的地方,远远出了字形范围,通常也出了任何可见范围。Arial 的左双引号是单引号形状的两份拷贝、第二份带平移,于是渲染成一个孤零零的 ‘ 后面跟着一片空白
线性部分很少暴露这个 bug。变音分量通常根本不带缩放,而且均匀缩放反正与 M 可交换,所以只有平移会暴露颠倒的乘积。这也是为什么拿普通拉丁文本快速测一下就通过:颠倒在代码里躺了好多个版本,直到 v2.768.10 才纠正,而只有带偏移的分量才会暴露它
递归与循环
组合字形可能引用其它组合字形,所以渲染器要递归遍历,还得自我保护。HotPDF 把嵌套深度限制在 5 层、每个字形的分量总数限制在 1024,跳过引用自身字形索引的分量,并把少见的点匹配模式(参数是点编号而非偏移)按零偏移处理。这样恶意或损坏的字体最多丢一个分量,但挂不死页面渲染
CFF endchar 的 seac 形式是什么
endchar 的 seac 形式是 CFF Type 2 charstring 声明“这个字形是一个基础字母加一个变音符号”的弃用方式:当 endchar 带四个参数时,它们是 adx ady bchar achar。Adobe Technical Note #5177 在讲 Type 1 兼容性的附录里描述了它。这个名字来自 Type 1 的 seac 操作符(标准编码带音标字符),从 Type 1 字体转换来的 CFF 子集至今仍大量用它排带音标的拉丁字母
有三个细节坑实现者。其一,bchar 和 achar 不是字形 ID,而是 Adobe StandardEncoding 里的码,渲染器要先把码映射成字形名,再经字体 charset 查到 GID。其二,Type 2 没有 asb 参数,Type 1 的 seac 则接受 asb adx ady bchar achar。Type 2 里变音符号的原点直接落在相对基础字形原点的 (adx, ady) 上,没有边距修正。其三,CID 键控 CFF 字体根本不允许用 seac,因为没有可借以解析 StandardEncoding 码的字形名 charset
还有宽度问题。每个 Type 2 charstring 的第一个清栈操作符都可带一个可选的步进宽度参数,endchar 就是这类操作符之一。带一个参数的普通 endchar 携带宽度;带五个参数的 seac endchar 携带宽度加四个 seac 值。规则是把参数个数对着预期的固定数来数,seac 是四个。v2.768.11 之前,HotPDF 把任何 endchar 都当“取宽度然后收工”,于是 seac 字形画出空轮廓:没有基础字母,没有变音符号,只有行里一个尺寸正确的空位
// Type 2 解释器中 endchar 判定的示意。
// Stack 存 endchar 之前的操作数;Count 是个数。
if (Count >= 4) and not IsCIDFont then
begin
// 5 个操作数:第一个是步进宽度,其余是 seac
if Count = 5 then
TakeWidthAndShift; // 丢掉宽度,留 4 个操作数
if StackDepth = 4 then
begin
BaseGID := GIDForName(StdEncName(Round(Stack[2]))); // bchar
AccentGID := GIDForName(StdEncName(Round(Stack[3]))); // achar
DrawGlyph(BaseGID, Identity);
DrawGlyph(AccentGID, Translate(Stack[0], Stack[1])); // adx, ady
Exit;
end;
end;
// 否则:普通 endchar,可选宽度,闭合路径
真实的解释器里,两个分量字形都由同一个 charstring 引擎执行,变音符号的矩阵是当前字形矩阵复合一个到 (adx, ady) 的平移,之后矩阵被恢复,行里后面的字形不受影响。任何一个名字解析不出 GID 时,跳过那个分量而不是让整个字形失败——子集裁得略狠时,你要的正是这种宽容
在自己的 Delphi 代码里验证修复
验证组合字形与 seac 处理最快的办法,是让带音标文本、内嵌字体的页面走两条公开输出路径:THotPDF.RenderLoadedPageToBitmap 出栅格,THotPDF.BuildLoadedPageSVG 配 EmbedFonts 设为 True 出矢量。两者共用同一套字形轮廓代码,所以都收到了 v2.768.10 和 v2.768.11 的修复。管线全貌见HotPDF 如何渲染嵌入的 TrueType、CFF 与 Type 3 字形,位图选项见在 Delphi 中把 PDF 页面渲染成位图
uses
System.Classes, Vcl.Graphics, HPDFDoc;
procedure RenderAccentProbe(const FileName: string);
var
Pdf: THotPDF;
Bmp: TBitmap;
Svg: AnsiString;
Lines: TStringList;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile(FileName) <= 0 then
Exit;
// 栅格路径:144 DPI 下错位的变音符号一眼可见
Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
if Bmp <> nil then
try
Bmp.SaveToFile('accent-probe.bmp');
finally
Bmp.Free;
end;
// 矢量路径:EmbedFonts = True 会写出字形轮廓,
// 组合与 seac 的拼装直接可见于 SVG 路径本身
Svg := Pdf.BuildLoadedPageSVG(0, True);
if Svg <> '' then
begin
Lines := TStringList.Create;
try
Lines.Text := string(Svg);
Lines.SaveToFile('accent-probe.svg');
finally
Lines.Free;
end;
end;
finally
Pdf.Free;
end;
end;
测试文档要刻意去挑。你需要一份带嵌入 TrueType 子集、含 é、Å 和弯双引号的 PDF,再要一份嵌入 CFF(FontFile3,Type1C)子集、由 Type 1 字体产出的 PDF——seac 至今就活在那种地方,常见于老式排版输出和某些印刷工作流。生成器没嵌入的字体排的文本在这里说明不了任何问题,因为那些字形来自系统安装的 Windows 字体,而不是 ISO 32000-1 §9.9 描述的嵌入字体程序
组合字形为什么在字体子集化时坏掉
组合字形在子集化时坏掉,是因为分量带缩放或 2x2 变换时,子集化器用错误的步长跨过分量记录,接着读错下一个分量的字形索引。HotPDF 在 v2.770.83 修复了它。子集化是渲染 bug 在写入侧的孪生兄弟:HotPDF 嵌入已安装 TrueType 字体的子集时,必须保留组合字形引用的每个字形,并把那些引用重新编号到新的紧凑字形 ID
旧的遍历器只看参数尺寸标志和 more-components 标志,每条记录前进 6 或 8 字节,无视缩放标志附加的 2、4 或 8 字节变换数据。跨过一个带缩放的分量后,它读到的下一个“字形索引”其实是变换字节,于是真正的分量可能被排除出子集,重编号步骤还可能把新的字形 ID 直接写进变换里。结果就是带音标字母在某些查看器里变音符错位、变形,或者干脆消失,而且只发生在对分量做缩放的字体上——这正是大多数测试从未抓到它的原因
步长 bug 旁边还躺着两个问题。字形索引的重编号不是幂等的,嵌套组合被重编号了两次:一次是遍历所有标记组合的那一趟,另一次是递归下钻时又来一遍。HotPDF 现在对每个组合恰好重编号一次,“这个组合需要哪些字形”这一步由工作队列驱动,互相引用的组合环能够终止,超出字体 maxp 字形数的索引会被拒绝,空字形也不会再被当组合解析
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'accents-subset.pdf';
Pdf.FontEmbedding := True; // 嵌入用到的字形
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 14);
// é、Å、ü 和弯引号在 Arial 里都是组合字形
Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
'Café, Ångström, München, “quoted”');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
把那份文件拿去上面的探针和第二个查看器各过一遍。自 v2.770.83 起,每个组合字形的每个分量都能挺过子集化,包括缩放或 2x2 变换背后的分量,于是变音符号在 HotPDF、Acrobat 和浏览器查看器里渲染一致
组合字形处理不覆盖的情形
组合字形修复覆盖的是基于偏移的摆放——几乎所有真实字体都用这种方式——其余情形一律保守近似。TrueType 点匹配组合(两个参数指定要对齐的点而非给出偏移)按零偏移绘制。CID 键控 CFF 字体里的 seac 按设计忽略,因为规范在那里禁止它。裁剪过的子集里 seac 的基础码或变音码没有对应字形时,能找到什么分量就画什么。这些情况都不会让渲染器崩溃,但如果你产出归档或 PDF/A 用途的 PDF,更稳妥的做法是嵌入使用偏移组合、且 Type 2 charstring 不含 seac 的字体,让每一个消费方——而不只是某一个渲染器——看到同样的字母
FAQ
为什么我的 PDF 渲染器里带音标字母渲染出来没有音标
带音标字母通常是组合字形或 CFF seac 字形,变音符号是唯一带偏移的分量。渲染器若在设备矩阵之后才应用分量偏移,或无视 endchar 里的 seac,就会丢掉或放错变音符号,而基础字母渲染正常
HotPDF 支持 CFF endchar 的 seac 形式吗
支持。自 v2.768.11 起,HotPDF 把带四个参数、或连宽度在内五个参数的 endchar 画成基础字形加一个平移到 (adx, ady) 的变音字形,两者都经 StandardEncoding 名和字体 charset 解析,位图渲染和嵌入字体的 SVG 导出都覆盖
HotPDF 子集化 TrueType 字体时组合字形会保留吗
会。自 v2.770.83 起,HotPDF 正确跨过 2、4 或 8 字节的分量变换,把每个被引用的分量留在子集里,嵌套组合只重编号一次,遇到循环或越界的字形引用也能安全停下
组合字形渲染、CFF seac 支持和安全的 TrueType 子集化,都随 HotPDF Delphi PDF Component 提供——一个面向 Delphi 和 C++Builder 的原生 VCL 库,无需外部 DLL 即可创建、编辑、提取和渲染 PDF 文档