码到成功
字体压缩解决方案
先处理字体格式,再借助 `fonttools` 做裁剪,最后继续把体积往下压。我这篇会沿着这条线写成同主题原创版,顺手补上实用的 `Python` 做法。

## 为什么字体压缩这么有必要
原因其实很现实。
一套中文字体文件往往非常大,但页面实际用到的字远远没有那么多。你可能只是为了首页标题、几个按钮和少量装饰文案引入它,结果却把几万个字形一并打包。
这会带来几个直接问题:
- 首屏字体下载慢
- `@font-face` 资源过大
- 弱网环境加载体验很差
- 明明只用了几十 KB 的文本,却要付出几 MB 的代价
所以字体压缩的真正重点,不只是“把文件转个格式”,而是**尽量只保留你真正会显示出来的字符**。
## 一条够用的压缩思路
如果把字体压缩做成一条清晰流程,大致可以这么走:
1. 确认原始字体格式
2. 准备实际用到的字符集合
3. 用 `fontTools` 做子集化
4. 输出 `woff2`
5. 在页面里替换成子集字体
这个顺序很重要。很多人上来就急着把 `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](https://fonttools.readthedocs.io/en/stable/subset/index.html)
所以更实用的判断是:
- 如果你的 `otf` 能被工具正常处理,就不一定非得先转成 `ttf`
- 如果现有链路对 `ttf` 更稳,那先转一次也完全合理
换句话说,**格式整理是准备动作,真正的主角还是子集化**。
## 真正省体积的关键:字体子集
`fontTools` 官方的 `subset` 工具很直白,它允许你按下面这些方式指定要保留的字符:
- `--text`
- `--text-file`
- `--unicodes`
- `--unicodes-file`
而且官方文档明确说明:至少要指定一组 glyph、文本或 Unicode 范围,工具才知道该留下什么。[fontTools subset docs](https://fonttools.readthedocs.io/en/stable/subset/index.html)
这就是字体压缩最核心的一步:
你不是“压缩一整套字体”,而是在“构造一套更小的、只包含所需字符的新字体”。
## 最基础的一版命令
如果你已经有一份字符列表文件,比如 `sc_unicode.txt`,最基础的命令就可以写成这样:
```bash
pyftsubset YouYuan.ttf --unicodes-file=sc_unicode.txt
```
这也是参考文章主线里最关键的一步。只不过在实际工程里,我更建议把输出文件和格式一次写全,不要让流程停在“先裁完再说”。
比如可以直接写成:
```bash
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](https://fonttools.readthedocs.io/en/stable/subset/index.html)
## 一个更像实战的命令版本
如果你是拿它做 Web 字体,通常还会顺手做几件更激进但合理的优化:
```bash
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](https://fonttools.readthedocs.io/en/stable/subset/index.html)
## Python 这部分,才是最适合工程化的地方
如果你只是手工压一份字体,命令行已经够用了。
但一旦你要把它接进构建流程,或者希望自动从项目源码里提取字符,Python 就非常顺手。
### 先做一个“把项目文本扫出来”的小脚本
最笨也最稳的一种做法,就是从源码文件里把实际出现过的字符提出来,再喂给 `pyftsubset`。
```python
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` 格式:
```python
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`:
```python
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](https://fonttools.readthedocs.io/en/stable/subset/index.html)
所以更推荐的工程习惯通常是:
- 本地或 CI 里直接产出 `woff2`
- 在线转换工具只作为兜底方案
## 页面接入时,别忘了做回退
字体压好了,页面还得接得漂亮。
一个简单的 `@font-face` 例子可以写成这样:
```css
@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` 很有用,它能避免页面为了等字体而长时间空白。
真正成熟一点的接法,通常会把“自定义字体”和“系统回退字体”一起配上,免得某个字符没裁进去时页面直接炸出豆腐块。
