技术文章

纯 Delphi 中的 OpenType GSUB 样式替换

设计师为标题选择了一款带有单层 a 的字体,或者为表格选择带有斜杠的零,或者为封面选择一组花体大写字母。这些字形已经存在于字体中。它们只是不是默认设置。默认的 a 通过 cmap 表从字符映射到一个字形,而其替换字形位于几个字形 ID 之外,只能通过替换规则访问。在 PDF 中生成该替换形式意味着读取规则并在内容流中发出替换字形。本文是关于在 Object Pascal 中,在没有任何原生塑形库(shaping library)作为底层支持的情况下,读取这些单替换(single-substitution)类规则的方法

探讨的范围被有意缩小了。样式集和替换形式属于“一进一出”(single-glyph-in, single-glyph-out)的替换。它们是 OpenType 布局的一部分,可以通过一次小规模且确定性的表遍历来解析,这使它们非常适合希望避免 C 语言依赖的 Pascal 引擎

为什么选择纯 Delphi 而非 HarfBuzz

对于“对这段文本进行塑形”这个问题,HarfBuzz 显然是解决方案,而且对于完整的双向、印度语或阿拉伯语塑形,它是正确的答案。同时它也是一个 C 库。将其绑定到 Delphi 或 C++Builder 产品中,意味着要为每个目标平台和架构提供一个原生对象文件,匹配其调用约定,跟踪其发布节奏,并核对它的许可条款以确保与你的一致。孤立地看,这些都不难。但所有这些都是挥之不去的摩擦,并且当实际需求仅仅是“给我这个字母的 ss01 形式”时,它没有带来任何好处

单替换不需要塑形引擎。它需要几个 GSUB 子表格式的解析器和一两次二分查找。在 Pascal 中编写它可以让整个工具链保持在一个编译器内。老实说,这种方法的局限性在于它只能处理字形替换查找,别无其他。它不是双向解析,不是印度语重排,也不是自动的上下文塑形。在需要这些功能的地方,你别无选择,单替换查询无法替代它们

GSUB 层次结构,自上而下

字形替换表(GSUB)被组织成一个间接链(chain of indirections),替换查询从顶端顺着这条链向下走。最顶端是 ScriptList。像 latn 这样的脚本标签(script tag)选择一个条目,而特殊的标签 DFLT 是在没有更具体的脚本匹配时应用的默认脚本。脚本条目指向一个 LangSys(语言系统),其中有一个用于常见情况的默认 LangSys,以及为需要不同行为的语言提供的可选的命名 LangSys。土耳其语是通常的例子,在那里,带点和不带点的 i 需要各自特殊的处理

LangSys 命名了一组特征索引(feature indices)。每个索引指向 FeatureList,在那里面特征记录携带一个四字节标签(其中就包括 ss01)和一个查找索引列表。这些索引最终指向 LookupList,那里才是实际的替换子表所在的位置。所以解析 ss01 意味着:找到脚本,找到其 LangSys,找到标签为 ss01 的特征,收集它所命名的查找(lookups),然后应用它们。HotPDF 默认使用 DFLT 脚本和默认的 LangSys,这也是绝大多数拉丁文本设计所交付的内容;当某种字体将其特征绑定在特定的脚本下时,它公开了一种覆盖脚本标签的方法

覆盖表(Coverage tables)决定哪些字形参与

每个替换子表都以同一个问题开始:此输入字形是否参与此规则?如果是,它在规则自身的索引中处于什么位置?这个问题由 Coverage 表回答,答案是一个覆盖索引(coverage index),即一个小的序数,子表的其余部分使用它来查找该字形变成了什么

Coverage 有两种格式。格式 1 是一个按升序排列的字形 ID 列表。通过二分查找找到字形,它在列表中的位置就是其覆盖索引。格式 2 是一系列范围记录的列表,每条记录包含一个起始字形、一个结束字形,以及起始字形所映射到的覆盖索引。范围内的字形通过相对于范围起点的偏移获得其覆盖索引。当参与的字形分散时,格式 1 很紧凑;当它们落入连续的区间时,格式 2 很紧凑。两者都经过排序,因此都在对数时间内完成搜索,并且两者都返回一个覆盖索引或者干净的“不包含”,使引擎能够保持字形原样

单替换,两种格式

单替换(Single Substitution)是 LookupType 1,它将一个字形精确地映射到一个替换字形。它也有两种格式,这种区分是为了优化空间。格式 1 存储单个有符号增量(delta)。输出字形 ID 是输入字形 ID 加上该增量,对 65536 取模。字体就是这样编码那些参与的字形与它们的替换形式之间保持相同固定偏移的替换的,例如,与对应的旧式数字(oldstyle figures)相距固定距离的一组等高数字(lining figures)。Coverage 表说明哪些字形符合条件,那一个增量服务于所有符合条件的字形

格式 2 存储一个显式的替换字形 ID 数组。来自 Coverage 表的覆盖索引即为该数组的索引,所以覆盖索引为 0 的字形变成第一个数组条目,覆盖索引为 1 的字形变成第二个,依此类推。当替换字形不处于统一偏移时(这是手工构建样式集的常见情况),就会使用格式 2。从调用者的角度来看,两者的查询是相同的。获取输入字形,通过 Coverage 检查,如果它包含在内,应用增量或者读取数组槽

var
  Pdf: THotPDF;
  BaseGID, AltGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\MyStylisticFace.ttf');
    Pdf.SetFont('My Stylistic Face', 12, []);

    // Default glyph for 'a' through the font's cmap.
    BaseGID := Pdf.GetUnicodeGlyphForCodepoint(Ord('a'));

    // Stylistic Set 1: resolve the alternate via GSUB LookupType 1.
    AltGID := Pdf.GetSingleSubstituteGlyph(BaseGID, 'ss01');

    // AltGID = BaseGID means the feature did not touch this glyph.
    if AltGID <> BaseGID then
      { emit AltGID in the content stream };
  finally
    Pdf.Free;
  end;
end;

值得注意的契约是直通性(pass-through)。在每次未命中时(无论是没有字体、没有 GSUB 表、没有匹配的特征还是没有匹配到覆盖表),GetSingleSubstituteGlyph 都会原封不动地返回输入的字形 ID。这意味着无条件地进行调用是安全的。当你请求替换字形时,如果没有,你得到的就是你最初传入的内容,所以调用代码不需要为缺乏该特征的字体编写特殊处理逻辑

样式特征标签的含义

特征标签囊括了你正在请求哪种替换形式的所有词汇,与样式工作相关的标签属于一个较短的列表。其中最引人注目的一对是 salt(样式替换)和 ss01ss20,前者是访问字形替换形式的通配方式;后者是字体可以定义的 20 个带编号的样式集,每一个都是设计师组合在一起的命名替换捆绑包。例如,字体可能会将单层的 a 和直腿的 R 放在 ss03 下,因此启用这一个集合就能重新塑造这两者

除了它们,还有好几个单替换标签。aalt 是“访问所有替换”(access-all-alternates),即一个字形拥有的每个替换形式的并集,通常作为字形调色板(glyph-palette)功能呈现。titl 选择为大尺寸切割的标题大写字母。subssups 换入真正的下标和上标数字,而不是缩小的默认数字。ordn 生成序数形式,如 1st 和 2nd 中升高的字母。frac 构建分数,尽管完整的斜线分数也依赖连字和超越普通单替换的上下文逻辑。对于单字形情况,其机制与 ss01 相同:将标签传递给替换查询,然后读回替换字形

// Try a stylistic-set feature, then fall back to plain alternates.
function ResolveAlternate(Pdf: THotPDF; BaseGID: Word;
  const PreferredTag: AnsiString): Word;
begin
  Result := Pdf.GetSingleSubstituteGlyph(BaseGID, PreferredTag);
  if Result = BaseGID then
    Result := Pdf.GetSingleSubstituteGlyph(BaseGID, 'salt');
  // Still BaseGID if neither feature covers this glyph.
end;

cmap 格式 12 与补充平面(supplementary planes)

在能够运行任何替换之前,字符必须先成为字形,这是 cmap 表的工作。替换查询从字形 ID 开始,所以路径总是从字符通过 cmap 到字形,然后字形通过 GSUB 找到其替换。cmap 表有趣的部分是它的范围。格式 4 的子表涵盖了基本多语言平面(BMP,即前 65536 个码位),这足以满足大多数拉丁文本的需求。但它不足以覆盖从 U+10000 起向上的码位,即补充平面,而数学字母数字、许多符号以及几个仍然在使用的书写系统现在就驻留在这里

格式 12 是涵盖整个 U+0000 至 U+10FFFF 范围的子表。它是一个经过排序的群组列表,每个群组包含一个起始码位、一个结束码位和一个起始字形 ID,因此连续的码位映射到连续的字形段。HotPDF 采用与数据形状匹配的混合策略来解析码位。BMP 中的码位通过直接以码位为索引的数组提供,进行单次查找且无需搜索。补充平面中的码位通过稀疏表提供服务,该表按码位排序并通过二分查找搜索。结果就是,GetUnicodeGlyphForCodepoint 接受一个完整的 Cardinal,并在整个范围内准确回答,对于字体未映射的任何码位返回字形 ID 0,也就是 .notdef 字形

var
  Pdf: THotPDF;
  Cp: Cardinal;
  GID, StyledGID: Word;
begin
  // A supplementary-plane code point: U+1D49C MATHEMATICAL SCRIPT CAPITAL A.
  Cp := $1D49C;
  GID := Pdf.GetUnicodeGlyphForCodepoint(Cp);  // format 12 lookup
  if GID <> 0 then
    StyledGID := Pdf.GetSingleSubstituteGlyph(GID, 'ss01')
  else
    StyledGID := 0;  // font has no glyph for this code point
end;

这些查询的边界在哪里

单替换 API 回答了一种形式的问题,澄清它们不回答什么是值得的。LookupType 1 属于八种替换类型之一。查询不处理 LookupType 2 多重替换(一个字形变成几个),也不处理 LookupType 4 连字替换(几个字形变成一个)。它也不处理上下文和链式上下文替换,即 LookupTypes 5 和 6,这些类型仅当字形出现在特定邻域时才触发;此外,它也不涵盖扩展和反向链式类型。斜线分数、天城文合字,或者阿拉伯文首-中-尾的级联都是序列问题,每个字形的单替换查找无法表达它

它也不执行自动塑形。这里没有什么能检查一段文本、决定开启哪些特征,然后按照脚本要求的顺序应用它们。调用者选择特征标签并逐字形应用它。对于属于自愿加入并且是局部化的样式集和替换形式来说,这是完全正确的工具;但对于需要重排的脚本来说,这则是完全错误的工具。保持边界清晰正是替换路径能够保持小巧且可预测的原因

对于那些确实需要序列级别工作的情况,复杂脚本的故事在我们在关于 Delphi 中的复杂脚本文字塑形的文章中进行了论述。如果你的替换是在一个包含图像和其他字体在页面上排列的更大的报告生成任务中进行的,包含字体和图像的报告输出指南涵盖了如何将这些部分结合在一起。所有这些都运行在同一个引擎上,即用于 Delphi 和 C++Builder 的 HotPDF Component,它提供 GSUB 替换查询功能,与本博客其他地方涵盖的字体嵌入、子集化和文本 API 并列提供