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 | 操作系统相关的度量与许可信息 | 做兼容性检查时读 |
post | PostScript 相关信息,含字形名 | 需要字形名时读 |
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 3(Windows)配 Encoding ID 1(Unicode BMP)或 10(Unicode Full),还有 Platform ID 0(Unicode)配各种 Encoding ID。getBestCmap()的挑选逻辑是优先选 Unicode 全量映射,再退而求其次选 BMP 映射。如果你手动去遍历font["cmap"].tables,会看到每个子表都有自己的platformID、platEncID和language。
我踩过的一个坑是:某些老字体只有 Platform ID 1(Macintosh)的映射,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 Name)2:子家族名(Subfamily Name),如 Regular、Bold4:完整字体名(Full Name)5:版本字符串(Version String)6:PostScript 名称0:版权声明
这里要注意编码问题。同一条信息可能在不同平台下用不同编码存储,toUnicode()方法会帮你做转换,但前提是这条记录本身是可解码的。我遇到过某些字体里nameID=0的版权信息用了一种奇怪的编码,toUnicode()直接抛异常。稳妥的做法是加一层异常处理:
try: text = record.toUnicode() except Exception: text = record.toBytes().decode("utf-8", errors="ignore")还有一个实际经验:中文字体的 name 表里,英文名和中文名可能分属不同记录。比如nameID=1可能同时存在一条英文的 "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(字体集合)或者.otf(CFF 轮廓),fontTools也是支持的,但需要额外注意fontNumber参数:
font = TTFont("example.ttc", fontNumber=0)我建议在项目里固定fontTools的版本,因为不同版本对某些表的解析行为可能有细微差异。比如早期版本对cmap格式 14(Unicode 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, lazy=True) 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 reportlazy=True是TTFont的一个参数,表示惰性加载。对于只读cmap的场景,这个参数能明显减少不必要的解析。但要注意,lazy=True下某些表的访问方式可能不同,如果你后续要读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:表示码点U+4E2D,这是最常见的。glyph00123:没有语义的编号,常见于子集化字体。cidXXXXX:CFF 字体里用 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(options=options) 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表 nameID=1 | 识别字体 |
| 子家族名 | name表 nameID=2 | 区分粗细斜 |
| 版本 | name表 nameID=5 | 版本管理 |
| 字符总数 | 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打开可能会报权限错误。解决办法是先复制一份到临时目录再读,或者用lazy=True减少文件占用时间。我在批量处理系统字体时,统一先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, lazy=True) 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", encoding="utf-8") as f: json.dump(reports, f, ensure_ascii=False, indent=2) 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是我从常用汉字表里截取的一段,用来快速判断字体是否覆盖基础中文。实际使用时可以根据业务需求替换成自己的字符集。
我在实际使用中发现,lazy=True配合getBestCmap()是最稳的组合,既快又不容易出内存问题。唯一需要注意的是,lazy=True下某些表的访问可能触发全量加载,所以如果你后续要读glyf,最好还是用默认模式。
另外,ttc文件(字体集合)需要指定fontNumber,上面的脚本没有处理这种情况。如果你要处理.ttc,可以在analyze_font里加一个循环,遍历所有子字体。这个扩展不难,留给你自己动手。
字体读取这件事,入门很容易,但要做到稳定、高效、覆盖各种边界情况,需要对字体内部结构有真正的理解。我上面讲的这些,都是我在实际项目里一行行代码试出来、一个个坑踩出来的。希望它能帮你少走点弯路。