feat(client): 字体改本地打包(拉丁全量 + Noto Sans SC GB2312 子集)

生产 PangolinFonts.useBundled=true,离线可用,不再运行时拉 google_fonts;
中文经 fontFamilyFallback=[Noto Sans SC] 兜底,子集外字符再兜系统 CJK 字体。
- 拉丁:Sora/Manrope/JetBrains Mono 静态权重(复用 test/fonts)。
- 中文:Noto Sans SC 变量字体 instance 到 Regular + subset 到 GB2312 6763 字
  (单权重,粗体引擎合成),client/fonts 合计约 3.4MB。
- tools/fonts/make-cjk-subset.sh 可复现生成(GB2312 字表无需联网)。
- 新增 test/unit/fonts_test.dart 锁定接线;golden 因 notdef 占位字形微调已更新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-22 07:48:21 +08:00
parent 14498a3ccd
commit 6fc032bac8
19 changed files with 163 additions and 7 deletions
+6
View File
@@ -67,3 +67,9 @@ client/pubspec.lock
/server/server
/server/nodectl
/server/agent
# 字体子集生成的中间产物(源字体/临时字表;产物 client/fonts/ 才入库)
tools/fonts/_*
# golden 测试失败时生成的对比图(临时产物)
client/test/golden/failures/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -5
View File
@@ -154,12 +154,14 @@ class PangolinFonts {
static const cjk = 'Noto Sans SC';
static const mono = 'JetBrains Mono';
/// 测试钩子:true 时用 bundled familytest/fonts/ 经 FontLoader 注册),不走 google_fonts
static bool useBundled = false;
/// 生产默认 true:用本地打包 ttfpubspec fonts: 段),离线可用,不走 google_fonts
/// 运行时拉取。测试态由 flutter_test_config.dart 经 FontLoader 注册 test/fonts/。
/// 中文经 [cjk]Noto Sans SC 子集)走 fontFamilyFallback;子集外字符再兜系统 CJK 字体。
static bool useBundled = true;
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
useBundled
? TextStyle(fontFamily: display, fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
? TextStyle(fontFamily: display, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing)
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
static TextStyle manrope(
@@ -167,6 +169,7 @@ class PangolinFonts {
useBundled
? TextStyle(
fontFamily: sans,
fontFamilyFallback: const [cjk],
fontSize: fontSize,
fontWeight: fontWeight,
height: height,
@@ -177,11 +180,11 @@ class PangolinFonts {
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
useBundled
? TextStyle(fontFamily: mono, fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
? TextStyle(fontFamily: mono, fontFamilyFallback: const [cjk], fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures)
: GoogleFonts.jetBrainsMono(fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures);
static TextTheme manropeTextTheme(TextTheme base) =>
useBundled ? base.apply(fontFamily: sans) : GoogleFonts.manropeTextTheme(base);
useBundled ? base.apply(fontFamily: sans, fontFamilyFallback: const [cjk]) : GoogleFonts.manropeTextTheme(base);
}
/// ── Type scale ──────────────────────────────────────────────────────
+27 -2
View File
@@ -46,5 +46,30 @@ flutter:
- assets/app-icon.svg
- assets/tray_icon.png
# ── 字体(由 google_fonts 运行时加载,无需本地 ttf)──
# 若需离线/打包字体,下载 ttf 放 fonts/,并按 pubspec 模板恢复 fonts: 段。
# ── 字体(本地打包,全平台离线)──
# 拉丁全量(Sora/Manrope/JetBrains Mono)+ Noto Sans SC 子集(GB2312 6763 字,单 Regular
# 权重;CJK 粗体由引擎合成)。子集由 tools/fonts/make-cjk-subset.sh 可复现生成。
# 生产经 PangolinFonts.useBundled=true 走这些 family;中文经 fontFamilyFallback 兜底。
fonts:
- family: Sora
fonts:
- asset: fonts/Sora-Regular.ttf
- asset: fonts/Sora-SemiBold.ttf
weight: 600
- asset: fonts/Sora-Bold.ttf
weight: 700
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttf
- asset: fonts/Manrope-Medium.ttf
weight: 500
- asset: fonts/Manrope-SemiBold.ttf
weight: 600
- asset: fonts/Manrope-Bold.ttf
weight: 700
- family: JetBrains Mono
fonts:
- asset: fonts/JetBrainsMono-Regular.ttf
- family: Noto Sans SC
fonts:
- asset: fonts/NotoSansSC-Regular-subset.otf
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+54
View File
@@ -0,0 +1,54 @@
// fonts_test.dart — 本地打包字体接线回归。
//
// 锁定 Task 3 的字体改造:生产走 bundled family(非 google_fonts 运行时拉取),
// 且每个字体函数都把中文兜到 Noto Sans SCfontFamilyFallback: [cjk])。
// flutter_test_config.dart 在测试启动已置 PangolinFonts.useBundled = true。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
void main() {
group('PangolinFonts 本地打包接线', () {
test('测试态(useBundled=true)由 flutter_test_config 设置', () {
// 该 hook 由 test/flutter_test_config.dart 在所有测试前置 true
// 生产代码默认值也是 true(见 pangolin_theme.dart)。
expect(PangolinFonts.useBundled, isTrue);
});
test('cjk family 为 Noto Sans SC(与 pubspec fonts: 段一致)', () {
expect(PangolinFonts.cjk, 'Noto Sans SC');
});
test('sora() 用 bundled display family 且中文兜底到 cjk', () {
final s = PangolinFonts.sora(fontSize: 16);
expect(s.fontFamily, PangolinFonts.display);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('manrope() 用 bundled sans family 且中文兜底到 cjk', () {
final s = PangolinFonts.manrope(fontSize: 16);
expect(s.fontFamily, PangolinFonts.sans);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('jetBrainsMono() 用 bundled mono family 且中文兜底到 cjk', () {
final s = PangolinFonts.jetBrainsMono(fontSize: 14);
expect(s.fontFamily, PangolinFonts.mono);
expect(s.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('manropeTextTheme 应用 sans family 且中文兜底到 cjk', () {
const base = TextTheme(bodyMedium: TextStyle(fontSize: 16));
final t = PangolinFonts.manropeTextTheme(base);
expect(t.bodyMedium?.fontFamily, PangolinFonts.sans);
expect(t.bodyMedium?.fontFamilyFallback, contains(PangolinFonts.cjk));
});
test('PangolinText 标度经由 bundled 接线(中文兜底存在)', () {
// 抽查几个常用标度,确保未绕过 PangolinFonts 封装。
expect(PangolinText.h1.fontFamilyFallback, contains(PangolinFonts.cjk));
expect(PangolinText.body.fontFamilyFallback, contains(PangolinFonts.cjk));
expect(PangolinText.mono.fontFamilyFallback, contains(PangolinFonts.cjk));
});
});
}
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# 生成 Noto Sans SC 子集,用于 client 离线打包(全平台)。
#
# 策略:GB2312 一级+二级常用字(6763 字,经 Python 编解码确定性生成,无需联网)
# 仓库 client/lib 实际用到的 CJK 字符 → 子集字表;
# 源用 Noto Sans CJK SC 变量字体,先 instance 到 Regular(wght=400)再 subset
# (变量 CFF2 的字重轴体积很大;单 Regular 静态 + 引擎合成粗体,显著缩小)。
# 产物:client/fonts/NotoSansSC-Regular-subset.otf (~3MB,6763 字)。
#
# 依赖:python3 + fonttools(pip install fonttools brotli)。
# 用法:bash tools/fonts/make-cjk-subset.sh
set -euo pipefail
cd "$(dirname "$0")/../.." # 仓库根
SRC_DIR="tools/fonts/_src"
CHARS="tools/fonts/common-hanzi.txt"
OUT="client/fonts/NotoSansSC-Regular-subset.otf"
mkdir -p "$SRC_DIR"
# fonttools:优先用隔离 venv(避免污染系统 python),否则用 python3 -m。
PY="python3"
SUB="python3 -m fontTools.subset"
if [ -x "/tmp/fonttools-venv/bin/python" ]; then
PY="/tmp/fonttools-venv/bin/python"
SUB="/tmp/fonttools-venv/bin/pyftsubset"
fi
# 1) 字表:GB2312 6763 常用字 ∪ 仓库实际用字。
"$PY" - "$CHARS" <<'PYGEN'
import sys, glob
chars=set()
for hi in range(0xB0,0xF8):
for lo in range(0xA1,0xFF):
try:
ch=bytes([hi,lo]).decode('gb2312')
if '一'<=ch<='鿿': chars.add(ch)
except UnicodeDecodeError:
pass
# 仓库实际用到的 CJK(确保 UI/l10n 文案一定在子集内)
for path in glob.glob('client/lib/**/*.dart', recursive=True):
try:
for ch in open(path,encoding='utf-8').read():
if '一'<=ch<='鿿': chars.add(ch)
except (UnicodeDecodeError,OSError):
pass
open(sys.argv[1],'w',encoding='utf-8').write(''.join(sorted(chars)))
print('charset hanzi:',len(chars))
PYGEN
# 2) 源字体(变量 OTF);若已存在则跳过。失效时换 google/fonts 或 notofonts 最新路径。
SRC_TTF="$SRC_DIR/NotoSansSC.ttf"
if [ ! -f "$SRC_TTF" ]; then
curl -fsSL "https://github.com/notofonts/noto-cjk/raw/main/Sans/Variable/OTF/NotoSansCJKsc-VF.otf" -o "$SRC_TTF"
fi
# 3) instance 到 Regular(wght=400)。
STATIC="$SRC_DIR/NotoSC-Regular.otf"
"$PY" -m fontTools.varLib.instancer "$SRC_TTF" wght=400 -o "$STATIC" --quiet
# 4) subset(不用 --retain-gids,否则保留全部原 gid 致体积暴涨)。
"$SUB" "$STATIC" \
--text-file="$CHARS" \
--output-file="$OUT" \
--layout-features='*' \
--name-IDs='*'
ls -la "$OUT"