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>
@@ -67,3 +67,9 @@ client/pubspec.lock
|
|||||||
/server/server
|
/server/server
|
||||||
/server/nodectl
|
/server/nodectl
|
||||||
/server/agent
|
/server/agent
|
||||||
|
|
||||||
|
# 字体子集生成的中间产物(源字体/临时字表;产物 client/fonts/ 才入库)
|
||||||
|
tools/fonts/_*
|
||||||
|
|
||||||
|
# golden 测试失败时生成的对比图(临时产物)
|
||||||
|
client/test/golden/failures/
|
||||||
|
|||||||
@@ -154,12 +154,14 @@ class PangolinFonts {
|
|||||||
static const cjk = 'Noto Sans SC';
|
static const cjk = 'Noto Sans SC';
|
||||||
static const mono = 'JetBrains Mono';
|
static const mono = 'JetBrains Mono';
|
||||||
|
|
||||||
/// 测试钩子:true 时用 bundled family(test/fonts/ 经 FontLoader 注册),不走 google_fonts。
|
/// 生产默认 true:用本地打包 ttf(pubspec fonts: 段),离线可用,不走 google_fonts
|
||||||
static bool useBundled = false;
|
/// 运行时拉取。测试态由 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}) =>
|
static TextStyle sora({double? fontSize, FontWeight? fontWeight, double? height, double? letterSpacing}) =>
|
||||||
useBundled
|
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);
|
: GoogleFonts.sora(fontSize: fontSize, fontWeight: fontWeight, height: height, letterSpacing: letterSpacing);
|
||||||
|
|
||||||
static TextStyle manrope(
|
static TextStyle manrope(
|
||||||
@@ -167,6 +169,7 @@ class PangolinFonts {
|
|||||||
useBundled
|
useBundled
|
||||||
? TextStyle(
|
? TextStyle(
|
||||||
fontFamily: sans,
|
fontFamily: sans,
|
||||||
|
fontFamilyFallback: const [cjk],
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
fontWeight: fontWeight,
|
fontWeight: fontWeight,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -177,11 +180,11 @@ class PangolinFonts {
|
|||||||
|
|
||||||
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
|
static TextStyle jetBrainsMono({double? fontSize, FontWeight? fontWeight, List<FontFeature>? fontFeatures}) =>
|
||||||
useBundled
|
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);
|
: GoogleFonts.jetBrainsMono(fontSize: fontSize, fontWeight: fontWeight, fontFeatures: fontFeatures);
|
||||||
|
|
||||||
static TextTheme manropeTextTheme(TextTheme base) =>
|
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 ──────────────────────────────────────────────────────
|
/// ── Type scale ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,5 +46,30 @@ flutter:
|
|||||||
- assets/app-icon.svg
|
- assets/app-icon.svg
|
||||||
- assets/tray_icon.png
|
- 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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
// fonts_test.dart — 本地打包字体接线回归。
|
||||||
|
//
|
||||||
|
// 锁定 Task 3 的字体改造:生产走 bundled family(非 google_fonts 运行时拉取),
|
||||||
|
// 且每个字体函数都把中文兜到 Noto Sans SC(fontFamilyFallback: [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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||