feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
# vendor/fonts —— 本地字体(离线部署,禁止 CDN)
|
||||
|
||||
现场部署无外网,所有中文字体必须随仓库分发、从本目录本地加载。本目录只放**子集化后的 woff2**,
|
||||
原始大字体(每套 8–12 MB)不入库。
|
||||
|
||||
## 字体清单
|
||||
|
||||
| id | 显示名 | font-family | 字重 | 单套合计 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `noto-sans` | Noto Sans SC | `Noto Sans SC` | 400 / 700 | 2.72 MB |
|
||||
| `noto-serif` | Noto Serif SC | `Noto Serif SC` | 400 / 700 | 3.71 MB |
|
||||
| `misans` | MiSans | `MiSans` | 400 / 700 | 1.76 MB |
|
||||
| `source-han-sans` | 思源黑体 Source Han Sans CN | `Source Han Sans CN` | 400 / 700 | 2.72 MB |
|
||||
|
||||
合计 8 个 woff2、10.91 MB。逐文件体积与 sha256 见 [`manifest.json`](./manifest.json),
|
||||
每个文件均 ≤ 4 MB / 套的目标(最大为 Noto Serif SC 的 3.71 MB)。
|
||||
|
||||
## 来源与授权
|
||||
|
||||
| 字体 | 来源 | 授权 |
|
||||
| --- | --- | --- |
|
||||
| Noto Sans SC | Google Noto(`~/Fonts/noto-sans/sc`) | SIL Open Font License 1.1 |
|
||||
| Noto Serif SC | Google Noto(`~/Fonts/noto-serif/sc/SubsetOTF/SC`) | SIL Open Font License 1.1 |
|
||||
| MiSans | 小米(`~/Fonts/MiSans/ttf`) | MiSans 字体知识产权许可协议 |
|
||||
| 思源黑体 Source Han Sans CN | Adobe(`~/Fonts/思源黑体`) | SIL Open Font License 1.1 |
|
||||
|
||||
> **授权说明**:本项目为**内部赛事**使用,不对外公开发布,字体授权不构成障碍
|
||||
> (见 `devdocs/06-大屏端设计.md` 9.1、`devdocs/07-管理面板设计.md` 9)。
|
||||
> 若将来对外分发,需逐字体复核许可:Noto / 思源黑体为 OFL,可自由分发;
|
||||
> **MiSans 为厂商许可,公开分发前必须另行确认**。OFL 字体保留其原始版权声明,
|
||||
> 本目录未附 LICENSE 副本,需要时从 `~/Fonts/<字体目录>/LICENSE` 取回。
|
||||
|
||||
## 子集化命令
|
||||
|
||||
依赖 `fonttools` + `brotli`(生成 woff2 必需):
|
||||
|
||||
```bash
|
||||
pip install fonttools brotli
|
||||
# 系统 Python 受 PEP 668 保护时,用带系统包的虚拟环境:
|
||||
python3 -m venv --system-site-packages ~/.tmp/fontenv
|
||||
~/.tmp/fontenv/bin/pip install brotli
|
||||
PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh
|
||||
```
|
||||
|
||||
脚本会:生成字符集 → 逐字体跑 `fontTools.subset` → 重新生成 `manifest.json`。
|
||||
|
||||
**子集字符集**:ASCII 可打印字符 + GB2312 全部合法码位(一级 3755 + 二级 3008 汉字,
|
||||
含全角标点、希腊/俄文、全角数字)+ 常用中文标点与数字符号,共 **7553 字符**。
|
||||
|
||||
**子集化参数**:
|
||||
|
||||
```bash
|
||||
python -m fontTools.subset <源字体> \
|
||||
--flavor=woff2 \
|
||||
--layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk' \
|
||||
--name-IDs='*' --name-languages='*' \
|
||||
--recommended-glyphs --drop-tables+=DSIG \
|
||||
--text="$(字符集)" --output-file=<id>-<weight>.woff2
|
||||
```
|
||||
|
||||
去掉 CJK 竖排(`vert`/`vrt2`)等用不到的 OpenType 特性以压体积;保留 `kern`/`liga` 等排版特性。
|
||||
|
||||
## 如何使用
|
||||
|
||||
`web/src/shared/styles/fonts.css` 为每套字体写好 `@font-face`(`font-display: swap`)
|
||||
与 `--font-<id>` 变量。在全局样式入口加一行即可生效:
|
||||
|
||||
```css
|
||||
@import './fonts.css';
|
||||
```
|
||||
|
||||
CSS 里的 `url()` 是相对 `web/src/shared/styles/` 的 `../../../../vendor/fonts/...`。
|
||||
Vite 构建时会把这些 woff2 作为资源打进产物(输出到 `server/internal/webui/assets/`),
|
||||
运行时不再依赖仓库外的路径。
|
||||
|
||||
## 如何新增字体
|
||||
|
||||
1. 把源字体(`.otf`/`.ttf`)放到 `~/Fonts/<目录>/`(不要直接复制进仓库)。
|
||||
2. 在 `tools/fonts.conf` 追加一行:`id|显示名|font-family|分类|授权|来源说明|Regular 源文件|Bold 源文件`。
|
||||
3. 重跑 `PYTHON=... vendor/fonts/tools/subset-fonts.sh`,它会生成 `<id>-regular.woff2`、
|
||||
`<id>-bold.woff2` 并更新 `manifest.json`。
|
||||
4. 在 `web/src/shared/styles/fonts.css` 补对应 `@font-face` 和 `--font-<id>` 变量。
|
||||
5. 检查单套合计 ≤ 4 MB;超了就精简字符集或换更轻的字重。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- MiSans 缺 `・`(U+30FB,日文片假名中点,GB2312 内含),其余 7552 个字符齐全;
|
||||
中文场景无影响。其余三套字符集全覆盖。
|
||||
- 只做 Regular + Bold 两档;如需 Medium/Light,按上节流程加字重(需扩展 `fonts.conf` 的字段格式)。
|
||||
- 字体文件已子集化,**不要**用原始 `~/Fonts` 大字体覆盖本目录文件。
|
||||
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-09-09",
|
||||
"generator": "vendor/fonts/tools/subset-fonts.sh",
|
||||
"subset": {
|
||||
"charset": "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号",
|
||||
"charCount": 7553,
|
||||
"format": "woff2",
|
||||
"layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk"
|
||||
},
|
||||
"totalBytes": 11434920,
|
||||
"totalSize": "10.91 MB",
|
||||
"fonts": [
|
||||
{
|
||||
"id": "noto-sans",
|
||||
"displayName": "Noto Sans SC",
|
||||
"fontFamily": "Noto Sans SC",
|
||||
"cssVar": "--font-noto-sans",
|
||||
"category": "sans-serif",
|
||||
"license": "OFL-1.1",
|
||||
"source": "Google Noto Sans SC (noto-sans/sc)",
|
||||
"totalBytes": 2849388,
|
||||
"totalSize": "2.72 MB",
|
||||
"weights": [
|
||||
{
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"file": "noto-sans-regular.woff2",
|
||||
"path": "vendor/fonts/noto-sans-regular.woff2",
|
||||
"bytes": 1399176,
|
||||
"size": "1.33 MB",
|
||||
"sha256": "a126c29405324acddf2cf3d47ba76762f1fa39cd7b1c0be6dc98f4fd26cfafb8"
|
||||
},
|
||||
{
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"file": "noto-sans-bold.woff2",
|
||||
"path": "vendor/fonts/noto-sans-bold.woff2",
|
||||
"bytes": 1450212,
|
||||
"size": "1.38 MB",
|
||||
"sha256": "206e958255a9e961243fbbd44c52ba05fb166e452de315fa3967e3c78bb8d499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "noto-serif",
|
||||
"displayName": "Noto Serif SC",
|
||||
"fontFamily": "Noto Serif SC",
|
||||
"cssVar": "--font-noto-serif",
|
||||
"category": "serif",
|
||||
"license": "OFL-1.1",
|
||||
"source": "Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)",
|
||||
"totalBytes": 3887184,
|
||||
"totalSize": "3.71 MB",
|
||||
"weights": [
|
||||
{
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"file": "noto-serif-regular.woff2",
|
||||
"path": "vendor/fonts/noto-serif-regular.woff2",
|
||||
"bytes": 1892872,
|
||||
"size": "1.81 MB",
|
||||
"sha256": "666a88ddc3f50f8ae999991d9ebd5eeb58ad267f6f9b38b75a580599e0fe72c2"
|
||||
},
|
||||
{
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"file": "noto-serif-bold.woff2",
|
||||
"path": "vendor/fonts/noto-serif-bold.woff2",
|
||||
"bytes": 1994312,
|
||||
"size": "1.90 MB",
|
||||
"sha256": "7a8fc67787af8d4a8ff88b63f672b888ff29746ad08af0c4b4b31273d15ffce3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "misans",
|
||||
"displayName": "MiSans",
|
||||
"fontFamily": "MiSans",
|
||||
"cssVar": "--font-misans",
|
||||
"category": "sans-serif",
|
||||
"license": "MiSans Font License",
|
||||
"source": "小米 MiSans (MiSans/ttf)",
|
||||
"totalBytes": 1841888,
|
||||
"totalSize": "1.76 MB",
|
||||
"weights": [
|
||||
{
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"file": "misans-regular.woff2",
|
||||
"path": "vendor/fonts/misans-regular.woff2",
|
||||
"bytes": 910956,
|
||||
"size": "890 KB",
|
||||
"sha256": "ca7c73c33dde913833012f16334e59d8f6a3cdea856e537a52ff27b95ab01493"
|
||||
},
|
||||
{
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"file": "misans-bold.woff2",
|
||||
"path": "vendor/fonts/misans-bold.woff2",
|
||||
"bytes": 930932,
|
||||
"size": "909 KB",
|
||||
"sha256": "18119cd03ed6931c339d8f7cfc4bcf2c7258ee6a9e2191b43d75dcc26ef64f41"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "source-han-sans",
|
||||
"displayName": "思源黑体 Source Han Sans CN",
|
||||
"fontFamily": "Source Han Sans CN",
|
||||
"cssVar": "--font-source-han-sans",
|
||||
"category": "sans-serif",
|
||||
"license": "OFL-1.1",
|
||||
"source": "Adobe Source Han Sans CN (思源黑体)",
|
||||
"totalBytes": 2856460,
|
||||
"totalSize": "2.72 MB",
|
||||
"weights": [
|
||||
{
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"file": "source-han-sans-regular.woff2",
|
||||
"path": "vendor/fonts/source-han-sans-regular.woff2",
|
||||
"bytes": 1399540,
|
||||
"size": "1.33 MB",
|
||||
"sha256": "f85482af4eb07d66df271e475f00484f764326c1cb4fb9b65c90589bc4588875"
|
||||
},
|
||||
{
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"file": "source-han-sans-bold.woff2",
|
||||
"path": "vendor/fonts/source-han-sans-bold.woff2",
|
||||
"bytes": 1456920,
|
||||
"size": "1.39 MB",
|
||||
"sha256": "3e2e3f54161b8a96c59001d40855835539ef97f6c80c58cdb93fcc4f2baae4dd"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# 字体清单(subset-fonts.sh 与 gen-manifest.py 的唯一数据源)
|
||||
#
|
||||
# 字段(| 分隔):
|
||||
# id | 显示名 | font-family | 分类 | 授权 | 来源说明 | Regular 源文件 | Bold 源文件
|
||||
#
|
||||
# 源文件相对 $FONT_SRC(默认 ~/Fonts)。
|
||||
# 新增字体:在下面加一行,然后重跑
|
||||
# PYTHON=<带 fonttools+brotli 的 python> vendor/fonts/tools/subset-fonts.sh
|
||||
noto-sans|Noto Sans SC|Noto Sans SC|sans-serif|OFL-1.1|Google Noto Sans SC (noto-sans/sc)|noto-sans/sc/NotoSansSC-Regular.otf|noto-sans/sc/NotoSansSC-Bold.otf
|
||||
noto-serif|Noto Serif SC|Noto Serif SC|serif|OFL-1.1|Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Regular.otf|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Bold.otf
|
||||
misans|MiSans|MiSans|sans-serif|MiSans Font License|小米 MiSans (MiSans/ttf)|MiSans/ttf/MiSans-Regular.ttf|MiSans/ttf/MiSans-Bold.ttf
|
||||
source-han-sans|思源黑体 Source Han Sans CN|Source Han Sans CN|sans-serif|OFL-1.1|Adobe Source Han Sans CN (思源黑体)|思源黑体/SourceHanSansCN-Regular.otf|思源黑体/SourceHanSansCN-Bold.otf
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成 vendor/fonts/manifest.json。
|
||||
|
||||
数据源:vendor/fonts/tools/fonts.conf(字体元数据)+ vendor/fonts/*.woff2(实际产物)。
|
||||
每次跑完 subset-fonts.sh 会自动调用;也可单独运行:
|
||||
python3 vendor/fonts/tools/gen-manifest.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUT_DIR = HERE.parent
|
||||
ROOT = OUT_DIR.parent.parent # 仓库根,用于生成 vendor/fonts/... 形式的相对路径
|
||||
CONF = HERE / "fonts.conf"
|
||||
|
||||
WEIGHTS = (("regular", 400), ("bold", 700))
|
||||
SUBSET_DESC = "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号"
|
||||
|
||||
|
||||
def charset_count() -> int:
|
||||
"""复用 subset-fonts.sh 的字符集生成逻辑,保证清单与产物一致。"""
|
||||
code = (
|
||||
"chars=set()\n"
|
||||
"for c in range(0x20,0x7F): chars.add(chr(c))\n"
|
||||
"for hi in range(0xA1,0xFF):\n"
|
||||
" for lo in range(0xA1,0xFF):\n"
|
||||
" try: chars.add(bytes((hi,lo)).decode('gb2312'))\n"
|
||||
" except UnicodeDecodeError: pass\n"
|
||||
"chars.update(' ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇')\n"
|
||||
"chars.update('「」『』【】〔〕〖〗《》〈〉')\n"
|
||||
"chars.update('0123456789')\n"
|
||||
"chars.update('!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~')\n"
|
||||
"print(len(chars))\n"
|
||||
)
|
||||
return int(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout)
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
return f"{n / 1024 / 1024:.2f} MB" if n >= 1024 * 1024 else f"{n / 1024:.0f} KB"
|
||||
|
||||
|
||||
def read_conf() -> list[dict[str, str]]:
|
||||
rows = []
|
||||
for line in CONF.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
fid, name, family, category, license_, source, reg, bold = (p.strip() for p in line.split("|"))
|
||||
rows.append(
|
||||
{
|
||||
"id": fid,
|
||||
"displayName": name,
|
||||
"fontFamily": family,
|
||||
"category": category,
|
||||
"license": license_,
|
||||
"source": source,
|
||||
"regular": reg,
|
||||
"bold": bold,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fonts = []
|
||||
grand_total = 0
|
||||
for row in read_conf():
|
||||
weights = []
|
||||
font_total = 0
|
||||
for weight_name, weight_class in WEIGHTS:
|
||||
path = OUT_DIR / f"{row['id']}-{weight_name}.woff2"
|
||||
if not path.is_file():
|
||||
print(f"缺少产物: {path},请先运行 subset-fonts.sh", file=sys.stderr)
|
||||
return 1
|
||||
data = path.read_bytes()
|
||||
size = len(data)
|
||||
font_total += size
|
||||
weights.append(
|
||||
{
|
||||
"weight": weight_class,
|
||||
"style": "normal",
|
||||
"file": path.name,
|
||||
"path": str(path.relative_to(ROOT)),
|
||||
"bytes": size,
|
||||
"size": human(size),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
}
|
||||
)
|
||||
grand_total += font_total
|
||||
fonts.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"displayName": row["displayName"],
|
||||
"fontFamily": row["fontFamily"],
|
||||
"cssVar": f"--font-{row['id']}",
|
||||
"category": row["category"],
|
||||
"license": row["license"],
|
||||
"source": row["source"],
|
||||
"totalBytes": font_total,
|
||||
"totalSize": human(font_total),
|
||||
"weights": weights,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"generatedAt": date.today().isoformat(),
|
||||
"generator": "vendor/fonts/tools/subset-fonts.sh",
|
||||
"subset": {
|
||||
"charset": SUBSET_DESC,
|
||||
"charCount": charset_count(),
|
||||
"format": "woff2",
|
||||
"layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk",
|
||||
},
|
||||
"totalBytes": grand_total,
|
||||
"totalSize": human(grand_total),
|
||||
"fonts": fonts,
|
||||
}
|
||||
out = OUT_DIR / "manifest.json"
|
||||
out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"manifest.json: {len(fonts)} 套字体,{len(fonts) * 2} 个文件,合计 {human(grand_total)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把子集字体内部的 name 表 / OS2 字重统一成 CSS 期望的规范值。
|
||||
|
||||
为什么需要:思源黑体的老版本 OTF 把每个字重写成独立族名
|
||||
(`Source Han Sans CN Regular` / `Source Han Sans CN Bold`),
|
||||
MiSans 的 usWeightClass 是 330/630 这类非标准值。
|
||||
@font-face 的 `font-family` 描述符虽然能屏蔽内部族名,但保持一致更省心,
|
||||
也避免字体被单独安装/引用时族名对不上。
|
||||
|
||||
用法(由 subset-fonts.sh 调用):
|
||||
python3 vendor/fonts/tools/normalize-names.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUT_DIR = HERE.parent
|
||||
CONF = HERE / "fonts.conf"
|
||||
|
||||
WEIGHTS = (("regular", "Regular", 400), ("bold", "Bold", 700))
|
||||
STALE_NAME_IDS = (1, 2, 3, 4, 6, 16, 17, 21, 22)
|
||||
PLATFORMS = ((3, 1, 0x409), (1, 0, 0)) # Windows / Mac,英文
|
||||
|
||||
|
||||
def set_name(name, name_id: int, value: str) -> None:
|
||||
for platform_id, plat_enc_id, lang_id in PLATFORMS:
|
||||
name.setName(value, name_id, platform_id, plat_enc_id, lang_id)
|
||||
|
||||
|
||||
def normalize(path: Path, family: str, style: str, weight: int) -> None:
|
||||
font = TTFont(path)
|
||||
name = font["name"]
|
||||
for record in list(name.names):
|
||||
if record.nameID in STALE_NAME_IDS:
|
||||
name.removeNames(record.nameID, record.platformID, record.platEncID, record.langID)
|
||||
|
||||
full_name = family if style == "Regular" else f"{family} {style}"
|
||||
set_name(name, 1, family) # family
|
||||
set_name(name, 2, style) # subfamily
|
||||
set_name(name, 3, f"{full_name}:subset") # unique id
|
||||
set_name(name, 4, full_name) # full name
|
||||
set_name(name, 6, full_name.replace(" ", "")) # postscript name
|
||||
set_name(name, 16, family) # typographic family
|
||||
set_name(name, 17, style) # typographic subfamily
|
||||
|
||||
os2 = font["OS/2"]
|
||||
os2.usWeightClass = weight
|
||||
if weight >= 700:
|
||||
os2.fsSelection = (os2.fsSelection & ~0x40) | 0x20 # BOLD
|
||||
font["head"].macStyle |= 0x01
|
||||
else:
|
||||
os2.fsSelection = (os2.fsSelection & ~0x20) | 0x40 # REGULAR
|
||||
font["head"].macStyle &= ~0x01
|
||||
|
||||
font.flavor = "woff2"
|
||||
font.save(path)
|
||||
font.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for line in CONF.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
fid, _, family = [p.strip() for p in line.split("|")][:3]
|
||||
for weight_name, style, weight_class in WEIGHTS:
|
||||
path = OUT_DIR / f"{fid}-{weight_name}.woff2"
|
||||
if not path.is_file():
|
||||
print(f"缺少产物: {path}", file=sys.stderr)
|
||||
return 1
|
||||
normalize(path, family, style, weight_class)
|
||||
print(f" {path.name:32s} -> {family} / {style} ({weight_class})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# 字体子集化脚本 —— 把 ~/Fonts 下的中文字体裁剪成 woff2 并写入 vendor/fonts/
|
||||
#
|
||||
# 用途:离线部署,字体必须随仓库分发,禁止 CDN。
|
||||
# 产物:vendor/fonts/<id>-<weight>.woff2 + vendor/fonts/manifest.json
|
||||
#
|
||||
# 依赖:python3 + fonttools + brotli
|
||||
# pip install fonttools brotli
|
||||
# # 系统 Python 受 PEP 668 保护时用虚拟环境:
|
||||
# # python3 -m venv --system-site-packages ~/.tmp/fontenv
|
||||
# # ~/.tmp/fontenv/bin/pip install brotli
|
||||
# # PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh
|
||||
#
|
||||
# 用法:
|
||||
# vendor/fonts/tools/subset-fonts.sh
|
||||
# FONT_SRC=~/Fonts PYTHON=python3 vendor/fonts/tools/subset-fonts.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FONT_SRC="${FONT_SRC:-$HOME/Fonts}"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
FONTS_CONF="$SCRIPT_DIR/fonts.conf"
|
||||
|
||||
# 子集字符集:ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字/全角符号
|
||||
CHARSET="$("$PYTHON" - <<'PY'
|
||||
import sys
|
||||
|
||||
chars = set()
|
||||
# 1) ASCII 可打印字符
|
||||
for c in range(0x20, 0x7F):
|
||||
chars.add(chr(c))
|
||||
# 2) GB2312 全部合法双字节码位(一级 3755 + 二级 3008 汉字,及全角标点/希腊/俄文等)
|
||||
for hi in range(0xA1, 0xFF):
|
||||
for lo in range(0xA1, 0xFF):
|
||||
try:
|
||||
chars.add(bytes((hi, lo)).decode("gb2312"))
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
# 3) 常用中文标点 / 数字符号补充
|
||||
chars.update(
|
||||
" ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇"
|
||||
"「」『』【】〔〕〖〗《》〈〉"
|
||||
"0123456789"
|
||||
"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"
|
||||
)
|
||||
sys.stdout.write("".join(sorted(chars)))
|
||||
PY
|
||||
)"
|
||||
CHAR_COUNT="$(printf '%s' "$CHARSET" | "$PYTHON" -c 'import sys; print(len(sys.stdin.read()))')"
|
||||
|
||||
# 子集化参数:
|
||||
# --flavor=woff2 输出 woff2(需 brotli)
|
||||
# --layout-features 保留字距/连字等,丢弃 CJK 竖排等无用特性以压体积
|
||||
# --name-IDs='*' 保留完整 name 表(含本地化族名)
|
||||
# --recommended-glyphs 保留 .notdef/.null 等推荐字形
|
||||
# --drop-tables+=DSIG 丢弃无用的数字签名表
|
||||
SUBSET_OPTS=(
|
||||
--flavor=woff2
|
||||
--layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk'
|
||||
--name-IDs='*'
|
||||
--name-languages='*'
|
||||
--recommended-glyphs
|
||||
--drop-tables+=DSIG
|
||||
)
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
echo "charset: ${CHAR_COUNT} 个字符(ASCII + GB2312 一级/二级 + 中文标点 + 数字符号)"
|
||||
|
||||
while IFS='|' read -r id name family category license source_note reg_src bold_src; do
|
||||
case "$id" in ''|'#'*) continue ;; esac
|
||||
for pair in "regular:400:$reg_src" "bold:700:$bold_src"; do
|
||||
IFS=':' read -r weight_name weight_class src_rel <<<"$pair"
|
||||
src="$FONT_SRC/$src_rel"
|
||||
out="$OUT_DIR/$id-$weight_name.woff2"
|
||||
[ -f "$src" ] || { echo "缺少源字体: $src" >&2; exit 1; }
|
||||
printf ' %-26s %s -> %s\n' "$id-$weight_name" "$(basename "$src")" "$(basename "$out")"
|
||||
"$PYTHON" -m fontTools.subset "$src" "${SUBSET_OPTS[@]}" \
|
||||
--text="$CHARSET" --output-file="$out"
|
||||
done
|
||||
done < "$FONTS_CONF"
|
||||
|
||||
"$PYTHON" "$SCRIPT_DIR/normalize-names.py"
|
||||
"$PYTHON" "$SCRIPT_DIR/gen-manifest.py"
|
||||
|
||||
echo "完成。产物目录:$OUT_DIR"
|
||||
ls -la "$OUT_DIR"/*.woff2
|
||||
Reference in new issue
Block a user