chore(fonts): 字体二进制不入库,改用脚本生成
- 远程仓库对单次推送体积有限制,6.7 MB 字体无法一次推送,先从版本库移除 - 新增 tools/build-fonts.py:下载官方分包、合并并子集化,一条命令重建 - @font-face 保留,字体缺失时回退系统字体;离线用例改为按可用性断言
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user