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:
1 parent
1331d90ff4
commit
e77a5df6e8
12 files changed
+1418
-3
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user