Technical Article

纯 Delphi 下实现 OpenType GSUB 风格替代字形

设计师为标题选择具有单层 a 的字体,或为表格选择带斜线的零,或者为封面选择一套花体大写字母。这些字形已经存在于字体中。它们只是不是默认字形。默认的 a 从字符通过 cmap 表映射到一个字形,而替代字形相隔几个字形 ID,只有通过替换规则才能触及。在 PDF 中生成该替代字形意味着读取规则并在内容流中输出替换字形。本文是关于在没有底层原生整形库的情况下,如何使用 Object Pascal 读取这些替换规则(单替换类型)。

其范围被刻意限制。风格集和替代字形是单字形输入、单字形输出的替换。它们是 OpenType 布局中您可以通过微小且确定的表遍历来解析的部分,这使其非常适合希望免受 C 依赖项影响的 Pascal 引擎。

为什么是纯 Delphi 而不是 HarfBuzz

HarfBuzz 是对“调整此文本形状”的显而易见的答案,对于完整的双向、印度语支或阿拉伯语整形,它是正确的答案。It 也是一个 C 库。将其绑定到 Delphi 或 C++Builder 产品中意味着要为每个目标平台和架构交付原生对象、匹配其调用约定、跟踪其发布节奏并根据您自己的许可条款阅读其许可条款。孤立来看,这些都不难。但所有这些都是永远不会消失的摩擦,并且当实际要求只是“给我这封信的 ss01 形式”时,它什么也没有带来。

单替换不需要整形引擎。它需要一个解析器来处理少数几个 GSUB 子表格式,以及进行一两次二分搜索。用 Pascal 编写它可以将整个工具链保留在一个编译器中。显而易见的限制是,这种方法处理字形替换查找,其他什么也不处理。它不是双向解析,不是印度语支重排,也不是自动上下文整形。当需要这些时,它们是不可或缺的,而单替换查询无法替代它们。

GSUB 层级结构:自顶向下

字形替换表被组织为一条间接引用链,而替换查询自顶向下步行遍历该链。最顶部是 ScriptList。诸如 latn 的脚本标签选择一个条目,特殊标签 DFLT 是在没有更具体的脚本匹配时应用的默认脚本。脚本条目指向 LangSys(语言系统),对于常见情况有一个默认的 LangSys,对于需要不同行为的语言有可选的命名 LangSys。土耳其语是常见的例子,其中带点和不带点的 i 需要它们自己的处理方式。

LangSys 命名了一组特征索引。每个索引指向 FeatureList,其中特征记录携带一个四字节标签(包括 ss01)和查找索引列表。这些索引最终指向 LookupList,实际的替换子表存放在那里。因此,解析 ss01 意味着:找到脚本、找到其 LangSys、找到标签为 ss01 的特征、收集它命名的查找并应用它们。HotPDF 默认使用 DFLT 脚本和默认的 LangSys(这是绝大多数拉丁文本设计所附带的),并且当字体在特定脚本下连接其特征时,它提供了一种覆盖脚本标签的方法。

覆盖表决定谁参与

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

覆盖表有两种格式。格式 1 是按升序排序的字形 ID 列表。您通过二分搜索找到字形,它在列表中的位置就是其覆盖索引。格式 2 是范围记录列表,每个记录包含起始字形、结束字形以及起始字形映射到的覆盖索引。范围内的字形通过从范围的起始处进行偏移来获取其覆盖索引。格式 1 在参与字形分散时很紧凑,格式 2 在参与字形落在连续运行中时很紧凑。两者都是排序的,因此都在对数时间内进行搜索,并且都返回覆盖索引或干净的“未覆盖”,从而让引擎保留字形不变。

单替换,两种格式

单替换是 LookupType 1,它将一个字形精确映射到一个替代字形。它也有两种格式,这种划分是空间优化。格式 1 存储单个有符号增量(delta)。输出字形 ID 是输入字形 ID 加上该增量对 65536 取模。这就是字体编码替换的方式,其中每个参与字形与其替代字形处于相同的固定偏移量,例如,一组等高数字与匹配的旧体数字保持恒定距离。覆盖表说明了哪些字形符合条件,并且该增量服务于所有这些字形。

格式 2 存储替代字形 ID 的显式数组。来自覆盖表的覆盖索引是进入该数组的索引,因此位于覆盖索引 0 处的字形成为第一个数组条目,覆盖索引 1 处成为第二个,依此类推。当替代字形不处于统一的偏移量时,使用格式 2,这是手动构建的风格集的常见情况。无论哪种方式,从调用者的角度来看查询是相同的。获取输入字形,通过覆盖表运行它,如果被覆盖,应用增量或读取数组槽。

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;

值得注意的契约是直接传递。GetSingleSubstituteGlyph 在每次未命中时都会原样返回输入字形 ID:没有字体、没有 GSUB 表、没有匹配特征、没有覆盖命中。这意味着该调用是可以安全地无条件进行的。您请求替代字形,如果没有,您会得到与输入完全相同的字形,因此调用代码永远不需要对缺乏该特征的字体进行特殊处理。

风格特征标签意味着什么

特征标签是您所请求的替代字形的主要字典,与风格工作相关的标签列举起来并不长。最醒目的是 salt(风格替代,获取字形替代形式的通用接口)以及 ss01ss20(字体可以定义的 20 个编号风格集,每个集是设计师组合在一起的命名替换包)。例如,字体可能会将单层 a 和直腿 R 放在 ss03 下,因此启用该集可以同时重新设置两者的样式。

围绕这些的还有其他几个单替换标签。aalt 是 access-all-alternates,即字形拥有的每个替代字形的并集,通常呈现为字形调色板特征。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 与辅助平面

在运行任何替换之前,字符必须变成字形,这就是 cmap 表的工作。替换查询从字形 ID 开始,因此路径总是通过 cmap 将字符转换为字形,然后通过 GSUB 将字形转换为替代字形。cmap 的有趣部分在于它的覆盖面。格式 4 子表覆盖了基本多语言平面(前 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 中复杂脚文本整形的文章中进行介绍。如果您的替换是更大型报告工作的一部分(该工作还在页面上放置图像和其他字体),使用字体 and 图像进行报告输出的指南涵盖了这些部分是如何组合在一起的。所有这些都运行在同一个引擎上,即适用于 Delphi 和 C++Builder 的 HotPDF 组件,它携带了 GSUB 替换查询以及本博客其他地方介绍的字体嵌入、子集化和文本 API。