#!/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())