Files
Unlucky-Launcher/crates/i18n/src/font.rs
T
AskaEth e77a5df6e8 feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)
M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。

- lang:`Lang::{En, ZhCn}` + 可注入的 `LocaleSource`(`EnvLocale` 按 POSIX 优先级读
  LC_ALL > LC_MESSAGES > LANG)。未知语种一律英文——把 ja/fr 猜成中文会连带触发中文字体
  下载,比英文界面更糟。配置值(`ui.language` 的 zh_cn/en_us)走严格解析,locale 走宽松
  解析,两条路刻意分开。**已知缺口**:Windows 上这三个变量通常都不存在,
  `GetUserDefaultLocaleName` 属于 platform 的 FFI(后续 mission)。
- catalog:`Key` 枚举(拼错的 key = 编译错误,附 compile_fail 文档测试守着)
  + `catalog!` 宏「一行 = 一个 key 的全部语言」⇒ 内置两套目录缺译在结构上不可能发生。
  缺译诊断(`Catalog::diagnose`)留给注入目录与回归守门:`get` 严格返回 None、
  `text` 返回 ⟪missing⟫、`render` 返回 ⟪missing:KeyName⟫——**不回落**到另一种语言。
  带变量的句子走 `{name}` 占位符,缺参时占位符原样留在文案里(可见,不静默变空)。
- font:`decide(lang, installed)` 是纯函数。英文环境恒 `NotNeeded`(D28 的零字体/零网络);
  中文环境按候选族名单命中系统字体,扫不到就发 `NeedsDownload(Noto Sans SC)` 信号,
  **本 crate 不联网**;`Fetcher` 是注入式接口,网络实现留给后续 mission。
  URL/哈希显式留白(`Source::PendingPin`)——不编造 CDN 路径,猜错的地址会被后面所有人当事实用。
- bridge:文案边界规则全文(R1 不许拼句 / R2 core 中文串不进 UI / R3 未知串不许画原文)
  + `classify()` 示例契约:过渡表把已知漏到界面的中文串映射到 key,品牌专名显式放行,
  其余一律 `Untranslated`(UI 只许显示兜底 key)。
- app-iced/fonts:新增 `fonts/cjk.rs` 适配层,把系统字面表喂给 `decide`;读不到字体库时给
  `Unprobeable` 而不是「需下载」(否则会误触发一次 CDN 下载)。结论并入同一行启动日志:
  `[font] … | lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`。

测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
2026-11-02 16:13:22 +08:00

332 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 中文字体策略(D28 后半):**只做判据,不联网**。
//!
//! ## 三句话
//!
//! 1. **英文环境不要字体**:`Lang::En` → [`Strategy::NotNeeded`],与系统里装了什么无关。
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。
//!
//! ## 扫描为什么在 UI 壳层而不是这里
//!
//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台
//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool`
//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 mission 不引依赖);
//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task`
//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」
//! 属于下载 mission,本 crate 不碰。
use crate::lang::Lang;
/// 已知的系统 CJK 族(Windows / macOS / Linux,按「观感优先」排序,命中第一个就用)。
///
/// 只收**简体**优先的常见族;`Microsoft JhengHei` 这类繁体族留在名单里垫底——
/// 仓内只有一套中文目录(简体),繁体用户看简体的观感仍好过看英文。
pub const CJK_FAMILIES: &[&str] = &[
// Windows 10/11
"Microsoft YaHei UI",
"Microsoft YaHei",
"DengXian",
"SimHei",
"SimSun",
"Microsoft JhengHei",
// macOS
"PingFang SC",
"Hiragino Sans GB",
"Heiti SC",
"Songti SC",
// Linux
"Noto Sans CJK SC",
"Noto Sans SC",
"Source Han Sans SC",
"Source Han Sans CN",
"Sarasa Gothic SC",
"WenQuanYi Micro Hei",
"Droid Sans Fallback",
];
/// 中文字体必须覆盖的字重(M-决定日志 ③:Android 16 的可变思源黑体缺 100–400,
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点),
/// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**:
/// 猜错的 CDN 路径会被后面所有人当事实用。
PendingPin,
}
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CjkFont {
/// 族名(下载并注册后,`Font` 用它取字)。
pub family: &'static str,
/// 许可证(必须是 OFL / Apache 这类可再分发的;D28 明令不得内置 Segoe)。
pub license: &'static str,
/// 这套字体可用的字重。
pub weights: &'static [u16],
/// 下载源(尚未钉死)。
pub source: Source,
}
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。
pub const NOTO_SANS_SC: CjkFont = CjkFont {
family: "Noto Sans SC",
license: "OFL-1.1",
weights: &[300, 400, 500, 700, 900],
source: Source::PendingPin,
};
/// 字体策略结论。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
/// 不需要中文字体(英文环境)。
NotNeeded,
/// 系统里已有可用族,直接用。
System {
/// 命中的族名。
family: &'static str,
},
/// 需要下载(本模块只发信号,不联网)。
NeedsDownload(&'static CjkFont),
/// 探测不出(拿不到系统字体库)——**不等于**「需下载」:不许凭猜测去下载。
Unprobeable,
}
impl Strategy {
/// 一行日志片段(`cjk=system:"Microsoft YaHei"` / `cjk=needs-download(Noto Sans SC)` …)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Strategy::NotNeeded => "cjk=not-needed".to_owned(),
Strategy::System { family } => format!("cjk=system:\"{family}\""),
Strategy::NeedsDownload(font) => format!("cjk=needs-download({})", font.family),
Strategy::Unprobeable => "cjk=unprobeable".to_owned(),
}
}
}
/// 判据(纯函数):语言 + 「某族装没装」→ 策略。
///
/// `installed` 由调用方提供(iced 版查系统字体库,单测给假闭包)。
#[must_use]
pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
if lang != Lang::ZhCn {
// D28:纯英文环境不加载任何中文字体,也不下载。
return Strategy::NotNeeded;
}
CJK_FAMILIES
.iter()
.copied()
.find(|family| installed(family))
.map_or(Strategy::NeedsDownload(&NOTO_SANS_SC), |family| {
Strategy::System { family }
})
}
/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。
pub trait Fetcher {
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
///
/// # Errors
/// 网络失败 / 校验失败等人话错误。
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
}
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FetchError {
/// 网络层失败(超时 / 连不上 / 非 2xx)。
Network(String),
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
Integrity(String),
}
/// [`ensure`] 的结局。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fetched {
/// 本来就不需要(英文环境)。
NothingToDo,
/// 系统已有,直接用。
AlreadySystem {
/// 命中的族名。
family: &'static str,
},
/// 下载成功,拿到字节(交给调用方注册)。
Downloaded(Vec<u8>),
/// 探测不出,什么都没做。
Unknown,
}
/// 按策略执行:需要下载时才碰 [`Fetcher`]。
///
/// # Errors
/// 下载器返回的错误原样透出(不吞、不改写分类)。
pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, FetchError> {
match strategy {
Strategy::NotNeeded => Ok(Fetched::NothingToDo),
Strategy::System { family } => Ok(Fetched::AlreadySystem { family }),
Strategy::Unprobeable => Ok(Fetched::Unknown),
Strategy::NeedsDownload(font) => fetcher.fetch(font).map(Fetched::Downloaded),
}
}
#[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");
}
}