4、字体解析原理:字体文件的内部结构、表(Table)的概念、字形(Glyph)的存储方式
说到字体渲染,很多人第一反应是「把字画出来」。但真正动手做的时候,你会发现——连字体文件长什么样都不清楚。
我刚开始接触字体解析时,直接拿二进制编辑器打开一个 .ttf 文件,满屏的十六进制看得我头皮发麻。后来才明白,字体文件不是一团乱码,而是一个结构清晰的数据库。今天我们就把它拆开看看。
4.1 字体文件:一个精心设计的数据库
一个 TrueType 或 OpenType 字体文件,说白了就是一堆表(Table)的集合。每个表负责存储一类特定数据。比如:
- cmap:字符编码到字形索引的映射
- glyf:字形的轮廓数据(点、线、曲线)
- head:字体全局信息(版本、单位、边界框)
- hhea:水平排版度量
- hmtx:每个字形的水平步进宽度
你想想看,一个字体文件可能有几十个表。但真正渲染时,核心就三四个。我在项目中遇到过,有些字体文件为了兼容老旧系统,塞了一堆废弃表,解析时直接跳过就行。
核心概念:字体文件 = 文件头 + 表目录 + 表数据。表目录告诉你在哪里找哪个表,表数据才是真正的「干货」。
4.2 表目录:字体文件的「地图」
每个字体文件开头都有一个偏移表(Offset Table),也叫文件头。它长这样:
typedef struct {
uint32_t sfVersion; // 0x00010000 为 TrueType,0x4F54544F 为 OpenType (CFF)
uint16_t numTables; // 表的数量
uint16_t searchRange; // 二分查找参数
uint16_t entrySelector;// 二分查找参数
uint16_t rangeShift; // 二分查找参数
} OffsetTable;
紧接着就是表记录数组(Table Record),每个记录 16 字节:
typedef struct {
char tag[4]; // 表标签,如 'cmap', 'glyf'
uint32_t checkSum; // 校验和
uint32_t offset; // 表数据在文件中的偏移
uint32_t length; // 表数据的长度
} TableRecord;
嗯,这里要注意:表标签是 4 字节 ASCII 码,不是字符串。比如 'cmap' 就是 0x636D6170。我早期写解析器时,直接用 memcmp 比较,后来发现用 uint32_t 强转更高效。
4.3 cmap 表:从字符到字形的「翻译官」
你输入一个字符 'A',系统拿到的是 Unicode 编码 0x0041。但字体文件里存的是字形索引,比如第 36 号字形。谁来搭这座桥?cmap 表。
cmap 表内部包含多个子表(Subtable),每个子表对应一种编码格式。最常见的格式是:
- Format 4:分段映射,适合 BMP(基本多语言平面)字符,覆盖绝大多数常用字
- Format 12:连续分段映射,支持 Unicode 全平面(包括 emoji)
我建议你优先解析 Format 4,因为它结构紧凑、查找快。它的核心是一个分段数组:
struct Segment {
uint16_t startCode; // 段起始编码
uint16_t endCode; // 段结束编码
int16_t idDelta; // 字形索引偏移
uint16_t idRangeOffset; // 范围偏移(用于复杂映射)
};
查找逻辑很简单:遍历分段,找到 startCode <= charCode <= endCode 的段,然后 glyphIndex = (charCode + idDelta) & 0xFFFF。我曾经踩过一个坑:idDelta 是有符号的,如果直接加会溢出,必须做掩码处理。
避坑指南:cmap 表可能有多个子表,优先选择 platformID=3, encodingID=1(Windows Unicode BMP)的子表。如果找不到,再回退到 platformID=0(Unicode 默认)。
4.4 glyf 表:字形数据的「仓库」
拿到字形索引后,下一步就是去 glyf 表里取数据。glyf 表存储了所有字形的轮廓信息。每个字形由一系列轮廓(Contour)组成,每个轮廓又是一串点(Point)。
字形数据分为两部分:
- 字形头(Glyph Header):包含边界框、轮廓数量、指令长度等
- 轮廓数据:点的坐标和标志位
typedef struct {
int16_t numberOfContours; // 轮廓数量,0 表示复合字形,负数表示空字形
int16_t xMin, yMin, xMax, yMax; // 边界框
} GlyphHeader;
如果 numberOfContours > 0,后面跟着轮廓结束点索引数组和点数据。每个点包含:
- 坐标:x, y(相对于前一个点的差值,用标志位控制)
- 标志位:是否在曲线上、是否控制点、是否重复等
我记得第一次解析 glyf 表时,被复合字形(Composite Glyph)搞晕了。复合字形本身没有轮廓,而是引用其他字形,通过矩阵变换组合而成。比如带声调的字母 'Á',就是 'A' 加上重音符号。解析时遇到 numberOfContours < 0,就要递归处理子字形。
注意:复合字形可以嵌套,而且可能有循环引用。解析时必须做深度限制,我一般设最大 16 层,超过就报错。否则遇到恶意字体文件,直接栈溢出崩溃。
4.5 知识体系总览
下面这张图把字体解析的核心流程串起来了。你可以把它当作一个「导航图」,遇到问题时回来看看。
4.6 字形存储的两种方式
glyf 表里的字形,存储方式分两种:
| 类型 | 特点 | 适用场景 | 解析复杂度 |
|---|---|---|---|
| 简单字形 | 直接存储轮廓点坐标和标志位 | 大多数单字符(字母、数字、标点) | 低 |
| 复合字形 | 引用其他字形,通过变换组合 | 带声调字母、连字、部分符号 | 中(需递归) |
简单字形的点数据采用差值编码:每个点的坐标只存与前一个点的差值,而不是绝对坐标。这样能大幅压缩数据量。我算过,一个包含 2000 个点的中文字形,差值编码比绝对坐标节省约 40% 空间。
复合字形的变换矩阵长这样:
typedef struct {
int16_t arg1, arg2; // 匹配点索引或偏移量
int16_t a, b, c, d; // 2x2 变换矩阵
int16_t e, f; // 平移量
uint16_t flags; // 标志位(是否缩放、是否旋转等)
} CompositeGlyphComponent;
嗯,这里要注意:变换矩阵的默认值是单位矩阵(a=1, b=0, c=0, d=1)。如果标志位没设置缩放或旋转,矩阵部分可以省略。我早期解析时没注意这个细节,结果遇到省略矩阵的复合字形,直接读错了数据偏移。
个人经验:解析复合字形时,建议先递归解析所有子字形,再应用变换矩阵。顺序是:先子后父。否则你拿到的坐标是局部坐标,没法直接渲染。
4.7 其他关键表
除了 cmap 和 glyf,还有几个表你一定会用到:
- head 表:字体全局信息,包括 unitsPerEm(每个 em 包含的单位数)、macStyle(粗体/斜体标志)、字体边界框等。我建议你第一个解析它,因为后面很多计算都依赖 unitsPerEm。
- hhea 表:水平排版度量,包括 ascent、descent、lineGap。这些值决定了行高和基线位置。
- hmtx 表:每个字形的水平步进宽度(advance width)和左侧轴承(lsb)。排版时,每个字占多宽,全靠它。
- maxp 表:最大轮廓数、最大点数等。可以用来预分配内存,避免动态扩容。
我曾经在解析一个日文字体时,发现 hmtx 表的条目数比 glyf 表的字形数少。查了半天,原来是字体文件里有些字形是空的(比如未定义的编码区域),hmtx 表只给有实际数据的字形分配了度量。所以不要假设两个表的条目数相等,要以 maxp 表的 numGlyphs 为准。
4.8 小结
字体解析说白了就是查表 + 读数据。你只要记住三条:
- 用 cmap 把字符编码转成字形索引
- 用 glyf 拿到字形轮廓
- 用 hmtx 知道每个字占多宽