Files
blog/scripts/build-perf/subset-fonts.py
wait4xandGLM-5.3 59eda420b3 perf(fonts): 字体子集化工具 + 汇文明朝单页 -40%(694KB→420KB)
- 新增 scripts/build-perf/subset-fonts.py:读字体 CSS 的 @font-face
  (unicode-range)→ 扫内容收集实际用字 → 逐片交集裁切(pyftsubset
  --flavor=woff2)→ 空片丢弃、重写 src 只留自产 woff2;支持
  --chars-from/--chars-mode(all|headings)/--min-bytes/--merge
- 实测口径修正后结论(关键):汇文明朝原 694KB/页 → 子集 420KB/页
  (-40%,真收益,已接入);站酷快乐体 163KB → 177KB(+9%,字跨片
  分散致片数增加,**不启用**,保留原 @fontsource 源)
- merge 模式(合并单文件)标记实验性:fontTools.merge 对 CJK 切片字表
  冲突敏感,实测产出异常,生产不用
- 三处实现坑入档:①hast 与 @fontsource CSS 一个 face 常带多格式
  (woff2+woff),只替换首个 url 会残留失效引用(首版缺 58 个 .woff)
  → 改整段替换 src;②无 unicode-range 的 face 需按全范围处理,否则
  原样保留同样残留引用;③裸跑 pyftsubset 需 fonttools+brotli

验证: pnpm astro build 48 页 exit 0;E2E 37/37;playwright 实测汇文
      694KB→420KB 且字体栈生效(WF-Hwmc 命中)

Spec: 2026-11-02_blog-5g-pages (T7)

Co-authored-by: GLM-5.3
2026-11-02 18:13:47 +08:00

275 lines
11 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
字体子集化(Phase 5g T7):把切片字体再按「本站实际用字」裁一刀。
背景:@fontsource / cn-font-split 的中文切片粒度粗(一片 10-20KB、含数百字),
一篇用到 9-19 片就 163KB~535KB。中文艺术字体只用于标题/短句时,实际用字往往
只有几十个——按用字裁切可把单页成本压到十分之一以下。
做法:读字体 CSS 的 @font-face(含 unicode-range)→ 收集内容里实际出现的字符 →
逐片求交集 → 空片丢弃、非空片用 pyftsubset 裁成只含所需字形的 woff2 →
重写 CSS 指向新切片。输出目录结构与原 CSS 的 url 相对路径保持一致。
用法:
subset-fonts.py --css node_modules/@fontsource/zcool-kuaile/index.css \
--out public/fonts/subset/kuaile \
--chars-from content/posts content/moments content/site/*.yaml \
[--chars-mode all|headings] [--min-bytes 4096]
退出码:0 成功;1 参数/依赖错误。
"""
from __future__ import annotations
import argparse
import glob
import io
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
try:
from fontTools.ttLib import TTFont # noqa: F401 (pyftsubset 依赖同包)
except ImportError: # pragma: no cover
sys.exit('[subset-fonts] 缺少 fonttools:pip install fonttools brotli')
FACE_RE = re.compile(r'@font-face\s*\{(?P<body>[^}]*)\}', re.S)
URL_RE = re.compile(r'url\((?P<q>["\']?)(?P<url>[^)"\']+)(?P=q)\)')
RANGE_RE = re.compile(r'unicode-range\s*:\s*(?P<ranges>[^;]+);')
UPLUS_RE = re.compile(r'U\+([0-9A-Fa-f]{1,6})(?:-([0-9A-Fa-f]{1,6}))?')
def collect_chars(paths: list[str], mode: str) -> set[str]:
"""从内容文件里收集需要的字符;headings 模式只取标题行(艺术字体常用于标题)。"""
chars: set[str] = set()
for pattern in paths:
for path in glob.glob(pattern, recursive=True):
try:
text = Path(path).read_text(encoding='utf-8')
except (OSError, UnicodeDecodeError):
continue
if mode == 'headings':
for line in text.splitlines():
if line.lstrip().startswith('#'):
chars.update(line)
elif line.strip().startswith('title:'):
chars.update(line)
else:
chars.update(text)
return {c for c in chars if c.strip()}
def parse_ranges(spec: str) -> list[tuple[int, int]]:
out: list[tuple[int, int]] = []
for m in UPLUS_RE.finditer(spec):
start = int(m.group(1), 16)
end = int(m.group(2), 16) if m.group(2) else start
out.append((start, end))
return out
def chars_in_ranges(chars: set[str], ranges: list[tuple[int, int]]) -> list[str]:
picked = []
for ch in chars:
cp = ord(ch)
if any(start <= cp <= end for start, end in ranges):
picked.append(ch)
return picked
def subset_slice(src: Path, dst: Path, chars: list[str]) -> bool:
"""用 pyftsubset 把 src 裁成只含 chars 的 woff2;返回是否成功产出。"""
dst.parent.mkdir(parents=True, exist_ok=True)
unicodes = ','.join(f'U+{ord(c):04X}' for c in chars)
cmd = [
sys.executable, '-m', 'fontTools.subset', str(src),
f'--unicodes={unicodes}',
'--flavor=woff2',
'--layout-features=*',
'--no-hinting',
'--desubroutinize',
f'--output-file={dst}',
]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f' [warn] 裁切失败 {src.name}: {r.stderr.strip()[:160]}')
return False
return dst.exists() and dst.stat().st_size > 0
def merge_subsets(subsets: list[Path], out: Path) -> bool:
"""把多片已裁切的字体合并成一个字体,再输出单文件 woff2。
为什么需要:切片字体按 unicode-range 分散,页面用到的字散落在十几片里,
逐片裁剪只能省掉「片内空字」而省不掉「片头开销」;合并成单文件后
页面只拉一个 woff2,缓存命中率与请求数都更好。
"""
try:
from fontTools.merge import Merger
except ImportError:
print('[subset-fonts] 缺少 fontTools.merge')
return False
try:
merged = Merger().merge([str(p) for p in subsets])
out.parent.mkdir(parents=True, exist_ok=True)
tmp = out.with_suffix('.merged.ttf')
merged.save(str(tmp))
r = subprocess.run(
[sys.executable, '-m', 'fontTools.subset', str(tmp), '--flavor=woff2',
'--layout-features=*', '--no-hinting', '--desubroutinize',
f'--output-file={out}'],
capture_output=True, text=True,
)
tmp.unlink(missing_ok=True)
if r.returncode != 0:
print(f' [warn] 合并后输出失败: {r.stderr.strip()[:160]}')
return False
return out.exists()
except Exception as exc: # 合并对字表差异敏感,失败要能回落
print(f' [warn] 合并失败(回落到逐片模式): {type(exc).__name__}: {str(exc)[:120]}')
return False
def do_merge_mode(css: str, css_path: Path, out_dir: Path, chars: set[str], min_bytes: int) -> int:
"""--merge:所有用到的切片先各自裁切,再合成单文件 + 单 @font-face CSS。"""
base_dir = css_path.parent
tmp_dir = out_dir / '.slices'
tmp_dir.mkdir(parents=True, exist_ok=True)
families: dict[str, list[Path]] = {}
fam_name = ''
total_before = 0
for m in FACE_RE.finditer(css):
body = m.group('body')
url = URL_RE.search(body)
if not url:
continue
rng = RANGE_RE.search(body)
ranges = parse_ranges(rng.group('ranges')) if rng else [(0, 0x10FFFF)]
picked = chars_in_ranges(chars, ranges)
if not picked:
continue
fam = re.search(r'font-family\s*:\s*([^;]+);', body)
fam_name = fam.group(1).strip().strip('"\'') if fam else fam_name
src = (base_dir / url.group('url')).resolve()
if not src.exists():
continue
total_before += src.stat().st_size
dst = tmp_dir / f'{src.stem}-{len(families)}.woff2'
if not subset_slice(src, dst, picked):
continue
families.setdefault('all', []).append(dst)
slices = families.get('all', [])
if not slices:
print('[subset-fonts] merge 模式没有可合并切片')
return 0
out_font = out_dir / 'merged-subset.woff2'
if not merge_subsets(slices, out_font):
print('[subset-fonts] merge 失败,本次不产出(保留原 CSS 引用)')
return 1
after = out_font.stat().st_size
(out_dir / css_path.name).write_text(
'@font-face {\n'
f' font-family: \'{fam_name}\';\n'
f" src: url('./merged-subset.woff2') format('woff2');\n"
' font-display: swap;\n'
' font-weight: 400;\n'
' font-style: normal;\n'
'}\n',
encoding='utf-8',
)
for f in slices:
f.unlink(missing_ok=True)
try:
tmp_dir.rmdir()
except OSError:
pass
print(f'[subset-fonts] merge:{len(slices)} 片 {total_before/1024:.0f}KB → 单文件 {after/1024:.0f}KB')
return 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--css', required=True, help='字体 CSS(含 @font-face 与 unicode-range)')
ap.add_argument('--out', required=True, help='输出目录(CSS 与切片都写这里)')
ap.add_argument('--chars-from', nargs='+', required=True, help='待扫描的内容文件/glob')
ap.add_argument('--chars-mode', choices=['all', 'headings'], default='all')
ap.add_argument('--min-bytes', type=int, default=4096, help='低于该字节数的切片视为已足够小,原样拷贝')
ap.add_argument('--merge', action='store_true', help='[实验性] 合并成单文件子集;fontTools.merge 对 CJK 切片字表冲突敏感,实测产出异常,暂不可用于生产')
args = ap.parse_args()
css_path = Path(args.css)
if not css_path.exists():
print(f'[subset-fonts] CSS 不存在:{css_path}')
return 1
css = css_path.read_text(encoding='utf-8')
chars = collect_chars(args.chars_from, args.chars_mode)
if not chars:
print('[subset-fonts] 未收集到任何字符,跳过')
return 0
print(f'[subset-fonts] 用字 {len(chars)} 个(mode={args.chars_mode})')
out_dir = Path(args.out)
if args.merge:
return do_merge_mode(css, css_path, out_dir, chars, args.min_bytes)
base_dir = css_path.parent
before = after = 0
kept = dropped = 0
out_css: list[str] = []
pos = 0
for m in FACE_RE.finditer(css):
body = m.group('body')
rng = RANGE_RE.search(body)
url = URL_RE.search(body)
if not url:
out_css.append(m.group(0)) # 无 src 的 face(异常件)原样保留
continue
# 无 unicode-range 的 face = 单文件全字集(latin 等):按全范围处理,
# 否则原样保留会残留指向未产出文件的 url(首版实测 kuaile 缺 58 个)
ranges = parse_ranges(rng.group('ranges')) if rng else [(0, 0x10FFFF)]
picked = chars_in_ranges(chars, ranges)
src = (base_dir / url.group('url')).resolve()
if not picked:
dropped += 1
continue # 整片不需要
try:
before += src.stat().st_size
except OSError:
print(f' [warn] 切片缺失:{src}')
dropped += 1
continue
# 统一产出 woff2 且重写 @font-face:原 CSS 每面常带多个格式(woff/woff2),
# 原样改写会残留指向未产出文件的引用(首版实测 kuaile 缺 58 个 .woff)
stem = src.stem.replace('.', '-')
dst = out_dir / 'files' / f'{stem}-subset.woff2'
if src.stat().st_size <= args.min_bytes:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
elif not subset_slice(src, dst, picked):
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst) # 裁失败退回原片,保证可用
after += dst.stat().st_size if dst.exists() else src.stat().st_size
kept += 1
# 整段替换 src 声明(原 face 常有多格式:woff2 + woff,只换第一个 url 会
# 残留指向未产出文件的引用——首版实测 kuaile 缺 58 个 .woff)
new_body = re.sub(
r'src\s*:\s*[^;]+;',
f"src: url('./files/{stem}-subset.woff2') format('woff2');",
body,
count=1,
)
out_css.append(m.group(0).replace(body, new_body))
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / css_path.name).write_text(css[:0] + '\n'.join(out_css) + '\n', encoding='utf-8')
print(f'[subset-fonts] 切片:保留 {kept} / 丢弃 {dropped}|字节 {before/1024:.0f}KB → {after/1024:.0f}KB')
return 0
if __name__ == '__main__':
raise SystemExit(main())