
简介这是面向字体开发、游戏引擎与图形编程学习者的一套TrueType格式解析类源码解决了在自定义项目中解析和操作TTF字体文件的常见难点。类的设计覆盖TrueType字体的核心结构包括头表、maxp、hhea/hmtx、loca、glyf等关键表的读取与访问逻辑字形轮廓通过贝塞尔曲线进行描述兼顾轮廓与拐角的控制使开发者能够获取字符宽度、加载绘制字形、计算字距以及按需调整字体大小。资源共包含2个文件1个C实现文件负责实际的字体文件解析与数据提取1个头文件对外声明类接口与数据结构整体压缩包仅42KB结构紧凑适合直接集成到游戏渲染、文本排版或字体工具中二次开发。目前已有603人学习下载源码清晰呈现了TrueType表解析流程与字形数据组织方式能够帮助阅读者深入理解字体格式规范并快速实现自定义字体加载、显示和处理功能。 做了几年的字体排版和渲染相关开发有一个体会FreeType这类现成库能解决绝大部分问题但真到了做字体子集化、字形轮廓提取、自定义极简渲染引擎这些场景还是得老老实实把TTF的二进制结构吃透。TrueType格式解析并不是什么新鲜事网上资料也不少但大多是照着规范念一遍真正自己从头写一个解析类的时候字节序、偏移计算、差分坐标、复合字形这些坑会一个不落地找上门。这篇文章就把我手写TrueType解析类时沉淀下来的关键点整理一遍从sfnt容器结构一路拆到glyf轮廓数据最后给出可落地的读取顺序、代码思路和踩坑记录。适合正在做字体工具链、排版引擎、图标抽取工具或者纯粹想弄懂字体文件内部长什么样的人参考。1. 哪些场景值得手写解析类先想清楚要不要造轮子在决定自己写TrueType解析类之前先泼一盆冷水很多场景真的不需要重复造轮子。桌面端有FreeType、CoreText、DirectWriteWeb端有Canvas自带的字体渲染移动端Skia内部也集成了完善的字体解析能力。在这些环境下想拿到字形轮廓调用现成API反而更稳、更快没必要自己读二进制。但我确实遇到过几类不得不自己动手的情况字体子集化工具。比如要把一个几十MB的字体文件裁剪成只包含某几个字符的子集需要读取字体内的字形数据、重新组织表结构、重算checksum这时候不读懂sfnt表布局和glyf数据结构寸步难行。端上包体积极敏感的定制渲染。某些嵌入式设备、小程序插件、游戏内嵌字体系统无法接受引入一个完整的字体渲染库但只需要把几个固定字形解析成网格或矢量路径自己写一个轻量解析类反而更合适。需要拿到轮廓做二次加工。把TTF字形转成SVG路径、导成JSON给设计工具用、做文字特效形变、生成点阵字模这些场景本质上是“结构解析 业务加工”平台自带的渲染API给不到你要的原始轮廓数据。对渲染细节有绝对控制权。比如要自己实现字体提示(hinting)的替代方案或者要在曲线层面对字形做统一简化底层解析必须握在自己手里。有人觉得手写TrueType解析很难其实不然。字体文件本身是表驱动结构不需要解压不需要复杂算法需要的几乎只有“按偏移量读整数”这一件事。一个能读到字形轮廓的解析骨架几百行代码就能跑通难度集中在细节的严谨性上。下表总结了不同实现方式的权衡方案工作量能力边界典型场景FreeType/系统API极小渲染、度量、轮廓齐全常规应用HarfBuzz FreeType中小复杂文本整形 字体处理排版引擎自研解析类中大可精确控制到每个字节子集化/轮廓加工/轻量内嵌自研渲染全链路很大需要hinting解释器等极简专用字体引擎本文的边界我提前划清楚围绕静态TTF字体的黑色字形解析展开包括sfnt表结构、cmap字符映射、glyf轮廓解码、二次贝塞尔还原和复合字形递归。OpenType/CFF三次贝塞尔、可变字体轴插值、hinting指令解释器这些不在这次范围内CFF是另一套逻辑以后有机会单独写。2. sfnt容器和表目录从文件前12个字节开始所有TrueType字体本质上都是一个字节序列它的容器格式叫sfnt。一个TTF的物理布局是最前面是sfnt头紧接着是表目录之后是各个表在文件后面的实际数据。sfnt头一共12个字节结构非常固定前4字节是scaler type也就是字体类型标识。TTF一般是0x00010000或者ASCII字符串“true”如果这里是“OTTO”说明这是CFF轮廓的OpenType字体不是本文要处理的TrueType轮廓。第5、6字节是numTables表示这个字体文件里有多少张表。常见的中文字体可能有三四十张表最少也得有head、hhea、maxp、loca、glyf、cmap、hmtx这几张才能正常工作。后面6个字节searchRange、entrySelector、rangeShift是给上古时代的二分查找优化用的。现在CPU查内存都很快解析器直接遍历表目录即可这几个字段可以读出来但不影响逻辑。sfnt头结束后紧接着是numTables条表目录记录每条固定16字节4字节表标签(tag)比如“head”“glyf”“cmap”每个字符可以直接按ASCII读出来。4字节checksum校验和。4字节offset表示该表的数据从文件头部开始算的绝对偏移量。4字节length表数据长度。这里有一个重要约束表目录必须按表标签的字典序排列。解析时可以依赖这一点也可以不依赖直接线性查找反正只有几十条记录。拿到表目录之后整个解析类的核心设计思路就很清晰了读文件时先把sfnt头和表目录解析出来构建一个tag到表数据偏移和长度的映射表后续任何一张表都从映射表里取读取位置。这相当于给整个字体文件建立了一张“地图”后续所有读取操作都是“地图定向 目标表内精确偏移”。用Dart写这段逻辑时有一个关键点TrueType字体所有多字节整数都是大端序Big-Endian而Dart的ByteData默认是按当前平台字节序解读的必须显式指定大端。class SfntTable { final String tag; final int offset; final int length; // ... } ListSfntTable parseTableDirectory(ByteData data) { final numTables data.getUint16(4, Endian.big); final tables SfntTable[]; for (var i 0; i numTables; i) { final recordOffset 12 i * 16; final tag String.fromCharCodes([ data.getUint8(recordOffset), data.getUint8(recordOffset 1), data.getUint8(recordOffset 2), data.getUint8(recordOffset 3), ]); final offset data.getUint32(recordOffset 8, Endian.big); final length data.getUint32(recordOffset 12, Endian.big); tables.add(SfntTable(tag, offset, length)); } return tables; }这里最容易踩的第一个坑是“绝对偏移量”。表目录中的offset是从文件字节0开始计算的绝对偏移不是相对sfnt头的相对偏移。我见过有人习惯了某种数据包格式下意识把offset当成相对于表目录的偏移结果读出来的数据全乱。另一个坑是有些字体文件尾部会追加其他数据表的length不一定能覆盖到文件末尾所以解析时最好始终以表目录里记录的length为准而不是拿文件总长度去倒推表格边界。还有一个验证技巧正常TTF的head表里有一个magicNumber固定是0x5F0F3CF5。如果解析表目录后读headmagic数字对不上基本可以断定文件损坏或者读错了偏移位置。这招在调试初期特别好用能迅速区分“文件给的偏移有问题”和“自己读数据的逻辑有问题”。3. unitsPerEm、字形数和度量表渲染前的三个定海神针解析类搭好sfnt容器之后下一步不是去读轮廓而是先把几个全局元数据表读出来。这些表的数据量不大但它们决定了后面所有坐标和度量的语义顺序不能乱。head表的长度是54字节包含字体全局信息。对这个解析类最关键的字段是两个unitsPerEm位于head表偏移18处的uint16。它表示一个em方块被划分成多少个单位常见值是1000或2048中文字体有的用到4096。轮廓里所有原始坐标都基于这个单位体系后续任意大小渲染都靠它换算scale pixelSize / unitsPerEm。indexToLocFormat位于head表偏移50处的int16。它决定loca表用短格式还是长格式存储0表示短格式每个条目2字节1表示长格式每个条目4字节。这个字段经常有人忘记判导致后续loca解析全部错位属于“一错错一串”的典型。maxp表里必须读的是numGlyphs位于该表偏移4处的uint16。它告诉你字体总共有多少个字形。这个数字的重要性在于loca表里正好有numGlyphs1个条目多出来的最后一个条目用来标记最后一个字形的结束边界cmap映射出来的glyphId如果大于等于numGlyphs基本可以断定映射数据有问题。hhea表记录水平排版度量要读的是numberOfHMetrics位于偏移34处的uint16。它说明hmtx表里前面有多少条“完整度量记录”。hmtx表的布局比较特殊前面numberOfHMetrics条是4字节一组包含uint16的advanceWidth水平前进宽度和int16的leftSideBearing左轴承从第numberOfHMetrics条之后每条只剩下int16的leftSideBearingadvanceWidth不再重复记录统一复用最后一个有完整度量的字形的advance宽度。为什么这个设计容易让人懵因为hmtx表后面部分的长度依赖前面对应字形的advanceWidth当你遍历所有字形去取advance和lsb时下标要通过min(i, numberOfHMetrics - 1)的方式回落到最后一个完整条目。我最初写遍历逻辑时没做这个回落处理直接越界读数据拿到的lsb全部是垃圾值中文排版时行内间距忽大忽小排查了很久才定位到是这里的问题。关键字段和偏移可以整理成一张速查表方便写代码时对照表名字段偏移类型说明headunitsPerEm18uint16坐标单位体系常见2048headindexToLocFormat50int160为短loca1为长locamaxpnumGlyphs4uint16字形总数hheanumberOfHMetrics34uint16hmtx完整记录条数hmtxadvanceWidth0uint16每个字形前进宽度hmtxleftSideBearing2int16左轴承后续只有此项另外还有一个容易被忽略但实用的小细节cmap表、glyf表这些核心表在sfnt目录里通常排在靠前的位置但不是绝对的。真正靠谱的做法永远是按tag从表映射表里查不要假设表在文件中的物理顺序。有些字体生成工具会打乱物理顺序按固定位置读文件会直接被坑。4. cmap确定每个Unicode码点对应的字形id拿到形态数据前得先把“字符”和“字形”之间的桥梁搭好这就是cmap表要做的事。用户输入一个字符“A”渲染系统想知道它对应第几个字形就得查cmap。cmap表的结构分两层。第一层是表头包括version和numTables两个uint16之后是一组encoding record每条8字节platformIDuint16、encodingIDuint16、subtableOffsetuint32相对cmap表起始位置的偏移。同一张cmap表里可以挂多个子表适用于不同平台和编码标准。选子表的策略直接决定兼容性表现。在Windows平台一般首选platformID3、encodingID10的子表这是Unicode full repertoire覆盖所有Unicode码点如果找不到退而求其次选platformID3、encodingID1Unicode BMP覆盖基本多文种平面。在macOS/iOSplatformID0的子表也是常见选择。实际生产建议写一个打分排序优先选覆盖面大的Unicode子表而不是固定选第一个。子表的格式有好几种但解析类最常碰到的就是格式4和格式12。格式4专门映射BMP内的码点格式12是分段映射组结构能覆盖完整的Unicode范围。格式4子表的核心是一组并行数组按segment段组织。关键是segCountX2字段它除以2得到段的数量。每个段有四组数据endCode当前段的最后一个码点uint16数组。startCode当前段的第一个码点uint16数组。idDelta当前段中所有码点的字形id增量int16数组。idRangeOffset指向字形id数组的位置为0时直接用idDelta计算。格式4的查找逻辑有一个历史包袱idDelta是int16但参与计算时要用无符号加法再对65536取模。这个“先加再截断”的规则第一次写很容易翻车。如果你用Dart的getInt16读取idDelta还记得强制转成无符号值相加否则遇到高位为1的idDelta字形id会算成一个巨大的负数。核心查找逻辑可以简化成int lookupCmapFormat4(ByteData cmapData, int offset, int codepoint) { final segCountX2 cmapData.getUint16(offset 6, Endian.big); final segCount segCountX2 ~/ 2; // endCode数组从 offset14 开始startCode紧随其后idDelta再往后idRangeOffset最后 // 顺序endCode[segCount], reservedPad, startCode[segCount], idDelta[segCount], idRangeOffset[segCount] // 对每个segment判断 codepoint 是否在 [startCode, endCode] 区间内 // 然后根据 idRangeOffset 是否为0决定直接加idDelta还是跳转读取glyphId for (var i 0; i segCount; i) { final endCode cmapData.getUint16(offset 14 i * 2, Endian.big); if (codepoint endCode) continue; final startCode cmapData.getUint16(offset 14 segCount * 2 2 i * 2, Endian.big); if (codepoint startCode) break; final idDelta cmapData.getInt16(offset 14 segCount * 4 2 i * 2, Endian.big); final idRangeOffset cmapData.getUint16(offset 14 segCount * 6 2 i * 2, Endian.big); if (idRangeOffset 0) { return (codepoint idDelta) 0xFFFF; } final glyphIndexAddr offset 14 segCount * 6 2 i * 2 idRangeOffset (codepoint - startCode) * 2; final glyphId cmapData.getUint16(glyphIndexAddr, Endian.big); return (glyphId 0) ? 0 : (glyphId idDelta) 0xFFFF; } return 0; // 找不到映射返回 .notdef 字形 }这里特别注意一下idRangeOffset的偏移基准不是cmap表开头而是idRangeOffset这个字段自身的存储地址。这是整个格式4里最容易混乱的偏移基准读的时候要记住“idRangeOffset指向的地址是相对于当前字段位置的”。格式12就友好得多。子表头之后是一组组group每组12字节startCharCode、endCharCode、startGlyphID三个都是uint32。查找时只要找到满足group.startCharCode codepoint group.endCharCode的那一组glyphId startGlyphID (codepoint - startCharCode) 就完事了。没有idDelta再取模的那些历史包袱按顺序遍历或二分都很干净。有些TTF文件里的cmap子表还会出现格式0、格式6、格式8等老古董实际主流字体内出现率极低解析类实现格式4和格式12已经能覆盖99%以上的真实文件。对暂时不支持的子表格式正确的处理是直接跳过该子表、按优先级选下一个而不是抛异常把整个字体判死。5. 从loca到glyf把差分坐标从二进制里“抠”出来字符到字形id的映射搞定后终于到了核心环节读轮廓数据。这一步由loca表和glyf表配合完成。loca表可以理解成一个“字形数据目录”它有numGlyphs1个条目。第i个条目记录第i个字形的数据在glyf表中的起始字节偏移第i1个条目就是第i个字形的结束位置两者相减就是该字形的原始字节长度。这种半开区间设计让“下一个字形的起点”天然成为“当前字形的终点”空字形自然得到长度为0不需要额外标记。loca表的条目宽度由head.indexToLocFormat决定0表示short格式每个条目是uint16存的是实际偏移除以2的值1表示long格式每个条目是uint32按正常字节偏移存储。所以解析时如果indexToLocFormat为0一定要记得把读到的值乘以2再当偏移用。很多刚接触解析的人看到loca偏移对不上glyf的实际位置多半就是忘了这步。进了glyf表每个字形以10字节的头部开始numberOfContoursint16。大于0表示简单字形轮廓数就是它等于0表示空字形没有轮廓但有度量小于0表示复合字形具体规则后面专门讲。xMin、yMin、xMax、yMax四个int16构成字形的边界盒。这个值对排版布局有用但解析轮廓时不是必需。简单字形的轮廓数据按顺序排列先是endPtsOfContours数组每个元素是一个uint16含义是“该轮廓线的最后一个点在总点序列中的索引”。理解这个字段很关键它不是每个轮廓的点数而是累计结束索引。比如第一个轮廓end3第二轮廓end8说明第一个轮廓由点0到点3组成第二个轮廓由点4到点8组成。endPts数组之后是指令长度和指令字节对只做轮廓提取的解析来说可以跳过。紧接着就是真正的点信息分两部分先是一串uint8的flags位然后才是x坐标和y坐标。每个点对应一个flag字节flag决定这个点到底是曲线上的端点还是控制点、坐标用什么宽度存储、是增量还是绝对值。坐标的解码是这个环节最大的难点必须严格按flag位来分段处理。点坐标全部采用差分编码当前点的实际坐标 上一个点的坐标 当前点的差分值x和y分别独立累加。x坐标的解码规则是这样的如果flag的bit1为0且bit4为0该点的x差分值是16位有符号整数读2字节。如果bit1为0且bit4为1x差分值为0就是上一个点的x坐标不变。如果bit1为1表示x差分值用1字节无符号存储bit4决定正负号bit4为1时差分值为正数bit4为0时差分值为该字节的负数。y坐标与它的规则完全对称只是用的bit2和bit5。有人会把x和y的short标志位弄混这里建议给x用bit1/bit4、y用bit2/bit5写出一个对照表写代码时随时校验。更麻烦的是flags还有repeat机制如果某个flag的bit3为1说明紧接着还有一个uint8表示重复次数后面那几个点共用这个flag。写循环时如果不处理repeat点序会全面错位轮廓直接画出各种诡异的斜线。用Dart写坐标解码时我建议用一个循环一次把所有点的绝对坐标算出来存成Point数组不要分两遍处理x和y因为差分状态要在同一循环里保持。核心逻辑类似int x 0, y 0; for (var i 0; i pointCount; i) { final flag flags[i]; // 解析x差分 if (flag 0x02 ! 0) { // xShortVector final v data.getUint8(pos); x (flag 0x10 ! 0) ? v : -v; } else if (flag 0x10 ! 0) { // xIsSame x 0; } else { x data.getInt16(pos, Endian.big); pos 2; } // 解析y差分flag用0x04和0x20 // ... points[i] Point(x, y); }还有一点很实际Coordinates和Flags的存储顺序不是“每个点完整一组”而是“先所有的x坐标再所有的y坐标”。也就是说每个点的x差分全读完后紧接着才读所有点的y差分而不是“点1的x、点1的y、点2的x、点2的y”。这是解析轮廓时非常容易犯的顺序性错误别问我怎么知道的。6. 二次贝塞尔补点与复合字形递归展开坐标数组解出来后我们手里有一堆点每个点是on-curve还是off-curve由flag的bit0决定。TrueType字形有一个硬约束只用直线段和二次贝塞尔曲线没有三次贝塞尔。on-curve点落在曲线上off-curve点是二次贝塞尔的控制点。但这里有一个几乎所有教程都会一笔带过、实际写代码时却绕不开的规则当两个off-curve点连续出现、中间没有on-curve点时必须在两者的正中间补一个“隐式on-curve点”作为曲线段的端点。换句话说一段“off-off”之间实际对应两个二次贝塞尔段。举个例子点序列是P0(on)、P1(off)、P2(off)、P3(on)直接按三个点的顺序画贝塞尔是画不出来的因为二次贝塞尔需要三个点起点(on)、控制点(off)、终点(on)。P1和P2连续都是off所以要在P1和P2的中点M补一个on点然后实际路径是三条段P0到M的控制点是P1M到P3的控制点是P2。另一个复杂情况是轮廓首尾相接。如果轮廓的最后一个点和第一个点都是off-curve也要按同样的规则在其间补一个隐式on-curve点。极端的例子里如果整个轮廓所有点都是off-curve它们会把整个轮廓给“圆润”成一个近似圆的形状。构造路径时的策略可以统一成遍历轮廓的点序列时始终维护“上一个on-curve点”遇到off-curve点先缓存遇到下一个off-curve点时补中点再连段遇到下一个on-curve点时直接用缓存的控制点画段。闭轮廓收尾时单独处理首尾的off状态。这段逻辑写完后可以用一个已知字体做视觉验证随便导出一个字符的轮廓转成SVG路径肉眼看出字形跟原始字体一致基本就成了。下面讲复合字形。前面说过numberOfContours小于0时这个字形不是直接存储轮廓点而是由多个子字形component经过变换组合而成。复合字形的数据就是一连串component记录每个component包含2字节flags指示参数宽度和变换类型。2字节glyphIndex子字形的id。2字节或4字节的arg1、arg2可能是偏移量也可能是点匹配索引。可选的变换数据可以是1个scale、2个scalex/y独立缩放或者2x2矩阵。读取component必须逐条解析而不是固定长度。因为每条component的长度取决于flags不能假设每条都是固定字节数。flags里几个关键位的含义ARG_1_AND_2_ARE_WORDS0x0001为1时arg1/arg2各占2字节为0时各占1字节。ARGS_ARE_XY_VALUES0x0002为1时arg1/arg2表示平移坐标为0时它们表示“当前复合字形中的某个点索引”和“子字形中的某个点索引”是点匹配模式使用时要把子字形的对应点对齐到当前字形的对应点。MORE_COMPONENTS0x0020为1说明后面还有component为0说明这是最后一个。WE_HAVE_A_SCALE0x0008、WE_HAVE_AN_X_AND_Y_SCALE0x0040、WE_HAVE_A_TWO_BY_TWO0x0080三种变换模式的开关互斥。解析复合字形时最简单可靠的做法是用递归函数读出子字形id先递归解析子字形的轮廓点然后把变换矩阵和平移应用到这些点上所有子字形的点合并成一个总轮廓集合。注意这里还要维护points和endPtsOfContours的关系因为每个子字形可能带多个轮廓合并时对end索引要重新累加。复合字形还有一个刁钻的字符当ARGS_ARE_XY_VALUES为0的点匹配模式出现时arg1/arg2是“点索引”而不是坐标。这种模式在英文字体里很少见但在一些组合字形里会出现处理不好会导致整个字形飞掉。保险做法是优先实现XY平移模式如果遇到点匹配模式且实在不想深入支持可以记录一个“不完美支持”的标记至少保证不崩然后在生成轮廓时用子字形原始位置作为近似。生产环境里这个回退策略比直接抛异常要实用得多。另外复合字形展开时如果出现循环引用比如字形A引用字形B字形B又引用字形A递归会无限循环。所以解析时要带一个visited集合或设置递归深度上限正常字体最多嵌套几层设个20层上限足够超过就终止并提示文件异常。7. 生产环境最容易翻车的几个边界条件把核心链路跑通之后真正的考验才刚开始。下面这些边界条件都是我在实际文件里遇到过的不是理论问题。第一类是空字形。很多空格、不可见字符对应的glyph在glyf表里的长度就是0numberOfContours字段根本不存在更不说什么轮廓。解析时必须把“length为0”和“numberOfContours为0”统一对待返回一个没有轮廓但带advance宽度的字形对象。UI层如果拿这个字形去画路径要直接跳过绘制而不是报错。第二类是cmap返回字形id越界。文件损坏或子表选择失误时查出来的glyphId可能大于numGlyphs。解析器所有对外接口都应该做一次范围校验越界就回退成.notdef字形也就是id为0的那个字形。不要小看这个处理很多“这个字体在我这边渲染崩了”的诡异问题溯源到头都是越界访问。第三类是hinting指令缺失导致的渲染差异。glyf表里有instruction字节段这是一套类似汇编的TrueType字节码用于在低分辨率下修正字形形状。如果解析类只做轮廓提取跳过指令完全没问题但如果目标是做实际渲染却不执行hinting指令小字号下的字形会出现明显的变形和笔画粘连和系统自带渲染器的效果对不上。hinting解释器是一个独立的大工程选型时要想清楚是否需要。第四类是性能与内存的平衡。一个中文字体动辄好几MB字形数上万如果每次访问都对整个文件做一遍解析再丢弃效率很低。我的做法是sfnt表目录一次性解析并缓存cmap查找结果用LRU缓存glyf原始数据按需惰性读取只解析需要用的字形已经展开过的简单字形轮廓可以缓存Path对象但要注意中文字体轮廓通常较大不适合无限缓存设个1024条左右的上限比较合理。第五类是字节序和文件截断。使用Dart时ByteData创建后显式用固定的大端模式读取不要依赖默认平台字节序。如果是截断的字体文件读取时直接用getUint32越界会抛RangeError外层统一接住把字体判定为“解析失败”别让异常一路冒到UI层。关于文件读取方式如果字体文件很大尽量用mmap或文件流局部读取不要一次性把整个文件load进内存再解析。尤其在做字体预览工具时用户可能同时打开几十个字体每个都全量加载内存很快就爆了。解析类设计上就应该接受一个“按偏移回调读取字节”的抽象而不是内部强依赖一个完整的byte数组。写到这里这篇解析类经验基本覆盖了从sfnt容器到字形轮廓输出的完整链路。手写一遍TrueType解析最大的价值并不是为了替代FreeType而是让你真正理解字体渲染底层到底在做什么一个字符如何通过cmap变成一个glyphIdglyphId如何通过loca找到一段二进制数据这段数据又是如何通过差分坐标和二次贝塞尔变成屏幕上能看见的轮廓。摸清这条链路之后再去看FreeType的源码或者调字体相关API思路会清晰非常多。本文还有配套的精品资源点击获取