2020-11-13
前端项目里,图片越来越省,脚本越来越会拆,结果一看资源包,最大的家伙居然是字体文件,这事一点都不稀奇。尤其是中文字体,一旦整包塞进项目,体积很容易就上天。你明明只用了几百个字,最后却把整套字库一起拖进来了,网络层看了都想叹气。
先处理字体格式,再借助 fonttools 做裁剪,最后继续把体积往下压。我这篇会沿着这条线写成同主题原创版,顺手补上实用的 Python 做法。
原因其实很现实。
一套中文字体文件往往非常大,但页面实际用到的字远远没有那么多。你可能只是为了首页标题、几个按钮和少量装饰文案引入它,结果却把几万个字形一并打包。
这会带来几个直接问题:
@font-face 资源过大所以字体压缩的真正重点,不只是“把文件转个格式”,而是尽量只保留你真正会显示出来的字符。
如果把字体压缩做成一条清晰流程,大致可以这么走:
fontTools 做子集化woff2这个顺序很重要。很多人上来就急着把 ttf 转 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 能被工具正常处理,就不一定非得先转成 ttfttf 更稳,那先转一次也完全合理换句话说,格式整理是准备动作,真正的主角还是子集化。
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
这里多做了两件事:
woff2fontTools 官方文档也说明了 --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 下可能压得更小如果你只是手工压一份字体,命令行已经够用了。
但一旦你要把它接进构建流程,或者希望自动从项目源码里提取字符,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()
这段脚本的好处很直接:
当然,它也有局限:
所以更稳的做法通常是:静态扫描打底,再手工补一份保底字符表。
如果你就是想走 --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 直接调 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()
这类写法特别适合放进:
参考文章最后一步是把裁好的 ttf 继续转成 woff2,这一步非常合理,因为 Web 字体场景下,woff2 几乎就是更自然的终点。
原因也很简单:
@font-face 配合更自然而且你不一定非要靠在线工具转。fonttools subset 本身就支持通过 --flavor=woff2 直接输出 woff2。如果环境里装了相应依赖,完全可以走离线流程。fontTools subset docs
所以更推荐的工程习惯通常是:
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 很有用,它能避免页面为了等字体而长时间空白。
真正成熟一点的接法,通常会把“自定义字体”和“系统回退字体”一起配上,免得某个字符没裁进去时页面直接炸出豆腐块。