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十个数字,或者几十个常用汉字。但字体文件里却装着几万个字形,这太浪费了。
我做子集化的流程一般是这样的:
- 收集字符集——从UI设计稿或代码里提取所有用到的字符
- 建立映射——用cmap表找到每个字符对应的字形索引
- 复制字形——只复制这些索引对应的glyf表条目
- 重建表结构——重新生成cmap、hmtx等表,只包含子集
- 清理冗余——删除未使用的表和数据
这里我推荐一个工具——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个单位的阈值是比较安全的范围。
最后,我把这三步整合成一个完整的轻量化流程,画了张图方便你理解:
这三步走完,你的字体文件就基本达到了嵌入式系统的要求。当然,如果还想进一步压缩,可以考虑用WOFF2格式做无损压缩,或者把字体转成位图缓存。但那是后面章节的内容了。
最后提醒一句:每次修改字体后,一定要在目标设备上做实际渲染测试。PC上看没问题,不代表在低分辨率屏幕上也没问题。我吃过这个亏,希望你不用再踩一遍。