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
Generated
+5
@@ -6905,6 +6905,7 @@ dependencies = [
|
||||
"image",
|
||||
"unlucky-cli",
|
||||
"unlucky-design",
|
||||
"unlucky-i18n",
|
||||
"unlucky-presenter",
|
||||
]
|
||||
|
||||
@@ -6942,6 +6943,10 @@ dependencies = [
|
||||
name = "unlucky-design"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-i18n"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-palette-extract"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -15,6 +15,9 @@ members = [
|
||||
# M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方,
|
||||
# 使 `crates/design` 能继续宣称「零第三方依赖」。
|
||||
"crates/palette-extract",
|
||||
# M92(D28):国际化地基——语言探测 / 中英消息目录 / 中文字体策略。零第三方依赖、零 UI 依赖,
|
||||
# 壳层(iced 版现在,别的壳将来)与 presenter 都依赖它,所以它不能认任何一个渲染框架。
|
||||
"crates/i18n",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -50,6 +50,10 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.1" }
|
||||
|
||||
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
||||
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
||||
unlucky-i18n = { path = "../i18n", version = "0.1" }
|
||||
|
||||
# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal +
|
||||
# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是
|
||||
# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个,
|
||||
|
||||
@@ -20,7 +20,17 @@
|
||||
//!
|
||||
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||||
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||||
//!
|
||||
//! ## 中文那一半(M92 / D28 后半)
|
||||
//!
|
||||
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
|
||||
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
|
||||
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
|
||||
//! cosmic-text 的全局状态,不是本模块能管的事)。
|
||||
|
||||
mod cjk;
|
||||
|
||||
use cjk::CjkReport;
|
||||
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||||
|
||||
use crate::theme::Families;
|
||||
@@ -56,6 +66,8 @@ pub struct Resolved {
|
||||
pub families: [FamilyReport; 3],
|
||||
/// 三档字重的落点(对**正文**栈求值)。
|
||||
pub weights: [WeightReport; 3],
|
||||
/// 语言 + 中文环境的中文字体策略(D28;英文环境恒为 `NotNeeded`)。
|
||||
pub cjk: CjkReport,
|
||||
}
|
||||
|
||||
impl Resolved {
|
||||
@@ -75,7 +87,7 @@ impl Resolved {
|
||||
.any(|weight| weight.quality == WeightMatch::Synthetic)
|
||||
}
|
||||
|
||||
/// 启动日志(两行:一行族,一行字重)。
|
||||
/// 启动日志(**一行**:族 / 字重 / 语言与中文字体策略)。
|
||||
pub fn describe(&self) -> String {
|
||||
let families = self
|
||||
.families
|
||||
@@ -110,8 +122,9 @@ impl Resolved {
|
||||
.join(" ");
|
||||
|
||||
format!(
|
||||
"[font] platform={} {families} | weights {weights}",
|
||||
self.platform.name()
|
||||
"[font] platform={} {families} | weights {weights} | {}",
|
||||
self.platform.name(),
|
||||
self.cjk.describe()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,10 +163,15 @@ pub fn resolve() -> Resolved {
|
||||
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||||
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe(faces.as_deref());
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
families,
|
||||
weights,
|
||||
cjk: cjk_report,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 中文环境的 CJK 字体探测(D28 后半):把 iced 系统字体库里的族名喂给 `unlucky_i18n::font::decide`。
|
||||
//!
|
||||
//! ## 分工
|
||||
//!
|
||||
//! - **判据**(要不要中文字体、哪些族算候选、缺了要不要下载)全在 `unlucky_i18n::font`,是纯函数,
|
||||
//! 零 UI 依赖、脱机可测;
|
||||
//! - **本模块只做适配**:把 [`super::Face`] 列表变成一个「某族装没装」的查询,并处理
|
||||
//! **读不到字体库**这种情况。
|
||||
//!
|
||||
//! ## 为什么「读不到」不等于「需下载」
|
||||
//!
|
||||
//! `system_faces()` 拿不到字体库时返回 `None`。把它当成「没有中文字体」会直接触发一次 CDN 下载
|
||||
//! (D28 的自动下载路径),而真相是**我们不知道**。所以这一路走 [`Strategy::Unprobeable`]:
|
||||
//! 日志里留痕,不下载、不弹提示。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);
|
||||
//! - **抓不住**:族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` 查 cmap,
|
||||
//! 本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`)。
|
||||
|
||||
use unlucky_i18n::font::{self, Strategy};
|
||||
use unlucky_i18n::lang::{self, Detection};
|
||||
|
||||
use super::Face;
|
||||
|
||||
/// 一次中文探测的结论(启动日志的第二段)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CjkReport {
|
||||
/// 语言探测(含原始 locale,便于解释「为什么是英文」)。
|
||||
pub detection: Detection,
|
||||
/// 字体策略结论。
|
||||
pub strategy: Strategy,
|
||||
}
|
||||
|
||||
impl CjkReport {
|
||||
/// 一行日志片段(`lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`)。
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{} {}", self.detection.describe(), self.strategy.describe())
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定语言 + 字体库内容做判定。
|
||||
///
|
||||
/// `faces` 为 `None` = 字体库读不出来(→ [`Strategy::Unprobeable`],**不是**「需下载」)。
|
||||
pub fn report(detection: Detection, faces: Option<&[Face]>) -> CjkReport {
|
||||
let strategy = faces.map_or(Strategy::Unprobeable, |faces| {
|
||||
let is_installed = |family: &str| faces.iter().any(|face| face.family == family);
|
||||
font::decide(detection.lang, &is_installed)
|
||||
});
|
||||
|
||||
CjkReport {
|
||||
detection,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测本机(locale 走环境变量;换 Win32 真值源是后续 mission 的注入点)。
|
||||
pub fn probe(faces: Option<&[Face]>) -> CjkReport {
|
||||
report(lang::detect_env(), faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn faces(families: &[&str]) -> Vec<Face> {
|
||||
families
|
||||
.iter()
|
||||
.map(|family| Face {
|
||||
family: (*family).to_owned(),
|
||||
weight: 400,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn zh() -> Detection {
|
||||
Detection {
|
||||
lang: unlucky_i18n::Lang::ZhCn,
|
||||
raw: Some("zh_CN.UTF-8".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn en() -> Detection {
|
||||
Detection {
|
||||
lang: unlucky_i18n::Lang::En,
|
||||
raw: Some("en_US.UTF-8".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_host_with_a_cjk_font_uses_the_system_one() {
|
||||
// 抓得住:适配层把字体库查反/查漏(例如只按第一个族判断、或把 `Segoe UI` 当候选)。
|
||||
// 抓不住:这台机器实测有没有这个族(测试用的是注入的假字面表)。
|
||||
let installed = faces(&["Segoe UI", "Microsoft YaHei", "Cascadia Mono"]);
|
||||
let report = report(zh(), Some(&installed));
|
||||
assert_eq!(
|
||||
report.strategy,
|
||||
Strategy::System {
|
||||
family: "Microsoft YaHei"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
report.describe(),
|
||||
"lang=zh-CN locale=zh_CN.UTF-8 cjk=system:\"Microsoft YaHei\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_host_without_cjk_fonts_gets_the_download_signal() {
|
||||
// 抓得住:D28 的「中文环境扫不到 → 要下载」这条路(信号必须能被日志看出来)。
|
||||
// 抓不住:信号之后的下载与提示(本 mission 不联网,页面也没接)。
|
||||
let latin_only = faces(&["Segoe UI", "Roboto"]);
|
||||
let report = report(zh(), Some(&latin_only));
|
||||
assert_eq!(
|
||||
report.strategy,
|
||||
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC)
|
||||
);
|
||||
assert!(report
|
||||
.describe()
|
||||
.contains("cjk=needs-download(Noto Sans SC)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_host_never_asks_for_a_chinese_font() {
|
||||
// 抓得住:英文环境的「零字体负担」(D28)——系统里有没有中文字体都不影响结论。
|
||||
// 抓不住:程序真的没去读那些字体文件(那是 iced/cosmic-text 的加载行为)。
|
||||
assert_eq!(report(en(), Some(&[])).strategy, Strategy::NotNeeded);
|
||||
assert_eq!(
|
||||
report(en(), Some(&faces(&["Microsoft YaHei"]))).strategy,
|
||||
Strategy::NotNeeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_font_database_is_unknown_not_missing() {
|
||||
// 抓得住:把「读不到字体库」错判成「缺字体」——那会在探测失败时触发一次多余的 CDN 下载。
|
||||
// 抓不住:真的读不到字体库时的运行行为(那条路径要 poisoned lock,测试造不出来)。
|
||||
let report = report(zh(), None);
|
||||
assert_eq!(report.strategy, Strategy::Unprobeable);
|
||||
assert!(
|
||||
report.describe().ends_with("cjk=unprobeable"),
|
||||
"{}",
|
||||
report.describe()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。
|
||||
|
||||
use super::cjk::CjkReport;
|
||||
use super::*;
|
||||
use unlucky_design::typography::WeightCoverage;
|
||||
use unlucky_i18n::font::Strategy;
|
||||
use unlucky_i18n::lang::Detection;
|
||||
|
||||
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |family| installed.contains(&family)
|
||||
@@ -139,6 +142,13 @@ fn describe_is_one_line_and_mentions_all_three_stacks() {
|
||||
quality: WeightMatch::Synthetic,
|
||||
},
|
||||
],
|
||||
cjk: CjkReport {
|
||||
detection: Detection {
|
||||
lang: unlucky_i18n::Lang::En,
|
||||
raw: None,
|
||||
},
|
||||
strategy: Strategy::NotNeeded,
|
||||
},
|
||||
};
|
||||
|
||||
let line = resolved.describe();
|
||||
@@ -146,6 +156,7 @@ fn describe_is_one_line_and_mentions_all_three_stacks() {
|
||||
assert!(line.contains("text=\"Segoe UI\""), "{line}");
|
||||
assert!(line.contains("未装机"), "{line}");
|
||||
assert!(line.contains("w700=400(synthetic)"), "{line}");
|
||||
assert!(line.contains("cjk=not-needed"), "英文环境要进日志:{line}");
|
||||
assert!(resolved.has_synthetic());
|
||||
assert_eq!(resolved.families().text, "Segoe UI");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "unlucky-i18n"
|
||||
description = "Unlucky-Launcher 国际化地基(D28):语言探测 / 中英消息目录 + 编译期 key 校验 / 缺译诊断 / 中文字体策略与注入式下载器接口"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# 依赖面必须保持**空**(与 `unlucky-design` 同一条纪律):
|
||||
# 1. 它要被 UI 层(iced / 将来的任何壳)与 `presenter` 同时依赖,一旦引入 iced/slint 就锁死了渲染框架;
|
||||
# 2. 它是**纯数据 + 纯函数**:消息目录是 `&'static str` 常量表,探测与字体扫描全部靠**注入**拿外部世界;
|
||||
# 3. 语言探测/字体策略是启动路径上的第一件事,不该为了一句 "en"/"zh-CN" 拉进任何运行时。
|
||||
# 证据命令:cargo tree -p unlucky-i18n --edges normal(应当只打印它自己)。
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 文案边界(示例契约):core / presenter 的**状态**怎么进 UI。
|
||||
//!
|
||||
//! ## 现状(为什么需要这份契约)
|
||||
//!
|
||||
//! UI 文案硬编码英文,而 `core` / `presenter` 产出的状态串是中文,于是界面上已经出现
|
||||
//! `Launch failed · exit0 静默失败` 这种**一句话里中英混排**的东西,加载器徽标也在 `原版` / `Fabric`
|
||||
//! 之间混用。根因不是「译文不够」,是**边界没划**:谁产出文案、谁负责翻译,没有规则。
|
||||
//!
|
||||
//! ## 三条规则
|
||||
//!
|
||||
//! **R1 · UI 侧不产生文案,只翻译 key。**
|
||||
//! UI 不许把片段拼成句子(`"Launch failed · " + reason`),也不许写中文字面量。
|
||||
//! 一句话 = 一个 key([`crate::catalog`]),带变量的用 `{name}` 交给
|
||||
//! [`crate::catalog::Catalog::render`]。拼接一旦发生,任何一套语言都拼不对(语序、标点、
|
||||
//! 量词都不一样),而且混排会立刻出现在截图里。
|
||||
//!
|
||||
//! **R2 · core 的中文串不进 UI。**
|
||||
//! core / presenter 的中文(`原版`、`静默失败(exit 0 但游戏没起来)`、
|
||||
//! `缺少库文件 foo.jar(…)`)是**日志 / CLI / 诊断报告**的文案,不是界面文案。
|
||||
//! 界面只能通过两条合法通道拿到「要显示什么」:
|
||||
//! 1. **结构化出口**(首选):枚举 / DTO 字段 → 映射到 [`crate::Key`]。
|
||||
//! 例:`presenter::dto::LoaderKind::as_str()` 的稳定 id(`vanilla` / `fabric`)→
|
||||
//! [`crate::Key::LoaderVanilla`];`LogOutcome` 三态 → `LaunchOutcome*` 三 key。
|
||||
//! 好处是**新增一个枚举变体时编译器会逼你补映射**(`match` 非穷尽即报错)。
|
||||
//! 2. **过渡表**([`classify`]):只覆盖「已知会漏到界面的少数几个串」,逐条硬编码。
|
||||
//! 它是**债**,不是终点——新出现的未知串一律走第三条。
|
||||
//!
|
||||
//! **R3 · 认不出的串,不许画原文。**
|
||||
//! [`CoreText::Untranslated`] 就是那种情况:UI 只许显示 [`crate::Key::CoreTextUntranslated`]
|
||||
//! 这个**故意难看**的兜底(`[system text not translated]`),并把原文写进日志。
|
||||
//! 这条比「先画上去以后再翻」严格,是因为后者永远不会被回头修——难看才会被修。
|
||||
//! 同时它是一件**上游缺陷的证据**:说明 core 欠一个结构化出口,应当报 finding 而不是在 UI 贴创可贴。
|
||||
//!
|
||||
//! ## 品牌专名(唯一的豁免)
|
||||
//!
|
||||
//! `Fabric` / `Forge` / `NeoForge` / `Quilt` / `OptiFine` / `LiteLoader` 是专有名词,**不翻译**
|
||||
//! (翻成「锻造」就没人认识了)。它们可以原样显示,但必须经 [`CoreText::Brand`] 判定——
|
||||
//! 也就是说「原样显示」是一个**显式决定**,不是「反正没译文就画原文」的默认行为。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:本表里列到的串(映射正确、未知串不被放行);
|
||||
//! - **抓不住**:core 明天新加一句中文——本 crate 不依赖 core(零依赖纪律),没有跨 crate 的一致性检查。
|
||||
//! 那条只能靠「core 侧只给枚举」的纪律 + 后续在 `crates/presenter` 里加一条守门测试
|
||||
//! (例如:`*_view` 的字符串字段不许含 CJK 字符)。
|
||||
|
||||
use crate::catalog::Key;
|
||||
|
||||
/// 一段来自 `core` / `presenter` 的显示文本该怎么处理。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CoreText {
|
||||
/// 品牌专名:原样显示(不翻译,见模块文档的豁免)。
|
||||
Brand(&'static str),
|
||||
/// 有对应 key:按当前语言取词。
|
||||
Key(Key),
|
||||
/// 不认识:只许显示 [`Key::CoreTextUntranslated`],原文进日志。
|
||||
Untranslated,
|
||||
}
|
||||
|
||||
/// 已知会漏到界面的中文状态串 → key。
|
||||
///
|
||||
/// 每一条都得在 `crates/presenter` / `crates/core` 里找得到出处(注释里给位置),
|
||||
/// 不许凭想象加——凭空加的条目会让 [`classify`] 假装覆盖了实际没覆盖的东西。
|
||||
const LEGACY_STATUS: &[(&str, Key)] = &[
|
||||
// `presenter::dto::instances::LoaderKind::label()` / `host::launch::loader_label()`
|
||||
("原版", Key::LoaderVanilla),
|
||||
// `presenter::host::job.rs`:`LogOutcome::SilentFail` 的展示文字
|
||||
("静默失败(exit 0 但游戏没起来)", Key::LaunchOutcomeSilent),
|
||||
// `presenter::mock_data.rs` 里的演示数据
|
||||
("exit0 静默失败", Key::LaunchOutcomeSilent),
|
||||
];
|
||||
|
||||
/// 品牌专名(原样显示;**不得**翻译或拼进句子,UI 拼装仍然禁止)。
|
||||
const BRANDS: &[&str] = &[
|
||||
"Fabric",
|
||||
"Forge",
|
||||
"NeoForge",
|
||||
"Quilt",
|
||||
"OptiFine",
|
||||
"LiteLoader",
|
||||
];
|
||||
|
||||
/// 契约总入口:core / presenter 的显示文本 → 该画什么。
|
||||
///
|
||||
/// 这是**示例契约**:形状(三分类 + 未知不放行)比表里的具体条目重要,接线 mission 按这个形状
|
||||
/// 把每条 UI 路径改过来,条目随上游结构化出口的落地逐条删掉。
|
||||
#[must_use]
|
||||
pub fn classify(text: &str) -> CoreText {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return CoreText::Untranslated;
|
||||
}
|
||||
|
||||
if let Some((_, key)) = LEGACY_STATUS.iter().find(|(legacy, _)| *legacy == trimmed) {
|
||||
return CoreText::Key(*key);
|
||||
}
|
||||
|
||||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == trimmed) {
|
||||
return CoreText::Brand(brand);
|
||||
}
|
||||
|
||||
// `Fabric 0.16.5` 这类「品牌 + 版本号」也是合法显示:品牌之后只跟一个版本号时放行。
|
||||
// 注意这里是**保守**放行:只有「一个词 + 空格 + 版本号」的形状才算,含中文的串绝不会进来。
|
||||
if let Some((head, tail)) = trimmed.split_once(' ') {
|
||||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == head) {
|
||||
if !tail.is_empty() && !tail.contains(char::is_whitespace) && !tail.chars().any(is_cjk)
|
||||
{
|
||||
return CoreText::Brand(brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoreText::Untranslated
|
||||
}
|
||||
|
||||
/// 是否 CJK 字符(`classify` 的保守放行判据用)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
// 0x2E80..=0x9FFF 已含 CJK 部首 / 假名 / 统一表意文字与 0x3000 段的中文标点,不再单列。
|
||||
matches!(ch as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_chinese_status_strings_map_to_keys() {
|
||||
// 抓得住:过渡表条目的映射写错(把「原版」映到别的 key),以及「同一段意思两个 key」的漂移。
|
||||
// 抓不住:core 那边改了这个中文串的措辞——那时这里会落到 Untranslated(**故意的**,
|
||||
// 让漏译可见,而不是继续拿旧文案糊过去)。
|
||||
assert_eq!(classify("原版"), CoreText::Key(Key::LoaderVanilla));
|
||||
assert_eq!(
|
||||
classify("静默失败(exit 0 但游戏没起来)"),
|
||||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||||
);
|
||||
assert_eq!(
|
||||
classify("exit0 静默失败"),
|
||||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(" 原版 "),
|
||||
CoreText::Key(Key::LoaderVanilla),
|
||||
"前后空白要吃掉"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brands_pass_through_and_are_never_translated() {
|
||||
// 抓得住:品牌名被当成待翻译文案(`Fabric` → 某个 key 的译文就没人认识它了),
|
||||
// 以及「品牌 + 版本号」这种合法徽标被判成未翻译。
|
||||
// 抓不住:品牌名与版本号之间该用什么分隔(M3 令牌 / 排版层的事,不是本契约的事)。
|
||||
assert_eq!(classify("Fabric"), CoreText::Brand("Fabric"));
|
||||
assert_eq!(classify("Fabric 0.16.5"), CoreText::Brand("Fabric"));
|
||||
assert_eq!(classify("NeoForge 21.1.72"), CoreText::Brand("NeoForge"));
|
||||
assert_eq!(classify("Fabric 中文"), CoreText::Untranslated);
|
||||
assert_eq!(classify("Fabric 0.16.5 beta"), CoreText::Untranslated);
|
||||
assert_eq!(classify("FabricX"), CoreText::Untranslated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_core_prose_is_refused_instead_of_printed_raw() {
|
||||
// 抓得住:R3——未知中文(core 的诊断prose、`describe_warning` 的拼句)被放行到界面,
|
||||
// 那正是 `Launch failed · exit0 静默失败` 这类混排的来源。
|
||||
// 抓不住:UI 拿到 Untranslated 之后有没有真的只显示兜底(页面侧测试 + 截图验收)。
|
||||
let leaks = [
|
||||
"缺少库文件 lwjgl.jar(C:\\Users\\x\\.minecraft\\libs\\lwjgl.jar)",
|
||||
"版本 JSON 没有 mainClass",
|
||||
"退出码为 0 但疑似静默失败(3 条错误行):游戏可能刚起来就被 mod 异常终止",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
for leak in leaks {
|
||||
assert_eq!(classify(leak), CoreText::Untranslated, "{leak}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_never_returns_a_raw_unknown_string() {
|
||||
// 抓得住:将来给 `CoreText` 加了「原样字符串」变体(那等于把 R3 废掉)。
|
||||
// 抓不住:枚举里已有变体被误用——那要靠调用点 review。
|
||||
let odd = "some untranslated english";
|
||||
assert_eq!(classify(odd), CoreText::Untranslated);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! 中文字体策略(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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 语言与系统 locale 探测(D28 前半)。
|
||||
//!
|
||||
//! ## 判据只有两条
|
||||
//!
|
||||
//! 1. **中文环境**(`zh*` 任意变体)→ [`Lang::ZhCn`];
|
||||
//! 2. **其它一切**(`en*`、`ja*`、`fr*`、`C`、`POSIX`、变量缺失、解析不了)→ [`Lang::En`]。
|
||||
//!
|
||||
//! 「其它一切 → 英文」不是偷懒:D28 的原话是「纯英文环境不用管」,而把 ja/fr/de 猜成中文才是真错
|
||||
//! (会连带触发中文字体下载)。宁可给一个英文界面,也不给一个语种错误的中文界面。
|
||||
//!
|
||||
//! ## 为什么 locale 源要能注入
|
||||
//!
|
||||
//! [`detect`] 的形状是 `detect(&dyn LocaleSource)`,[`EnvLocale`] 只是其中一个实现。这样
|
||||
//! **单测不依赖跑测试那台机器的 locale**(真值表测试在 [`lang`] 自己的 `tests` 里跑固定输入),
|
||||
//! 后续 mission 加「Win32 `GetUserDefaultLocaleName`」或「读配置里的 `ui.language`」也只是再实现一遍
|
||||
//! 这个 trait。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符);
|
||||
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);
|
||||
//! - **抓不住**:**Windows 上这三个变量通常都不存在**,`GetUserDefaultLocaleName` 才是真值源。
|
||||
//! 本 mission 不碰 FFI(`app-iced::platform` 才是全仓唯一 `unsafe` 落点),所以 Windows 目前
|
||||
//! 一律落到 [`Lang::En`];配置里的 `ui.language` 覆盖也是后续接线 mission 的事。两者都是**已知缺口**,
|
||||
//! 不是「已完成」。
|
||||
|
||||
use std::env;
|
||||
|
||||
use crate::catalog::Catalog;
|
||||
|
||||
/// 界面语言。目前只有中英两套(D28 的「至少 zh-CN / en-US」)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Lang {
|
||||
/// 英文(默认;未知 locale 也落在这里)。
|
||||
En,
|
||||
/// 简体中文。
|
||||
ZhCn,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
/// 全部语言(顺序即设置页下拉框的顺序)。
|
||||
pub const ALL: &'static [Lang] = &[Lang::En, Lang::ZhCn];
|
||||
|
||||
/// BCP-47 标签(日志 / 字体请求里用;`zh-CN` 是本 crate 唯一的中文标识)。
|
||||
#[must_use]
|
||||
pub const fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en",
|
||||
Lang::ZhCn => "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置值(`core::setting::keys::LANGUAGES` 的同一份词表)。
|
||||
#[must_use]
|
||||
pub const fn setting_value(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en_us",
|
||||
Lang::ZhCn => "zh_cn",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析**配置值**(严格):只认 [`Lang::setting_value`] 那两个字符串。
|
||||
///
|
||||
/// 与宽松的 [`Lang::from_locale`] 分工明确:配置是我们自己写进去的,写错就该暴露(`None` 让设置层
|
||||
/// 报错),而不是悄悄换成别的语言;系统 locale 是别人给的,只能尽量理解。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Option<Lang> {
|
||||
Lang::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|lang| lang.setting_value() == value)
|
||||
}
|
||||
|
||||
/// 解析 **locale / 语言标签**(宽松):`zh`、`zh_CN.UTF-8`、`zh-Hans-CN`、`ZH_cn` 都是中文。
|
||||
///
|
||||
/// 只看**主语言子标签**:`zh-TW` / `zh-Hant` 也归 [`Lang::ZhCn`]——仓内只有一套中文目录,
|
||||
/// 繁体用户看简体比看英文更有用;真要繁体得加第三套目录(见 `bridge` 的上游约定)。
|
||||
#[must_use]
|
||||
pub fn from_locale(tag: &str) -> Lang {
|
||||
let trimmed = tag.trim();
|
||||
// `zh_CN.UTF-8@euro`:codeset 与修饰符与语种无关,先切掉。
|
||||
let without_codeset = trimmed.split('.').next().unwrap_or("");
|
||||
let without_modifier = without_codeset.split('@').next().unwrap_or("");
|
||||
let primary = without_modifier
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if primary == "zh" {
|
||||
Lang::ZhCn
|
||||
} else {
|
||||
Lang::En
|
||||
}
|
||||
}
|
||||
|
||||
/// 本语言的消息目录。
|
||||
#[must_use]
|
||||
pub const fn catalog(self) -> Catalog {
|
||||
Catalog::of(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 没有任何 locale 信息时的落点(D28:纯英文环境不用管)。
|
||||
impl Default for Lang {
|
||||
fn default() -> Self {
|
||||
Lang::En
|
||||
}
|
||||
}
|
||||
|
||||
/// locale 的来源。实现方负责回答「这台机器说哪种语言」。
|
||||
pub trait LocaleSource {
|
||||
/// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。
|
||||
fn locale(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct EnvLocale;
|
||||
|
||||
impl LocaleSource for EnvLocale {
|
||||
fn locale(&self) -> Option<String> {
|
||||
// POSIX 优先级:LC_ALL > LC_MESSAGES > LANG。空串按「没设置」处理。
|
||||
["LC_ALL", "LC_MESSAGES", "LANG"]
|
||||
.iter()
|
||||
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测结论(`raw` 一起带出来,是为了日志能解释「为什么是英文」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Detection {
|
||||
/// 结论。
|
||||
pub lang: Lang,
|
||||
/// 原始 locale 标签(`None` = 一个变量都没取到)。
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// 一行日志(`lang=zh-CN locale=zh_CN.UTF-8` / `lang=en locale=<未设置>`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"lang={} locale={}",
|
||||
self.lang.tag(),
|
||||
self.raw.as_deref().unwrap_or("<未设置>")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定来源探测语言。
|
||||
#[must_use]
|
||||
pub fn detect(source: &dyn LocaleSource) -> Detection {
|
||||
let raw = source.locale();
|
||||
let lang = raw.as_deref().map_or(Lang::En, Lang::from_locale);
|
||||
Detection { lang, raw }
|
||||
}
|
||||
|
||||
/// 用进程环境变量探测语言(启动日志用)。
|
||||
#[must_use]
|
||||
pub fn detect_env() -> Detection {
|
||||
detect(&EnvLocale)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Fixed(Option<&'static str>);
|
||||
|
||||
impl LocaleSource for Fixed {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.0.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_truth_table() {
|
||||
// 抓得住:主语言子标签的解析(分隔符 / 大小写 / codeset / 修饰符 / 变体)。
|
||||
// 抓不住:这些字符串是否真的是这台机器的 locale——真值来自 LocaleSource 实现。
|
||||
let zh = [
|
||||
"zh",
|
||||
"zh_CN",
|
||||
"zh_CN.UTF-8",
|
||||
"zh-CN",
|
||||
"zh-Hans-CN",
|
||||
"zh_hans_cn",
|
||||
"ZH_cn",
|
||||
" zh_TW ",
|
||||
"zh_CN.GB18030@euro",
|
||||
];
|
||||
for tag in zh {
|
||||
assert_eq!(Lang::from_locale(tag), Lang::ZhCn, "{tag}");
|
||||
}
|
||||
|
||||
let en = [
|
||||
"",
|
||||
"C",
|
||||
"POSIX",
|
||||
"en",
|
||||
"en_US.UTF-8",
|
||||
"en-GB",
|
||||
"ja_JP.UTF-8",
|
||||
"fr_FR@euro",
|
||||
"de",
|
||||
// 不是中文但含 zh 之外的字:必须落英文,不许猜成中文
|
||||
"zhuang",
|
||||
];
|
||||
for tag in en {
|
||||
assert_eq!(Lang::from_locale(tag), Lang::En, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_falls_back_to_english_without_a_locale() {
|
||||
// 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。
|
||||
// 抓不住:Windows 上「取不到」是否等于用户真的说英文——这正是本 crate 的已知缺口。
|
||||
let detection = detect(&Fixed(None));
|
||||
assert_eq!(detection.lang, Lang::En);
|
||||
assert_eq!(detection.raw, None);
|
||||
assert_eq!(detection.describe(), "lang=en locale=<未设置>");
|
||||
assert_eq!(Lang::default(), Lang::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_uses_the_injected_source_verbatim() {
|
||||
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染)。
|
||||
// 抓不住:EnvLocale 到底读了哪几个变量(那条路径只能靠人工/集成测试看日志)。
|
||||
let detection = detect(&Fixed(Some("zh_CN.UTF-8")));
|
||||
assert_eq!(detection.lang, Lang::ZhCn);
|
||||
assert_eq!(detection.raw.as_deref(), Some("zh_CN.UTF-8"));
|
||||
assert_eq!(detection.describe(), "lang=zh-CN locale=zh_CN.UTF-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_values_match_the_config_vocabulary() {
|
||||
// 抓得住:本 crate 的配置词表被改动(改名会让已落盘的 ui.language 读不出来)。
|
||||
// 抓不住:`core::setting::keys::LANGUAGES` 那边先改了——本 crate 不依赖 core(零依赖纪律),
|
||||
// 所以这条只能靠接线 mission 写一条跨 crate 的 pin 测试,或让 core 反向引用这份词表。
|
||||
let values = Lang::ALL
|
||||
.iter()
|
||||
.map(|lang| lang.setting_value())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(values, vec!["en_us", "zh_cn"]);
|
||||
assert_eq!(Lang::from_setting("zh_cn"), Some(Lang::ZhCn));
|
||||
assert_eq!(Lang::from_setting("en_us"), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_parsing_is_strict_where_locale_parsing_is_lenient() {
|
||||
// 抓得住:宽松解析被误用到配置值上(`zh-CN` 是合法 locale 但不是合法配置值)。
|
||||
// 抓不住:有人把非法值直接塞进配置(那是设置层的 schema 校验与该键的 enumeration 约束)。
|
||||
for bogus in ["zh-CN", "ZH_CN", "zh", "", "en"] {
|
||||
assert_eq!(Lang::from_setting(bogus), None, "{bogus}");
|
||||
}
|
||||
assert_eq!(Lang::from_locale("zh-CN"), Lang::ZhCn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! # unlucky-i18n · 国际化地基(D28)
|
||||
//!
|
||||
//! 本 crate 只做**地基**,不接任何页面:语言探测、两套消息目录、缺译诊断、中文字体策略、
|
||||
//! 以及「core 的中文状态串怎么进 UI」的契约。页面侧接线的 mission 用它,而不是各自硬编码英文
|
||||
//! (先硬编码再全量重写就是本 crate 要避免的那条路)。
|
||||
//!
|
||||
//! | 模块 | 职责 | 不负责 |
|
||||
//! |---|---|---|
|
||||
//! | [`lang`] | `Lang`(en / zh-CN)+ 系统 locale 探测(locale 源可注入) | 读配置(`ui.language` 由壳层读) |
|
||||
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
|
||||
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
|
||||
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
|
||||
//!
|
||||
//! ## 三条硬规矩(细则见 [`bridge`])
|
||||
//!
|
||||
//! 1. **一句话 = 一个 key**。UI 侧不得把片段拼成一句话,尤其不得拼中文:
|
||||
//! `"Launch failed · exit0 静默失败"` 这种混排就是拼接的后果。带变量的句子用 `{name}` 占位符,
|
||||
//! 由 [`catalog::Catalog::render`] 统一替换。
|
||||
//! 2. **core 的中文串不是文案**。core / presenter 里的中文状态串(`原版` / `静默失败(…)`)只用于
|
||||
//! 日志与 CLI;UI 要么走结构化出口(枚举)映射到 key,要么走 [`bridge::key_of_legacy_status`] 的
|
||||
//! 过渡表。**任何情况下都不许把未知中文串直接画到界面上。**
|
||||
//! 3. **英文环境零中文负担**(D28 原话):`Lang::En` 下不要求任何中文字体、不触发任何下载;
|
||||
//! 判据在 [`font::decide`],是纯函数,可脱机单测。
|
||||
//!
|
||||
//! ## 与别处的关系
|
||||
//!
|
||||
//! - **不依赖 iced / slint / presenter / core**:`cargo tree -p unlucky-i18n --edges normal` 只应打印它自己。
|
||||
//! 系统字体扫描由 UI 壳层做(iced 版在 `crates/app-iced/src/fonts/cjk.rs`),扫出来的族名喂给
|
||||
//! [`font::decide`];
|
||||
//! - 配置里的 `ui.language` 允许值 `zh_cn` / `en_us`(见 `core::setting::keys::LANGUAGES`)与
|
||||
//! [`lang::Lang::setting_value`] 必须是同一份词表,由 [`lang`] 的 pin 测试守门。
|
||||
|
||||
pub mod bridge;
|
||||
pub mod catalog;
|
||||
pub mod font;
|
||||
pub mod lang;
|
||||
|
||||
pub use catalog::{Catalog, Key};
|
||||
pub use lang::Lang;
|
||||
Reference in new issue
Block a user