用户拍板「字族扩展都补」。六款按三类各两款落地,全部单页增量 ≤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
525 lines
21 KiB
Python
Executable File
525 lines
21 KiB
Python
Executable File
#!/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())
|