chore(fonts): 字体二进制不入库,改用脚本生成
- 远程仓库对单次推送体积有限制,6.7 MB 字体无法一次推送,先从版本库移除 - 新增 tools/build-fonts.py:下载官方分包、合并并子集化,一条命令重建 - @font-face 保留,字体缺失时回退系统字体;离线用例改为按可用性断言
This commit is contained in:
@@ -21,6 +21,9 @@
|
||||
/release/
|
||||
/samples/python-task/.venv/
|
||||
|
||||
# 字体二进制体积大(约 6.7 MB),不入库;用 tools/build-fonts.py 重新生成
|
||||
/frontend/public/fonts/LXGWWenKai-*.woff2
|
||||
|
||||
# Editors and operating system files
|
||||
/.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -6,7 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
|
||||
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
||||
- SQLite 持久化,重启后任务与历史仍在
|
||||
- 运行日志实时推送(SSE),断线自动回退轮询
|
||||
- 前端零外部依赖:字体、样式、脚本全部随包自带,**断网可用**
|
||||
- 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体
|
||||
|
||||
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
|
||||
|
||||
@@ -48,7 +48,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
|
||||
```text
|
||||
release/TaskPilot/
|
||||
├── task-pilot-backend.exe # 单可执行文件(内嵌 Web 资源托管)
|
||||
├── web/ # 前端静态资源(含本地字体)
|
||||
├── web/ # 前端静态资源(字体可选)
|
||||
├── data/task_pilot.db # SQLite 数据库(首次启动创建)
|
||||
├── logs/{task_id}/{run_id}.log
|
||||
├── start.ps1
|
||||
@@ -112,7 +112,7 @@ uv run --no-project tools/verify.py --base-url http://127.0.0.1:3000
|
||||
| Python | 自动关联 `.venv`/`venv`/`env`、注入 PATH/`VIRTUAL_ENV`/`PYTHONIOENCODING`、虚拟环境校验 |
|
||||
| 界面 | 三类动态表单、Cron 预览、虚拟环境状态条、页面内目录浏览器、深色日志抽屉 |
|
||||
| 安全 | 仅回环监听、Origin 白名单、写操作令牌、常量时间比较、请求体上限 |
|
||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、离线字体、OpenAPI 契约 |
|
||||
| 交付 | 单进程静态托管、SPA 回退、Release 打包、OpenAPI 契约、可选离线字体 |
|
||||
|
||||
---
|
||||
|
||||
@@ -222,8 +222,9 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||
- **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动),字体 `LXGW WenKai` +
|
||||
`Outfit-Medium` 全部本地托管,断网仍可完整渲染。
|
||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
||||
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
||||
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
@@ -466,6 +467,18 @@ pnpm --dir frontend test:e2e
|
||||
|
||||
生成文件不要手工修改。
|
||||
|
||||
### 界面字体(可选)
|
||||
|
||||
字体二进制体积约 6.7 MB,**不纳入仓库**。首次克隆后如需与设计一致的中文界面:
|
||||
|
||||
```powershell
|
||||
uv run --with fonttools --with brotli tools/build-fonts.py
|
||||
```
|
||||
|
||||
脚本会从 `lxgw-wenkai-webfont` 的 CDN 下载分包、合并并裁剪到 GB2312 + 中英文标点,
|
||||
输出到 `frontend/public/fonts/`(已被 `.gitignore` 忽略)。不生成也能正常运行,
|
||||
只是正文回退到系统字体;`Outfit-Medium.ttf`(55 KB)仍随仓库提供。
|
||||
|
||||
### 测试构成
|
||||
|
||||
| 层 | 覆盖 |
|
||||
@@ -473,7 +486,7 @@ pnpm --dir frontend test:e2e
|
||||
| Rust 单元(52) | Cron、虚拟环境解析与探测、执行器(编码/截断/清理/句柄归属)、安全、目录列举 |
|
||||
| Rust 集成(30) | 任务 CRUD 与校验、运行生命周期、取消与超时、日志分块、调度恢复、统计与调度接口、重启对账 |
|
||||
| 前端 Vitest(13) | 仪表盘渲染、表单载荷、虚拟环境状态、目录浏览器(进入/取消/错误)、下次触发时间 |
|
||||
| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**完全离线渲染**(拦截所有外部请求) |
|
||||
| Playwright(4) | 真实任务执行与日志、编辑/停用/删除、窄屏、**无外部网络渲染**(拦截所有外部请求) |
|
||||
| Python 自检(14 项) | `tools/verify.py` 覆盖 API 全链路 |
|
||||
|
||||
---
|
||||
@@ -504,6 +517,7 @@ pnpm --dir frontend test:e2e
|
||||
- 本地单用户工具:读取接口免令牌,不支持远程部署与多用户。
|
||||
- 未安装为 Windows 服务;关闭启动脚本后调度停止。
|
||||
- 错过的调度不会补跑。
|
||||
- 界面字体不入库(体积原因),需用 `tools/build-fonts.py` 生成;缺失时回退系统字体。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,3 +30,13 @@
|
||||
- 只包含 400 / 700 两个字重,500 / 600 由 CSS 字重匹配落到 400 / 700;主题的 Light(300)未包含。
|
||||
- 未实现参考站点的暗色模式(`data-scheme="dark"`)。
|
||||
- 字体文件随 `frontend/public/fonts` 一起进入安装包,体积增加约 6.7 MB。
|
||||
|
||||
## 后续调整(2026-10-26)
|
||||
|
||||
仓库托管的远程仓库对单次推送体积有上限,6.7 MB 的字体二进制无法一次推上去。因此:
|
||||
|
||||
- 中文字体二进制 **不再纳入版本库**(`/frontend/public/fonts/LXGWWenKai-*.woff2` 已加入 `.gitignore`);
|
||||
- 新增 `tools/build-fonts.py`,一条命令即可从官方 CDN 重新下载、合并并子集化生成这两个文件;
|
||||
- `@font-face` 仍然保留,字体缺失时浏览器回退到系统字体,功能不受影响;
|
||||
- 对应的端到端用例改为「无外部网络渲染」:始终断言没有外部请求,只有字体文件存在时才断言字体命中;
|
||||
- `Outfit-Medium.ttf`(55 KB)体积很小,继续随仓库提供。
|
||||
|
||||
@@ -222,7 +222,7 @@ test('cancels a running task from the log drawer', async ({
|
||||
await expect(drawer.locator('.status-cancelled').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders fully offline with bundled fonts', async ({ page }) => {
|
||||
test('renders without external network access', async ({ page }) => {
|
||||
const blocked: string[] = []
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url()
|
||||
@@ -241,19 +241,24 @@ test('renders fully offline with bundled fonts', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'TaskPilot' })).toBeVisible()
|
||||
|
||||
const fonts = await page.evaluate(async () => {
|
||||
const state = await page.evaluate(async () => {
|
||||
// 字体二进制不入库;未生成时应回退系统字体,而不是报错。
|
||||
const probe = await fetch('/fonts/LXGWWenKai-Regular.woff2', {
|
||||
method: 'HEAD',
|
||||
})
|
||||
await document.fonts.ready
|
||||
return {
|
||||
wenkai: document.fonts.check('14px "LXGW WenKai"'),
|
||||
display: document.fonts.check('500 26px "Outfit-Medium"'),
|
||||
loaded: Array.from(document.fonts).map((face) => face.family),
|
||||
wenkaiAvailable: probe.ok,
|
||||
wenkaiLoaded: document.fonts.check('14px "LXGW WenKai"'),
|
||||
displayLoaded: document.fonts.check('500 26px "Outfit-Medium"'),
|
||||
}
|
||||
})
|
||||
|
||||
expect(blocked).toEqual([])
|
||||
expect(fonts.wenkai).toBe(true)
|
||||
expect(fonts.display).toBe(true)
|
||||
expect(fonts.loaded).toContain('LXGW WenKai')
|
||||
expect(state.displayLoaded).toBe(true)
|
||||
if (state.wenkaiAvailable) {
|
||||
expect(state.wenkaiLoaded).toBe(true)
|
||||
}
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-offline.png',
|
||||
fullPage: true,
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
name="description"
|
||||
content="TaskPilot - Windows 本地定时任务管理平台"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/LXGWWenKai-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/Outfit-Medium.ttf"
|
||||
|
||||
@@ -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