中文字体压缩实战:用Python+fontTools将字体从10MB压到几百KB 做 Web 前端的同学应该都有过这种体验页面里引了一个中文字体写个标题结果字体文件动不动 10MB、20MB网速稍微差一点首屏渲染直接被字体加载拖成幻灯片。我自己也踩过这个坑后来用 Python 配合字体工具把中文字体从 10MB 级别压到 1MB 甚至几百 KB页面加载体感明显改善。这个过程并不复杂真正花时间的是理解字体文件的结构和压缩的边界。这篇内容就来完整梳理一遍为什么中文字体这么大用 Python 怎么有效压缩压缩之后字体加载能优化到什么程度以及实际操作中容易踩的坑。1. 先搞清楚一个关键的为什么一套中文字体到底大在哪很多朋友一上来就想找压缩工具结果压出来的字体不是缺字就是直接损坏。问题往往出在没弄明白字体为什么大。一旦理解了体积来源后面的压缩方案基本就顺理成章了。1.1 中文字体与拉丁字体的体积差距拿一套拉丁字体举例常用字体只需要包含 26 个字母大小写、数字、常见标点加上一些扩展拉丁字符字形数量大概在 200 到 300 个之间。一个精心优化的西文字体文件TTF 格式通常只有几十 KB 到几百 KB。中文字体就完全不一样了。常用的 GB2312 字符集包含 6763 个汉字GBK 扩展到 20000 多个字符而完整覆盖 Unicode CJK 统一汉字的字体字形数量常常超过 40000 个。每个汉字字形需要记录轮廓数据、度量信息、名称和属性这套数据量累积下来一个完整的黑体或宋体 TTF 字体动辄十几 MB是西文字体的几十倍甚至上百倍。这个差距的本质不是文件格式不够先进而是信息量本身就大。每个汉字都是独立设计的字形不能像西文字母那样通过几十个基础字形组合出所有单词。中文字体的制作成本高数据量也天然巨大。1.2 字体加载的真正瓶颈字符集、字体格式与网络传输字体文件在网页加载中的瓶颈有两个层面。第一个层面是字符集。如果不做任何处理浏览器会下载整个字体文件哪怕页面里只用了 100 个汉字也要为另外几万个工作字形的体积买单。这就像你只点了一份外卖却要求餐厅把整个后厨搬到你家。字体文件包含的内容远比页面实际需要的多。第二个层面是字体格式与传输。传统 TrueType 字体TTF为了保持字形质量和兼容各种系统内部数据组织得比较松散压缩率不高。而 Web 专为网络设计的 WOFF 格式在 TTF 基础上做了压缩WOFF2 更进一步使用了 Brotli 压缩算法体积通常能比 TTF 小 30% 到 50% 以上。所以中文 Web 字体优化的核心思路其实是两条线并行一是做子集化把不需要的字形从文件里删掉二是做格式转换用 WOFF2 这种为网络传输优化过的格式承载剩下的字形。Python 生态里的 fontTools 正好能同时完成这两件事。2. 压缩前必须弄懂的几个字体基础概念如果你的目标只是跑一个脚本把字体变小确实可以跳过这一节直接看脚本。但如果你想调试那些为什么这个字没了为什么加粗效果不对之类的问题就一定要理解字体文件内部的基本构成。2.1 字体文件里到底有什么一个典型的 TTF 字体文件由若干表Table组成常见的有cmap字符到字形的映射表负责把 Unicode 编码映射到具体的字形 ID这是子集化最依赖的表之一。glyfTrueType 字形轮廓数据每个字形由若干轮廓点和指令组成是文件体积的主要来源。head、hhea、hmtx字体头部和水平度量信息记录字体的全局属性、字形宽度等。name字体名称信息包含字体族名、子家族名等。kern、GPOS、GSUB字距调整和字形替换等排版特性。这些表加起来构成了完整的字体。子集化不是简单地把整个字体丢弃而是精确保留用到的字符映射和对应的字形同时去掉其他无用数据并重新计算各项度量。如果字体里有 OpenType 特性表比如竖排、连字、字距调整还要决定哪些特性保留、哪些可以丢弃。理解这点很重要压缩后的字体是新的合法字体不是简单改个后缀也不是粗暴裁剪二进制文件。这也是为什么必须用专业工具来处理而不能用压缩软件硬压。2.2 子集化为什么能有效压缩子集化Subsetting的本质是给定一个字符集合从原字体中提取出这些字符对应的字形以及必要的字体表生成一个只包含这些字形信息的新字体。举一个具体例子。某开源中文字体完整版 TTF 体积 12MB包含 40000 多个字形。我做一个博客站点正文大概会用到 3500 个常用汉字加上标点和数字。这 3500 个字符对应的字形在字体文件中可能只占不到 10% 的存储空间。子集化之后这个字体的体积可以降到 1.5MB 左右。再转成 WOFF2体积能进一步压到 800KB 甚至更低。子集化的压缩收益不是线性的因为字体表本身有一些固定开销字符越少压缩率越高但不是保留 10% 的字符体积就一定是 10%。字形越复杂、轮廓点越多压缩后的体积越大。黑体字形厚重轮廓点通常比瘦体更多压缩效果会有所不同。2.3 压缩后的格式选哪种TTF/WOFF/WOFF2 的选择子集化之后我们手头通常是一个精简的 TTF 或 OTF。这个文件已经比原来小很多但用于 Web 加载最好再转换成 WOFF 或 WOFF2 格式。TTF 是容器格式虽然字体内部可能用了 zlib 压缩但压缩率有限。WOFF 是在 TTF/OTF 外部包装了一层压缩浏览器必须解压后再解析。WOFF2 不仅换了压缩算法还重新组织表结构对字形的数据表示也做了进一步优化压缩率明显更高。现代浏览器对 WOFF2 的支持已经非常完善建议作为首选。实际工作中我通常子集化后同时输出 WOFF2 和 TTFWOFF2 给现代浏览器TTF 作为兼容备用。TTF 可以在本地、旧版浏览器或某些不支持 WOFF2 的环境下使用。注意如果不指定浏览器兼容范围直接把 TTF 子集化后用于 Web 是可以的但体积没有榨干。建议输出 WOFF2这是中文字体加载优化里性价比最高的一步。3. Python 压缩字体实操环境准备与依赖安装理论说得再多不如动手跑一次。这一节开始进入真正的实操环节。3.1 环境准备与依赖安装压缩中文字体主要依赖 fontTools 这个开源 Python 库。它提供了一整套字体解析、编辑、子集化的工具是业界的标准选择。安装很简单pip install fonttools brotli这里有两个依赖需要注意fonttools提供fontTools.subset模块负责子集化。brotli是 Brotli 压缩算法的 Python 绑定生成 WOFF2 格式时必须用到。如果只输出 TTF不安装 brotli 也能跑但只要涉及 WOFF2就必须装。Python 版本建议 3.7 以上我这边用的是 3.10没有遇到兼容性问题。安装完成后可以用一行代码验证python -c from fontTools.subset import Subsetter; print(ok)如果输出了ok说明环境就绪。3.2 确认字体文件与测试语料准备一个原始中文字体文件我这里用的是一款免费可商用的开源中文字体完整 TTF 格式体积约 12.6MB。文件名暂且命名为source.ttf。你手头如果没有中文字体也可以用系统中自带的中文字体拷贝一份需要注意的是商用项目要确认字体授权别在版权上踩坑。同时准备好你期望保留的字符集合。这里有两种方式方式一提供一个文本文件脚本提取其中所有字符作为保留集合。比如你把网站的全部文案、文章正文放到一个text.txt里。方式二直接指定一个或多个 Unicode 范围比如U4E00-9FFF是 CJK 统一汉字基本区U3000-303F是中文标点UFF00-FFEF是半角及全角形式。我的建议是两者结合大部分场景用文本文件最准确因为你只需要页面上真正出现的字。如果做的是可能由用户动态输入内容的系统那适合用范围法保留完整常用汉字区避免漏字。实际操作时我一般先收集站点静态文案生成文本再额外补充数字、英文字母、常用标点、全角标点这些范围。4. Python 脚本实现字体子集化与格式转换环境准备好了接下来写核心脚本。这一节完整给出可复现的代码并解释每一段的作用方便你根据自己的需求调整。4.1 核心工具fontTools 的 subset 模块fontTools 的subset模块提供的主要入口是Subsetter类。它的基本工作流程是创建Subsetter实例配置需要保留的 Unicode 范围或字符列表。调用subset(font)传入已解析的字体对象它会在内存中完成子集化操作。然后保存为 TTF 或通过 WOFF2 格式写出。这里有一个关键点subset()执行后原字体对象已经被修改。如果你想要同时输出多种格式不必重新加载原字体可以直接对子集化后的字体对象调用save方法或者用它来生成 WOFF2。4.2 完整压缩脚本与逐段解读下面是一个精简但完整的压缩脚本。你把它保存成subset_font.py把source.ttf和text.txt放到同一目录下运行后就能生成subset.woff2和subset.ttf。from fontTools.ttLib import TTFont, woff2 from fontTools.subset import Subsetter, Options import os # 1. 读取需要保留的字符集合的函数 def load_text_chars(text_path): chars set() with open(text_path, r, encodingutf-8) as f: for line in f: # 这里会丢掉空白字符但中文文本场景没关系 chars.update(line.strip()) return chars # 2. 补充常用字符范围数字、拉丁字母、常用中文标点、全角标点 def add_common_ranges(chars): # 数字 0-9 chars.update([chr(code) for code in range(0x30, 0x3A)]) # 大写字母 A-Z chars.update([chr(code) for code in range(0x41, 0x5B)]) # 小写字母 a-z chars.update([chr(code) for code in range(0x61, 0x7B)]) # 常见英文标点 chars.update(!\#$%()*,-./:;?[\\]^_{|}~) # CJK 标点区 chars.update([chr(code) for code in range(0x3000, 0x303F)]) # 全角 ASCII 和半角/全角形式区 chars.update([chr(code) for code in range(0xFF00, 0xFFEF)]) # 特殊常用符号间隔号、破折号等 chars.update(·―…’‘“”《》·) return chars def main(): # 3. 收集字符集 chars set() if os.path.exists(text.txt): chars | load_text_chars(text.txt) else: print(warning: text.txt not found, only keep common ranges.) chars add_common_ranges(chars) if not chars: print(error: no chars specified) return # 4. 创建 Subsetter options Options() # 这些选项的作用见下方解释 options.flavor woff2 # 同时输出 woff2 需要设置 flavor options.layout_features [*] # 保留所有 OpenType 特性避免丢了字距与连字 options.name_IDs [*] # 保留所有 name 表条目 options.notdef_outline True # 保留 .notdef 字形轮廓并重新打包 options.recalc_bounds True # 重新计算字形边界框 options.recalc_timestamp False # 不修改字体时间戳利于可重现构建 subsetter Subsetter(optionsoptions) subsetter.populate(unicodeschars) # 5. 加载原始字体并执行子集化 font TTFont(source.ttf) subsetter.subset(font) # 6. 输出 woff2 # 注意此时 font 对象已经被子集化可以直接转换输出 font.flavor woff2 font.save(subset.woff2) # 7. 重新加载一份原始字体生成 TTF 版本 font2 TTFont(source.ttf) subsetter.subset(font2) font2.flavor None font2.save(subset.ttf) print(fchar count: {len(chars)}) print(saved subset.woff2 and subset.ttf) if __name__ __main__: main()下面对脚本里的几个关键配置做详细解释避免你迷迷糊糊地照抄。第一个是options.flavor woff2。它是让save方法以 WOFF2 格式写出字体的开关。但要注意这里设置flavor是在创建一个输出目标格式的偏好真正生成 WOFF2 时我们通过修改font.flavor的写法来触发。上面代码中第 6 步先设置font.flavor woff2再保存这是最直接的写法。options.flavor在populate时不是必须的但写上不会错。第二个是options.layout_features [*]。默认情况下子集化可能会丢弃部分 OpenType 特性例如kern、liga、clig等。中文排版里字距调整和连字特性的影响可能没有西文那么明显但也不要随意丢弃。[*]表示保留所有特性代价是文件可能大一点点。如果你明确知道自己不需要某种特性可以改成[kern, liga]或干脆[]删除所有特性。第三个是options.name_IDs [*]。这个选项保留字体名称表中的所有条目。如果把它设成[]有些字体在部分系统上会变成未命名字体导致 CSS 里font-family失效。所以建议保留。第四个是options.notdef_outline True。.notdef是字体里的默认缺失字形通常是一个空框或问号形状。有些字体为了省空间.notdef没有轮廓或者轮廓非常简单。设为True后子集器会尽量保留一个合法的.notdef轮廓避免缺字时显示异常。4.3 不使用脚本用 CLI 一行命令完成子集化如果你不想维护 Python 脚本fontTools 也提供了命令行工具pyftsubset。安装fonttools后它就在你的 Python 脚本目录下。pyftsubset source.ttf --text-filetext.txt --output-filesubset.woff2 --flavorwoff2 --layout-features* --name-IDs* --notdef-outline这个命令的效果和上面的脚本基本一致适用于快速验证子集化效果。但命令行方式不方便做复杂的字符集合并和批量处理所以我通常把它当作调试工具正式的批量压缩流程还是写脚本。5. 压缩效果实测体积从多大到多大脚本跑完我们来关注最终结果。压缩不是越小越好达到体积和覆盖范围的平衡才是目标。5.1 压缩前后对比与加载优化我用之前提到的那款开源中文字体做了一个真实测试。原始 TTF 体积约 12.6MB把一份包含完整站内文案的text.txt作为字符来源实际字符数约 3820 个包含重复去除后的独立汉字、标点、数字、字母生成的subset.ttf体积约 1.9MBsubset.woff2体积约 760KB。这个体积差异非常可观。原来的字体资源在网络传输中按普通 4G 网络的带宽估算可能要几秒才能下载完成现在只有 760KB几乎能在一瞬间加载完成。如果把字符集进一步收紧比如只保留页面标题和首屏内容涉及的 500 个汉字WOFF2 体积可以压到 150KB 以内。我也试过只做子集化但不转 WOFF2TTF 停在 1.9MB 左右转成 WOFF2 后体积少了 60%。可见格式转换带来的收益相当可观。如果你的使用场景是桌面软件内嵌字体那么子集化后的 TTF 已经足够因为不需要走网络传输但如果目标是 Web 加载请务必输出 WOFF2。5.2 在 Web 项目中启用压缩后的字体字体文件生成后Web 端的使用方式和平常一样通过font-face引入font-face { font-family: MyCnFont; src: url(subset.woff2) format(woff2), url(subset.ttf) format(truetype); font-weight: normal; font-style: normal; font-display: swap; } body { font-family: MyCnFont, PingFang SC, Microsoft YaHei, sans-serif; }这里给两点提醒。第一font-display: swap适合大多数中文页面。它会让文本先用系统字体渲染等自定义字体加载完成后立即换为自定义字体。如果你的页面里中文文字非常多且依赖自定义字体展示品牌调性可以考虑font-display: optional减少 FOIT不可见文本闪烁和 FOUT字体跳动对用户的干扰。第二format(woff2)放在前面浏览器会优先选择它。现代浏览器都支持 WOFF2完全可以将 TTF 作为兜底。如果你的站点大部分访客用的是现代浏览器甚至只需要 WOFF2 就行做兼容备份时再附加 TTF。6. 常见问题与排查技巧实录子集化这套流程本身不算复杂但实际操作中我踩过不少坑也遇到过同行问过来。这部分整理成几个典型的场景每个都附带我的排查思路。6.1 缺字、乱码字符范围没覆盖全最常见的问题是页面上某些汉字变成方框、空白或乱码原因是子集化时没有把这些字纳入保留集合。排查方法很直接把出问题的文字复制出来在脚本里打印一下chars集合中是否包含该字符编码。比如文本文件中漏掉了自己书写出的龘字而脚本没有覆盖该扩展区那么字体中就没有这个字形。解决方法是把缺失字符补充到text.txt或手动加入chars。另一种情况是只用了U4E00-9FFF基本区但页面需要的是扩展 A 区、B 区等生僻字。遇到这种情况就要用文本文件方式或显式指定扩展区范围。我的经验是除非你的系统有明确的生僻字需求否则维护一份精确的文本字符集合比开放好几万个编码更稳妥。6.2 某些样式丢失OpenType 特性被裁剪有人压缩完字体后发现英文单词的连字符效果没有了或者中文标点挤压效果异常。这通常是layout_features设置不对。如果你在Options里没写layout_features[*]子集器可能默认裁剪掉一些特性比如liga连字、kern字距调整、palt标点挤压。中文字体尤其是高质量字体会在GSUB/GPOS表里定义很多精彩的字距与替换规则。压缩时如果把这些规则删了字词之间的间距会变得不太自然。如果你用 JavaScript 或 CSS 属性无感知可能不会立刻发现但排版效果确实下降了。解法就是我在脚本里写的那样显式设置layout_features[*]。这个选项会保留所有特性代价是文件体积稍增但通常只有几百 KB 的差异相比缺特性带来的排版损失我认为完全值得。如果你确实知道某个特性用不上再把那段特性名从列表里删掉即可。6.3 字体无法生成或打开报错偶尔会遇到fontTools报错比如TableError: can not read cmap table或ttLib的一些解析异常。这通常是因为原始字体经过了一些非标准修改或者字体内部表结构不完整。此时先检查原始字体是否损坏在 macOS 的字体册或 Windows 的字体预览里打开看看或者用fonttools ttLib里自带的TTFont直接加载。如果确认字体本身没问题再检查 fontTools 版本是否太旧。字体解析领域的问题是变化的新版 fontTools 会兼容更多不规范字体。升级到最新版往往能解决问题。另一个隐蔽的坑是文件路径包含中文。某些字体工具在 Windows 下读中文路径会踩编码问题。如果报FileNotFoundError或UnicodeDecodeError试试把字体文件和脚本放在纯英文路径下能避开很多麻烦。6.4 快速验证字库覆盖工具压缩完成后怎么确认保留的字都还在没保留的字真的被删了我推荐一个简单的方法写一小段 Python 脚本用TTFont读取生成的字体遍历需要检查的文本里的字符查cmap表中是否存在对应字形。from fontTools.ttLib import TTFont def check_coverage(font_path, text): font TTFont(font_path) cmap font.getBestCmap() missing [] for ch in set(text): if ord(ch) not in cmap: missing.append(ch) font.close() return missing missing check_coverage(subset.woff2, 这是一段需要检查的文本。) print(missing:, missing)把这段代码封装成一个校验函数每次生成新字体后跑一遍能有效避免上线后才发现缺字的尴尬。注意getBestCmap()会选择最适合的字符映射表对于普通中文字体一般没问题。如果字体有多个 cmap 子表必要时需要单独查看。6.5 字体压缩经验速查表如果你刚刚开始接触字体压缩可以直接把下面的建议当成检查清单工作项建议做法字体来源确认授权允许子集化与 Web 嵌入字符集来源优先用站点文案文本文件必要时补充范围保留特性设置layout_features[*]避免排版特性丢失输出格式WOFF2 作为 Web 首选TTF 作为兼容备用体积目标常用站点建议压在 1MB 以下首屏专用可以更小校验写脚本检查覆盖字符或直接用缺失字测试缓存策略字体文件设置长时间缓存CDN 优先收尾前再补一句如果你做的是内部管理系统而不是公开站点也可以把字符集进一步缩小到系统内实际业务使用的几千字甚至只有几百字。但要注意这类系统如果未来有用户输入扩展记得留一手要么把输入框用到的字符也纳入抓取范围要么干脆用大一点的常用字集。我个人在多次实践后的体会是中文字体压缩优化的核心其实不是在压体积这一个动作而是建立一套字符集管理的流程。每次页面迭代后把新增文案重新收集、重新生成字体并纳入自动化构建流程才能让字体体积长期维持在可控范围。不要指望一个脚本永久搞定所有场景字体体积、字符覆盖、渲染效果这三者之间始终需要根据实际情况做权衡。希望这套流程能帮你把字体从 10MB 级别的包袱变成真正为页面体验服务的轻量资源。