Files
wait4xandGLM-5.3 9df86cb05b feat(fonts): 新增六款自托管字族(体积闸门内)(5i T9)
用户拍板「字族扩展都补」。六款按三类各两款落地,全部单页增量 ≤150KB:
小薇(文学·宋体气质)、新晰黑(文学·现代黑)、得意黑(标题)、庆科黄油(标题·圆润)、
马善政毛笔与柳建毛草(书法),许可证均 OFL。

关键取舍:@fontsource 官方中文切片粒度太粗(每片约 33 字、单片 40KB+),一句话命中
7 片即 308–442KB/页,四款全部超闸门;即便用 subset-fonts.py 按站内用字重裁仍剩
205–346KB(笔画类字族单片就 50KB)。故改为从 google/fonts 取原始 TTF,按**字频序
细切**(约 19 字/片、单片 2–12KB),每族 99–100 片、族总量 0.5–1.2MB,浏览器只拉
命中的片。四个 @fontsource 依赖随之移除,package.json 净零变更。

实测单页增量(一句话场景):xiaowei 44.9KB / huangyou 27.1KB / mashanzheng 45.7KB /
liujianmaocao 30.0KB / smiley 27.1KB / neoxihei 30.0KB——全部 PASS;
六族同页共 105KB。

接线(一处漏就静默失效,踩过两次):
- rehype-class-whitelist 放行新类(不放行则 span 的 class 被整条剥掉,页面有字没脸)
- site-content 的 ALLOWED_PAGE_FONTS 与后台排版下拉同源补齐(三处下拉 + hint)
- font-needs 扩六个 key,保证「内容用到才加载」的按需契约继续成立
- global.css 六组规则含 .mom-text 作用域(5h 补的瞬间作用域不能漏)
- typography-classes.js(后台可视化面板单一事实源)35→42 项
- subset-fonts.py 新增 --split-ttf / --slices / --order-from(hwmc 的 --css 用法未动)

覆盖边界(已在 docs 与后台 hint 写明):切片只含站内用字(约 1900 字),
新写生僻字会回落系统字族,需按 docs 命令重切。

验证: 构建 50 页 exit 0;content-lint 51 页 0/0/0;config-check 211 字段/66 hint;
      六类逐项 getComputedStyle 首项 = ZCOOL XiaoWei / ZCOOL QingKe HuangYou /
      Ma Shan Zheng / Liu Jian Mao Cao / Smiley Sans / LXGW Neo XiHei(6/6);
      六族全部出现在 document.fonts;首页字体请求数保持 17(零回归)

Spec: 2026-11-02_blog-5i-fonts-polish (T9)

Co-authored-by: GLM-5.3
2026-11-03 12:35:20 +08:00

525 lines
21 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 build_freq_order(font_cps: set[int], order_css: Path | None, site_chars: set[str]) -> list[int]:
"""构造“字频序”码位列表:先用 --order-from 窗口(hwmc 的 cn-font-split 产物)
给出的顺序,再接上窗口外但站内用到的字(按码位)。
只返回**站内用字**(∩ 字库):展示字体的体积优势来自“只覆盖站点真用得上的字”,
把字库全量字也切进来只会把单片撑大(实测:新晰黑 27687 字全切 → 单片 42KB)。
站外生僻字回落系统字族,与 hwmc 的子集化取舍同源。
"""
order: list[int] = []
seen: set[int] = set()
wanted = {ord(c) for c in site_chars if ord(c) in font_cps}
if not wanted:
return sorted(font_cps)
if order_css and order_css.exists():
for win in parse_windows(order_css.read_text(encoding='utf-8')):
for cp in sorted(wanted):
if cp in seen:
continue
if any(a <= cp <= b for a, b in win):
order.append(cp)
seen.add(cp)
order.extend(sorted(wanted - seen))
return order
def do_split_ttf_chunked(
src: Path, out_dir: Path, family: str, site_chars: set[str], slices_n: int, order_css: Path | None
) -> int:
"""--split-ttf + --slices:把字体按**站内用字**切成 N 份细片(字频序)。
为什么不用借来的粗窗口(hwmc 每片 50-70 字):展示字体(书法/标题)字形复杂,
单片就 18-27KB;页面用一句 20 字的短句会碰十几片 → 单页增量 300KB+,
远超 150KB 闸门(2026-11-03 实测:马善政 346KB/页、柳建毛草 342KB/页)。
改成“按站内用字细切”(约 19 字/片,单片 2-6KB)后,一句短句只拉 1-2 片。
代价:站外生僻字回落系统字族(与 hwmc 的子集化取舍同源,文档已注明)。
"""
try:
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover
print('[subset-fonts] 缺少 fonttools')
return 1
font = TTFont(str(src))
have: set[int] = set()
for table in font['cmap'].tables: # type: ignore[attr-defined]
have.update(table.cmap.keys())
order = build_freq_order(have, order_css, site_chars)
if not order:
print('[subset-fonts] 无可切码位')
return 1
per = max(1, -(-len(order) // slices_n)) # ceil 分块
files_dir = out_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True)
faces: list[str] = []
total = 0
biggest = (0, '')
for i in range(0, len(order), per):
chunk = order[i : i + per]
idx = i // per + 1
name = f'{idx:03d}-subset.woff2'
dst = files_dir / name
if not subset_slice_cps(src, dst, chunk):
continue
size = dst.stat().st_size
total += size
if size > biggest[0]:
biggest = (size, name)
faces.append(
'@font-face{'
f'font-family:"{family}";src: url(\'./files/{name}\') format(\'woff2\');'
'font-style:normal;font-display:swap;font-weight:400;'
f'unicode-range:{ranges_spec(chunk)};}}'
)
(out_dir / 'fonts.css').write_text('\n'.join(faces) + '\n', encoding='utf-8')
print(
f'[subset-fonts] {family}:码位 {len(order)} / {len(have)} → {len(faces)} 片,'
f'合计 {total/1024:.0f}KB(最大片 {biggest[1]} {biggest[0]/1024:.1f}KB)→ {out_dir}'
)
return 0
def parse_windows(css: str) -> list[list[tuple[int, int]]]:
"""按文件顺序取出每个 @font-face 的 unicode-range 窗口(片序 = 字频序)。
用途:手工字体(TTF/OTF)没有现成的切片包,直接借一份已验证的切片方案
(目前是 public/fonts/subset/hwmc/fonts.css,文风开源 cn-font-split 产物)
当**字频序**用:排在前面的窗口是常用字,页面只用常用字时只拉前几片。
"""
windows: list[list[tuple[int, int]]] = []
for m in FACE_RE.finditer(css):
rng = RANGE_RE.search(m.group('body'))
if not rng:
continue
windows.append(parse_ranges(rng.group('ranges')))
return windows
def ranges_spec(codepoints: list[int]) -> str:
"""把码位列表压成 `U+4E00-4E05,U+4E10` 形式的 unicode-range 串。"""
cps = sorted(set(codepoints))
parts: list[str] = []
start = prev = cps[0]
for cp in cps[1:] + [None]: # type: ignore[list-item]
if cp is not None and cp == prev + 1:
prev = cp
continue
parts.append(f'U+{start:04X}' if start == prev else f'U+{start:04X}-{prev:04X}')
if cp is not None:
start = prev = cp
return ','.join(parts)
def do_split_ttf_mode(
src: Path,
out_dir: Path,
family: str,
ranges_css: Path | None,
extra_chars: set[str],
) -> int:
"""--split-ttf:把单个 TTF/OTF 切成一目录 unicode-range 切片 + fonts.css。
为什么需要:源源只能拿到整包字体(得意黑 5.8MB zip / 霞鹜新晰黑 7.5MB TTF),
直接丢进 public 会让**每个**用到它的页面都付全量字节(远超 150KB/页闸门)。
切片的收益与 hwmc 一致:浏览器只拉与页面文字 unicode-range 相交的片。
切片顺序:优先用 --ranges-from 提供的窗口(字频序);随后补一片“站内用字兜底”
(站内实际出现但不在窗口里的字符),保证全站内容不丢字。
"""
try:
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover
print('[subset-fonts] 缺少 fonttools')
return 1
font = TTFont(str(src))
have: set[int] = set()
for table in font['cmap'].tables: # type: ignore[attr-defined]
have.update(table.cmap.keys())
print(f'[subset-fonts] {src.name}:码位 {len(have)} 个 → 切片中')
windows = parse_windows(ranges_css.read_text(encoding='utf-8')) if ranges_css else []
files_dir = out_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True)
faces: list[str] = []
covered: set[int] = set()
total = 0
biggest = (0, '')
idx = 0
for win in windows:
picked = sorted(cp for cp in have if any(a <= cp <= b for a, b in win))
if not picked:
continue
covered.update(picked)
idx += 1
name = f'{idx:03d}-subset.woff2'
dst = files_dir / name
if not subset_slice_cps(src, dst, picked):
continue
size = dst.stat().st_size
total += size
if size > biggest[0]:
biggest = (size, name)
faces.append(
'@font-face{'
f'font-family:"{family}";src: url(\'./files/{name}\') format(\'woff2\');'
'font-style:normal;font-display:swap;font-weight:400;'
f'unicode-range:{ranges_spec(picked)};}}'
)
# 站内用字兜底:内容里真实出现、但不在借来窗口里的字,单独一片
extra = sorted({ord(c) for c in extra_chars if ord(c) in have} - covered)
if extra:
idx += 1
name = f'{idx:03d}-subset.woff2'
dst = files_dir / name
if subset_slice_cps(src, dst, extra):
size = dst.stat().st_size
total += size
if size > biggest[0]:
biggest = (size, name)
faces.append(
'@font-face{'
f'font-family:"{family}";src: url(\'./files/{name}\') format(\'woff2\');'
'font-style:normal;font-display:swap;font-weight:400;'
f'unicode-range:{ranges_spec(extra)};}}'
)
(out_dir / 'fonts.css').write_text('\n'.join(faces) + '\n', encoding='utf-8')
print(
f'[subset-fonts] {family}:{len(faces)} 片,合计 {total/1024:.0f}KB'
f'(最大片 {biggest[1]} {biggest[0]/1024:.1f}KB)→ {out_dir}'
)
return 0
def subset_slice_cps(src: Path, dst: Path, codepoints: list[int]) -> bool:
"""按码位列表切一片 woff2(与 subset_slice 同参,但输入是码位而非字符)。"""
dst.parent.mkdir(parents=True, exist_ok=True)
unicodes = ','.join(f'U+{cp:04X}' for cp in codepoints)
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] 切片失败 {dst.name}: {r.stderr.strip()[:160]}')
return False
return dst.exists() and dst.stat().st_size > 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--css', help='字体 CSS(含 @font-face 与 unicode-range);与 --split-ttf 二选一')
ap.add_argument(
'--split-ttf',
help='[新] 从一个整包 TTF/OTF 切出 unicode-range 切片(配合 --family 与 --ranges-from)',
)
ap.add_argument('--family', help='[新] --split-ttf 模式输出的 font-family 名(需与 CSS 引用完全一致)')
ap.add_argument('--ranges-from', help='[新] 借用的窗口定义 CSS(窗口模式),通常用 hwmc 的 fonts.css')
ap.add_argument('--order-from', help='[新] 字频序参照 CSS(细切模式):只取窗口顺序,不限制覆盖范围')
ap.add_argument('--slices', type=int, help='[新] 细切模式:把站内用字切成 N 片(建议 100)')
ap.add_argument('--out', required=True, help='输出目录(CSS 与切片都写这里)')
ap.add_argument('--chars-from', nargs='+', 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()
if args.split_ttf:
if not args.family:
print('[subset-fonts] --split-ttf 需要 --family')
return 1
site = collect_chars(args.chars_from, args.chars_mode) if args.chars_from else set()
if args.slices:
return do_split_ttf_chunked(
Path(args.split_ttf), Path(args.out), args.family, site, args.slices,
Path(args.order_from) if args.order_from else None,
)
return do_split_ttf_mode(
Path(args.split_ttf), Path(args.out), args.family,
Path(args.ranges_from) if args.ranges_from else None,
site,
)
if not args.css:
print('[subset-fonts] 需要 --css 或 --split-ttf')
return 1
if not args.chars_from:
print('[subset-fonts] --css 模式需要 --chars-from')
return 1
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())