feat(m168): 中文字体 CDN 下载真接线(D28 后半)
M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。
判据 → 下载 → 注册(一条链,四个 crate 各管一段):
* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
`FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
(英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
`install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
视图模型,所以那一行会自己刷新。
配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。
守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。
测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
1 parent
2a004a5754
commit
449ded2cb7
22 files changed
+2191
-184
No files matched your search
+94
-167
@@ -1,4 +1,4 @@
|
||||
//! 中文字体策略(D28 后半):**只做判据,不联网**。
|
||||
//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。
|
||||
//!
|
||||
//! ## 三句话
|
||||
//!
|
||||
@@ -6,22 +6,28 @@
|
||||
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
|
||||
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
|
||||
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
|
||||
//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。
|
||||
//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是
|
||||
//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持
|
||||
//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。
|
||||
//!
|
||||
//! ## 扫描为什么在 UI 壳层而不是这里
|
||||
//! ## 谁在什么时刻问这里
|
||||
//!
|
||||
//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台
|
||||
//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool`
|
||||
//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。
|
||||
//! | 调用点 | 时机 | 后果 |
|
||||
//! |---|---|---|
|
||||
//! | `crates/app-iced/src/fonts.rs::resolve` | 启动(按系统 locale) | 打一行 `cjk=…` 日志 |
|
||||
//! | `crates/app-iced/src/fonts.rs::cjk_for` | 设置页切语言 | 复算 + 需要时**触发下载** |
|
||||
//!
|
||||
//! 两条路都**只**在 [`Strategy::NeedsDownload`] 时才碰 [`Fetcher`]([`ensure`] 的判据),
|
||||
//! 英文环境与「系统已有字体」两条路一次网络调用都没有(守卫见本文件的单测与
|
||||
//! `crates/app-iced/src/fonts/install/tests.rs`)。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
|
||||
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
|
||||
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 mission 不引依赖);
|
||||
//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task`
|
||||
//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」
|
||||
//! 属于下载 mission,本 crate 不碰。
|
||||
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖);
|
||||
//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux
|
||||
//! 没有真机验证,见 M168 报告)。
|
||||
|
||||
use crate::lang::Lang;
|
||||
|
||||
@@ -56,13 +62,26 @@ pub const CJK_FAMILIES: &[&str] = &[
|
||||
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
|
||||
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
|
||||
|
||||
/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。
|
||||
/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点),
|
||||
/// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**:
|
||||
/// 猜错的 CDN 路径会被后面所有人当事实用。
|
||||
PendingPin,
|
||||
/// 钉死的一套下载参数(M168)。
|
||||
///
|
||||
/// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、
|
||||
/// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit**
|
||||
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
||||
/// `unlucky_core::download::font`)。
|
||||
///
|
||||
/// `urls` 是**候选**:首选挂了才试下一个(两个都是同一份字节的镜像,哈希只认一份——
|
||||
/// 镜像若给了别的内容,校验那关会当场拒掉,而不是把别的东西注册成字体)。
|
||||
Pinned {
|
||||
/// URL 候选(按顺序尝试)。
|
||||
urls: &'static [&'static str],
|
||||
/// 期望 `sha256`(小写十六进制,64 位)。
|
||||
sha256: &'static str,
|
||||
/// 期望字节数(进度预算 + 大小上限判据)。
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
|
||||
@@ -78,12 +97,37 @@ pub struct CjkFont {
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。
|
||||
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)**可变字体**(wght 100–900,整个简体字符集)。
|
||||
///
|
||||
/// 钉死的东西(M168 实测抓的,不是猜的):
|
||||
///
|
||||
/// | 项 | 值 |
|
||||
/// |---|---|
|
||||
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
|
||||
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
|
||||
/// | 字节数 | 17 772 300(≈17.0 MiB) |
|
||||
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
||||
///
|
||||
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
||||
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
|
||||
///
|
||||
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
|
||||
/// 而可变字体一份 17 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
|
||||
/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。
|
||||
pub const NOTO_SANS_SC: CjkFont = CjkFont {
|
||||
family: "Noto Sans SC",
|
||||
license: "OFL-1.1",
|
||||
weights: &[300, 400, 500, 700, 900],
|
||||
source: Source::PendingPin,
|
||||
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
source: Source::Pinned {
|
||||
urls: &[
|
||||
// 首选:jsDelivr(国内多数线路可达;`@<commit>` 保证内容不变)
|
||||
"https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
// 备用:同一份内容的另一个 jsDelivr 边缘(首选被墙/被限速时才用)
|
||||
"https://fastly.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
],
|
||||
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||
bytes: 17_772_300,
|
||||
},
|
||||
};
|
||||
|
||||
/// 字体策略结论。
|
||||
@@ -134,22 +178,50 @@ pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
|
||||
})
|
||||
}
|
||||
|
||||
/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。
|
||||
/// 字体下载器接口(**注入式**:i18n 不碰网络,真实现由 app 层提供)。
|
||||
pub trait Fetcher {
|
||||
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
|
||||
///
|
||||
/// 契约:**同一个进程里第二次调用应当零网络**(缓存命中),且返回的字节已通过校验
|
||||
/// (实现方的责任,见 `unlucky_core::download::font`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 网络失败 / 校验失败等人话错误。
|
||||
/// 网络失败 / 校验失败 / 磁盘失败三类人话错误。
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
|
||||
}
|
||||
|
||||
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
|
||||
///
|
||||
/// 三类对应三句不同的话:网络类「过会儿再试」、校验类「这个源给的东西不对」、磁盘类
|
||||
/// 「腾点空间」。UI 文案按 [`FetchError::class`] 选词,**不要**按错误串猜。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FetchError {
|
||||
/// 网络层失败(超时 / 连不上 / 非 2xx)。
|
||||
Network(String),
|
||||
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
|
||||
Integrity(String),
|
||||
/// 本地磁盘失败(建目录 / 写文件 / 换名)。
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
impl FetchError {
|
||||
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词)。
|
||||
#[must_use]
|
||||
pub const fn class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Network(_) => "network",
|
||||
Self::Integrity(_) => "integrity",
|
||||
Self::Disk(_) => "disk",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人类可读描述。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ensure`] 的结局。
|
||||
@@ -182,150 +254,5 @@ pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, Fetc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |family| installed.contains(&family)
|
||||
}
|
||||
|
||||
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
|
||||
struct FakeFetcher {
|
||||
calls: Cell<usize>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
seen: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
FakeFetcher {
|
||||
calls: Cell::new(0),
|
||||
result,
|
||||
seen: Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.seen.set(Some(font.family));
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_never_wants_a_cjk_font() {
|
||||
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
|
||||
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
|
||||
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
|
||||
assert_eq!(
|
||||
decide(Lang::En, &only(&["Microsoft YaHei"])),
|
||||
Strategy::NotNeeded,
|
||||
"装了中文字体也不该进入中文分支"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_prefers_the_first_installed_candidate() {
|
||||
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
|
||||
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
|
||||
assert_eq!(
|
||||
decide(
|
||||
Lang::ZhCn,
|
||||
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
|
||||
),
|
||||
Strategy::System {
|
||||
family: "Microsoft YaHei"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["PingFang SC"])),
|
||||
Strategy::System {
|
||||
family: "PingFang SC"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
|
||||
Strategy::System {
|
||||
family: "Noto Sans CJK SC"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_without_cjk_fonts_emits_the_download_signal() {
|
||||
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
|
||||
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
|
||||
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
|
||||
let Strategy::NeedsDownload(font) = strategy else {
|
||||
panic!("应当要求下载:{strategy:?}");
|
||||
};
|
||||
assert_eq!(font.family, "Noto Sans SC");
|
||||
assert_eq!(font.license, "OFL-1.1");
|
||||
assert_eq!(font.source, Source::PendingPin, "URL/哈希留给下载 mission");
|
||||
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_fonts_cover_the_required_weights() {
|
||||
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
|
||||
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
|
||||
for weight in REQUIRED_WEIGHTS {
|
||||
assert!(
|
||||
NOTO_SANS_SC.weights.contains(weight),
|
||||
"缺 {weight} 档:{:?}",
|
||||
NOTO_SANS_SC.weights
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
|
||||
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
|
||||
// 也抓得住下载失败被吞掉(错误必须原样透出)。
|
||||
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
|
||||
assert_eq!(
|
||||
ensure(Strategy::System { family: "SimSun" }, &idle),
|
||||
Ok(Fetched::AlreadySystem { family: "SimSun" })
|
||||
);
|
||||
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
|
||||
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
|
||||
|
||||
let ok = FakeFetcher::new(Ok(vec![9; 4]));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
|
||||
Ok(Fetched::Downloaded(vec![9; 4]))
|
||||
);
|
||||
assert_eq!(ok.calls.get(), 1);
|
||||
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
|
||||
|
||||
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
|
||||
Err(FetchError::Network("超时".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_describe_is_one_line_for_every_case() {
|
||||
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
|
||||
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
|
||||
let cases = [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::Unprobeable,
|
||||
];
|
||||
for case in cases {
|
||||
let line = case.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk="), "{line}");
|
||||
}
|
||||
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
|
||||
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
|
||||
}
|
||||
}
|
||||
#[path = "font/tests.rs"]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user