中文字体子集化实战:从3.2MB到200KB的压缩指南

发布时间:2026/9/19 2:58:21

中文字体子集化实战:从3.2MB到200KB的压缩指南 上个月给个人博客换一套中文字体时正正经经被 3.2MB 的字体文件卡了一次。首屏加载从原先一秒出头直接飙到四五秒移动端更是肉眼可见的白屏转圈。研究了一圈解决方案最后用 fontTools 做了字体子集化把整套字体从 3.2MB 压到 200KB 左右加载速度几乎回到换字体之前的水平。整个过程踩了不少坑也推翻了几个我原本以为的常识。这篇文章把完整脚本、排查思路和三条反直觉经验一次性写清楚给同样被中文字体体积折磨的前端开发者、博客站长、小工具作者做个参考。中文字体子集化本身不是新东西但真正把这件事做成可复用流程的人并不多。网上的资料大多是零散的命令行片段要么只讲原理不给代码要么给了一段脚本却没说清楚字符集怎么维护、动态内容怎么处理、输出格式怎么选。我这次做的不只是压体积而是把子集化、批量处理、构建流程融合在一起形成一个可持续维护的字体发布链路。这篇文章适合谁看前端开发者、个人博客作者、做文档站或电子书渲染的人以及任何想在网页里优雅地使用中文字体但不想牺牲加载性能的开发者。1. 为什么中文字体动辄 3MB先算清楚这笔体积账在动手写脚本之前得先搞清楚中文字体到底大在哪里。不然你会拿着压缩工具瞎调参数压完发现效果很差甚至渲染出错然后开始怀疑工具不好用——实际上是你对字体内部结构缺乏基本认知。1.1 中文字体的体积从哪来一个字体文件的核心内容是字形轮廓数据。英文字体只需要 52 个字母大小写各 26 个加上数字和标点撑死一百多个字形。中文字体呢GB2312 标准收录了 6763 个汉字GBK 收录了 20902 个汉字Unicode CJK 统一表意文字块更是超过 8 万个字符。就算你只覆盖现代汉语常用字几千个字也跑不掉。每个汉字在字体内部都是一段独立的轮廓数据用贝塞尔曲线描述笔画。一个简化汉字字形平均要占几百字节到一两 KB 不等取决于笔画复杂度和轮廓是否经过优化。3500 个常用汉字 × 平均 1KB ≈ 3.5MB这就是中文字体体积大的最根本原因。你看到的大部分 OTF/TTF 中文字体在 5MB 到 20MB 之间都是这么堆出来的。西文字体也有大体积的比如包含几十个光学大小和多种字重的全家桶字体单个文件可以超过 10MB。但日常使用中网页通常只加载一个字重所以问题不大。中文字体的问题在于你通常至少需要常规字重和粗体两个文件每个文件都很大而且不管什么字重字符集都是一样的规模。双倍的大双倍的痛。1.2 子集化的本质只保留你真正会用到的那几百个字字体子集化font subsetting的思路和它名字一样直白从完整字体里挑出你实际需要的字符只保留这些字符的字形数据丢弃其他所有内容重新打包成一个新的字体文件。这么做的底气来自一个现实中非常普遍的现象——一个网页真正用到的中文字符可能只有几百个甚至一百多个。拿这篇博文来说正文加上代码示例里出现的中文汉字我粗略统计过不会超过一千个很多是重复的。3500 个常用汉字里面可能有将近一半根本不会出现。保存 3500 个字形的完整字体是 3.2MB只保存 300 个实际用到的字形体积能压缩到多少答案是 100KB 到 300KB 之间具体取决于你不保留 hinting字体微调指令的话能省多少空间。我这个项目从 3.2MB 压到 200KB就是只保留了博客全站页面中实际出现的中文字符、标点符号、数字和拉丁字母。但你千万不要以为只保留几十个字符就能无限缩小。字体文件有一些强制表结构head、hhea、maxp、cmap 等是必须保留的这些表本身就要占几十 KB。也就是说哪怕你只保留你好两个字的字形也不太可能低于 40KB。这个固定开销在后面的反直觉经验里我会再展开。1.3 子集化适合什么场景不适合什么场景适合个人博客、内容站、文档站、营销活动页、电子书、聊天界面、Banner生成工具等内容范围可预期的场景。不适合依赖用户实时输入、内容完全不可控的场景。比如一个用户可以在评论框里输入任意汉字的社区网站你没法预知他会不会打出某个生僻字。这种情况更合理的做法是不做单文件子集化而是用 Unicode Range 把字体拆成多个子集按需加载或者干脆用系统字体栈。我这次做的博客以静态文章为主评论功能关闭内容完全可控所以单文件子集化是最优解。2. 工具选型在线工具、fontmin、pyftsubset 到底选哪个压下体积的方法不止一种我先说试过的几个路径以及最后为什么锁定 pyftsubset。很多人一上来就搜在线字体压缩工具拖进去就能出结果看起来很省事但对做项目的人来说在线工具基本都是坑。2.1 在线工具和半自动工具的局限性网上常见的在线字体压缩工具包括各种字蛛类服务有几个问题。一是隐私不可控。你上传的字体文件往往是有版权的商业字体传到第三方服务器意味着字体文件脱离你的控制。虽然大部分工具声称处理后即删但你无法核实。二是不可自动化。每压缩一次都要打开网页、上传、等待、下载做一次两次还行做成常态化流程完全不可接受。尤其是字体文件更新、新增字符时手动操作难免遗漏。三是输出参数不可控。在线工具大多是黑盒你想关闭 hinting、想保留某些 OpenType 特性如竖排标点自动调整、连字等工具不给你暴露这些选项。后来我还试过 fontmin一个基于 fonttools 的 Node.js 字体压缩工具界面简单通过传入文本内容压缩字体。它做得不错但问题是它的 API 和 CLI 在某些场景下不够灵活——比如我需要同时处理多个字体文件、需要精确控制输出格式为 woff2、需要把字符集定义作为独立配置维护这些我用 fontmin 的根脚本折腾了大半天体验有点别扭。2.2 为什么最终选择 pyftsubsetpyftsubset 是 fontTools 库自带的命令行工具也是很多在线工具背后的引擎。它最大的优势是既适合命令行单次操作也适合写成 Python 脚本做自动化。支持 TTF、OTF、WOFF、WOFF2 输入输出可以互相转换支持精确指定字符集--text、--text-file和 Unicode 范围--unicodes可细粒度控制布局特性保留--layout-features可控制是否保留 hinting--no-hinting、--hinting可处理 CFF 轮廓OTF 内部常见格式和 TrueType 轮廓支持--desubroutinize优化 CFF 结构输出 woff2 时需要搭配 Brotli 压缩库安装配置也不复杂。安装方式很简单pip install fonttools pip install brotli装完之后在终端验证pyftsubset --help能看到一大段参数说明就说明环境没问题。我建议顺手把 fonttools 升级到最新版因为子集化相关的 bug 修复和 WOFF2 支持改进一直在推进。版本太老的话碰到新字体格式容易报奇奇怪怪的错误。提示在 Windows 上如果出现无法将 pyftsubset 项识别为 cmdlet、函数、脚本文件或可运行程序的名称通常是 Python Scripts 目录没有加入 PATH。去环境变量里把%USERPROFILE%\AppData\Local\Programs\Python\PythonXXX\Scripts加进去重开终端即可。如果提示 PowerShell 禁止运行脚本用管理员身份执行Set-ExecutionPolicy RemoteSigned调整执行策略。2.3 选型对比参考方案自动化程度可控性woff2输出批处理我的评价在线工具低低部分支持不支持偶尔应急可以项目里别用fontmin中中支持可以但麻烦适合单字体快速压缩复杂需求受限fontTools/pyftsubset高高支持天然支持推荐值得花十分钟学习如果你已经有 Node.js 生态且只想做单字体压缩fontmin 也不是不行。但只要你需要把字体生成接入构建流程、需要维护多个字体、需要定期更新字符集pyftsubset 几乎是唯一让我觉得可控的方案。3. 完整脚本字符集提取、子集生成、批量处理一次到位工具选定后剩下的就是写脚本。我把这次实践的完整链路拆成三段字符集提取、单字体生成、批量处理。每一段都是可以直接复制到项目里用的状态。3.1 从 HTML 页面提取字符集子集化的第一步不是执行压缩而是确定要保留哪些字符。我用一个脚本把博客所有 HTML 文件扫一遍提取出去掉标签后的文本去重排序后得到字符集。import re import glob def extract_chars_from_html(file_path): with open(file_path, encodingutf-8) as f: content f.read() # 去掉 script/style 里的内容避免把 JS 代码里的英文字符也收录进去 content re.sub(r(script|style)[^]*.*?/\1, , content, flagsre.S | re.I) # 去掉 HTML 标签 text re.sub(r[^], , content) # 去掉 HTML 实体如 amp;后续统一处理 text re.sub(r[a-zA-Z];, , text) # 去重并保持稳定排序 chars sorted(set(text)) return .join(chars) all_chars set() for file_path in glob.glob(_site/**/*.html, recursiveTrue): all_chars.update(extract_chars_from_html(file_path)) # 手动补充页面里没有但确实会出现的字符 extra_chars 。、【】《》—…·“”‘’%#*- all_chars.update(extra_chars) chars_str .join(sorted(all_chars)) with open(chars.txt, w, encodingutf-8) as f: f.write(chars_str) print(f共提取到 {len(chars_str)} 个字符)这段脚本有三个细节值得注意。第一一定要先排除 script 和 style 标签里的内容。我第一次跑的时候没做这一步结果 JS 代码里的英文关键字、数字、符号全被收进字符集导致字体文件白白多出几十个拉丁字形。虽然影响不大但不干净的字符集会让你后续维护时摸不清这些字符到底从哪来。第二HTML 实体、 等要先移除。它们是用来转义的真正的字符在浏览器渲染后会变成、而 HTML 源码里的实体文本并不等于真实显示字符。你不移除的话提取出来的字符集里会有a m p l t q u o这些无辜的字母造成无效字形保留。第三手动维护一个额外字符集合。页面上没有但业务上必然会出现的字符比如版权号 ©、注册商标 ®、Emoji、中文标点需要人工补充。我上面代码里extra_chars那个字符串就是干这个用的。这个集合建议独立成一个文件维护后面会讲为什么。3.2 单字体子集化命令行和 Python 两种写法字符集确定之后生成子集就很简单了。命令行方式是pyftsubset 完整字体.otf \ --text-filechars.txt \ --output-file子集字体.woff2 \ --flavorwoff2 \ --no-hinting \ --desubroutinize \ --layout-features*对几个关键参数做一下说明。--text-filechars.txt指定字符集文件。也可以直接--text你好世界传字符串但文件方式更适合维护。--flavorwoff2输出 woff2 格式。不指定的话默认输出 TTF或 OTF体积会大不少。woff2 的 Brotli 压缩对字体数据的压缩率非常可观。--no-hinting关闭 hinting。hinting 是一种用于低分辨率屏幕上优化小字号显示效果的技术但它会占用大量空间。在移动端高清屏Retina上关闭 hinting 后的渲染差异肉眼很难察觉体积却能减少不少。桌面端低分辨率显示器可能会有细微差异这个取舍下面细说。--desubroutinize如果源字体是 CFF 轮廓的 OTF这个参数会把 CFF 的子程序结构展开有时候能进一步提高压缩率。对 TrueType 轮廓的 TTF 无效但保留也无妨。--layout-features*保留所有 OpenType 布局特性。中文排版中比较重要的是vert竖排、kern字距调整、liga连字等。如果不需要可以改为--layout-features减体积。也可以写成 Python 脚本方便嵌入其他流程from fontTools.subset import Options, Subsetter from fontTools.ttLib import TTFont def make_subset(font_path, text_file, output_path): with open(text_file, encodingutf-8) as f: text f.read() font TTFont(font_path, fontNumber0) options Options() options.flavor woff2 options.hinting False options.desubroutinize True options.layout_features [*] subsetter Subsetter(optionsoptions) subsetter.populate(texttext) subsetter.subset(font) font.save(output_path) if __name__ __main__: make_subset(完整字体.otf, chars.txt, 子集字体.woff2)两种方式效果等价看你习惯哪种。我平时在终端里调参数用命令行写进项目自动化脚本时用 Python 方式因为更容易统一处理错误信息。3.3 批量处理多个字体文件很多项目不只有一个字体文件常规体、粗体、斜体、标题字重往往有好几个。手工一条条跑 pyftsubset 不现实写个 bash 循环就能搞定#!/bin/bash set -e INPUT_DIR./original_fonts OUTPUT_DIR./web_fonts TEXT_FILE./chars.txt mkdir -p $OUTPUT_DIR for font_file in $INPUT_DIR/*.ttf $INPUT_DIR/*.otf; do [ -e $font_file ] || continue filename$(basename $font_file) name${filename%.*} pyftsubset $font_file \ --text-file$TEXT_FILE \ --output-file$OUTPUT_DIR/${name}.woff2 \ --flavorwoff2 \ --no-hinting \ --desubroutinize \ --layout-features* echo 已生成: $OUTPUT_DIR/${name}.woff2 done这里加了一个set -e脚本遇到第一个错误就退出避免生成一半文件、另一半缺失导致线上字体不完整的问题。如果你用的是 Windows 环境bash 循环需要 Git Bash 或 WSL 支持。实在没有 bash直接在 Python 脚本里用subprocess或glob也能轻松做到同样的事for f in ../fonts/*.ttf; do name$(basename $f .ttf); pyftsubset $f --text-filechars.txt --output-file../web/fonts/${name}.woff2 --flavorwoff2 --no-hinting; done这个一行版本适合快速测试正式项目建议还是用带错误处理的脚本。3.4 接入构建流程并生成 CSS字体文件生成之后再写一个对应的font-face声明。font-face { font-family: MyFont; src: url(/fonts/MyFont.woff2) format(woff2); font-display: swap; font-weight: 400; }我在构建流程里把提取字符集 → 生成子集 → 输出 CSS串成了一个总脚本每次内容有更新时一键执行全站字体自动跟随更新。博客是静态站这个价格的自动化基本零成本。如果你用的动态站可以在发布流程里加一个钩子每次部署前自动跑一遍。4. 踩坑清单七个我实际遇到且文档里没写清楚的问题这一节是全文最值钱的部分。工具本身不难学难的是那些看起来对实际跑起来就出问题的细节。我把这次踩过的坑按排查链路一条条列出来每个都给出根因和最终解决方案。4.1 坑一SPA 页面里提取的字符集是假的我的博客是静态站最开始用上面的 HTML 提取脚本一切正常。但帮朋友处理一个 Vue 单页应用时同样的脚本提取出来的字符集只有几十个字符生成的字体文件小得异常上线后很明显缺字。排查过程先直接访问线上页面看渲染效果发现大量文字变成了系统字体回退方块或者默认字体。再打开浏览器开发者工具的 Network 面板发现 WOFF2 文件只有 40KB明显不正常。回到本地的构建产物里检查 HTML 文件发现 SPA 的 HTML 骨架里确实只有div idapp/div和一个入口脚本所有正文内容都是 JS 运行时渲染出来的我那个提取 HTML 源码文本的脚本自然什么都抓不到。解决方案这个场景不能扫源码 HTML要扫运行时渲染后的真实 DOM 文本。可以用 Puppeteer 无头浏览器打开页面等待渲染完成后执行document.body.innerText提取文本内容再拿这份文本生成字符集。如果你不想引入重型的 Puppeteer也可以把全站内容数据Markdown 源文件、JSON 数据等直接作为提取源——关键是找到你内容真正所在的源头而不是被构建产物带偏。这类问题的排查思路是先确认线上缺字的字符到底有没有进入字符集再回溯到内容源。别一上来就怀疑 pyftsubset 参数错了。4.2 坑二标点符号和特殊字符被顺路丢掉我做第一版子集化时字符集是从博客正文里提取的文章内容以汉字为主。生成的字体在本地测试一切正常但发到线上后有一部分标点的显示明显不对——最典型的是引号变成了直引号破折号变成了两个连字符。根因我提取的chars.txt里虽然有标点但很多中文排版常用的符号不在文章正文里。比如引号正文里用的是弯引号“”但我手动补充的字符集只涵盖了这种 ASCII 直引号。尤其文档站的内容详情页里会出现《》、—…·这些符号正文里偶尔有提取时又因为 HTML 实体转义漏掉了部分。解决方式无论你的文本提取脚本多完美都必须维护一个不受页面内容影响的基础字符集。我直接做了一份中文版式基础字符集身体燥热。、【】《》〈〉—…·“”‘’…-–—¡¿ 0123456789 abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ ©®™°这个字符集和正文提取结果做并集确保不管页面有没有这些字符渲染时都不会缺。尤其是空格和制表符很容易被忽略但任何一段文本都少不了它们。4.3 坑三OTF 和 TTF 的轮廓格式导致字体文件压不下去有一次处理一个日文设计字体源文件是 OTF但用 pyftsubset 生成后体积始终在 1MB 以上怎么调参数都没用。后来检查才发现这个字体虽然扩展名是 OTF内部却是 TrueType 轮廓glyf而它的字重版本里包含了大量复杂 OpenType 特性比如假名替换、变体选择器默认的--layout-features*会把这些特性全部保留自然压不下去。排查要点先用ttx工具查看字体的内部表结构ttx -l font.otf确定是 CFF 表还是 glyf 表再看看有哪些 OpenType 特性表。如果是 CFF 表--desubroutinize会帮助压缩如果是 TrueType 轮廓这个参数无效。OpenType 特性表如果确实不需要就缩减--layout-features为kern,vert,liga甚至直接传空字符串。# 查看字体内部表结构 ttx -l 完整字体.otf这个工具同样在 fonttools 安装后可用一行命令就能看清单体内的表名称。看到CFF还是glyf你就知道该怎么处理了。4.4 坑四关闭 hinting 后低分辨率屏上小字号发虚之前提到--no-hinting能明显减体积但这不是没有代价的。我自己的测试中在 150% 缩放的 Windows 设备等效 DPI 约 144和普通 1080P 显示器上12px 以下的中文小字如果没有 hinting笔画边缘会显得有点糊字重感也不如完整字体锐利。所以最后我的取舍是正文场景16px 以上可以放心用--no-hinting但涉及小字号界面文案时单独保留 hinting 生成一份小字号专用子集。体积多几十 KB换来的是低分辨率下还算清晰的渲染效果。如果你主要面向移动端用户高清屏占比高直接--no-hinting问题不大。如果你做的是桌面端重用户的产品建议两版都生成测试后决定。4.5 坑五字体子集的缓存与文件名问题我第一次生成的字体文件叫MyFont.woff2发版后因为内容更新重新生成了一次文件内容变了但文件名没变。用户浏览器本地缓存了旧字体导致新页面上的新字符依然缺字。排查思路就是经典的缓存失效问题。解决方案有两种一是生成时给文件名加内容哈希比如MyFont.a1b2c3.woff2内容变了文件名就变二是在 CSS 上做版本号控制url(/fonts/MyFont.woff2?v20240115)不过这种方法有时候不太可靠还是哈希文件名更稳妥。我的建议是直接上哈希文件名。生成脚本里计算文件内容的 MD5拼到文件名里同时更新 CSS 里的url引用。这个自动化逻辑可以在脚本里一并用 Python 完成。4.6 坑六字体许可证问题这个坑不是报错但比报错更严重。我最初用的是朋友给的商业字体验证方案子集化做完后生成的文件自己用没问题但如果要把包含子集字体的博客公开或者交付给客户使用就涉及许可范围问题。很多商业字体的许可协议规定不得二次分发、修改子集化或格式转换是否允许不同厂商的规定不一样。有些字体明确允许在 Web 上通过font-face使用但不允许把修改后的字体文件提供给第三方。我的建议动手之前先读一遍字体附带的 EULA或者到版权方官网查 Web 许可。如果厂商提供Web 字体服务如通过 CDN 动态子集化那通常是合法且最优的解决方案。自己做的子集字体尽量只用在自有可控渠道。4.7 坑七动态内容和用户生成内容导致缺字这是个边界问题。如果你做的页面包含评论、弹幕、用户昵称、表格筛选等动态内容这些内容里的任意汉字都可能不在你的静态字符集里。子集化之后的字体遇到没包含的字符浏览器会回退到下一个字体族通常是系统默认字体视觉上会出现字体混排很突兀。处理方案最稳妥的是对动态内容统一回退方案。我的博客虽然关闭了评论但页面上会有搜索关键词、文章标签这类动态数据我把这些数据源也加进了字符集生成脚本里。如果是用户完全自由输入的场景比如聊天工具建议不要用子集字体渲染用户消息保留系统字体即可。实在想用可以按 Unicode Range 拆分字体把用户可能输入的生僻字拆到另一个延迟加载的子集里。5. 三条反直觉经验做完之后我重新理解了子集化踩完坑之后我对字体子集化有了几个和直觉相悖的认识。这些认知直接改变了我做字体方案的策略拿出来单说。5.1 字符数量与文件大小不是线性关系大多数人的第一反应是我用的字符越少字体文件就应该越小。实际并不是。字体文件有大量固定开销头部表、cmap 映射表、字体度量表、名称表等这些表跟字符数无关只要是个合法的字体文件就必须存在。在 woff2 压缩后这部分固定开销大约有 30KB 到 60KB。我做了个测试同一个字体只保留常⽤100 字、保留 500 字、保留 1000 字文件大小分别为约 60KB、150KB、240KB。虽然总体趋势还是正相关但你可以看到从 100 字增加到 500 字字体体积翻了不止一倍而从 1000 字继续往上涨增幅反而放缓。这说明字符集从 0 到几百个字这段区间是体积变化的敏感区再往后每增加一个字带来的边际成本其实不高。落实到实际策略上就是只要页面可以承受不要追求把字符集压到极限。多保留几十个可能用到的字符比如候补词、历史文章标题、标点变体体积只差几 KB却能让字体内容的覆盖范围更稳妥。我最终的字符集大约 600 字比最初严格提取的 350 字只多了 20KB但覆盖能力强了接近一倍。5.2 单文件全量子集不如按需拆分的多子集缓存友好另一个反直觉的结论是我最初以为把所有用到的字放在同一个字体文件里加载时一次下载最省事。但实际上对于内容较多、更新频繁的站点把字体拆成基础字符集和扩展字符集两个文件可能更好。基础字符集包含高频汉字、数字、标点因为几乎所有页面都会用扩展字符集包含只在少数文章里出现的生僻字、历史文案、补充符号。CSS 里用Unicode-range把不同字符段的字体关联起来font-face { font-family: MyFont; src: url(/fonts/MyFont-base.woff2) format(woff2); unicode-range: U4E00-9FFF, U3000-303F, UFF00-FFEF; font-display: swap; } font-face { font-family: MyFont; src: url(/fonts/MyFont-ext.woff2) format(woff2); unicode-range: U3400-4DBF, U20000-2A6DF, U2600-26FF; font-display: swap; }浏览器碰到文本时只在需要用到扩展字符集里的字符时才会请求对应字体文件。首屏只需要加载基础子集几十 KB只有打开少数包含生僻字或特殊符号的页面时才额外下载扩展子集而且浏览器会缓存它之后再次访问不再重复加载。这个策略尤其适合文章数量多、话题跨度大的技术博客或文档站。如果你做的是固定文案的落地页单文件还是多文件差别不大但内容持续增长的站一定要考虑拆分子集的缓存收益。5.3 真正复杂的不是技术而是确定字符集这件事本身最后一条反直觉经验是关于项目管理的。字体子集化的技术难度极低核心就是一个命令真正复杂的是持续维护字符集定义。这个字符集定义必须回答三个问题你的内容源头在哪静态 HTML、Markdown、数据库、API 返回、哪些字符会动态出现、出现新内容时如何更新。我见过不少团队做过一版子集化之后就再也不管了。等到新页面新文章上线出现了字符集以外的字用户看到的是系统字体和页面字体的混排体验直接下降。这个问题的根源不是技术能力而是流程设计——字符集更新没有关联到内容发布流程里。我现在的做法是在构建脚本里天然地把全站文本重新提取 → 合并基础字符集 → 生成字体 → 用内容哈希命名 → 输出 CSS这个链路做成一个命令。无论谁更新了博客的文章下次构建都会自动重新走一遍完整的字体生成流程字符集永远是最新状态。把这个环节做成自动化反而比手动维护一份字符集文件更有可持续性。配套还有一个小习惯定期抽查线上页面翻到一些老文章、404 页面、搜索页检查有没有异常回退。出现异常就加进基础字符集不留到用户来报 bug。6. 额外分享几个提升体验的参数细节主题聊得差不多了再分享几个参数方面的细节这些可能在一开始不会注意到但会影响最终效果。--font-number参数值得留意。有些 OTF 文件内部包含多个字体比如同一文件里有 Regular、Bold 两个字重pyftsubset 默认处理第 0 个字体。如果你的字体文件是全家桶式结构用--font-number1指定要子集化的那一份否则生成结果可能不是你想要的字重。我用ttx -l列出表结构时也顺便检查过这种情况遇到这种文件建议先用字库工具拆分成单字重文件再做子集化不然 CSS 里定义font-weight: 700时可能匹配不到预期的粗体字形。还有一个容易踩的细节尽量在字体转换之前确认目标浏览器对 woff2 的支持程度。现在主流浏览器已经全量支持 woff2所以输出 woff2 基本没有兼容性风险。但如果你的用户环境还涉及旧版 iOSiOS 8 之前或老旧 Android WebView可能需要同时输出一份 woff 格式作为回退。为多个格式生成字体可以用--flavorwoff再跑一次成本很低别省。font-display: swap这个 CSS 属性强烈建议加上。它控制字体加载期间文本的渲染策略swap模式会让文本先用回退字体显示字体加载完成后无缝切换。这样就算字体加载稍慢用户也不会看到白屏或不可见文字。配合子集化后的小体积首屏几乎不会感受到字体加载延迟。如果字体文件里包含中文标点的竖排变体vert特性而你恰好要做竖排排版如电子书、海报务必在--layout-features里保留vert。我一开始为了极致压缩把 layout 特性清空了结果竖排文本里的引号方向全乱了排查了半小时才发现是这个参数导致的。玩排版的话这个特性一定要留。最后给出一份可以直接使用的发布脚本示例结合前面所有讨论点import hashlib import json import re import subprocess from pathlib import Path BASE_DIR Path(.) CONTENT_DIR BASE_DIR / content # 存放 Markdown 或 HTML 内容 FONT_SOURCE BASE_DIR / fonts / SourceHanSansSC-Regular.otf OUTPUT_DIR BASE_DIR / web / fonts BASE_CHARS 。、【】《》〈〉—…·“”‘’-–… 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ def collect_content_chars(): chars set(BASE_CHARS) for path in CONTENT_DIR.rglob(*.html): text path.read_text(encodingutf-8) # 简单移除标签 text re.sub(r[^], , text) text re.sub(rscript.*?/script, , text, flagsre.S | re.I) chars.update(text) # 去掉换行、制表符 chars.discard(\n) chars.discard(\r) chars.discard(\t) return .join(sorted(chars)) def build_subset(chars: str): OUTPUT_DIR.mkdir(parentsTrue, exist_okTrue) chars_file BASE_DIR / chars.txt chars_file.write_text(chars, encodingutf-8) output_prefix OUTPUT_DIR / site-font # 先输出 woff2 subprocess.run([ pyftsubset, str(FONT_SOURCE), f--text{chars}, --flavorwoff2, --no-hinting, --desubroutinize, --layout-featureskern,vert,liga, f--output-file{output_prefix}.woff2, ], checkTrue) # 计算哈希并重命名 data (output_prefix.with_suffix(.woff2)).read_bytes() digest hashlib.md5(data).hexdigest()[:8] final_name fsite-font-{digest}.woff2 (output_prefix.with_suffix(.woff2)).rename(OUTPUT_DIR / final_name) # 输出映射信息后续给构建插件用 (OUTPUT_DIR / font-manifest.json).write_text( json.dumps({fontUrl: f/fonts/{final_name}}, ensure_asciiFalse, indent2), encodingutf-8, ) print(f生成完成{final_name}{len(data)/1024:.1f}KB字符数 {len(chars)}) if __name__ __main__: build_subset(collect_content_chars())这个脚本把内容字符提取 → 子集生成 → 哈希命名 → 输出清单全串起来了。构建插件读取font-manifest.json动态写入 CSS 里的font-face的src既解决了缓存问题又保证每次发版字体都是最新的。我在实际使用中发现这套流程稳定运行几个月后最大的收益不是首屏快了 300ms这种性能数字而是我彻底不再担心字体问题了。之前每次加文章都要想新文章里有没有生僻字、有没有特殊标点、字体要不要重新生成现在这个心智负担完全消失了构建脚本替我兜底。如果你也被字体体积和缺字问题折磨按这篇文章的脚本和思路走一遍大概率也能一劳永逸。
延伸阅读

更多相关文章

2026/9/19 2:58:21

智慧校园一卡通系统架构:协议层、事件总线与GraphQL聚合

简介:本资源是一份面向高校信息化建设者、智慧校园项目实施方及物联网系统集成商的全场景一卡通解决方案PPT,聚焦数字迎新与智能控水两大核心子系统,解决迎新流程低效、水资源粗放管理等实际痛点。文件为单个37.93MB的PPTX演示文稿&#xff0…

2026/9/19 2:53:20

401 报错 WorkBuddy 时,TaoToken 的 Key 怎么换

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/19 4:03:23

Altium Designer死铜清理全攻略:从判定逻辑到实战排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/19 4:03:23

Qwen3.8本地部署全攻略:显存估算、量化选型与避坑指南

折腾了大概一周,中间翻车翻到怀疑人生,才把Qwen3.8在本地部署这件事彻底跑通。这期间踩过的坑五花八门:下载到损坏的模型权重、因为显存估算错误导致推理直接卡死、模型文件和服务端版本对不上、上下文稍微一长就开始吞字……如果你正准备把Q…

2026/9/19 4:03:23

LLVM项目深度解析:编译器基础设施核心架构与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/19 4:03:23

自然语言驱动开发:vibe coding与工具选型实战指南

1. “vibe coding”不是玄学,是自然语言驱动开发的实践范式演进最近在几个技术社区里频繁看到“vibe coding”这个词被反复提起——不是作为营销话术,而是真实出现在工程师的日常协作记录、内部分享PPT甚至代码评审备注里。它不像“低代码”那样强调拖拽…

2026/9/18 14:13:01

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/19 0:03:10

验证 OpenSpec 兼容性,Cursor 的 Token 从 TaoToken 出

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/19 0:03:10

书桌角落的 Mac mini,OpenClaw 通过 TaoToken 跑任务。

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/19 0:03:10

oh-my-hermes:打造跨工具的命令编排与插件化工作流

1. 项目概述与设计初衷1.1 它到底是什么先说结论:oh-my-hermes 是一个面向开发者日常终端操作的效率工具套件,核心定位是“把分散在各类命令行工具里的高频操作,统一收拢成一套插件化、可编排的工作流”。项目灵感来源很明显——oh-my-zsh 重…

2026/9/18 14:13:03

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/18 14:13:02

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/18 14:13:02

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码