#!/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
[^}]*)\}', re.S) URL_RE = re.compile(r'url\((?P["\']?)(?P[^)"\']+)(?P=q)\)') RANGE_RE = re.compile(r'unicode-range\s*:\s*(?P [^;]+);') 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())