码到成功
字体压缩解决方案
先处理字体格式,再借助 fonttools 做裁剪,最后继续把体积往下压。我这篇会沿着这条线写成同主题原创版,顺手补上实用的 Python 做法。
为什么字体压缩这么有必要
原因其实很现实。
一套中文字体文件往往非常大,但页面实际用到的字远远没有那么多。你可能只是为了首页标题、几个按钮和少量装饰文案引入它,结果却把几万个字形一并打包。
这会带来几个直接问题:
- 首屏字体下载慢
@font-face资源过大- 弱网环境加载体验很差
- 明明只用了几十 KB 的文本,却要付出几 MB 的代价
所以字体压缩的真正重点,不只是“把文件转个格式”,而是尽量只保留你真正会显示出来的字符。
一条够用的压缩思路
如果把字体压缩做成一条清晰流程,大致可以这么走:
- 确认原始字体格式
- 准备实际用到的字符集合
- 用
fontTools做子集化 - 输出
woff2 - 在页面里替换成子集字体
这个顺序很重要。很多人上来就急着把 ttf 转 woff2,结果体积确实变小了一点,但远远没有到“该小的程度”。真正省体积的大头,通常来自子集化,不是单纯换容器。
先说格式:OTF、TTF、WOFF、WOFF2 到底怎么看
简单点理解:
OTF / TTF更像原始字体文件WOFF / WOFF2更像 Web 场景下更友好的封装格式
参考文章里先用了字体编辑工具把 otf 处理成 ttf,这条路是能走的,尤其在一些工具链比较老、或者字体源文件本身兼容性不稳定的时候,会有人先做一次格式整理。
不过从现在的工具视角看,fonttools subset 本身就能接受 ttf / otf / woff 这类输入。官方文档直接写明它可以处理 TT- or CFF-flavored OpenType (.otf or .ttf) or WOFF (.woff) 字体,并且能按字符或 glyph 做子集化。fontTools subset docs
所以更实用的判断是:
- 如果你的
otf能被工具正常处理,就不一定非得先转成ttf - 如果现有链路对
ttf更稳,那先转一次也完全合理
换句话说,格式整理是准备动作,真正的主角还是子集化。
真正省体积的关键:字体子集
fontTools 官方的 subset 工具很直白,它允许你按下面这些方式指定要保留的字符:
--text--text-file--unicodes--unicodes-file
而且官方文档明确说明:至少要指定一组 glyph、文本或 Unicode 范围,工具才知道该留下什么。fontTools subset docs
这就是字体压缩最核心的一步:
你不是“压缩一整套字体”,而是在“构造一套更小的、只包含所需字符的新字体”。
最基础的一版命令
如果你已经有一份字符列表文件,比如 sc_unicode.txt,最基础的命令就可以写成这样:
pyftsubset YouYuan.ttf --unicodes-file=sc_unicode.txt
这也是参考文章主线里最关键的一步。只不过在实际工程里,我更建议把输出文件和格式一次写全,不要让流程停在“先裁完再说”。
比如可以直接写成:
pyftsubset YouYuan.ttf \
--unicodes-file=sc_unicode.txt \
--output-file=YouYuan.subset.woff2 \
--flavor=woff2
这里多做了两件事:
- 直接指定输出文件名
- 直接产出
woff2
fontTools 官方文档也说明了 --flavor 可以指定输出为 woff 或 woff2,其中 woff2 依赖 Brotli 扩展。fontTools subset docs
一个更像实战的命令版本
如果你是拿它做 Web 字体,通常还会顺手做几件更激进但合理的优化:
pyftsubset SourceHanSansSC-Regular.otf \
--text-file=used_chars.txt \
--output-file=SourceHanSansSC-Regular.subset.woff2 \
--flavor=woff2 \
--layout-features='*' \
--drop-tables+=DSIG \
--no-hinting \
--desubroutinize
这几个参数的味道可以这样理解:
--text-file:直接喂文本,不必自己先写 Unicode 范围--flavor=woff2:输出 Web 友好的压缩格式--no-hinting:在高分屏场景里经常能继续省体积--desubroutinize:对某些 CFF 字体子集后反而更省
官方文档里也提到了两个很有用的点:
--no-hinting有时会让字体进一步缩小- 对小子集而言,
--desubroutinize在WOFF/WOFF2下可能压得更小
fontTools subset docs
Python 这部分,才是最适合工程化的地方
如果你只是手工压一份字体,命令行已经够用了。
但一旦你要把它接进构建流程,或者希望自动从项目源码里提取字符,Python 就非常顺手。
先做一个“把项目文本扫出来”的小脚本
最笨也最稳的一种做法,就是从源码文件里把实际出现过的字符提出来,再喂给 pyftsubset。
from __future__ import annotations
from pathlib import Path
ROOT = Path("./src")
OUTPUT = Path("used_chars.txt")
SUFFIXES = {".html", ".vue", ".jsx", ".tsx", ".js", ".ts", ".md"}
def collect_text_chars(root: Path) -> str:
chars: set[str] = set()
for file in root.rglob("*"):
if file.suffix.lower() not in SUFFIXES or not file.is_file():
continue
try:
content = file.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for ch in content:
if ch.strip():
chars.add(ch)
return "".join(sorted(chars))
def main() -> None:
chars = collect_text_chars(ROOT)
OUTPUT.write_text(chars, encoding="utf-8")
print(f"collected {len(chars)} unique chars into {OUTPUT}")
if __name__ == "__main__":
main()
这段脚本的好处很直接:
- 不用手工维护字符表
- 页面文案一变,重新跑一遍就行
- 很适合和 CI 或构建脚本接起来
当然,它也有局限:
- 动态渲染出来的字不一定能被静态扫描到
- 服务端返回的文案可能漏掉
- 用户输入类内容更不能靠这套全覆盖
所以更稳的做法通常是:静态扫描打底,再手工补一份保底字符表。
再补一版“字符 -> Unicode 文件”的脚本
如果你就是想走 --unicodes-file 这条路,可以顺手把字符转成 U+XXXX 格式:
from __future__ import annotations
from pathlib import Path
TEXT_FILE = Path("used_chars.txt")
UNICODE_FILE = Path("sc_unicode.txt")
def main() -> None:
chars = TEXT_FILE.read_text(encoding="utf-8")
codes = sorted({f"U+{ord(ch):04X}" for ch in chars if ch.strip()})
UNICODE_FILE.write_text(",".join(codes), encoding="utf-8")
print(f"write {len(codes)} unicode items into {UNICODE_FILE}")
if __name__ == "__main__":
main()
这样你就能把“扫源码”和“压字体”接成一条流水线。
更进一步:用 Python 直接调子进程压缩字体
如果你想把这件事彻底做成脚本任务,可以让 Python 直接调 pyftsubset:
from __future__ import annotations
import subprocess
from pathlib import Path
FONT_FILE = Path("SourceHanSansSC-Regular.otf")
TEXT_FILE = Path("used_chars.txt")
OUTPUT_FILE = Path("SourceHanSansSC-Regular.subset.woff2")
def main() -> None:
cmd = [
"pyftsubset",
str(FONT_FILE),
f"--text-file={TEXT_FILE}",
f"--output-file={OUTPUT_FILE}",
"--flavor=woff2",
"--layout-features=*",
"--drop-tables+=DSIG",
"--no-hinting",
]
subprocess.run(cmd, check=True)
print(f"subset font generated: {OUTPUT_FILE}")
if __name__ == "__main__":
main()
这类写法特别适合放进:
- 前端构建前置脚本
- 静态资源发布流程
- 设计系统字体打包流程
WOFF2 为什么通常比 TTF 更适合 Web
参考文章最后一步是把裁好的 ttf 继续转成 woff2,这一步非常合理,因为 Web 字体场景下,woff2 几乎就是更自然的终点。
原因也很简单:
- 体积通常更小
- 浏览器加载场景更友好
- 和
@font-face配合更自然
而且你不一定非要靠在线工具转。fonttools subset 本身就支持通过 --flavor=woff2 直接输出 woff2。如果环境里装了相应依赖,完全可以走离线流程。fontTools subset docs
所以更推荐的工程习惯通常是:
- 本地或 CI 里直接产出
woff2 - 在线转换工具只作为兜底方案
页面接入时,别忘了做回退
字体压好了,页面还得接得漂亮。
一个简单的 @font-face 例子可以写成这样:
@font-face {
font-family: "BrandSubset";
src: url("/fonts/BrandSubset.woff2") format("woff2");
font-display: swap;
}
.hero-title {
font-family: "BrandSubset", "PingFang SC", "Microsoft YaHei", sans-serif;
}
这里 font-display: swap 很有用,它能避免页面为了等字体而长时间空白。
真正成熟一点的接法,通常会把“自定义字体”和“系统回退字体”一起配上,免得某个字符没裁进去时页面直接炸出豆腐块。