当字体子集化程序只保留从已输出码点可达的那些字形时,经过成形处理的字形就会渲染成.notdef方框。HotPDF是面向Delphi和C++Builder的原生VCL PDF组件,它在2.435.0版本之前一直带着这样一个缺陷:OpenType GSUB输出被记录进了一个内部使用位图,子集化程序声称会遵循这个位图,实际上却从来没有真正读取过它
这与曾经悄悄让字体子集化整体失效的EndDoc缺陷是不同性质的问题。那个缺陷关乎子集化相对于序列化在什么时候运行,而且它会让子集化整体失效。这一个则关乎当子集化按计划完美运行时,子集里到底包含了什么内容。整套流程在正确的时机触发,六字母的子集前缀正如ISO 32000-1 §9.6.4要求的那样出现在/BaseFont上,文件确实变小了,每一页拉丁文都校对无误,而一页阿拉伯文出来却是一排空方框。排序类缺陷一旦你去看就会很显眼。闭包类缺陷则会一直悄无声息,因为这个子集在结构上完全合法,只是在自己的成员名单上出了错
为什么成形后的字形会渲染成.notdef?
因为一份文档输出的码点集合,并不等于这份文档实际绘制的字形集合,而一个把两者混为一谈的子集化程序会把每一个由成形过程产生的字形都丢掉。文本成形把一个逻辑字符序列转换成一个带定位信息的字形序列,它存在的全部意义,就是产生没有任何单一输入字符能映射到的字形:一个阿拉伯文中间形态的heh、一个fi连字、一个天城文的连写字符(conjunct)、一个由rclt特性选中的上下文替代字形。这些每一个都是由GSUB查找规则加工制造出来的字形ID,而不是cmap表为你字符串中任何字符给出的那个字形ID。因此,一个纯粹由cmap驱动的子集化程序,走的其实是错误的索引。它忠实地保留了文本在成形之前可能用到的每一个字形,却恰恰丢弃了文本在成形之后真正用到的那些字形。渲染器随后向嵌入字体请求GID 1847,而子集已经在loca中把这个条目清零了,于是返回的是字形索引0。按OpenType定义,字形索引0就是.notdef,这正是为什么这个失败的特征表现是一个空方框,而不是一个错误的字母或者一次崩溃。PDF里没有任何东西格式不对;只是这个字体里根本没有内容流所请求的那个字形
码点不是字形:一个子集的三个来源
一个正确的子集闭包必须合并三个各自独立的来源,每一个都有自己的累加器。第一个是由码点推导出的集合:HotPDF在BMP字符被输出时累加FUnicodeUsedCps,在通过代理对(surrogate pair)到达的辅助平面字符时累加FUnicodeSmpUsed,然后通过FUnicodeCpToGid把每一个都映射成一个字形ID。第二个是由成形推导出的集合,也就是GSUB替换所产生的那些字形ID,通过MarkUnicodeGlyphUsed和EnableShapingFeatureForSubset记录进FUnicodeExtraUsedGlyphs。第三个是复合字形闭包:glyf中numberOfContours为-1的字形是由若干个组件字形ID组装而成的,如果保留了复合字形本身却丢弃了它的组件,得到的会是一个空轮廓而不是.notdef,这甚至可能更糟,因为看起来会像是一个间距方面的错误
第一个和第三个来源HotPDF一直处理得没问题。BuildAndApplyUnicodeFontSubset是EndDoc在序列化之前调用的子集化入口,它用GID 0给已用字形数组做种子,遍历BMP码点,遍历SMP使用列表,然后把这个数组交给一个子集构建器,由它在内部解析复合字形的组件。第二个来源虽然写好了却从未被消费,而且由于这三个来源分别在不同内容上出问题,这个缺口能在一个回归语料库以拉丁文为主的代码库里潜伏多年
被写入却从未被读取的数组
这份契约在三个地方都有文档说明,却没有一处真正被遵守。FUnicodeExtraUsedGlyphs的声明处写明,EndDoc的子集化程序会把它和由码点推导出的使用情况合并;ApplyArabicGSUBRefinement头部的注释承诺,每一个输出的替代字形ID都会经过MarkUnicodeGlyphUsed,从而让子集化程序把这个字形拉进嵌入字体;同样的承诺在ApplyArabicGSUBContextualRefinement中针对rclt路径逐字重复了一遍。两处调用方都各自履行了自己那一半的承诺。而对这个字段的每一处引用做一次grep,大约花了九十秒就把另一半的真相摸清楚了:一处声明,RegisterUnicodeTTF内部一次SetLength分配,以及两个标记例程里的写入操作。没有一处读取。这正是值得内化成本能的诊断方法,因为它的适用范围远远超出字体领域。当一个字段被好几处调用方写入、却没有任何地方读取时,它所代表的那个功能就并不真正存在,不管注释写得多么详尽。子集化程序的第1步小到一屏就能看完,一旦你知道该往哪里找,这个缺口就一目了然
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
只需一处循环的修复,以及如何自己标记字形
这次修复是一次并集运算,它的安全性论证来自这个操作的方向:它只会置位,从不清零,所以任何原本能在子集化中幸存下来的字形都不可能因此被丢弃
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
有三个特性让这更像是一次低风险的修改,而不是一次字体引擎重写。如上所述,它是单调的。对于从未做过任何成形处理的字体,它是一个空操作,因为FUnicodeExtraUsedGlyphs会始终保持全False,纯拉丁文文档的字节输出不受影响。而且它是在第2步之前生效的,所以两个子集构建器都能继承到它:一个是保留原始GID编号的稀疏构建器,另一个是_BuildCompactSubsetTTF这个紧凑构建器,HotPDF在PDF/A模式下会选用它,把保留下来的字形重新编号进一个密集区间,缩小maxp.numGlyphs,并按ISO 32000-1 §9.7.4.2的要求把新旧编号映射输出为/CIDToGIDMap流。两者内部都会调用_TTFWalkCompositeClosure,所以一个恰好是复合字形的成形字形,现在也会把它的组件一并带进来。复合字形闭包本身从来没有坏过;只是对这些字形ID来说它从来没有被触发过,因为这些字形ID根本不在它遍历的那个集合里。如果你直接驱动GSUB引擎,而不是依赖内置的精修流程,闭包就成了你自己的责任,你所输出的每一个替代字形ID,都必须在EndDoc冻结已用字形集合之前被标记
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset是对应单个GID调用的批量版本,它刻意做得比较保守。它会遍历GSUB查找规则列表,找出在当前选定的书写系统和语言路径下与某个四字节特性标签相关联的那些查找规则,并标记这些查找规则可能产生的替代字形ID。当字体不带GSUB表、或者该特性在这条路径上并不存在时,它会保守地什么也不做,所以无条件调用它是安全的。它按设计也是一种过度估计:它可能会保留某份文档实际上从未画过的字形。对子集化来说,多包含一些付出的代价是字节数,少包含则会付出正确性的代价,这笔账很容易算清楚。关于这些查找规则的结构,以及决定哪些字形参与其中的覆盖表,纯Delphi实现的GSUB风格替代字形详解一文有详细介绍
如何证明这个字形确实在子集里?
要靠读取输出的字体本身来证明,而不是靠在某个可能背着你悄悄替换成系统字体的查看器里凭肉眼看页面。能抓住这整类问题的检查方法是机械化的:从输出的PDF中提取/FontFile2流,解析loca,确认你期望的那个字形ID对应一个非空条目,也就是它的起止偏移量不相等。一个空条目就意味着子集化程序判定这个字形未被使用。养成两个习惯之后,再犯同样的错误会困难得多。把一页使用了成形文字的内容留在自动化冒烟测试语料库里,而不只是留在人工校对集合里,因为阿拉伯文、天城文和高棉文会走到纯拉丁文覆盖测试永远碰不到的闭包路径。而且只要存在一个累加器,就要断言确实有什么东西在消费它,因为一个只写不读的字段是一个能编译通过、能在错误的语料库上测试全绿、却什么也没做的"功能"
这次修复到此为止,还没解决的部分
子集闭包是成形字形能够渲染出来的必要条件,但不是充分条件。这个字形还必须能从内容流中被寻址到,这是另一个有着自己边界的问题。HotPDF内置的阿拉伯文精修流程,只有当每一个替代字形ID都能通过对U+FB50到U+FDFF以及U+FE70到U+FEFF这大约690个码点做反向cmap扫描、经由某个Unicode展示形式码点到达时,才会真正提交这次替换。当一个替代结果落在这个范围之外的某个字形ID上时,输入窗口会原样透传,而不会去输出一个读取器根本无法寻址的东西;那些落在任意字形ID上的、特定于某个字体的替代字形,需要在U+E000到U+F8FF之间分配一个合成的私有使用区码点,才能带着它们通过输出路径。所以老实说,2.435.0这次修复移除的是一个硬性阻断点,而不是把整个故事讲完了。在这次修复之前,一个字形可以被正确地成形、正确地输出,却仍然会在子集化那一刻消失,这意味着不管查找规则做得多好,整套成形引擎从头到尾都不可信。剩下的问题是可寻址性,而这个约束至少会在输出这一步就明显地失败,而不是在一个跑在你所关注的一切之后的构建步骤里悄无声息地出错。关于同一条流水线的输出侧,参见Delphi PDF中阿拉伯文与从右到左文本成形指南
这里介绍的字体子集化、GSUB引擎和复杂文字成形,都随标准版HotPDF Component一起提供,适用于Delphi和C++Builder;产品页收录了上文提到的Unicode字体与成形调用的完整API参考