- 远程仓库对单次推送体积有限制,6.7 MB 字体无法一次推送,先从版本库移除 - 新增 tools/build-fonts.py:下载官方分包、合并并子集化,一条命令重建 - @font-face 保留,字体缺失时回退系统字体;离线用例改为按可用性断言
120 lines
3.8 KiB
Python
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())
|