Files
CNWei 14fb3563c5 chore(fonts): 字体二进制不入库,改用脚本生成
- 远程仓库对单次推送体积有限制,6.7 MB 字体无法一次推送,先从版本库移除
- 新增 tools/build-fonts.py:下载官方分包、合并并子集化,一条命令重建
- @font-face 保留,字体缺失时回退系统字体;离线用例改为按可用性断言
2026-10-26 00:46:20 +08:00

120 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""重新生成界面字体(字体二进制不入库)。
用法:
uv run --with fonttools --with brotli tools/build-fonts.py
流程:从 lxgw-wenkai-webfont 的 CDN 下载 Regular / Bold 的全部分包 woff2 子集,
合并成单个字体后裁剪到 GB2312 + Latin + 中文标点,写入 frontend/public/fonts/。
生成的字体仅供本地构建与打包使用,不提交到仓库。
"""
from __future__ import annotations
import io
import re
import sys
import tempfile
import urllib.request
from pathlib import Path
try:
from fontTools.merge import Merger
from fontTools.subset import Options, Subsetter
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover - 由 uv --with 保证
sys.exit("缺少依赖:uv run --with fonttools --with brotli tools/build-fonts.py")
CDN = "https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/"
WEIGHTS = {
"Regular": "lxgwwenkai-regular.css",
"Bold": "lxgwwenkai-bold.css",
}
OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "fonts"
def fetch(url: str) -> bytes:
request = urllib.request.Request(
url, headers={"User-Agent": "taskpilot-font-builder"}
)
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def subset_urls(css_name: str) -> list[str]:
css = fetch(CDN + css_name).decode("utf-8")
# 形如:src: url('./files/lxgwwenkai-regular-subset-4.woff2')
matches = re.findall(r"url\(\s*['\"]?([^'\")]+\.woff2)['\"]?\s*\)", css)
urls = sorted({match.lstrip("./") for match in matches})
if not urls:
raise RuntimeError(f"未能从 {css_name} 解析出字体子集,CDN 格式可能已变更")
return urls
def build_codepoints() -> set[int]:
points: set[int] = set()
for start, end in (
(0x20, 0x7F), # ASCII
(0xA0, 0x100), # Latin-1
(0x2000, 0x2070), # 通用标点
(0x25A0, 0x2600), # 几何图形
(0x3000, 0x3040), # 中文标点
(0xFF00, 0xFFF0), # 全角字符
):
points.update(range(start, end))
points.update(
[0x00D7, 0x00F7, 0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717, 0x23F3]
)
# GB2312 汉字与符号行
for high in range(0xA1, 0xF8):
for low in range(0xA1, 0xFF):
try:
points.add(ord(bytes([high, low]).decode("gb2312")))
except UnicodeDecodeError:
continue
return points
def build(weight: str, css_name: str, points: set[int], workdir: Path) -> None:
urls = subset_urls(css_name)
print(f"[{weight}] 下载 {len(urls)} 个子集…")
paths: list[str] = []
for url in urls:
data = fetch(CDN + url)
font = TTFont(io.BytesIO(data))
target = workdir / Path(url).name.replace(".woff2", ".ttf")
font.save(target)
font.close()
paths.append(str(target))
print(f"[{weight}] 合并 {len(paths)} 个字体…")
merged = Merger().merge(paths)
options = Options()
options.flavor = "woff2"
options.hinting = False
options.layout_features = ["*"]
options.name_IDs = ["*"]
subsetter = Subsetter(options=options)
subsetter.populate(unicodes=points)
subsetter.subset(merged)
output = OUT_DIR / f"LXGWWenKai-{weight}.woff2"
merged.save(output)
print(f"[{weight}] 写出 {output} {output.stat().st_size / 1024 / 1024:.2f} MB")
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
points = build_codepoints()
print(f"目标字符集:{len(points)} 个码位")
with tempfile.TemporaryDirectory() as directory:
workdir = Path(directory)
for weight, css_name in WEIGHTS.items():
build(weight, css_name, points, workdir)
return 0
if __name__ == "__main__":
sys.exit(main())