字体压缩解决方案




2020-11-13

blog_main_img

前端项目里,图片越来越省,脚本越来越会拆,结果一看资源包,最大的家伙居然是字体文件,这事一点都不稀奇。尤其是中文字体,一旦整包塞进项目,体积很容易就上天。你明明只用了几百个字,最后却把整套字库一起拖进来了,网络层看了都想叹气。

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

处理流程图

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

原因其实很现实。

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

这会带来几个直接问题:

  • 首屏字体下载慢
  • @font-face 资源过大
  • 弱网环境加载体验很差
  • 明明只用了几十 KB 的文本,却要付出几 MB 的代价

所以字体压缩的真正重点,不只是“把文件转个格式”,而是尽量只保留你真正会显示出来的字符

一条够用的压缩思路

如果把字体压缩做成一条清晰流程,大致可以这么走:

  1. 确认原始字体格式
  2. 准备实际用到的字符集合
  3. fontTools 做子集化
  4. 输出 woff2
  5. 在页面里替换成子集字体

这个顺序很重要。很多人上来就急着把 ttfwoff2,结果体积确实变小了一点,但远远没有到“该小的程度”。真正省体积的大头,通常来自子集化,不是单纯换容器。

先说格式: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 可以指定输出为 woffwoff2,其中 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 有时会让字体进一步缩小
  • 对小子集而言,--desubroutinizeWOFF/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 直接调子进程压缩字体

如果你想把这件事彻底做成脚本任务,可以让 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 很有用,它能避免页面为了等字体而长时间空白。

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

Web 接入图