- 开发工具
【免费下载链接】fonttools
A library to manipulate font files from Python.
本文围绕 fontTools 的fontTools.ttLib.tables.grUtils模块展开,该模块是fontTools.ttLib中专门服务 Graphite 排版引擎私有表(Silf、Glat、Gloc、Sill、Feat)的底层辅助模块。读完本文,你将理解它提供的压缩/解压协议、属性游程分组算法、OpenType 风格数组头计算以及数字/Tag 双向转换的实现细节,并能对照源码中真实的调用点,看懂 fontTools 如何完成 Graphite 表的 decompile/compile 全流程。
一、模块定位:ttLib 表格包中的 Graphite 辅助层
官方文档页 Doc/source/ttLib/tables/grUtils.rst 对模块的定位只有一句话:
The
grUtilsmodule is a helper forfontTools.ttLibthat provides lower-level support for Graphite table converters.
即在fontTools.ttLib的表格转换器体系里,grUtils提供 Graphite 表转换所需的"更低一层"的公共能力。在文档总入口 Doc/source/ttLib/tables.rst 的 "Helper modules" 小节中,它与OpenType_related、TupleVariation、Bitmap_related并列为表格包的辅助模块,文档明确指出这类模块"通常不需要直接访问,但在为 fontTools 添加新表类型时查阅它们会有价值"。
实际源码位于 Lib/fontTools/ttLib/tables/grUtils.py,共 92 行,只依赖struct、warnings和一个可选的lz4包。模块顶部对 lz4 采用"可选导入"策略(grUtils.py 第 3-8 行):
try: import lz4 except ImportError: lz4 = None else: import lz4.block也就是说,不安装 lz4 时模块可以正常导入,只是涉及 scheme 1 的压缩/解压会降级为"警告 + 原样返回"。Graphite 表中只有Silf版本 ≥ 5.0 和Glat版本 ≥ 3.0 才使用压缩,因此处理旧版 Graphite 表时 lz4 并非必需。
当前仓库中直接依赖grUtils的表格实现共 4 个文件,每个工具函数都有明确的使用方:
| 工具函数 | 依赖方 | 用途 |
|---|---|---|
decompress/compress | S__i_l_f.py、G__l_a_t.py | 整表 lz4 压缩/解压 |
entries/_entries | G__l_a_t.py、S__i_l_f.py | 属性/列映射游程编码 |
bininfo | S__i_l_f.py、S__i_l_l.py | 生成 OpenType 风格数组头 |
num2tag/tag2num | F__e_a_t.py、S__i_l_l.py | 功能 ID 的数字/4 字符 Tag 互转 |
此外 G__l_o_c.py(Gloc 表)虽然不直接 import grUtils,但 Glat 的 decompile/compile 必须与它协作:Glat 在 compile 时把每个图形的属性数据追加成一段连续字节,再把各段起点写入ttFont["Gloc"].set(glocs)(G__l_a_t.py 第 131-136 行);decompile 时则反向用 Gloc 的偏移数组切分每个图形的属性数据(G__l_a_t.py 第 86-91 行)。从源码结构看,Glat 与 Gloc 是一对强耦合的表,Gloc 的dependencies = ["Glat"]声明了这种依赖关系。
二、compress / decompress:基于位字段的压缩方案协商
这是grUtils中最核心的两个函数,它们实现了 Graphite 表头中"compression 字段"的编码协议。源码位于 grUtils.py 第 13-41 行。
2.1 压缩头的位布局
Graphite 表(Silfv5+、Glatv3+)的原始二进制前 8 字节中,第 5~8 字节是一个大端 uint32 "compression" 字段。decompress对它的解读是:
def decompress(data): (compression,) = struct.unpack(">L", data[4:8]) scheme = compression >> 27 # 最高 3 位:压缩方案编号 size = compression & 0x07FFFFFF # 低 27 位:解压后的表总长度 if scheme == 0: pass elif scheme == 1 and lz4: res = lz4.block.decompress(struct.pack("<L", size) + data[8:]) if len(res) != size: warnings.warn("Table decompression failed.") else: data = res else: warnings.warn("Table is compressed with an unsupported compression scheme") return (data, scheme)要点有三:
- scheme 占 3 位(位 31~27),为协议预留了 0~7 的方案空间,当前只定义了 0(不压缩)和 1(lz4 block);
- size 占 27 位,表示解压后的完整表长度(含 8 字节表头本身),这是后续校验与 lz4 解压的前提;
- lz4.block 解压需要显式提供大小前缀:因为压缩时使用了
store_size=False,所以解压端必须手工拼上struct.pack("<L", size)(小端 uint32)作为 payload 的前缀。
compress是它的逆过程:
def compress(scheme, data): hdr = data[:4] + struct.pack(">L", (scheme << 27) + (len(data) & 0x07FFFFFF)) if scheme == 0: return data elif scheme == 1 and lz4: res = lz4.block.compress( data, mode="high_compression", compression=16, store_size=False ) return hdr + res else: warnings.warn("Table failed to compress by unsupported compression scheme") return data注意它复用原始表的前 4 字节作为输出头:传入的data本身就是完整表(前 4 字节是 16.16 定点的版本号字段),所以只需在其后追加 4 字节压缩字段。lz4 参数固定为mode="high_compression", compression=16,即 lz4 库的最高压缩档位。对于 scheme 0 或未知 scheme,函数原样返回数据并(对未知 scheme)发出warnings.warn,保证降级路径不会抛异常。
2.2 表侧调用点:版本驱动的压缩开关
两个使用方都遵循"版本判断 → 压缩/解压"的模式:
Silf表:decompile 时若self.version >= 5.0,先data, self.scheme = grUtils.decompress(data)再用解压数据解析后续字段(S__i_l_f.py 第 358-361 行);compile 时同样只在version >= 5.0分支调用grUtils.compress(self.scheme, hdr + data)(S__i_l_f.py 第 392-394 行)。v3~v4 的 Silf 固定self.scheme = 0,即不压缩。Glat表:decompile 在version >= 3.0分支调用 decompress,随后还会从 compression 字段的最低位读出hasOctaboxes = (self.compression & 1) == 1标志(G__l_a_t.py 第 80-84 行);compile 则先按self.compression = (self.scheme << 27) + (1 if self.hasOctaboxes else 0)组装该字段,最后整表压缩(G__l_a_t.py 第 126-139 行)。也就是说 compression 字段的位被复用了:高 3 位是压缩方案,最低位在 Glat v3 里额外承担"是否带 octabox 度量"的标志位,从源码结构看这是一种紧凑的位打包设计。
ttx XML 侧则通过compressionScheme属性记录 scheme,保证 roundtrip 时方案不丢失,例如测试数据 Tests/ttLib/tables/data/graphite/graphite_tests.ttx.Glat 中:
<Glat> <version compressionScheme="0" version="1.0"/>三、entries / _entries:属性字典的游程分组
entries是grUtils中唯一的"算法型"工具,用于把"键 → 值"字典压缩成连续键段的游程列表。源码位于 grUtils.py 第 44-60 行:
def _entries(attrs, sameval): ak = 0 vals = [] lastv = 0 for k, v in attrs: if len(vals) and (k != ak + 1 or (sameval and v != lastv)): yield (ak - len(vals) + 1, len(vals), vals) vals = [] ak = k vals.append(v) lastv = v yield (ak - len(vals) + 1, len(vals), vals) def entries(attributes, sameval=False): g = _entries(sorted(attributes.items(), key=lambda x: int(x[0])), sameval) return g它把排序后的键值对切分成若干游程,每个游程 yield 一个三元组(start_key, count, values):start_key是该段第一个键(由末键ak减去段长回推得到),count是段内元素数,values是值列表。断段条件有二:键不连续(k != ak + 1)是默认条件;当sameval=True时,值发生变化也会断段——这正是 Silf 列映射压缩的关键:连续且列号相同的图形 ID 区间可以合并为一条 range 记录。
实测行为(在当前仓库源码下运行验证):
list(grUtils.entries({3: 9, 1: 7, 2: 8, 6: 5})) # -> [(1, 3, [7, 8, 9]), (6, 1, [5])] list(grUtils.entries({1: 7, 2: 7, 3: 9, 6: 5}, sameval=True)) # -> [(1, 2, [7, 7]), (3, 1, [9]), (6, 1, [5])]两个消费场景:
Glat 属性编码。每个图形的属性是一个
{attrNum: attrValue}字典,compile 时逐游程写出"首属性号 + 游程长度 + 值数组"(G__l_a_t.py 第 142-148 行):def compileAttributes12(self, attrs, fmt): data = b"" for e in grUtils.entries(attrs): data += sstruct.pack(fmt, {"attNum": e[0], "num": e[1]}) + struct.pack( (">%dh" % len(e[2])), *e[2] ) return data游程头格式随 Glat 版本不同:v1 用单字节
attNum/num(Glat_format_1_entry),v3 用双字节(Glat_format_23_entry),值本身始终是 16 位有符号整数。从源码结构看还有一个细节值得注意:decompile 对 v2 使用双字节头解析,而 compile 在version <= 2.9分支里写入的仍是单字节头(G__l_a_t.py 第 120-129 行),即 v2 属性的写出采用了更紧凑的编码。Silf pass 的 colMap 压缩。编译 pass 时,图形列映射
gidcolmap用entries(..., sameval=True)分组后写成(first, last, col)三元组(S__i_l_f.py 第 874-878 行):for e in grUtils.entries(gidcolmap, sameval=True): if e[1]: passRanges.append((e[0], e[0] + e[1] - 1, e[2][0]))这正是
sameval参数的设计目的:键连续但列号改变的段必须断开,保证每条 range 内列值恒定。decompile 侧则反向展开每条 range,把first..last区间内的每个图形 ID 都映射回列号(S__i_l_f.py 第 792-796 行)。
四、bininfo:OpenType 风格数组头的计算
Graphite 表里多处存在"按序访问的变长数组",沿用了 OpenType 经典的搜索表头四元组(num, searchRange, entrySelector, rangeShift)。bininfo负责根据元素数量和元素大小生成该头,源码位于 grUtils.py 第 63-75 行:
def bininfo(num, size=1): if num == 0: return struct.pack(">4H", 0, 0, 0, 0) srange = 1 select = 0 while srange <= num: srange *= 2 select += 1 select -= 1 srange //= 2 srange *= size shift = num * size - srange return struct.pack(">4H", num, srange, select, shift)算法语义与 OpenType 规范一致:searchRange取不超过num的最大 2 的幂(2^floor(log2(num))),entrySelector = floor(log2(num)),rangeShift = num * size - searchRange,整体按大端>4H打包成 8 字节。num == 0时输出全零头。
实测验证:
grUtils.bininfo(0) # b'\x00\x00\x00\x00\x00\x00\x00\x00' grUtils.bininfo(1) # (num=1, srange=1, select=0, shift=0) grUtils.bininfo(3, 6) # (num=3, srange=12, select=1, shift=6)仓库内三处调用分别对应不同元素宽度:
- S__i_l_f.py 第 529 行:
grUtils.bininfo(numPseudo, 6)—— Silf 中 pseudo 映射数组,每条记录 6 字节(v3+ 为>LH,v2 为>HH); - S__i_l_f.py 第 710 行:非线形类(nonLinear class)内部是"图形 ID → 类内索引"的查找数组,每条 2 字节;
- S__i_l_f.py 第 901 行:pass 头部紧随其后的列映射 range 数组,每条 6 字节(
>3H); - S__i_l_l.py 第 62 行:
Sill表语言条目数组,默认size=1,语言条目本身 8 字节(>4sHH)按序排列。
Silf的 decompile 侧用同样的 8 字节头反向驱动解析,例如 pass 表头部先读numRange, _, _, _ = struct.unpack(">4H", data[:8])跳过搜索参数、只取numRange展开列映射(S__i_l_f.py 第 790-796 行)。
五、num2tag / tag2num:功能 ID 的双向表示
Graphite 的Feat/Sill表里的功能 ID 存在两种合法表示:纯数字 ID(如 123)和4 字符 Tag(如yesm)。fontTools 的 Python 对象内部统一用字符串作键,num2tag/tag2num负责二进制与 Python 表示之间的转换,源码位于 grUtils.py 第 78-92 行:
def num2tag(n): if n < 0x200000: return str(n) else: return ( struct.unpack("4s", struct.pack(">L", n))[0].replace(b"\000", b"").decode() ) def tag2num(n): try: return int(n) except ValueError: n = (n + " ")[:4] return struct.unpack(">L", n.encode("ascii"))[0]判别规则是0x200000(2097152)这个阈值:数值低于它的按数字 ID 处理,num2tag直接返回数字字符串;高于它的按 32 位大端整型重新解释为 4 字节 ASCII,并去掉尾部 NUL(Tag 不足 4 字符时右侧补的是\x00)。tag2num是逆操作:先尝试int(),失败则把字符串右侧空格填充/截断到 4 字符再打包为大端 uint32——填充用空格而非 NUL,是因为 XML 里的 Tag 属性天然以空格补位。
实测验证:
grUtils.num2tag(123) # '123' grUtils.num2tag(0x506C6179) # 'Play' grUtils.tag2num('123') # 123 grUtils.tag2num('play') # 1886151033 (0x706C6179)调用方与语义:
Feat表 decompile 时以grUtils.num2tag(fid)作为self.features字典键(F__e_a_t.py 第 62 行),compile 时再grUtils.tag2num(f)写回。这里有一个自愈机制:v1 的Feat功能 ID 字段只有 2 字节,若编译时发现fnum > 65535(即某个字母 Tag 超出 uint16 范围),会把self.version提升到 2.0 并递归重新 compile(F__e_a_t.py 第 90-92 行),从源码结构看这是一种针对字母型 ID 的自动版本升级。Sill表 toXML/fromXML 用同一对函数在 XML 的fid属性与二进制之间往返(S__i_l_l.py 第 74、91 行)。测试数据 Tests/ttLib/tables/data/graphite/graphite_tests.ttx.Sill 中同时出现两种形态,恰好覆盖了两条分支:<lang name="fred"> <feature fid="yesm" val="1"/> <feature fid="nom " val="0"/>yesm是 4 字符 Tag(nom的右侧空格正是 Tag 的补位形态),而数字型 fid 在 XML 中表现为数字字符串。
六、端到端验证:Graphite 表的 roundtrip 测试
仓库为这套 Graphite 支持提供了可直接查看的测试资产:
- 测试字体:Tests/ttLib/tables/data/graphite/graphite_tests.ttf;
- 各表的期望 ttx 快照:graphite_tests.ttx.Glat、graphite_tests.ttx.Silf、graphite_tests.ttx.Sill 及同名
.setup文件; - 测试注册点:Tests/ttLib/tables/tables_test.py 第 235 行 将
"graphite/graphite_tests.ttf"与("Silf", "Glat", "Feat", "Sill")四张表绑定进通用 roundtrip 用例表。测试框架会对每张目标表执行"二进制 decompile → XML 导出 → 与期望 ttx 对比 → fromXML → compile 回二进制"的完整往返,因此上述快照文件既是文档级的格式示例,也是grUtils各函数正确性的验收依据。
从快照内容也能直观看到 ttx 侧的表达方式:Glat 以<glyph name="..."><attribute index="..." value="..."/></glyph>逐图形列出属性(这些 index/value 正是 decompile 时由entries游程展开还原出来的字典);Silf 的version标签携带compressionScheme属性,保证重新 compile 时沿用原压缩方案。
七、小结
grUtils体量很小(单文件 92 行),但它是 fontTools 支持 Graphite 私有表的公共底座,四个工具函数各司其职:
| 函数 | 一句话职责 | 关键参数/返回值 |
|---|---|---|
compress(scheme, data)/decompress(data) | 按 compression 字段的高 3 位协商压缩方案,scheme 1 走 lz4.block(需可选 lz4 依赖) | 返回(data, scheme);size 校验不通过时发warnings.warn |
entries(attributes, sameval) | 把键值字典切成连续键段游程,sameval=True时值变化也断段 | yield(start_key, count, values) |
bininfo(num, size=1) | 计算 OpenType 风格数组搜索头 | 大端>4H:(num, searchRange, entrySelector, rangeShift) |
num2tag(n)/tag2num(n) | 功能 ID 的数字表示与 4 字符 Tag 表示互转 | 阈值0x200000;Tag 右侧空格补位 |
如果你要为 fontTools 新增一张 Graphite 相关表,或需要理解 Silf/Glat 的二进制细节,建议按 grUtils.py → G__l_a_t.py → S__i_l_f.py 的顺序阅读,并配合graphite_tests.ttf的 ttx 快照对照验证;完整表格模块文档索引见 Doc/source/ttLib/tables.rst,各 Graphite 表还有各自的 API 页(F__e_a_t.rst、G__l_a_t.rst、G__l_o_c.rst、S__i_l_f.rst、S__i_l_l.rst)。
- 开发工具
【免费下载链接】fonttools
A library to manipulate font files from Python.
相关推荐
fontTools 中的 Graphite Sill 表:S__i_l_l.py 实现深度解析
fontTools 中的 Graphite Sill 表:S__i_l_l.py 实现深度解析 Graphite 是一个面向复杂文字排版的高级字形变换引擎,其字
开发工具`Gloc` 表深度解析:fontTools 中 Graphite 字形属性索引表的实现与实战
Gloc 表深度解析:fontTools 中 Graphite 字形属性索引表的实现与实战 Gloc (Graphite index to glyph attr
开发工具fontTools gvar 表深度解析:TupleVariation 与 TrueType 可变字体字形变化的编码实现
fontTools gvar 表深度解析:TupleVariation 与 TrueType 可变字体字形变化的编码实现 导读 gvar (Glyph Vari
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考