资讯详情 Python读取.ttf字体文件:fontTools解析cmap与name表实战
📅 2026/10/15 14:00:19
1. 字体文件不只是文件它是一套可编程的矢量图形数据库很多人第一次接触.ttf文件是因为做海报、写文档时想换个好看的中文字体或者做 PPT 时被要求标题用某某体。但当你开始用 Python 去读取它你会发现这玩意儿根本不是一个文件那么简单——它是一个结构极其严谨的二进制容器里面装着字形轮廓、字符映射表、字距调整规则、甚至版权和版本信息。我最初动手写字体解析代码是因为一个很实际的需求批量检查一批字体文件里到底有没有某个生僻字。当时手头有几十个.ttf靠字体预览软件一个个翻眼睛都快瞎了。后来想Python 既然能读二进制为什么不能直接问字体文件你认不认识这个字结果一查fontTools这个库直接把问题解决了而且顺带让我搞明白了字体文件内部的运作逻辑。这篇文章就是把我从只会用字体到能读字体、能查字体、能改字体的整个过程拆开来讲。核心关键词是Python 读取 .ttf 字体文件围绕它我会讲清楚字体文件里到底有什么、用fontTools怎么把信息掏出来、遇到中文和特殊字符时要注意什么、以及几个我实际踩过的坑。适合已经会写基础 Python、想把手伸到字体处理领域的开发者也适合做设计工具、排版系统、字体管理后台的朋友参考。先说结论读取.ttf的核心工具是fontTools核心对象是TTFont核心操作是查表table。但真正决定你能不能顺利拿到结果的是对字体内部结构的理解而不是 API 调用本身。2. 拆开一个 .ttf从二进制头到字符映射表的完整链路2.1 字体文件的整体骨架表目录与表记录.ttf文件本质上是一个由多个表table组成的二进制容器。文件开头有一个表目录Table Directory它记录了整个文件里有多少张表、每张表的标签名、校验和、偏移量和长度。你可以把它理解成一本书的目录页先告诉你有哪些章节每章从第几页开始、有多长。用 Python 打开一个字体文件最直观的方式是先看它的表目录。fontTools提供了非常干净的接口from fontTools.ttLib import TTFont font TTFont(example.ttf) print(font.keys())font.keys()返回的就是当前字体里所有表的标签列表。常见的表包括表标签作用是否必读cmap字符到字形的映射关系必读查字符全靠它glyf字形轮廓数据TrueType 轮廓需要轮廓时读head字体全局信息如单位数、边界框常读hhea/hmtx水平排版度量字宽、边距排版相关必读name字体名称、版本、版权等元信息查字体身份时读OS/2操作系统相关的度量与许可信息做兼容性检查时读postPostScript 相关信息含字形名需要字形名时读GSUB/GPOS字形替换与定位复杂排版核心进阶排版才读这里有个经验不要一上来就把所有表都加载解析。TTFont默认是惰性加载的只有你真正访问某张表时才会去解析它。但如果你调用了font.save()或者遍历了所有表就会触发全量解析大字体文件会明显变慢。我在处理一个 20MB 的中文字体时一开始直接遍历font.keys()然后逐个font[tag]内存直接飙到几百兆。后来改成按需读取速度就正常了。2.2 cmap 表字符和字形之间的翻译官如果你只关心这个字体里有没有某个字那cmap表就是你的全部。它的作用是把 Unicode 码点映射到字形编号glyph ID。没有这张表字体就是一堆无法定位的图形。fontTools里读取cmap的标准写法是cmap font.getBestCmap()getBestCmap()会自动挑选当前平台下最合适的 Unicode 映射子表。返回的是一个字典键是码点整数值是字形名字符串。比如cmap font.getBestCmap() print(cmap.get(ord(中))) # 输出类似 uni4E2D这里有个细节值得展开cmap表内部其实有多个子表分别对应不同的平台和编码方式。常见的有 Platform ID 3Windows配 Encoding ID 1Unicode BMP或 10Unicode Full还有 Platform ID 0Unicode配各种 Encoding ID。getBestCmap()的挑选逻辑是优先选 Unicode 全量映射再退而求其次选 BMP 映射。如果你手动去遍历font[cmap].tables会看到每个子表都有自己的platformID、platEncID和language。我踩过的一个坑是某些老字体只有 Platform ID 1Macintosh的映射getBestCmap()可能返回空或者不完整。这时候需要手动指定for table in font[cmap].tables: if table.isUnicode(): print(table.platformID, table.platEncID, len(table.cmap))另一个坑是代理对字符比如一些 emoji 或扩展区汉字。这些字符的码点超过0xFFFF在 BMP 子表里是查不到的。如果你要检查字体是否支持某个扩展区字符必须确认cmap里有 Platform ID 3 / Encoding ID 10 的子表或者用getBestCmap()后直接查码点看返回是否为None。2.3 name 表字体自己怎么介绍自己name表存的是字体的元信息包括字体家族名、子家族名、完整名称、版本号、版权声明、商标信息等。这些信息在字体管理、版权核查、批量重命名场景里非常有用。读取方式name_table font[name] for record in name_table.names: print(record.nameID, record.platformID, record.toUnicode())nameID是一个数字编号对应不同的信息类型。常用的几个1字体家族名Family Name2子家族名Subfamily Name如 Regular、Bold4完整字体名Full Name5版本字符串Version String6PostScript 名称0版权声明这里要注意编码问题。同一条信息可能在不同平台下用不同编码存储toUnicode()方法会帮你做转换但前提是这条记录本身是可解码的。我遇到过某些字体里nameID0的版权信息用了一种奇怪的编码toUnicode()直接抛异常。稳妥的做法是加一层异常处理try: text record.toUnicode() except Exception: text record.toBytes().decode(utf-8, errorsignore)还有一个实际经验中文字体的 name 表里英文名和中文名可能分属不同记录。比如nameID1可能同时存在一条英文的 Source Han Sans 和一条中文的 思源黑体。如果你要做字体匹配不能只看第一条要把所有同nameID的记录都收集起来。2.4 glyf 与 hmtx字形轮廓和排版度量的读取逻辑当你需要的不只是有没有这个字而是这个字长什么样、占多宽时就要动glyf和hmtx了。glyf表存的是每个字形的轮廓数据。TrueType 用的是二次贝塞尔曲线每个字形由若干轮廓contour组成每个轮廓由若干点point组成点分为在曲线上和不在曲线上两种。fontTools把每个字形解析成一个Glyph对象你可以通过font[glyf][glyphName]拿到它。glyph font[glyf][uni4E2D] print(glyph.numberOfContours) print(glyph.coordinates)但这里有个性能陷阱glyf表的解析是按需触发的一旦你访问了某个字形fontTools可能会把整个glyf表都展开。对于中文字体这意味着几万个字形全部解析内存和时间都会爆炸。我的做法是如果只需要轮廓的边界框用glyph.xMin、glyph.yMin、glyph.xMax、glyph.yMax就够了不要碰coordinates。如果确实需要坐标尽量只处理少量字形或者用fontTools.pens里的记录笔RecordingPen来按需提取。hmtx表存的是每个字形的水平度量前进宽度advance width和左侧边距left side bearing。排版时计算一行文字的总宽度靠的就是它。hmtx font[hmtx] advance, lsb hmtx[uni4E2D] print(advance, lsb)注意advance的单位是字体设计单位font units不是像素。要转成像素需要结合head表里的unitsPerEm和实际字号units_per_em font[head].unitsPerEm font_size 16 # 像素 pixel_width advance / units_per_em * font_size这个换算关系是排版系统的基础很多人第一次算字宽时忘了除unitsPerEm结果宽度差了上千倍。3. 用 fontTools 跑通第一条读取链路从安装到拿到字符列表3.1 环境准备与版本选择的实际考量安装fontTools本身很简单pip install fonttools但版本选择有讲究。fontTools在 4.x 之后对 Python 3 的支持才比较完善如果你还在用 Python 3.6 以下建议先升级 Python。另外如果你要处理.ttc字体集合或者.otfCFF 轮廓fontTools也是支持的但需要额外注意fontNumber参数font TTFont(example.ttc, fontNumber0)我建议在项目里固定fontTools的版本因为不同版本对某些表的解析行为可能有细微差异。比如早期版本对cmap格式 14Unicode Variation Sequences的支持就不完整。用pip freeze锁版本是基本操作。还有一个容易被忽略的点fontTools本身不依赖任何图形库它纯粹做二进制解析。这意味着你可以在服务器上无头运行不需要装 FreeType 或 Cairo。但如果你想把字形渲染成图片那就需要额外的库了比如freetype-py或Pillow的ImageFont。读取和渲染是两件事不要混在一起。3.2 最小可用脚本检查字体是否包含指定字符下面这个脚本是我实际用得最多的给定一个字体文件和一个字符列表输出哪些字符有、哪些没有。from fontTools.ttLib import TTFont def check_chars(font_path, chars): font TTFont(font_path) cmap font.getBestCmap() result {} for ch in chars: code ord(ch) glyph_name cmap.get(code) result[ch] glyph_name is not None font.close() return result if __name__ __main__: chars 中文测试ABC123。 res check_chars(example.ttf, chars) for ch, ok in res.items(): print(f{ch}: {有 if ok else 无})这个脚本看起来简单但有几个细节值得说第一font.close()不能省。TTFont打开文件后会持有文件句柄虽然 Python 的垃圾回收最终会关掉但在批量处理几百个字体时不手动关闭可能导致句柄耗尽。用with语句更稳妥with TTFont(font_path) as font: cmap font.getBestCmap() ...第二getBestCmap()返回的字典可能很大。中文字体的cmap动辄几万条如果你只是查几个字符没必要把整个字典复制出来。直接font.getBestCmap().get(code)就行不要做dict(cmap)这种全量拷贝。第三注意字符的规范化。有些字符在 Unicode 里有多种表示方式比如带变音符号的字母。如果你从用户输入里拿字符最好先做一次unicodedata.normalize否则可能查不到。3.3 批量扫描字体目录性能与内存的平衡实际工作中更常见的是批量处理。比如一个字体目录下有几百个.ttf要统计每个字体支持多少汉字、是否包含某个特定字符集。直接循环打开每个字体当然可以但要注意两点一是及时释放二是避免重复解析。我见过有人把每个TTFont对象都存进列表最后内存爆掉。正确的做法是处理完一个就关一个import os from fontTools.ttLib import TTFont def scan_font_dir(dir_path, target_chars): report [] for fname in os.listdir(dir_path): if not fname.lower().endswith((.ttf, .otf)): continue fpath os.path.join(dir_path, fname) try: with TTFont(fpath, lazyTrue) as font: cmap font.getBestCmap() missing [ch for ch in target_chars if ord(ch) not in cmap] report.append((fname, len(cmap), len(missing))) except Exception as e: report.append((fname, -1, str(e))) return reportlazyTrue是TTFont的一个参数表示惰性加载。对于只读cmap的场景这个参数能明显减少不必要的解析。但要注意lazyTrue下某些表的访问方式可能不同如果你后续要读glyf可能还是得让它正常加载。另外异常处理必须做。字体文件损坏、格式不标准、编码异常这些在真实环境里太常见了。我处理过一批从网上下载的字体大概有 5% 是坏的不做异常处理整个脚本就崩了。4. 中文、CJK 与特殊字符读取时最容易翻车的几个地方4.1 中文字体的 cmap 为什么可能查不到常用字这是最反直觉的一个问题明明字体预览里能看到中字为什么cmap里查不到原因通常有三种。第一种是字体用了非 Unicode 的 cmap 子表。某些老字体或特定用途字体cmap里只有 Symbol 编码或 Mac 编码没有标准的 Unicode 映射。这时候getBestCmap()可能返回一个空字典或只包含部分字符。第二种是字体做了子集化。网页字体经常只保留页面用到的字符其他字符的映射被删掉了。你拿到一个.ttf它可能只包含几十个字符查别的字当然没有。第三种是字符在字体里以异体或兼容形式存在。比如某些字体把中映射到了一个非标准的码点或者用了 Unicode 兼容区如 CJK Compatibility Ideographs的码点。这时候你需要检查cmap里是否有多个子表并尝试不同的码点。排查方法先打印所有cmap子表的信息看看有没有 Unicode 子表以及每个子表里有多少条映射。for table in font[cmap].tables: print(table.platformID, table.platEncID, table.format, len(table.cmap))如果所有子表加起来映射数很少比如几百条那基本可以确定是子集化字体。4.2 代理对与扩展区汉字码点超过 0xFFFF 怎么办Unicode 基本多文种平面BMP只到0xFFFF超过这个范围的字符如扩展 B 区汉字、部分 emoji需要用代理对表示。在 Python 3 里字符串本身是 Unicode 码点序列ord()会直接返回完整码点不需要手动处理代理对。但字体文件里的cmap子表可能只覆盖 BMP。要检查扩展区字符必须确认字体有Platform ID 3 / Encoding ID 10的子表或者 Platform ID 0 的对应子表。getBestCmap()在fontTools较新版本里会优先选全量 Unicode 子表但老版本可能只选 BMP 子表。我的做法是显式检查def has_full_unicode_cmap(font): for table in font[cmap].tables: if table.platformID 3 and table.platEncID 10: return True if table.platformID 0 and table.platEncID in (4, 6): return True return False如果没有全量 Unicode 子表那扩展区字符基本查不到不用白费力气。4.3 字形名不等于字符uniXXXX 与 glyphXXXXX 的命名差异cmap返回的值是字形名不是字符本身。TrueType 字体的字形名通常遵循几种命名约定uni4E2D表示码点U4E2D这是最常见的。glyph00123没有语义的编号常见于子集化字体。cidXXXXXCFF 字体里用 CID 编号。自定义名称某些字体用拼音或英文单词命名字形。这意味着你不能通过字形名反推字符除非字形名恰好是uniXXXX格式。如果你需要从字形名拿到码点可以解析uni后面的十六进制但遇到glyphXXXXX就无能为力了。反过来从码点拿字形名是可靠的因为cmap就是干这个的。我在做一个字体对比工具时一开始想通过字形名来匹配两个字体里的同一个字结果发现一个字体用uni4E2D另一个用glyph12345完全对不上。后来改成统一用码点做键问题才解决。5. 从读取到修改字体子集化与信息提取的实战延伸5.1 子集化把大字体裁成小字体读取字体的一个自然延伸是子集化subsetting只保留需要的字符生成一个更小的字体文件。这在网页字体优化、嵌入式设备字体裁剪里非常常用。fontTools提供了subset模块from fontTools import subset options subset.Options() options.layout_features [*] options.name_IDs [*] options.notdef_outline True font subset.load_font(source.ttf, options) subsetter subset.Subsetter(optionsoptions) subsetter.populate(text需要保留的字符) subsetter.subset(font) subset.save_font(font, subset.ttf, options)这里的关键是populate的参数。你可以传text字符、unicodes码点列表、glyphs字形名列表。我一般用text因为最直观。子集化有几个坑第一layout_features要保留。如果你做的是中文排版GSUB和GPOS表里的特性如竖排、连字不能丢否则排版会出问题。options.layout_features [*]表示保留所有特性。第二name表要保留。有些子集化工具默认删掉name表导致生成的字体没有名称信息安装时显示乱码。options.name_IDs [*]可以避免。第三notdef字形要保留。notdef是缺字时显示的占位符删掉它可能导致渲染异常。我做过一个实验把一个 15MB 的中文字体裁剪到只保留 500 个常用字生成的文件只有 200KB 左右加载速度提升非常明显。但裁剪后一定要用字体查看工具确认一下看看常用字有没有丢、排版有没有乱。5.2 提取字体度量信息做排版计算如果你在做一个排版引擎或文字渲染工具读取hmtx和head只是第一步真正的难点是把字体单位换算成实际像素并处理不同字体的度量差异。一个完整的文字宽度计算流程是这样的从head表拿unitsPerEm。从hmtx表拿每个字形的advance。把advance除以unitsPerEm得到相对宽度。乘以字号得到像素宽度。累加所有字形的宽度再加上字距调整GPOS里的 kerning。如果没有GPOS数据或者你不想处理复杂的定位表可以退而求其次只用hmtx的advance累加。对于大多数场景这个精度已经够了。def measure_text(font, text, font_size): cmap font.getBestCmap() hmtx font[hmtx] units_per_em font[head].unitsPerEm total 0 for ch in text: glyph_name cmap.get(ord(ch)) if glyph_name is None: continue advance, _ hmtx[glyph_name] total advance return total / units_per_em * font_size这个函数返回的是文本的像素宽度。注意它没有处理换行、空格、制表符等特殊情况实际使用时需要根据场景补充。5.3 字体信息导出生成可读的字体报告最后一个实用场景是把字体信息导出成结构化数据方便做字体管理或对比。我通常会导出这些字段字段来源用途字体家族名name表 nameID1识别字体子家族名name表 nameID2区分粗细斜版本name表 nameID5版本管理字符总数cmap长度评估覆盖范围是否含中文检查常用汉字语言支持判断单位数head.unitsPerEm排版计算是否子集化cmap长度阈值质量判断导出成 JSON 或 CSV 都很方便。我一般用 JSON因为嵌套结构更自然import json def export_font_info(font_path): with TTFont(font_path) as font: cmap font.getBestCmap() name_table font[name] info { family: , subfamily: , version: , char_count: len(cmap), units_per_em: font[head].unitsPerEm, } for record in name_table.names: if record.nameID 1 and not info[family]: info[family] record.toUnicode() elif record.nameID 2 and not info[subfamily]: info[subfamily] record.toUnicode() elif record.nameID 5 and not info[version]: info[version] record.toUnicode() return info这个报告可以直接喂给字体管理后台做搜索、筛选、去重都很方便。6. 几个我实际踩过的坑和对应的处理方式6.1 字体文件被占用导致读取失败在 Windows 上如果字体已经安装到系统并被某个程序使用直接用TTFont打开可能会报权限错误。解决办法是先复制一份到临时目录再读或者用lazyTrue减少文件占用时间。我在批量处理系统字体时统一先shutil.copy到临时目录处理完再删稳定很多。6.2 不同字体对同一码点返回不同字形名前面提过字形名不可靠。如果你需要跨字体比较永远用码点做键不要用字形名。我写过一个字体对比工具一开始用字形名做交集结果两个字体明明都支持中字却因为一个叫uni4E2D、一个叫glyph8888而判定为不匹配。改成码点后立刻正确。6.3 大字体读取时的内存控制中文字体动辄几万字形全量解析glyf表可能占用几百兆内存。我的经验是只读cmap和name时内存占用很小一旦碰glyf就要小心。如果确实需要轮廓数据考虑用fontTools.pens的按需提取或者分批处理不要一次性把所有字形都加载到内存。6.4 编码异常导致 name 表读取崩溃某些字体的name表记录用了非标准编码toUnicode()会抛异常。加try/except是必须的同时可以退回到toBytes()手动解码。我在处理一批日文字体时遇到过这个问题大概有 2% 的记录需要手动处理。6.5 子集化后字体安装失败子集化时如果删掉了必要的表如name、post、OS/2生成的字体可能无法安装或被系统拒绝。fontTools的subset模块默认会保留这些表但如果你手动改了options一定要确认没有误删。我一般会在子集化后用TTFont重新打开验证一遍确认cmap、name、head、hmtx都在。7. 把读取能力变成工具一个可复用的字体检查脚本最后分享一个我日常用得最多的脚本它把前面讲的东西串起来了扫描目录、检查字符覆盖、导出报告、标记异常。import os import json from fontTools.ttLib import TTFont COMMON_CHARS 的一是了我不人在他有这个上们来到时大地为子中你说生国年着就那和要她出也得里后自以会家可下而过天去能对小多然于心学么之都好看起发当没成只如事把还用第样道想作种开美总从无情己面最女但现前些所同日手又行意动方期它头经长儿回位分爱老因很给名法间斯知世什两次使身者被高已亲其进此话常与活正感 def analyze_font(font_path): result {path: font_path, ok: False} try: with TTFont(font_path, lazyTrue) as font: cmap font.getBestCmap() result[char_count] len(cmap) result[has_common] all(ord(ch) in cmap for ch in COMMON_CHARS) result[missing_common] [ch for ch in COMMON_CHARS if ord(ch) not in cmap] result[units_per_em] font[head].unitsPerEm for record in font[name].names: if record.nameID 1: try: result[family] record.toUnicode() break except Exception: continue result[ok] True except Exception as e: result[error] str(e) return result def scan_directory(dir_path, output_json): reports [] for fname in os.listdir(dir_path): if fname.lower().endswith((.ttf, .otf, .ttc)): fpath os.path.join(dir_path, fname) reports.append(analyze_font(fpath)) with open(output_json, w, encodingutf-8) as f: json.dump(reports, f, ensure_asciiFalse, indent2) return reports if __name__ __main__: reports scan_directory(./fonts, font_report.json) for r in reports: status OK if r.get(ok) else FAIL print(f{status} {r[path]} chars{r.get(char_count, ?)})这个脚本的实用之处在于它把能不能读读到了什么有没有问题三件事一次性做完输出 JSON 方便后续处理。COMMON_CHARS是我从常用汉字表里截取的一段用来快速判断字体是否覆盖基础中文。实际使用时可以根据业务需求替换成自己的字符集。我在实际使用中发现lazyTrue配合getBestCmap()是最稳的组合既快又不容易出内存问题。唯一需要注意的是lazyTrue下某些表的访问可能触发全量加载所以如果你后续要读glyf最好还是用默认模式。另外ttc文件字体集合需要指定fontNumber上面的脚本没有处理这种情况。如果你要处理.ttc可以在analyze_font里加一个循环遍历所有子字体。这个扩展不难留给你自己动手。字体读取这件事入门很容易但要做到稳定、高效、覆盖各种边界情况需要对字体内部结构有真正的理解。我上面讲的这些都是我在实际项目里一行行代码试出来、一个个坑踩出来的。希望它能帮你少走点弯路。