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),每条断言都写了「抓得住什么 / 抓不住什么」。
This commit is contained in:
AskaEth committed 2026-11-02 16:13:22 +08:00
1 parent 1331d90ff4
commit e77a5df6e8
12 files changed
+1418 -3

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
//! 消息目录:`Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 / 缺译诊断。
//!
//! ## 为什么 key 是枚举而不是字符串
//!
//! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。
//! 枚举 key 把这类错误全部前移到**编译期**:
//!
//! ```compile_fail
//! // `Key::NavInstance` 不存在(真名是 `NavInstances`)⇒ E0599,不会编译过。
//! let _ = unlucky_i18n::Key::NavInstance;
//! ```
//!
//! 这条 `compile_fail` 用例本身就是一个测试:哪天有人把 `Key` 改成字符串包装类型,它会**变绿失败**
//! (能编译过 = 编译期校验没了)。
//!
//! ## 为什么两套译文写在**同一行**
//!
//! ```ignore
//! NavInstances => ("Instances", "实例"),
//! ```
//!
//! 一行就是「一个 key 的全部语言」。于是「加了 key 忘了译文」在结构上不可能发生——这比「运行时发现
//! 缺译再诊断」更强。缺译诊断([`Catalog::diagnose`])因此有两个真实用途:
//!
//! 1. **注入目录**:后续 mission 用 [`Catalog::from_entries`] 加第三套(繁体 / 更多语言)时,
//! 表格是手写的,缺条目的风险回来了;
//! 2. **回归守门**:万一有人把这张表改回「两套各写一遍」,[`Catalog::missing`] 的守卫测试立刻红。
//!
//! ## 缺译时给什么
//!
//! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知):
//!
//! - [`Catalog::get`] → `None`(严格调用方用它);
//! - [`Catalog::text`] → [`MISSING`](`⟪missing⟫`,UI 上刺眼但一眼可辨);
//! - [`Catalog::render`] → `⟪missing:KeyName⟫`(带上 key 名,方便照着日志补译)。
use crate::lang::Lang;
/// 缺译占位符(`text()` 的返回;故意刺眼——宁可难看,也不许静默顶替)。
pub const MISSING: &str = "⟪missing⟫";
/// 生成 `Key` 枚举 + 两套常量表(一行 = 一个 key 的全部语言)。
macro_rules! catalog {
($($variant:ident => ($en:literal, $zh:literal)),+ $(,)?) => {
/// 可翻译文案的 key。**不存在时编译失败**(枚举变体名即契约)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Key {
$(
#[doc = concat!("en: `", $en, "` / zh-CN: `", $zh, "`")]
$variant,
)+
}
impl Key {
/// 全部 key(缺译诊断与完整性测试的遍历源)。
pub const ALL: &'static [Key] = &[$(Key::$variant),+];
/// 变体名(日志 / 缺译诊断里用来指认 key;不是文案)。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
$(Key::$variant => stringify!($variant),)+
}
}
}
const EN: &[(Key, &str)] = &[$((Key::$variant, $en)),+];
const ZH_CN: &[(Key, &str)] = &[$((Key::$variant, $zh)),+];
};
}
catalog! {
// ── 导航轨(`pages::Route` 的四项)
NavInstances => ("Instances", "实例"),
NavDownload => ("Downloads", "下载"),
NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"),
// ── 实例页
InstancesNew => ("New instance", "新建实例"),
InstancesImport => ("Import", "导入"),
InstancesBack => ("Back to instances", "返回实例列表"),
InstancesPlay => ("Play", "启动"),
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
LoaderVanilla => ("Vanilla", "原版"),
// ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key)
LaunchOutcomeNormal => ("Finished normally", "正常结束"),
LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"),
LaunchOutcomeSilent => (
"Suspected silent failure: exit code 0, but the game never really started",
"疑似静默失败:退出码 0,但游戏没真正跑起来"
),
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
// ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退)
FontCjkNeedsDownload => (
"No Chinese font found on this system. Download an open-source (OFL) Chinese font?",
"系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"
),
FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
FontCjkDownloadFailed => (
"Chinese font download failed; the interface stays in English.",
"中文字体下载失败,界面暂时保持英文。"
),
FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"),
// ── 设置
SettingsLanguage => ("Language", "界面语言"),
SettingsLanguageHint => (
"Switches the interface language. Chinese text needs a Chinese font.",
"切换界面语言。显示中文需要中文字体。"
),
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
}
/// 一套语言的目录(不可变;`Copy`,随手传值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Catalog {
lang: Lang,
entries: &'static [(Key, &'static str)],
}
impl Catalog {
/// 内置目录。
#[must_use]
pub const fn of(lang: Lang) -> Catalog {
let entries: &'static [(Key, &'static str)] = match lang {
Lang::En => EN,
Lang::ZhCn => ZH_CN,
};
Catalog { lang, entries }
}
/// 用外部表构造目录(给后续 mission 的第三套语言 / 测试注入用)。
#[must_use]
pub const fn from_entries(lang: Lang, entries: &'static [(Key, &'static str)]) -> Catalog {
Catalog { lang, entries }
}
/// 这套目录是哪种语言。
#[must_use]
pub const fn lang(&self) -> Lang {
self.lang
}
/// 严格取词:缺译就是 `None`(**不回落**到别的语言)。
#[must_use]
pub fn get(&self, key: Key) -> Option<&'static str> {
self.entries
.iter()
.find(|(entry, _)| *entry == key)
.map(|(_, text)| *text)
}
/// 这套目录的全部条目(声明顺序)。给导出 / 校对工具与完整性守门测试用。
#[must_use]
pub const fn entries(&self) -> &'static [(Key, &'static str)] {
self.entries
}
/// 取词(UI 便利路径):缺译返回 [`MISSING`]。
#[must_use]
pub fn text(&self, key: Key) -> &'static str {
self.get(key).unwrap_or(MISSING)
}
/// 带变量的句子:把 `{name}` 换掉。
///
/// 参数没给全时**占位符原样留在文案里**(`Launch failed (exit {code})`),不 panic、不猜空串——
/// 缺参在截图上一眼可见,比静默变成 `exit ` 好查。
#[must_use]
pub fn render(&self, key: Key, args: &[(&str, &str)]) -> String {
let Some(template) = self.get(key) else {
return format!("⟪missing:{}⟫", key.name());
};
let mut out = template.to_owned();
for (name, value) in args {
out = out.replace(&format!("{{{name}}}"), value);
}
out
}
/// 缺译的 key(自带语言里从来是空;注入目录与回归守门才用得上)。
#[must_use]
pub fn missing(&self) -> Vec<Key> {
Key::ALL
.iter()
.copied()
.filter(|key| self.get(*key).is_none())
.collect()
}
/// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。
#[must_use]
pub fn diagnose(&self) -> Option<String> {
let missing = self.missing();
if missing.is_empty() {
return None;
}
let mut out = format!("{} 缺 {} 条译文:", self.lang.tag(), missing.len());
for key in missing {
let reference = Catalog::of(Lang::En)
.get(key)
.map_or_else(|| "<en 也缺>".to_owned(), |text| format!("\"{text}\""));
out.push_str(&format!("\n - {} · en={reference}", key.name()));
}
Some(out)
}
}
/// 抽取文案里的 `{name}` 占位符(守门测试用:同一 key 的两套译文占位符必须一致)。
#[must_use]
pub fn placeholders(text: &str) -> Vec<&str> {
let mut found = Vec::new();
let mut rest = text;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
break;
};
found.push(&after[..close]);
rest = &after[close + 1..];
}
found
}
#[cfg(test)]
mod tests {
use super::*;
/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。
const PARTIAL_ZH: &[(Key, &str)] = &[
(Key::NavInstances, "实例"),
(Key::NavLogs, "日志"),
(Key::NavSettings, "设置"),
(Key::NavDownload, "下载"),
(Key::InstancesNew, "新建实例"),
(Key::InstancesImport, "导入"),
(Key::InstancesBack, "返回实例列表"),
(Key::InstancesPlay, "启动"),
(Key::InstancesLastPlayed, "上次游玩 {when}"),
(Key::InstancesEmptyTitle, "未选择实例"),
(Key::InstancesEmptyBody, "选择一个实例查看详情。"),
(Key::LoaderVanilla, "原版"),
(Key::LaunchOutcomeNormal, "正常结束"),
(Key::LaunchOutcomeCrash, "游戏崩溃"),
// 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated
];
#[test]
fn built_in_catalogs_have_every_key() {
// 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。
// 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。
// 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。
for lang in Lang::ALL {
let catalog = Catalog::of(*lang);
assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag());
assert_eq!(catalog.diagnose(), None);
let mut keys = catalog
.entries()
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag());
assert_eq!(
keys.len(),
Key::ALL.len(),
"{} 条目数与 key 数不等",
lang.tag()
);
for key in Key::ALL {
assert!(
catalog.get(*key).is_some(),
"{} 缺 {}",
lang.tag(),
key.name()
);
}
}
assert!(!Key::ALL.is_empty());
}
#[test]
fn key_names_are_unique_and_stable() {
// 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。
// 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。
let mut names = Key::ALL.iter().map(|key| key.name()).collect::<Vec<_>>();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total);
assert_eq!(Key::NavInstances.name(), "NavInstances");
}
#[test]
fn chinese_catalog_is_actually_chinese() {
// 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。
// 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
for key in Key::ALL {
let english = en.text(*key);
assert_ne!(english, zh.text(*key), "{} 未翻译", key.name());
assert!(!english.is_empty() && !zh.text(*key).is_empty());
}
}
#[test]
fn placeholder_sets_match_across_languages() {
// 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名——
// 那会让 render() 输出半截句子,或让参数静默失效。
// 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
for key in Key::ALL {
let mut left = placeholders(en.text(*key));
let mut right = placeholders(zh.text(*key));
left.sort_unstable();
right.sort_unstable();
assert_eq!(left, right, "{}", key.name());
}
assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]);
assert_eq!(placeholders("没有占位符"), Vec::<&str>::new());
}
#[test]
fn missing_rows_are_diagnosed_in_plain_words() {
// 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。
// 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len());
let report = partial.diagnose().expect("应当报缺译");
assert!(report.starts_with("zh-CN 缺 "), "{report}");
assert!(report.contains("LaunchOutcomeSilent"), "{report}");
assert!(
report.contains("Suspected silent failure"),
"诊断要带上英文原文当参考:{report}"
);
assert!(
!report.contains("NavInstances"),
"已译的 key 不该出现在报告里:{report}"
);
}
#[test]
fn missing_translation_never_falls_back_silently() {
// 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。
// 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
assert_eq!(partial.get(Key::LaunchOutcomeSilent), None);
assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING);
assert_eq!(
partial.render(
Key::LaunchFailedWithCode,
&[("code", "0"), ("reason", "JVM 拒绝启动")]
),
"⟪missing:LaunchFailedWithCode⟫"
);
}
#[test]
fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
// 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。
// 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。
let zh = Catalog::of(Lang::ZhCn);
assert_eq!(
zh.render(
Key::LaunchFailedWithCode,
&[("code", "0"), ("reason", "mod 冲突")]
),
"启动失败(退出码 0):mod 冲突"
);
assert_eq!(
zh.render(Key::LaunchFailedWithCode, &[("code", "0")]),
"启动失败(退出码 0):{reason}"
);
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
assert_eq!(zh.lang(), Lang::ZhCn);
}
}