码到成功

字体压缩解决方案


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

![处理流程图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/font-compress-pipeline-kvhn.svg)

## 为什么字体压缩这么有必要

原因其实很现实。

一套中文字体文件往往非常大,但页面实际用到的字远远没有那么多。你可能只是为了首页标题、几个按钮和少量装饰文案引入它,结果却把几万个字形一并打包。

这会带来几个直接问题:

- 首屏字体下载慢
- `@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 处理图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/font-compress-python-kvhn.svg)

## 更进一步:用 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` 很有用,它能避免页面为了等字体而长时间空白。

真正成熟一点的接法,通常会把“自定义字体”和“系统回退字体”一起配上,免得某个字符没裁进去时页面直接炸出豆腐块。

![Web 接入图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/font-compress-webfont-kvhn.svg)