字体文件解析:TTF/OTF 基础与字形数据读取
上一章我们聊了字体在屏幕上的显示原理。这一章,咱们得把手弄脏——直接打开字体文件,看看里面到底存了什么。
说实话,我第一次用十六进制编辑器打开一个 .ttf 文件时,整个人是懵的。满屏的 00 和 FF,完全不知道从哪下手。后来啃了几个月规范,踩了不少坑,才慢慢摸清楚门道。今天我把这些经验整理出来,帮你少走弯路。
TTF 与 OTF:同根同源,略有不同
先搞清楚这两个格式的关系。TTF(TrueType Font)是 Apple 和 Microsoft 在 90 年代搞出来的。OTF(OpenType Font)是 Adobe 和 Microsoft 后来在 TTF 基础上扩展的。说白了,OTF 就是 TTF 的升级版。
| 对比维度 | TTF | OTF |
|---|---|---|
| 轮廓技术 | 二次贝塞尔曲线(TrueType 轮廓) | 三次贝塞尔曲线(CFF 轮廓)或兼容 TTF |
| 文件扩展名 | .ttf | .otf |
| 最大字形数 | 65535 | 65535(相同) |
| 高级排版特性 | 有限 | 丰富(连字、替换、位置调整等) |
| 内部结构 | 基于 TrueType 表结构 | 基于 OpenType 表结构(扩展了 TTF 的表) |
我在项目中遇到过最坑的事:一个客户给的 .otf 文件,里面实际用的是 TrueType 轮廓。你光看后缀根本判断不了。所以,永远不要靠扩展名判断字体内部格式——得读文件头。
字体文件的核心结构:表(Table)
TTF 和 OTF 文件本质上是一堆「表」的集合。每个表负责存储特定类型的数据。你可以把字体文件想象成一个文件柜,每个抽屉(表)里放着不同类别的资料。
核心概念:字体文件 = 文件头 + 表目录 + 表数据。表目录告诉你在哪里能找到哪个表,表数据才是真正的内容。
下面这张图是我自己画的 TTF 文件结构,你看一眼就能明白整体布局:
必须掌握的关键表
字体文件里表很多,但对我们做字体显示和动画来说,真正核心的就这几个:
- cmap 表:字符编码到字形索引的映射。你输入一个 'A'(Unicode 0x41),cmap 告诉你这个字符对应第几个字形。
- glyf 表:真正的字形轮廓数据。每个字形由一系列轮廓点、曲线命令组成。这就是我们做动画要操作的核心数据。
- loca 表:字形在 glyf 表中的偏移位置。没有它,你找不到每个字形的数据从哪开始读。
- head 表:全局信息,包括字体单位、坐标范围、创建时间等。
- hmtx 表:每个字形的水平度量(宽度、左侧轴承)。排版时离不开它。
我的经验:刚开始学字体解析时,别贪多。先把 cmap → loca → glyf 这条链路打通。能从一个字体文件里读出 'A' 的轮廓点,你就已经掌握了 80% 的核心技术。
用 Python 读取字形数据
理论说完了,上代码。我个人习惯用 Python 的 struct 模块来解析二进制数据。虽然也有现成的库(比如 fontTools),但自己手写一遍,理解才深刻。
下面这个例子,演示如何读取一个 TTF 文件的文件头,然后找到 cmap 表:
import struct
def read_ttf_header(filepath):
with open(filepath, 'rb') as f:
# 读取文件头(12 字节)
header = f.read(12)
sfversion, num_tables, search_range, entry_selector, range_shift = \
struct.unpack('>IHHHH', header)
print(f'SFVersion: 0x{sfversion:08X}')
print(f'表数量: {num_tables}')
# 读取表目录
for i in range(num_tables):
table_entry = f.read(16)
tag, checksum, offset, length = \
struct.unpack('>4sIII', table_entry)
tag_str = tag.decode('ascii').strip('\x00')
print(f'表 {tag_str}: offset=0x{offset:08X}, length={length}')
# 如果是 cmap 表,记录位置
if tag_str == 'cmap':
cmap_offset = offset
cmap_length = length
# 读取 cmap 表头
f.seek(cmap_offset)
cmap_header = f.read(4)
version, num_subtables = struct.unpack('>HH', cmap_header)
print(f'\ncmap 版本: {version}, 子表数量: {num_subtables}')
# 读取第一个子表(通常是最常用的格式 4 或 12)
f.seek(cmap_offset + 4)
subtable_entry = f.read(8)
platform_id, encoding_id, subtable_offset = \
struct.unpack('>HHH', subtable_entry[:6])
# 注意:这里简化了,实际 subtable_offset 是 4 字节
# 完整代码需要处理不同平台和格式
print(f'平台 ID: {platform_id}, 编码 ID: {encoding_id}')
print(f'子表偏移: 0x{subtable_offset:04X}')
# 使用示例
read_ttf_header('path/to/your/font.ttf')
注意:上面的代码是教学简化版。实际项目中,cmap 子表的 offset 字段是 4 字节(uint32),不是 2 字节。我曾经因为这个疏忽,在生产环境上排查了整整两天——所有字符都显示成方块。嗯,从那以后我再也不敢偷懒看规范了。
字形轮廓数据:从二进制到可绘制点
拿到字形索引后,下一步就是读轮廓。TrueType 轮廓使用二次贝塞尔曲线,每个轮廓由一系列「点」和「标志位」组成。
一个轮廓点的数据结构是这样的:
| 字段 | 大小 | 说明 |
|---|---|---|
| flags | 1 字节 | 点的属性(曲线上/曲线外、是否在控制点等) |
| x 坐标 | 2 字节(有符号) | 相对于前一个点的 x 偏移 |
| y 坐标 | 2 字节(有符号) | 相对于前一个点的 y 偏移 |
你想想看,为什么用相对坐标而不是绝对坐标?说白了,为了压缩。相邻点之间的差值通常很小,用 2 字节有符号整数就能表示,比存绝对坐标省空间。
下面是一个更完整的例子,读取 'A' 字形的轮廓点:
def read_glyph_points(font_file, glyph_index):
"""
读取指定字形的轮廓点列表
返回: [(x1, y1, on_curve), (x2, y2, on_curve), ...]
"""
# 这里假设已经通过 loca 表找到了 glyph 的 offset
# 实际代码需要先解析 loca 表
with open(font_file, 'rb') as f:
# 读取 glyph 头(前 10 字节)
f.seek(glyph_offset)
glyph_header = f.read(10)
num_contours, x_min, y_min, x_max, y_max = \
struct.unpack('>hhhhh', glyph_header)
print(f'轮廓数: {num_contours}')
print(f'边界框: ({x_min}, {y_min}) - ({x_max}, {y_max})')
if num_contours <= 0:
# 复合字形或空字形,处理更复杂
return []
# 读取每个轮廓的结束点索引
end_pts_of_contours = []
for _ in range(num_contours):
end_pt, = struct.unpack('>H', f.read(2))
end_pts_of_contours.append(end_pt)
total_points = end_pts_of_contours[-1] + 1
# 读取指令长度(跳过,我们只关心点)
instruction_length, = struct.unpack('>H', f.read(2))
f.read(instruction_length) # 跳过指令
# 读取 flags
flags = []
i = 0
while i < total_points:
flag, = struct.unpack('>B', f.read(1))
flags.append(flag)
i += 1
# 处理重复标志位(bit 3)
if flag & 0x08:
repeat_count, = struct.unpack('>B', f.read(1))
for _ in range(repeat_count):
flags.append(flag)
i += 1
# 读取 x 坐标(相对偏移)
x_coords = []
prev_x = 0
for flag in flags:
if flag & 0x02: # x 是 1 字节
if flag & 0x10: # 正数
dx, = struct.unpack('>B', f.read(1))
else: # 负数
dx, = struct.unpack('>b', f.read(1))
elif flag & 0x10: # x 与上一个相同
dx = 0
else: # x 是 2 字节
dx, = struct.unpack('>h', f.read(2))
prev_x += dx
x_coords.append(prev_x)
# y 坐标同理(略)
# ...
# 组装结果
points = []
for i in range(total_points):
on_curve = bool(flags[i] & 0x01)
points.append((x_coords[i], y_coords[i], on_curve))
return points
关键理解:on_curve 标志位决定这个点是在曲线上还是曲线外。曲线上的点直接连接,曲线外的点是贝塞尔控制点。二次贝塞尔曲线需要两个曲线外点之间自动补一个「隐含的曲线中点」——这是 TrueType 渲染器帮你做的。
避坑指南:我踩过的三个坑
做字体解析,有些坑几乎每个人都会遇到。我帮你列出来:
- 字节序问题:TTF 文件统一使用大端序(Big Endian)。但有些工具或系统默认是小端序。忘记转换,读出来的全是乱码。
- 复合字形:有些字形(比如带变音符号的字母)由多个简单字形组合而成。你不能只读一层,得递归解析。
- 坐标单位:字体里的坐标单位是「字体单位」(font units),不是像素。要显示到屏幕上,得根据字体大小和 DPI 做缩放。我见过有人直接把字体单位当像素用,结果字形大得离谱。
我的建议:刚开始练习时,选一个简单的英文字体,比如 DejaVu Sans Mono 或 Noto Sans。这些字体结构规范,复合字形少,适合用来验证你的解析逻辑。
小结
这一章我们拆开了字体文件的黑盒子。你知道了 TTF 和 OTF 的区别,理解了表结构,学会了用 Python 读取文件头、cmap 表和字形轮廓点。