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 知识体系总览

下面这张图把字体解析的核心流程串起来了。你可以把它当作一个「导航图」,遇到问题时回来看看。

字体解析核心流程 字体解析核心流程 字体文件 (.ttf / .otf) 偏移表 (Offset Table) 表目录 (Table Directory) cmap 表 字符 → 字形索引 glyf 表 字形轮廓数据 hmtx 表 水平步进宽度 字体渲染引擎 轮廓解析 + 缩放 + 光栅化 屏幕像素点阵 核心三表缺一不可 cmap + glyf + hmtx

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 小结

字体解析说白了就是查表 + 读数据。你只要记住三条:

  1. 用 cmap 把字符编码转成字形索引
  2. 用 glyf 拿到字形轮廓
  3. 用 hmtx 知道每个字占多宽