字体文件解析: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 文件结构,你看一眼就能明白整体布局:

TTF/OTF 字体文件结构 文件头 (Offset Table) — 12 字节 表目录 (Table Directory) — 每个表 16 字节 表数据区域 (Table Data) cmap 字符到字形映射 glyf 字形轮廓数据 head 全局字体信息 hmtx 水平度量 name 字体名称 loca 字形偏移索引 maxp 最大数据量 OS/2 兼容性信息 一个字体文件通常包含 10~20 个表,但只有 cmap、glyf、head、loca 是必须的

必须掌握的关键表

字体文件里表很多,但对我们做字体显示和动画来说,真正核心的就这几个:

  • 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 渲染器帮你做的。

避坑指南:我踩过的三个坑

做字体解析,有些坑几乎每个人都会遇到。我帮你列出来:

  1. 字节序问题:TTF 文件统一使用大端序(Big Endian)。但有些工具或系统默认是小端序。忘记转换,读出来的全是乱码。
  2. 复合字形:有些字形(比如带变音符号的字母)由多个简单字形组合而成。你不能只读一层,得递归解析。
  3. 坐标单位:字体里的坐标单位是「字体单位」(font units),不是像素。要显示到屏幕上,得根据字体大小和 DPI 做缩放。我见过有人直接把字体单位当像素用,结果字形大得离谱。

我的建议:刚开始练习时,选一个简单的英文字体,比如 DejaVu Sans Mono 或 Noto Sans。这些字体结构规范,复合字形少,适合用来验证你的解析逻辑。

小结

这一章我们拆开了字体文件的黑盒子。你知道了 TTF 和 OTF 的区别,理解了表结构,学会了用 Python 读取文件头、cmap 表和字形轮廓点。