☰
fontTools grUtils 深度解析:Graphite 表的压缩方案、游程编码与 Tag 数字转换
2026/10/13 12:53:46 网站建设 项目流程
  • 开发工具

【免费下载链接】fonttools

A library to manipulate font files from Python.

项目地址:https://gitcode.com/gh_mirrors/fo/fonttools
点击查看免费下载

本文围绕 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 对模块的定位只有一句话:

ThegrUtilsmodule 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/compressS__i_l_f.py、G__l_a_t.py整表 lz4 压缩/解压
entries/_entriesG__l_a_t.py、S__i_l_f.py属性/列映射游程编码
bininfoS__i_l_f.py、S__i_l_l.py生成 OpenType 风格数组头
num2tag/tag2numF__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)

要点有三:

  1. scheme 占 3 位(位 31~27),为协议预留了 0~7 的方案空间,当前只定义了 0(不压缩)和 1(lz4 block);
  2. size 占 27 位,表示解压后的完整表长度(含 8 字节表头本身),这是后续校验与 lz4 解压的前提;
  3. 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])]

两个消费场景:

  1. 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 属性的写出采用了更紧凑的编码。

  2. 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.

项目地址:https://gitcode.com/gh_mirrors/fo/fonttools
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询