用 TrueType composite glyph 生成拼音字体
起因
最近在 Codex 的帮助下,把四年前写到一半的 pinyin-font 又捡了起来。
这是一个从普通 TrueType 字体生成拼音字体的命令行工具。生成后的字体中,每个汉字上方都带有对应的拼音:

做这个项目的起因,是当时在研究 TrueType 字体格式时发现,glyf 表中的 composite glyph(复合字形)刚好适合解决一个很实际的问题。
在语文教学中,给汉字标注拼音一直不算方便。MS Word/WPS 之类的软件提供「拼音指南」,网页也可以使用 ruby 标签,但它们都需要应用程序专门支持,并且拼音的输入、排版和字体选择往往是另一套流程。复制到不支持这些功能的软件里,标注还可能丢失或变形。
另一种思路是把拼音直接做进字体。这样用户仍然输入普通汉字,应用程序也仍然把它当作普通汉字处理;字体只是把原本的汉字 glyph 换成一个「上面是拼音、下面是汉字」的新 glyph。拼音和汉字来自同一套字体,字号、颜色和样式天然保持一致,也不需要排版软件理解什么是拼音标注。
TrueType composite glyph
TrueType 字体的轮廓主要保存在 glyf 表中。一个 glyph 可以是 simple glyph,也可以是 composite glyph。前者直接保存轮廓上的点,后者不再重复保存轮廓,而是引用其它 glyph,并为每个组件指定平移和缩放变换。
例如,字体中的 é 不一定需要重新画一份完整轮廓,它可以由 e 和 acute accent 两个 glyph 组合出来:
é
├── e scale = 1.0, dx = 0, dy = 0
└── acute accent scale = 1.0, dx = 240, dy = 450
glyf 表中的复合字形大致记录如下信息:
component glyph ID
component flags
argument 1 / argument 2 # 通常作为 dx / dy
2×2 transform # scale、非等比 scale、旋转等
这和拼音字体的需求几乎完全吻合。假设原字体中已经有「汉」「h」「à」「n」这些 glyph,我们只需要追加一个新的 composite glyph:
汉(带拼音)
├── h scale = 35%, dx = ..., dy = ...
├── à scale = 35%, dx = ..., dy = ...
├── n scale = 35%, dx = ..., dy = ...
└── 汉 scale = 65%, dx = ..., dy = ...
然后在 cmap 表中,把字符 汉 从原 glyph ID 改为这个新 glyph ID。对于应用程序来说,文本仍然只有一个 U+6C49;对于光栅化器来说,它只是正常渲染一个复合字形。
这种做法还有一个重要优点:新 glyph 主要保存引用和变换,不需要复制汉字和拉丁字母的轮廓。生成字体会增加很多 glyph,但不会为每个汉字重复保存一套拼音轮廓。
当然,真正实现时并不是把几个字符简单缩小后堆在一起。不同字体的度量差异很大;拼音有长有短,声调字符也未必齐全;汉字还可能有多个读音。后面的工作基本都在处理这些细节。
字体生成流程
pinyin-font 的输入包括一个 TrueType 字体和一份拼音数据库。数据库格式来自 pinyin-db,每行包含汉字、Unicode code point 和一个或多个读音:
三 4E09 sān
上 4E0A shàng,shǎng
下 4E0B xià
生成过程可以概括为:
解析源字体和拼音数据库
↓
保留源字体的 cmap
↓
为每条读音解析字母、ü 和声调组件
↓
计算拼音与汉字的缩放、位置和 bbox
↓
追加 composite glyph
↓
用新 glyph 更新 cmap
↓
为多音字生成 GSUB ligature
↓
重写 glyf / loca / hmtx / cmap / maxp 等表
如果某个汉字或拼音组件无法合成,生成器会保留源 cmap 映射,而不是让这个字符变成 .notdef。这使得局部失败不会破坏整套字体。
布局算法
当前布局把原 advance width 保持不变,汉字缩放到 65%,拼音的纵向比例为 35%。也就是说,生成字体不会让一行文字突然变宽;变化只发生在一个汉字原有的 advance cell 内部。
这里需要区分两个概念:
- advance cell 是排版引擎为 glyph 前进的宽度;
- ink bounds 是轮廓实际覆盖的范围。
拼音最终应该在汉字的 advance cell 中居中,而不是相对汉字轮廓居中。后者看起来似乎更直观,但「广」「川」这类轮廓不对称或左右 side bearing 不同的字,会导致同一个拼音在相邻汉字上左右跳动。
垂直布局
垂直方向先为整个字体选择一个公共排版区间:
OS/2.sTypoDescender .. OS/2.sTypoAscender
↓ 无效时
hhea.Descender .. hhea.Ascender
↓ 无效时
head.YMin .. head.YMax
优先使用 OS/2 和 hhea,是因为它们描述的是字体设计的排版空间。head 中的 Y 范围是全字体轮廓的极值,很容易被某个无关的超高或超低 glyph 拉大。如果直接按 head 定位,一个装饰字符甚至可能改变所有汉字上方拼音的位置。
设选出的区间为 [layoutYMin, layoutYMax],当前汉字和拼音的公共偏移为:
baseRatio = 0.65
pinyinRatio = 0.35
baseDY = layoutYMin < 0
? layoutYMin × (1 - baseRatio)
: 0
pinyinDY = baseDY
+ layoutYMax × baseRatio
- pinyinCharYMin × pinyinRatio
pinyinCharYMin 取源字体中 f/g/j/p/q/y 的最小 YMin,用来为拉丁字母的 descender 留出空间。这个值是字体级的,而不是按每个拼音单独计算,因此相邻汉字的拼音会落在同一条基线上。
汉字本体以 65% × 65% 等比缩放;拼音的 Y 缩放固定为 35%。如果一个拼音太长,只压缩 X 轴,拼音高度和基线不会跟着变化。
水平布局
一个读音先被拆成若干 cluster。cluster 至少包含一个 base,后面最多跟两个 combining mark:
u
u + ◌̄
u + ◌̈ + ◌̌ # ǚ
base 按源字体 hmtx 中的 AdvanceWidth 依次排列。mark 不推动游标,而是相对 base 的 advance cell 居中:
markOffsetX = cursor + baseAdvance / 2
- (markXMin + markXMax) / 2
这里特意使用 advance,而不是轮廓宽度加一个人工字距。字体设计者已经通过 advance 和 side bearing 决定了拉丁字母间距,复用这些度量通常比重新猜一个 tracking 值更可靠。
排列完成后,算法计算所有 base 和 mark 的墨迹并集:
inkMin = min(component.XMin + component.OffsetX)
inkMax = max(component.XMax + component.OffsetX)
inkWidth = inkMax - inkMin
设汉字 advance 为 A,拼音的最大缩放比例为 R = 0.35,X 方向缩放为:
scaleX = min(R, A / inkWidth)
scaleY = R
短拼音的 scaleX == scaleY,可以保持原始宽高比;长拼音装不下时,只有 X 轴会被进一步压缩。所有字母和声调共享同一个变换,避免声调相对字母发生位移。
TrueType 实际使用 F2DOT14 定点数保存缩放,偏移也是整数。浮点公式得到的结果经过量化后,边界偶尔会多出一个设计单位。因此实现会用编码后的定点缩放重新计算所有组件的 bbox;如果仍然超过 advance cell,就把 scaleX 减少一个 F2DOT14 单位再试。最后根据真实墨迹宽度整体平移,让拼音在 [0, A] 中居中。
这部分看起来有点啰嗦,但它解决的是字体代码中很常见的一类问题:数学上放得下,不代表序列化成定点数以后仍然放得下。
字体缺少声调时怎么办
拼音数据库中的预组字符会先规范化成 base 与 combining mark。例如:
mā → m + a + U+0304
nǚ → n + u + U+0308 + U+030C
合成时并不会一律使用分解形式,而是按下面的顺序尽量复用源字体:
- 如果源字体有完整的预组字符,例如
ā或ǚ,直接使用它; - 否则查找对应的 combining mark,例如 U+0304;
- combining mark 不存在时,尝试 spacing 形式,例如 U+00AF;
- 两种都没有时,在目标字体内部生成一个简单声调 glyph。
内部生成支持一声的 macron、二声的 acute、三声的 caron、四声的 grave,以及 ü 使用的 diaeresis。它们的笔画宽度和尺寸根据目标字体的 x-height 计算:优先读取 OS/2.sxHeight,不可靠时从一组小写字母的轮廓取中位数,最后才回退到 UnitsPerEm / 2。
这不能完整复制源字体设计师的 accent 风格,但至少会随目标字体的 UPM 和小写字母尺寸变化,比引入另一套字体中的符号更一致,也避免了额外的字体授权问题。
i 是一个特殊情况。如果把声调直接叠在普通 i 上,会出现两个点。源字体有 ī/í/ǐ/ì 时可以直接使用;否则生成器会分析普通 i 的 simple glyph 轮廓,移除位于上方、面积较小且与主体分离的点,派生一个内部 dotless i,再把声调放到上面。若无法可靠识别轮廓,则放弃当前读音的合成,而不是输出一个明显错误的 glyph。
多音字
默认情况下,数据库中的第一个读音用于更新汉字的 cmap。例如:
藏 cáng,zàng
直接输入「藏」会显示 cáng。问题是怎样选择第二个读音,又不要求输入法或应用程序增加一套私有协议。
当前方案约定在汉字后写 @序号:
收藏@1 → cáng
西藏@2 → zàng
其中序号是数据库候选读音的一基索引,当前最多支持四个读音。生成器会为非默认读音追加没有 Unicode 映射的内部 composite glyph,再生成一个最小的 OpenType GSUB liga feature:
默认读音的「藏」glyph + @ glyph + 2 glyph
↓ GSUB Lookup Type 4
zàng composite glyph
@1 也会生成一条规则,它替换回默认 glyph,但同时消费后面的 @1。
这个方案的好处是底层文本仍然是普通 Unicode 字符串 藏@2。复制、搜索和存储不会得到一个私用区字符;支持 OpenType shaping 且启用标准连字的渲染器会隐藏选择器,只显示指定读音。不执行 GSUB、关闭 liga 或遇到无效序号时,文本会安全降级为默认读音的汉字加上可见的 @2,而不是悄悄选错读音。
这里还存在一个值得注意的实现取舍:当前生成器不会合并源字体原有的 GSUB,而是跳过源 GSUB 后重新生成只包含多音字选择器的表。因此它目前还不是一个通用的 OpenType layout table 编辑器。
从初版到现在
这个项目的初版完成于 2022 年。当时已经能解析和重写 TrueType 的主要表,并为汉字合成拼音 glyph,但更接近一个用于验证想法的原型:它默认源字体恰好拥有需要的拼音字符,布局依赖一些全字体极值,多音字也只能固定使用第一项读音。
2026 年重新捡起来以后,我主要借助 Codex 补齐了这些工程细节:
- 缺失 combining mark 时生成声调,并处理带声调的 dotless
i; - 让常用标点和缩小后的汉字保持一致的视觉尺寸与基线;
- 通过 GSUB
liga支持显式选择多音字读音; - 修正垂直布局对全字体极端 bbox 的依赖;
- 重写水平适配,使长拼音、宽声调和不对称汉字都能稳定布局;
- 保留源 cmap 和字体度量,补充生成字体的结构完整性检查与回归测试;
- 增加浏览器预览页,方便与源字体并排检查。
这个过程很适合让 coding agent 参与:字体格式中有大量「改动很小,但需要同时维护二进制结构、边界条件和测试不变量」的工作。人的精力主要放在确定布局原则和失败策略上,Codex 则帮助把这些原则落实到 parser、writer、测试和文档的各个位置。
虽然当前最强模型之一 GPT 5.6 Sol 也会写出这种逆天的代码,但如果你有足够的专业知识和判断力,AI 真的是超强的放大器!

局限性
目前的实现仍然有不少限制:
- 只支持 TrueType
glyf/loca轮廓。 使用 CFF 或 CFF2 轮廓的 OpenType 字体不受支持。 - 不支持可变字体。
fvar/gvar/avar/HVAR/MVAR等 variation 数据没有被解析和重建;即使输入文件同时带有glyf,也不能指望生成字体保留各个 variation axis 的行为。 - 不会保留源 GSUB。 输出字体中的 GSUB 目前仅包含项目生成的多音字
liga,源字体原有的连字、字形替换和本地化形式会丢失。 - 不应用 kerning 或 GPOS。 拼音字母之间只使用源 glyph 的 advance,不执行完整的 OpenType shaping,因此结果不等同于排版引擎对一段拉丁文本的输出。
- 多音字协议依赖 shaping 支持。
@1到@4需要渲染器执行liga;不支持时选择器会按设计保持可见。 - 默认读音依赖数据库顺序。 没有上下文分词或词组级注音,普通文本总是显示数据库中的第一项读音,非默认读音需要显式选择。
- 声调回退只是几何近似。 自动生成的 accent 会参考字体尺寸,但不会理解字体的笔形、粗细对比和书法风格。
- 输入字体必须包含必要字形。 工具不会补齐源字体缺少的汉字或普通拉丁字母;字体授权也仍然适用于生成后的衍生字体。
最后
pinyin-font 最初只是学习 TrueType 格式时冒出来的一个实验:既然 composite glyph 可以用几个已有轮廓构造一个新字符,那能不能把拼音也看作汉字的一部分?
事实证明这条路是可行的。它不能代替 ruby、专业排版软件或能够理解上下文的注音系统,但它提供了一个很轻量的选择:只要应用程序能加载 TrueType 字体,普通汉字就可以拥有稳定、一致的拼音标注。
项目代码和使用方法见 zhuyie/pinyin-font。