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
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 18:13:47 +08:00
1 parent 740ec470fa
commit 59eda420b3
103 files changed
+376 -2

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
#!/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())