2、字体文件解剖:深入解析 TrueType/OpenType 字体表结构
上一章我们聊了字体压缩的整体思路。但说实话,如果你不了解字体文件内部长什么样,压缩就无从下手。就像修车,你得先打开引擎盖看看里面都有啥。
今天我就带你拆开一个 .ttf 或 .otf 文件,看看里面那些关键的表(Table)到底在干什么。我个人习惯把字体文件想象成一个「集装箱」,里面装着各种功能不同的箱子——每个箱子就是一个表。
2.1 字体文件的基本骨架:SFNT 容器
不管是 TrueType 还是 OpenType,它们都基于一个叫 SFNT 的容器格式。这个容器本身不复杂,就是一个文件头 + 一堆表。
文件头(Offset Table)只有 12 个字节,告诉你三件事:
- sfVersion:0x00010000 是 TrueType,0x00020000 是 OpenType(CFF 轮廓)
- numTables:这个字体里有多少个表
- searchRange / entrySelector / rangeShift:二分查找用的,不用太在意
紧接着就是表目录(Table Directory),每个表占 16 字节,记录着:
| 字段 | 大小 | 说明 |
|---|---|---|
| tag | 4 字节 | 表标签,比如 'cmap'、'glyf'、'head' |
| checksum | 4 字节 | 校验和,用于验证表数据完整性 |
| offset | 4 字节 | 该表在文件中的起始偏移 |
| length | 4 字节 | 该表的长度 |
2.2 核心表逐个拆解
一个完整的字体通常有十几个表。但对我们压缩来说,真正需要关心的只有 5-6 个。其他的要么太小,要么压缩收益极低。
2.2.1 head 表:字体的身份证
head 表(Font Header)只有 54 字节,但信息量很大。它记录了字体的全局属性:
- unitsPerEm:设计网格大小,通常是 1000 或 2048。这个值决定了所有坐标的缩放基准。
- macStyle / flags:粗体、斜体等样式标记。
- bbox:字体整体的边界框。
- created / modified:时间戳,压缩时可以直接扔掉。
压缩要点: head 表本身很小,但 unitsPerEm 会影响 glyf 表中坐标值的范围。如果你在压缩时修改了 unitsPerEm,必须重新计算所有轮廓坐标——我踩过这个坑,结果字体全挤成一团。
2.2.2 hhea 与 hmtx:横排排版的核心
这两个表是「搭档」。hhea(Horizontal Header)记录全局横排度量,hmtx(Horizontal Metrics)记录每个字形的宽度和左轴承(LSB)。
hhea 的关键字段:
- ascent / descent:基线上下高度
- lineGap:行间距
- numberOfHMetrics:hmtx 表中的条目数
hmtx 的结构很简单:每个字形占 4 字节(2 字节宽度 + 2 字节 LSB)。如果一个字体有 10000 个字形,hmtx 就是 40KB。
2.2.3 cmap 表:字符到字形的映射
cmap(Character to Glyph Index Mapping)可能是最重要的表。它负责把 Unicode 编码映射到字形索引。没有它,你输入字母 A,字体不知道应该显示哪个图形。
cmap 支持多种子表格式(format):
| 格式 | 适用场景 | 大小 |
|---|---|---|
| Format 4 | BMP 字符(U+0000 ~ U+FFFF) | 中等 |
| Format 12 | 全 Unicode 范围(U+0000 ~ U+10FFFF) | 较大 |
| Format 0 | 单字节编码(256 字符以内) | 很小 |
| Format 14 | 变体选择器(表情符号肤色等) | 特殊 |
压缩时,cmap 是重点优化对象。尤其是 Format 4 和 Format 12,它们用分段(segment)来压缩映射关系——说白了就是「如果 U+4E00 到 U+4E10 都映射到字形 100-110,那就只存一个段」。但有些字体厂商实现得不好,分段很碎,导致 cmap 膨胀。
2.2.4 glyf 与 loca:真正的图形数据
这两个表也是搭档。loca(Index to Location)记录每个字形在 glyf 表中的偏移,glyf(Glyph Data)存储实际的轮廓数据。
glyf 表的结构:
- numberOfContours:轮廓数。负数表示复合字形(由多个简单字形组合而成)。
- xMin / yMin / xMax / yMax:字形边界框。
- endPtsOfContours:每个轮廓的结束点索引。
- flags / xCoordinates / yCoordinates:实际的点坐标和标志位。
glyf 表通常是字体中最大的表,占 70%-90% 的体积。压缩的核心就在这里。
坐标存储用了很多技巧:
- 用标志位表示「当前点与前一个点的差值」
- 用标志位表示「坐标是 1 字节还是 2 字节」
- 用标志位表示「x 和 y 是否同时变化」
说白了,TrueType 在设计之初就考虑了一定的压缩——但那是 90 年代的标准,放到今天来看,冗余还是很大。
2.2.5 其他值得关注的表
| 表名 | 作用 | 压缩价值 |
|---|---|---|
| name | 字体名称信息(版权、厂商、样式名等) | 低(通常很小) |
| post | PostScript 相关数据(字形名称等) | 中(可以裁剪) |
| OS/2 | Windows 平台兼容性信息 | 低(必须保留) |
| maxp | 最大轮廓数、最大点数的声明 | 低(很小) |
| kern | 字距调整 | 中(可以精简) |
| GPOS / GSUB | OpenType 高级排版特性 | 高(复杂且大) |
2.3 字体表结构全景图
下面这张图展示了字体文件的核心结构。你可以看到,所有表都挂在 SFNT 容器下,而压缩工具主要瞄准的是 cmap、glyf、loca 和 GPOS/GSUB 这些「大块头」。
2.4 表之间的依赖关系
这些表不是孤立的。它们之间有严格的依赖关系。举个例子:
- 你输入字符 'A'(U+0041)
- cmap 表查到 U+0041 对应字形索引 68
- loca 表告诉你字形 68 在 glyf 表中的偏移位置
- glyf 表读出轮廓数据,交给渲染器
- hmtx 表提供字形 68 的宽度,用于排版
- head 表提供 unitsPerEm,用于坐标缩放
你看,任何一个表出问题,整个渲染链条就断了。所以压缩时,表之间的引用关系绝对不能破坏。
2.5 理解字体数据构成的实战意义
为什么要花这么大篇幅讲这些表?因为压缩策略完全取决于你对表的理解:
- glyf 表大 → 优化轮廓坐标编码、去除冗余点、合并复合字形
- cmap 表碎 → 合并分段、裁剪未使用的 Unicode 范围
- GPOS/GSUB 复杂 → 只保留需要的语言特性,去掉用不到的
- name 表多语言 → 只保留目标语言,去掉其他语言的字体名称
说白了,你只有知道每个表里存的是什么,才能判断「这个能不能砍?砍了会有什么后果?」。盲目压缩的结果就是——字体变小了,但显示出来全是乱码或者方块。