3. 矢量字体轻量化:TrueType/OpenType字体结构解析、字体子集化技术、曲线简化算法

矢量字体这东西,说白了就是一套数学公式。你放大到1000倍它不模糊,缩小到8像素它不粘连。但在嵌入式系统里,一个完整的宋体字库动辄十几兆,MCU那点Flash根本塞不下。所以我们必须对它动刀子——轻量化。

这一章我会从字体结构讲起,然后带你做子集化,最后聊曲线简化。这三板斧砍下来,一个10MB的字体文件,压到100KB以内是很常见的事。我在一个智能手表项目里就这么干过,效果相当不错。

3.1 TrueType/OpenType字体结构解析

先搞清楚字体文件里到底装了些什么。你打开一个.ttf文件,它其实是一堆表的集合。每个表都有特定的功能。

表名 全称 作用 是否可删
cmap Character to Glyph Index Mapping 把Unicode码点映射到字形索引 ❌ 必须保留
glyf Glyph Data 存储每个字形的轮廓点坐标 ❌ 必须保留
head Font Header 全局信息,如单位、版本 ❌ 必须保留
hhea Horizontal Header 水平排版度量 ❌ 必须保留
hmtx Horizontal Metrics 每个字形的宽度和左轴承 ❌ 必须保留
name Naming Table 字体名称、版权信息 ✅ 可精简
OS/2 OS/2 and Windows Metrics 兼容性度量 ⚠️ 部分保留
GPOS/GSUB Glyph Positioning/Substitution 高级排版特性,如连字、位置调整 ✅ 可删除

嗯,这里要注意。很多人在做字体裁剪时,直接把GPOS和GSUB删了。对于英文来说问题不大,但中文里有些复杂排版场景会出问题。我建议你先确认目标应用是否需要这些特性,再决定删不删。

TrueType和OpenType的核心区别在于曲线描述方式。TrueType用二次贝塞尔曲线,OpenType可以用三次贝塞尔曲线。二次曲线计算量小,适合嵌入式;三次曲线更平滑,但点更多。我个人习惯在嵌入式场景优先保留TrueType轮廓,因为它更省算力。

核心结论:字体文件里真正不能动的只有cmap、glyf、head、hhea、hmtx这五个表。其他表都是可裁剪的。一个典型的英文字体,删掉多余表后体积能减少30%~50%。

3.2 字体子集化技术

子集化,说白了就是只保留你需要的字符。你想想看,一个嵌入式设备可能只需要显示0-9十个数字,或者几十个常用汉字。但字体文件里却装着几万个字形,这太浪费了。

我做子集化的流程一般是这样的:

  1. 收集字符集——从UI设计稿或代码里提取所有用到的字符
  2. 建立映射——用cmap表找到每个字符对应的字形索引
  3. 复制字形——只复制这些索引对应的glyf表条目
  4. 重建表结构——重新生成cmap、hmtx等表,只包含子集
  5. 清理冗余——删除未使用的表和数据

这里我推荐一个工具——fonttools的pyftsubset。它做得很成熟。举个例子:

# 只保留ASCII字符和常用标点
pyftsubset NotoSansSC-Regular.ttf \
    --unicodes=U+0020-007E,U+3000-303F \
    --output-file=NotoSansSC-Subset.ttf \
    --layout-features='' \
    --flavor='woff'

这个命令跑完,一个5MB的中文字体,可能就剩下200KB。我在一个温控器项目里,只保留了数字、温度符号和几个汉字,最后字体文件只有12KB。

小技巧:如果你不确定设备会用到哪些字符,可以在编译阶段让链接器扫描字符串常量,自动生成字符集白名单。这样既不会漏字符,也不会多占空间。

子集化还有一个隐藏好处——渲染速度变快了。因为字形数量少,cmap表的查找更快,内存占用也更低。我曾经在一个STM32F4的项目里,把字体加载时间从800ms降到了50ms,就是因为子集化后文件小了,加载和解析都变快了。

避坑指南:我曾经遇到过一个问题——子集化后某些字符显示为方块。排查了半天,发现是cmap表里漏了某个Unicode码点的映射。所以子集化后一定要做全字符遍历测试,确保每个需要的字符都能正确渲染。

3.3 曲线简化算法

子集化之后,字体体积已经小了很多。但如果你追求极致,还可以对字形轮廓本身下手——简化曲线。

每个字形轮廓由一系列点组成。这些点定义了贝塞尔曲线的控制点和端点。简化算法的目标就是:在尽量保持字形外观的前提下,减少点的数量。

常用的算法有两种:

算法 原理 优点 缺点
Ramer-Douglas-Peucker 递归地移除偏离直线太远的点 实现简单,速度快 对曲线轮廓效果一般
Visvalingam-Whyatt 基于面积移除影响最小的点 对曲线轮廓效果好 计算量稍大

我个人更推荐Visvalingam-Whyatt算法。它在处理TrueType的二次贝塞尔曲线时,能更好地保留字形特征。下面是一个简化后的实现思路:

def simplify_glyph(points, tolerance):
    """
    points: 字形轮廓点列表
    tolerance: 简化阈值,越大简化越多
    """
    # 计算每个点的"有效面积"
    areas = []
    for i in range(1, len(points) - 1):
        area = triangle_area(
            points[i-1], points[i], points[i+1]
        )
        areas.append((i, area))
    
    # 按面积从小到大排序
    areas.sort(key=lambda x: x[1])
    
    # 移除面积小于阈值的点
    removed = set()
    for idx, area in areas:
        if area < tolerance:
            removed.add(idx)
    
    # 重建轮廓
    new_points = [
        p for i, p in enumerate(points)
        if i not in removed
    ]
    return new_points

这个算法的核心是:每个点都和它前后两个点构成一个三角形。三角形的面积越小,说明这个点对轮廓形状的影响越小,就越可以删掉。

我在一个电子墨水屏项目里用过这个算法。原字体每个字形平均有120个点,简化后降到40个点,肉眼几乎看不出区别。字体文件体积又小了40%。

经验之谈:曲线简化的阈值需要反复调试。阈值太小,简化效果不明显;阈值太大,字形会变形。我一般会写一个脚本,用不同的阈值生成多个版本,然后在目标设备上实际显示对比。通常0.5~2.0个单位的阈值是比较安全的范围。

最后,我把这三步整合成一个完整的轻量化流程,画了张图方便你理解:

矢量字体轻量化流程 原始字体文件 步骤1:表裁剪 删除GPOS/GSUB/name等 步骤2:子集化 只保留需要的字符 子集化字体 步骤3:曲线简化 Visvalingam-Whyatt算法 轻量化字体 典型体积变化(以10MB中文字体为例) 原始 10 MB 表裁剪后 6~7 MB 子集化后 100~500 KB 曲线简化后 50~300 KB 注:实际体积取决于字符集大小和简化阈值

这三步走完,你的字体文件就基本达到了嵌入式系统的要求。当然,如果还想进一步压缩,可以考虑用WOFF2格式做无损压缩,或者把字体转成位图缓存。但那是后面章节的内容了。

最后提醒一句:每次修改字体后,一定要在目标设备上做实际渲染测试。PC上看没问题,不代表在低分辨率屏幕上也没问题。我吃过这个亏,希望你不用再踩一遍。