简介:这是一份面向字体技术开发者与图形编程学习者的TrueType格式解析类源码,聚焦于将复杂的TTF字体文件结构封装为可复用的C++类,帮助读者在游戏开发、图形设计软件或文本渲染引擎中集成自定义字体处理能力。资源包共2个文件,包含1个cpp实现文件与1个h头文件,压缩包约42KB,其中cpp承载读取字体文件、解析头部信息、提取字形数据、解码Glyf表与计算字距等核心逻辑,h则声明类接口与相关数据结构。内容围绕Head、Maxp、Hhea、Hmtx、CFF、Glyf、Loca等关键表展开,涉及立方与二次贝塞尔曲线轮廓描述,读者可借此理解字形加载、宽度获取、字号调整与字形查找的实现思路。目前已有605人学习下载,适合希望深入字体解析内部机制、提升字体编程专业素养的中高级开发者参考。
1. 字体文件里的黑匣子:为什么你的解析器总在复合字形上翻车
做过字体渲染或排版引擎的同行大概都有过这种经历:用现成的库读一个 TTF 文件,简单字符渲染得好好的,一碰到带重音符号的拉丁字母、阿拉伯文连写或者中日韩的异体字,要么缺笔画,要么位置全歪。翻文档才发现,问题出在glyf表里那套复合字形(composite glyph)的引用机制上——它允许一个字形由多个简单字形拼装而成,还带二维变换矩阵。很多解析器只处理了简单字形,遇到复合结构直接跳过或按默认值渲染,结果就是玄学般的显示异常。
TrueType 格式解析类要解决的核心问题,就是把.ttf文件从一堆二进制字节还原成可查询、可变换、可渲染的字形数据。它适合三类人:一是做嵌入式 UI 或游戏引擎、需要自己裁剪字体子集的开发者;二是做文档转换或 PDF 生成、要精确控制字形轮廓和度量的工程师;三是做字体编辑工具、需要读写字形轮廓的桌面软件作者。这篇文章不讲空泛的格式概览,而是从表目录解析开始,一路走到复合字形的递归展开和轮廓点提取,把每一步的参数含义和踩坑点都摊开。
2. 从表目录到字形数据:TrueType 解析的骨架与最小可跑通路径
TrueType 文件本质上是一个带索引的二进制容器。文件头之后紧跟一张表目录(Table Directory),记录了每张表的名字、偏移、长度和校验和。解析的第一步永远是读这张目录,而不是直接跳到glyf表去抠数据——因为不同字体文件的表顺序不固定,甚至可能包含厂商自定义表,硬编码偏移量是血泪教训里最常见的翻车起点。
2.1 文件头与表目录的字段布局
文件头固定 12 字节,结构如下:
| 偏移 | 长度 | 字段 | 含义 |
|---|---|---|---|
| 0 | 4 | sfntVersion | 0x00010000 表示 TrueType 轮廓 |
| 4 | 2 | numTables | 表数量 |
| 6 | 2 | searchRange | 二分查找范围,通常为 16×2^floor(log2(numTables)) |
| 8 | 2 | entrySelector | log2(searchRange/16) |
| 10 | 2 | rangeShift | numTables×16 - searchRange |
表目录紧跟在文件头之后,每张表占 16 字节:4 字节标签(如glyf、loca、cmap、head、hhea、hmtx、maxp)、4 字节校验和、4 字节偏移、4 字节长度。标签是大端序的 ASCII,比较时要注意字节序转换。
下面是一段 Python 读取表目录的最小实现:
import struct def read_table_directory(data: bytes): # 文件头前 12 字节 sfnt_version, num_tables = struct.unpack(">IH", data[0:6]) if sfnt_version != 0x00010000: raise ValueError("不是 TrueType 轮廓格式,可能是 CFF 或其它 sfnt 变体") tables = {} offset = 12 for _ in range(num_tables): tag, checksum, tbl_offset, tbl_length = struct.unpack( ">4sIII", data[offset:offset + 16] ) tag_str = tag.decode("ascii") tables[tag_str] = { "offset": tbl_offset, "length": tbl_length, "checksum": checksum, } offset += 16 return tables这段代码的关键点有三个。第一,>IH中的>表示大端序,TrueType 所有多字节整数都是大端,漏掉这个符号会导致表数量读成天文数字。第二,sfntVersion必须校验,因为 OpenType 字体可能用OTTO标签表示 CFF 轮廓,那种情况glyf表根本不存在,继续解析会直接越界。第三,表目录里每张表的偏移是相对于文件起始位置的绝对偏移,不是相对表目录的偏移,这个细节在拼接字体子集时特别容易搞混。
2.2 用 head、maxp、loca 三张表定位字形数据
拿到表目录后,不要急着遍历所有表。解析字形数据只需要先读三张关键表:head提供indexToLocFormat字段,决定loca表用短格式还是长格式;maxp提供numGlyphs,告诉你一共有多少个字形;loca则是一张偏移数组,第 i 项指向第 i 个字形的数据在glyf表中的起始位置。
head表里indexToLocFormat位于偏移 50 处,占 2 字节。值为 0 表示loca用短格式(每项 2 字节,实际偏移需乘以 2),值为 1 表示长格式(每项 4 字节)。这个设计是历史遗留的节省空间手段,但很多手写解析器忘了乘 2,导致读到的字形数据错位。
def read_loca(data: bytes, tables: dict): head_off = tables["head"]["offset"] index_to_loc_format = struct.unpack(">h", data[head_off + 50:head_off + 52])[0] maxp_off = tables["maxp"]["offset"] num_glyphs = struct.unpack(">H", data[maxp_off + 4:maxp_off + 6])[0] loca_off = tables["loca"]["offset"] offsets = [] if index_to_loc_format == 0: for i in range(num_glyphs + 1): val = struct.unpack(">H", data[loca_off + i * 2: loca_off + i * 2 + 2])[0] offsets.append(val * 2) # 短格式必须乘 2 else: for i in range(num_glyphs + 1): val = struct.unpack(">I", data[loca_off + i * 4: loca_off + i * 4 + 4])[0] offsets.append(val) return offsets, num_glyphs注意loca表的项数是numGlyphs + 1,最后一项指向glyf表的末尾,用来计算最后一个字形的长度。如果只读numGlyphs项,最后一个字形就没法确定边界。另外maxp表的numGlyphs在偏移 4 处,前面 4 字节是版本号,别读错位置。
2.3 简单字形的轮廓点提取与标志位解码
有了loca偏移数组,就可以逐个读取glyf表中的字形数据了。每个字形开头是一个 10 字节的头:numberOfContours(2 字节,有符号)、xMin/yMin/xMax/yMax(各 2 字节)。numberOfContours大于等于 0 表示简单字形,等于 -1 表示复合字形。
简单字形的数据布局是:先endPtsOfContours数组(每个轮廓的结束点索引),然后是instructionLength和指令字节(跳过即可),接着是标志位数组和坐标增量数组。标志位每个字节包含多个布尔信息,其中 bit 0 表示该点是否在曲线上(on-curve),bit 1 表示 x 坐标增量是否为 0,bit 2 表示 y 增量是否为 0,bit 3 和 bit 4 组合表示增量是 1 字节还是 2 字节,bit 5 表示增量是否为正。
def parse_simple_glyph(data: bytes, offset: int): num_contours = struct.unpack(">h", data[offset:offset + 2])[0] if num_contours < 0: return None # 复合字形,另行处理 pos = offset + 10 end_pts = [] for _ in range(num_contours): end_pts.append(struct.unpack(">H", data[pos:pos + 2])[0]) pos += 2 num_points = end_pts[-1] + 1 if end_pts else 0 instruction_length = struct.unpack(">H", data[pos:pos + 2])[0] pos += 2 + instruction_length flags = [] while len(flags) < num_points: f = data[pos] pos += 1 flags.append(f) if f & 0x08: # repeat 标志 repeat = data[pos] pos += 1 flags.extend([f] * repeat) x_coords = [] x = 0 for f in flags: if f & 0x02: dx = 0 elif f & 0x10: dx = struct.unpack(">b", data[pos:pos + 1])[0] pos += 1 else: dx = struct.unpack(">h", data[pos:pos + 2])[0] pos += 2 if not (f & 0x10) and not (f & 0x02): pass if f & 0x10 and not (f & 0x02): pass x += dx if (f & 0x10 or not (f & 0x02)) else dx x_coords.append(x) # y 坐标同理,此处省略重复逻辑 return {"end_pts": end_pts, "flags": flags, "x": x_coords}上面这段代码里有一个容易写错的地方:坐标增量的符号处理。当 bit 4 为 1 时增量是 1 字节有符号数,为 0 且 bit 1 为 0 时增量是 2 字节有符号数。但符号方向由 bit 5 决定——bit 5 为 1 表示正增量,为 0 表示负增量。很多实现直接对有符号字节取负,结果在负增量场景下符号翻倍,轮廓整个镜像。稳妥的做法是先把增量读成无符号值,再根据 bit 5 决定加减。
3. 复合字形与坐标变换:解析器最容易埋雷的地方
复合字形是 TrueType 里最容易被低估的结构。它不直接存储轮廓点,而是通过glyphIndex引用其它字形,并附带一个 2×3 的仿射变换矩阵(实际存储为 2×2 缩放/旋转加 2 个平移分量)。带重音符号的字母、阿拉伯文的连写形式、以及很多 CJK 字体的异体字都依赖这个机制。如果解析器只处理简单字形,这些字符要么消失,要么以未变换的原始形状堆叠在原点。
3.1 复合字形的组件结构与标志位含义
复合字形在 10 字节头之后,是一系列组件记录,最后以MORE_COMPONENTS标志位为 0 结束。每个组件的前 4 字节固定:2 字节flags、2 字节glyphIndex。之后根据 flags 决定后续参数的读取方式。
关键标志位如下:
| 位 | 名称 | 含义 |
|---|---|---|
| 0x0001 | ARG_1_AND_2_ARE_WORDS | 参数是 2 字节还是 1 字节 |
| 0x0002 | ARGS_ARE_XY_VALUES | 参数是 xy 偏移还是点匹配 |
| 0x0008 | WE_HAVE_A_SCALE | 有一个 2 字节缩放值 |
| 0x0020 | MORE_COMPONENTS | 后面还有组件 |
| 0x0040 | WE_HAVE_AN_X_AND_Y_SCALE | 有独立的 x/y 缩放 |
| 0x0080 | WE_HAVE_A_TWO_BY_TWO | 有完整的 2×2 矩阵 |
| 0x0200 | USE_MY_METRICS | 使用本组件的度量信息 |
如果ARGS_ARE_XY_VALUES为 1,参数是相对于组件原点的偏移;如果为 0,参数是两个点索引,表示当前组件的一个点要对齐到已解析部分的某个点——这种点匹配模式在手工制作的字体里偶尔出现,但绝大多数现代字体都用 xy 偏移。解析时如果遇到点匹配模式,需要先收集已解析的所有点,再计算平移量,实现复杂度会上升一个量级。
3.2 递归展开组件并应用仿射变换
复合字形可以嵌套引用另一个复合字形,所以解析必须是递归的,同时要维护一个已访问集合来防止循环引用导致栈溢出。下面是一个递归展开的实现框架:
def parse_composite_glyph(data, offset, loca_offsets, glyf_offset, depth=0, visited=None): if visited is None: visited = set() if depth > 5: raise RecursionError("复合字形嵌套过深,可能存在循环引用") num_contours = struct.unpack(">h", data[offset:offset + 2])[0] if num_contours != -1: return None pos = offset + 10 components = [] while True: flags, glyph_index = struct.unpack(">HH", data[pos:pos + 4]) pos += 4 if flags & 0x0001: # ARG_1_AND_2_ARE_WORDS arg1, arg2 = struct.unpack(">hh", data[pos:pos + 4]) pos += 4 else: arg1, arg2 = struct.unpack(">bb", data[pos:pos + 2]) pos += 2 scale = (1.0, 0.0, 0.0, 1.0) # a, b, c, d if flags & 0x0008: # WE_HAVE_A_SCALE s = struct.unpack(">e", data[pos:pos + 2])[0] # F2Dot14 scale = (s, 0.0, 0.0, s) pos += 2 elif flags & 0x0040: # X_AND_Y_SCALE sx, sy = struct.unpack(">ee", data[pos:pos + 4]) scale = (sx, 0.0, 0.0, sy) pos += 4 elif flags & 0x0080: # TWO_BY_TWO a, b, c, d = struct.unpack(">eeee", data[pos:pos + 8]) scale = (a, b, c, d) pos += 8 components.append({ "glyph_index": glyph_index, "dx": arg1 if flags & 0x0002 else 0, "dy": arg2 if flags & 0x0002 else 0, "matrix": scale, }) if not (flags & 0x0020): break # 递归解析每个组件引用的字形,应用变换后合并点集 all_points = [] for comp in components: sub_offset = glyf_offset + loca_offsets[comp["glyph_index"]] sub = parse_glyph(data, sub_offset, loca_offsets, glyf_offset, depth + 1, visited) if sub is None: continue a, b, c, d = comp["matrix"] for (px, py) in sub["points"]: nx = a * px + c * py + comp["dx"] ny = b * px + d * py + comp["dy"] all_points.append((nx, ny)) return {"points": all_points}这段代码里有几个参数细节值得展开。F2Dot14 是 TrueType 专用的定点数格式:2 字节中高 2 位是整数部分,低 14 位是小数部分,Python 的struct格式符e是半精度浮点,不能直接用来读 F2Dot14。正确做法是读成有符号 16 位整数后除以 16384.0。上面代码为了简洁用了e,实际工程中必须替换。另外变换公式是x' = a*x + c*y + dx、y' = b*x + d*y + dy,注意 c 和 b 的位置——矩阵存储顺序是 a、b、c、d,对应的是列主序还是行主序在不同文档里表述不一,实测时用单位矩阵和已知平移量验证一遍最稳妥。
3.3 用 cmap 表把 Unicode 映射到字形索引
解析出字形数据后,还需要知道用户输入的字符对应哪个字形。cmap表负责这个映射,它内部有多个子表,分别对应不同的平台和编码。现代字体通常包含一个格式 4 子表(BMP 范围)和一个格式 12 子表(全 Unicode 范围)。优先选格式 12,没有再用格式 4。
格式 4 的结构是分段映射:segCount个段,每段有endCode、startCode、idDelta、idRangeOffset。查找时先二分找到第一个endCode >= charCode的段,如果charCode < startCode则无映射,否则计算glyphIndex = (charCode + idDelta) & 0xFFFF,当idRangeOffset非零时还要通过偏移去读glyphIdArray。这个表的偏移计算是出了名的绕,idRangeOffset是相对于它自身位置的字节偏移,不是相对于表头。
def lookup_format4(data, cmap_sub_offset, char_code): seg_count_x2 = struct.unpack(">H", data[cmap_sub_offset + 6:cmap_sub_offset + 8])[0] seg_count = seg_count_x2 // 2 end_codes_off = cmap_sub_offset + 14 start_codes_off = end_codes_off + seg_count_x2 + 2 id_deltas_off = start_codes_off + seg_count_x2 id_range_offsets_off = id_deltas_off + seg_count_x2 for i in range(seg_count): end_code = struct.unpack(">H", data[end_codes_off + i * 2:end_codes_off + i * 2 + 2])[0] if char_code <= end_code: start_code = struct.unpack(">H", data[start_codes_off + i * 2:start_codes_off + i * 2 + 2])[0] if char_code < start_code: return 0 id_delta = struct.unpack(">h", data[id_deltas_off + i * 2:id_deltas_off + i * 2 + 2])[0] id_range_offset = struct.unpack(">H", data[id_range_offsets_off + i * 2:id_range_offsets_off + i * 2 + 2])[0] if id_range_offset == 0: return (char_code + id_delta) & 0xFFFF else: glyph_addr = id_range_offsets_off + i * 2 + id_range_offset + (char_code - start_code) * 2 glyph_index = struct.unpack(">H", data[glyph_addr:glyph_addr + 2])[0] if glyph_index == 0: return 0 return (glyph_index + id_delta) & 0xFFFF return 0idRangeOffset非零时的地址计算是id_range_offsets_off + i*2 + id_range_offset,其中id_range_offsets_off + i*2是当前idRangeOffset项自身的位置,再加上偏移值才是glyphIdArray里的目标位置。这个公式如果写成相对于表头或相对于id_deltas_off,查出来的字形索引会整体偏移,表现为部分字符映射到完全无关的字形。
4. 避坑与排查:TrueType 解析中最常见的五类翻车
4.1 字形轮廓整体镜像或上下颠倒
现象:渲染出来的字符左右翻转或上下颠倒,但字符本身形状正确。
原因:坐标增量的符号处理错误。TrueType 的 x/y 增量在标志位 bit 5 为 0 时表示负值,但增量本身以无符号形式存储。如果代码先把增量读成有符号数再根据 bit 5 取负,负负得正,符号就反了。
解决:统一按无符号读取增量,然后if not (flags & 0x20): delta = -delta。对 x 和 y 分别独立判断,不要共用一个符号变量。
4.2 复合字形的位置偏移全部为零
现象:带重音符号的字母,重音全部堆在基字母的原点上,没有正确偏移。
原因:ARGS_ARE_XY_VALUES标志位判断反了,或者把arg1/arg2当成了点索引而不是 xy 偏移。当该标志位为 0 时,参数确实是点索引,但很多字体在简单场景下仍然把这两个字段填成 0,导致看起来像偏移为零。
解决:先检查flags & 0x0002,为真才把参数当 xy 偏移使用。为假时实现点匹配逻辑,或者至少记录警告并跳过该组件,不要默认按零偏移处理。
4.3 短格式 loca 表读出的偏移量全部偏小一半
现象:所有字形数据都从错误的位置开始读,解析出的轮廓点数量异常或直接越界。
原因:indexToLocFormat为 0 时,loca表每项是实际偏移除以 2 后的值,读取后必须乘以 2。漏掉乘法是最常见的低级错误。
解决:在read_loca函数里对短格式分支显式乘以 2,并在单元测试里用一个已知字体验证第一个非空字形的偏移是否落在glyf表范围内。
4.4 cmap 格式 4 查出的字形索引整体偏移
现象:输入某些字符,渲染出的是另一个完全无关的字符,但映射关系看起来有规律地偏移。
原因:idRangeOffset非零时的地址计算错误。常见错误是把偏移基准搞成了id_deltas_off或cmap_sub_offset,而不是当前idRangeOffset项自身的位置。
解决:严格按照id_range_offsets_off + i*2 + id_range_offset计算,其中id_range_offsets_off + i*2是第 i 个idRangeOffset项的绝对偏移。用几个已知字符(如空格、大写 A、数字 0)做回归验证。
4.5 复合字形递归导致栈溢出
现象:解析某个字体时程序崩溃,报递归深度超限或栈溢出。
原因:复合字形之间形成了循环引用,A 引用 B,B 又引用 A。这在手工修改过的字体或损坏文件中可能出现。
解决:维护一个visited集合记录当前递归路径上已访问的字形索引,遇到重复立即返回空字形并记录警告。同时设置最大递归深度(比如 5 层),超过就截断。不要依赖 Python 的默认递归限制,显式检查更可控。
5. 从解析到子集化:一个可验证的裁剪技巧
把 TrueType 解析做到能正确读出轮廓点之后,一个很自然的进阶需求是字体子集化——只保留页面实际用到的字符,把文件体积压下来。嵌入式场景里一个完整中文字体动辄十几兆,子集化后可能只有几十 KB,收益非常直接。但子集化不是简单地把用到的字形数据复制出来,loca、glyf、cmap、hmtx、maxp这几张表必须同步重建,任何一张表的索引对不上,渲染就会出问题。
我一般会按下面的顺序做子集化,每一步都可以独立验证:
第一步,用cmap表收集目标字符集对应的字形索引集合,同时把复合字形递归展开,把所有被引用的组件字形索引也加入集合。这一步漏掉组件字形是子集化后缺笔画的主要原因。
第二步,按字形索引排序,重新生成loca偏移数组。新loca的项数等于保留的字形数加一,每项指向新glyf表中的偏移。如果原字体用短格式且新偏移都小于 0x20000,可以继续用短格式,否则切长格式并更新head表的indexToLocFormat。
第三步,重建cmap格式 4 子表。由于字形索引变了,idDelta和glyphIdArray都要重新计算。一个省事的做法是只保留格式 4,把目标字符按 Unicode 排序后重新分段,段数会变多但逻辑简单,不容易出错。
第四步,重建hmtx表。每个保留的字形对应一个 advanceWidth 和 leftSideBearing,从原表里按原字形索引取出,按新索引顺序排列。hhea表里的numberOfHMetrics也要同步更新。
第五步,更新maxp表的numGlyphs,以及head表的checkSumAdjustment。校验和可以先全部填零,最后用整个文件的校验和反推。很多工具链不校验这个值,但某些老式渲染器会检查,填对更稳妥。
验证子集化结果是否正确的办法很朴素:用解析器重新读一遍生成的文件,对每个目标字符查cmap得到字形索引,再解析该字形的轮廓点,和原字体同字符的轮廓点做逐点比对。如果所有点的坐标误差都在零(整数坐标不应有浮点误差),说明子集化没有破坏数据。这个回归测试跑一遍,比肉眼渲染截图可靠得多。
最后说一个我踩过的坑:子集化时如果目标字符集里包含复合字形,而组件字形恰好不在字符集里,必须在第一步就把它拉进来。我早期偷懒只收集了cmap直接映射到的字形,结果带重音的拉丁字母全部缺了重音部分,排查了半天才定位到组件引用。后来养成习惯,子集化前先跑一遍完整的复合字形展开,把依赖闭包算出来再动手。希望帮到你。
本文还有配套的精品资源,点击获取