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 字节 该表的长度
小技巧: 用十六进制编辑器打开字体文件,直接跳到 12 字节处,你就能看到一张表清单。我曾经用这个方法快速判断一个字体是不是「缺表」——缺 cmap 的字体在浏览器里根本显示不出来。

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。

注意: 很多压缩工具会尝试「合并」hmtx 中重复的宽度值。但如果你合并错了,文字就会对不齐。我见过一个案例,压缩后「一」和「二」的宽度一样了,排版直接崩掉。

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 膨胀。

避坑指南: 我曾经处理过一个中文字体,它的 cmap 用了 Format 12,但每个字符单独一个段,结果 cmap 占了 200KB。后来我重写了分段逻辑,压缩到 8KB。嗯,这就是「减容不减质」的典型操作。

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 这些「大块头」。

字体文件核心表结构全景图 字体文件核心表结构全景图 SFNT 容器(Offset Table + Table Directory) Offset Table (12B) Table Directory (16B × N) 核心表(Core Tables) head 字体全局属性 hhea 横排全局度量 hmtx 每个字形宽度 cmap Unicode → 字形索引映射 glyf + loca 轮廓数据(体积最大) name / post 元数据 OS/2 / maxp 兼容性声明 GPOS / GSUB 高级排版 kern / 其他 可选表 压缩重点 glyf > cmap > GPOS 占体积 90%+

2.4 表之间的依赖关系

这些表不是孤立的。它们之间有严格的依赖关系。举个例子:

  1. 你输入字符 'A'(U+0041)
  2. cmap 表查到 U+0041 对应字形索引 68
  3. loca 表告诉你字形 68 在 glyf 表中的偏移位置
  4. glyf 表读出轮廓数据,交给渲染器
  5. hmtx 表提供字形 68 的宽度,用于排版
  6. head 表提供 unitsPerEm,用于坐标缩放

你看,任何一个表出问题,整个渲染链条就断了。所以压缩时,表之间的引用关系绝对不能破坏。

血的教训: 我曾经写过一个压缩工具,为了省空间把 loca 表从 4 字节偏移改成了 2 字节偏移。结果 glyf 表超过 64KB 的字形全部定位错误——嗯,那次测试直接崩了半个系统的字体渲染。

2.5 理解字体数据构成的实战意义

为什么要花这么大篇幅讲这些表?因为压缩策略完全取决于你对表的理解:

  • glyf 表大 → 优化轮廓坐标编码、去除冗余点、合并复合字形
  • cmap 表碎 → 合并分段、裁剪未使用的 Unicode 范围
  • GPOS/GSUB 复杂 → 只保留需要的语言特性,去掉用不到的
  • name 表多语言 → 只保留目标语言,去掉其他语言的字体名称

说白了,你只有知道每个表里存的是什么,才能判断「这个能不能砍?砍了会有什么后果?」。盲目压缩的结果就是——字体变小了,但显示出来全是乱码或者方块。

我的习惯: 每次拿到一个新字体,第一件事就是用 ttx(fontTools 的命令行工具)把字体转成 XML,然后看每个表的大小。哪个表最大,哪个就是压缩的主战场。这个习惯帮我省了无数排查问题的时间。