diff --git a/Cargo.lock b/Cargo.lock index 90dd0b4..3235503 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ab81a1b..4ecace1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ members = [ # M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方, # 使 `crates/design` 能继续宣称「零第三方依赖」。 "crates/palette-extract", + # M92(D28):国际化地基——语言探测 / 中英消息目录 / 中文字体策略。零第三方依赖、零 UI 依赖, + # 壳层(iced 版现在,别的壳将来)与 presenter 都依赖它,所以它不能认任何一个渲染框架。 + "crates/i18n", ] [workspace.package] diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 5df31dc..6ec44fb 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -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 个, diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index c2c6c7c..b43761e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -20,7 +20,18 @@ //! //! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半, //! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。 +//! +//! ## 中文那一半(M92 / D28 后半) +//! +//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), +//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), +//! 结论作为 [`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 +67,8 @@ pub struct Resolved { pub families: [FamilyReport; 3], /// 三档字重的落点(对**正文**栈求值)。 pub weights: [WeightReport; 3], + /// 语言 + 中文环境的中文字体策略(D28;英文环境恒为 `NotNeeded`)。 + pub cjk: CjkReport, } impl Resolved { @@ -75,7 +88,7 @@ impl Resolved { .any(|weight| weight.quality == WeightMatch::Synthetic) } - /// 启动日志(两行:一行族,一行字重)。 + /// 启动日志(**一行**:族 / 字重 / 语言与中文字体策略)。 pub fn describe(&self) -> String { let families = self .families @@ -110,8 +123,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 +164,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, } } diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs new file mode 100644 index 0000000..a4dd5eb --- /dev/null +++ b/crates/app-iced/src/fonts/cjk.rs @@ -0,0 +1,187 @@ +//! 中文环境的 CJK 字体探测(D28 后半):把 iced 系统字体库里的族名喂给 `unlucky_i18n::font::decide`。 +//! +//! ## 分工 +//! +//! - **判据**(要不要中文字体、哪些族算候选、缺了要不要下载)全在 `unlucky_i18n::font`,是纯函数, +//! 零 UI 依赖、脱机可测; +//! - **语言**从 [`unlucky_i18n::lang::resolve`] 拿:`ui.language` 显式值 > [`SystemLocale`] +//! (Windows 读注册表,零 FFI)> 环境变量 > 英文; +//! - **本模块只做适配**:把 [`super::Face`] 列表变成一个「某族装没装」的查询,并处理 +//! **读不到字体库**这种情况。 +//! +//! ## 为什么「读不到」不等于「需下载」 +//! +//! `system_faces()` 拿不到字体库时返回 `None`。把它当成「没有中文字体」会直接触发一次 CDN 下载 +//! (D28 的自动下载路径),而真相是**我们不知道**。所以这一路走 [`Strategy::Unprobeable`]: +//! 日志里留痕,不下载、不弹提示。 +//! +//! ## 诚实边界(抓得住什么 / 抓不住什么) +//! +//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); +//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` +//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); +//! ②`ui.language` 这一级目前**没有接线**(设置层还没有读取点),调用点传 `None`。 + +use unlucky_i18n::font::{self, Strategy}; +use unlucky_i18n::lang::{self, Detection, EnvLocale}; + +use super::Face; +use crate::platform::locale::SystemLocale; + +/// 一次中文探测的结论(启动日志的第二段)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CjkReport { + /// 语言探测(含原始 locale 与来源,便于解释「为什么是英文」)。 + pub detection: Detection, + /// 字体策略结论。 + pub strategy: Strategy, +} + +impl CjkReport { + /// 一行日志片段(`lang=zh-CN locale=zh-CN source=registry 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, + } +} + +/// 探测本机:优先级见 [`unlucky_i18n::lang::resolve`]。 +/// +/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **还没读配置** +/// (设置层的读取点尚未接线,见模块文档的诚实边界)。注意 `None` 与 `Some("不认识的值")` +/// 效果相同:都不当显式选择,继续往下走。 +pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport { + report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces) +} + +/// 不读配置时的入口(启动路径用)。 +pub fn probe(faces: Option<&[Face]>) -> CjkReport { + probe_with(None, faces) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn faces(families: &[&str]) -> Vec { + 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".to_owned()), + origin: "injected", + } + } + + fn en() -> Detection { + Detection { + lang: unlucky_i18n::Lang::En, + raw: Some("en-US".to_owned()), + origin: "injected", + } + } + + #[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 source=injected 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() + ); + } + + #[test] + fn explicit_setting_overrides_the_platform_source() { + // 抓得住:`ui.language` 显式值被平台源盖掉(用户手选中文/英文必须赢过系统语言); + // 也让「平台源真的接在链路上」有个不依赖本机语言的证据。 + // 抓不住:`ui.language` 在真机上读到什么(设置层还没接线,调用点传 None)。 + let installed = faces(&["Microsoft YaHei"]); + + let forced_english = probe_with(Some("en_us"), Some(&installed)); + assert_eq!(forced_english.detection.lang, unlucky_i18n::Lang::En); + assert_eq!( + forced_english.detection.origin, + unlucky_i18n::lang::ORIGIN_SETTING + ); + assert_eq!(forced_english.strategy, Strategy::NotNeeded); + + let forced_chinese = probe_with(Some("zh_cn"), Some(&installed)); + assert_eq!( + forced_chinese.strategy, + Strategy::System { + family: "Microsoft YaHei" + } + ); + assert!(forced_chinese.describe().contains("source=setting")); + } +} diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index 079cd27..5616c58 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -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,14 @@ fn describe_is_one_line_and_mentions_all_three_stacks() { quality: WeightMatch::Synthetic, }, ], + cjk: CjkReport { + detection: Detection { + lang: unlucky_i18n::Lang::En, + raw: None, + origin: unlucky_i18n::lang::ORIGIN_DEFAULT, + }, + strategy: Strategy::NotNeeded, + }, }; let line = resolved.describe(); @@ -146,6 +157,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"); } diff --git a/crates/app-iced/src/platform/locale.rs b/crates/app-iced/src/platform/locale.rs new file mode 100644 index 0000000..bd28142 --- /dev/null +++ b/crates/app-iced/src/platform/locale.rs @@ -0,0 +1,301 @@ +//! 系统语言的**真值源**(D28):Windows 走 `reg.exe` 读注册表——**零 `unsafe`、零 FFI、零新依赖**。 +//! +//! ## 为什么是子进程而不是 FFI +//! +//! 先例是 M73 的壁纸探测(`crates/core/src/platform/wallpaper/windows.rs`):同样是「Windows 上 +//! 只有注册表知道答案」的问题,它用 `reg.exe query` 解决,`unsafe` 一个没加。本 crate 的 `unsafe` +//! 只允许出现在 [`crate::platform::win32`](D36 的纪律),所以这里照抄那条路。 +//! +//! 为什么不 `GetUserDefaultLocaleName`:它要 FFI,而它对**本用途**并不比注册表准——Windows 的 +//! 「区域格式」与「显示语言」本来就是两个设置,两者都有各自的说法(见下面的诚实边界)。 +//! +//! ## 读什么、按什么顺序 +//! +//! 1. `HKCU\Control Panel\International` 的 `LocaleName`——Win7+ 都有,值就是 `zh-CN` 这种标签, +//! [`unlucky_i18n::Lang::from_locale`] 直接吃得下; +//! 2. 读不到 `LocaleName` 时退到同一个键的 `Locale`(8 位十六进制 LCID,如 `00000804`), +//! 用一张**很短的**表翻成标签;表里没有的返回 `None`(宁可英文,也不猜错语种)。 +//! +//! 优先级链在 [`unlucky_i18n::lang::resolve`]:**`ui.language` 显式值 > 本模块 > 环境变量 > 英文**。 +//! +//! ## 诚实边界(抓得住什么 / 抓不住什么) +//! +//! - **抓得住**:`reg query` 输出的解析(含 CRLF、空值、值名大小写)、`LocaleName` 缺失时退 LCID +//! 的分支、命令失败/缺失时的 `None`; +//! - **抓不住**:①`LocaleName` 是**区域格式**,用户完全可能在设置里把「显示语言」改成英文而区域 +//! 仍是 `zh-CN`(或反过来)——真要读显示语言得 `GetUserDefaultUILanguage`(FFI)或多语言 +//! 用户界面注册表(`...\Control Panel\Desktop` 的 `PreferredUILanguages`,本 mission 未做); +//! ②策略禁用 `reg.exe` / Wine 环境 → `None` → 落到环境变量 → 英文; +//! ③代码页(`chcp`)与这里无关,注册表值不受它影响。 + +use unlucky_i18n::lang::LocaleSource; + +/// 语言相关的注册表键。 +const INTERNATIONAL_KEY: &str = r"HKCU\Control Panel\International"; + +/// 首选值名:值是 BCP-47 风格标签(`zh-CN`)。 +const LOCALE_NAME_VALUE: &str = "LocaleName"; + +/// 兜底值名:值是 8 位十六进制 LCID(`00000804`)。 +const LOCALE_VALUE: &str = "Locale"; + +/// 常见 LCID → 语言的短表(**故意短**:不认识的返回 `None`,让链路落到英文)。 +/// +/// 覆盖面:中文四地 + 英语三地 + 几个大语种。这张表只在 `LocaleName` 缺失时用到, +/// 而 `LocaleName` 从 Win7 起就有,所以它实际上是「老系统 / 被裁剪的系统」专用。 +const LCID_TAGS: &[(&str, &str)] = &[ + ("00000804", "zh-CN"), + ("00000404", "zh-TW"), + ("00000c04", "zh-HK"), + ("00001004", "zh-SG"), + ("00000409", "en-US"), + ("00000809", "en-GB"), + ("00000c09", "en-AU"), + ("00000411", "ja-JP"), + ("00000412", "ko-KR"), + ("00000407", "de-DE"), + ("0000040c", "fr-FR"), + ("00000410", "it-IT"), + ("00000c0a", "es-ES"), + ("00000416", "pt-BR"), + ("00000419", "ru-RU"), + ("00000415", "pl-PL"), + ("0000041f", "tr-TR"), + ("00000406", "da-DK"), + ("0000040b", "fi-FI"), + ("00000413", "nl-NL"), +]; + +/// 跑外部命令的接口(注入点:单测不 spawn 进程)。 +pub trait RegIo { + /// 跑一条命令取 stdout;命令缺失 / 非零退出 / 空输出都返回 `None`。 + fn run(&self, program: &str, args: &[&str]) -> Option; +} + +/// 真实实现:`reg.exe` 子进程(非 Windows 上恒 `None`)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct HostReg; + +impl RegIo for HostReg { + fn run(&self, program: &str, args: &[&str]) -> Option { + if !cfg!(windows) { + // 其它平台的「系统语言」真值源不是注册表;`resolve` 会继续往环境变量走。 + return None; + } + + let output = std::process::Command::new(program) + .args(args) + .output() + .ok()?; + // `Command::output` 对非零退出同样返回 `Ok`,退出码要单独判(`reg.exe` 查不到值就是非零)。 + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } +} + +/// 系统语言源(实现 [`LocaleSource`],日志里自称 `registry`)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemLocale; + +impl LocaleSource for SystemLocale { + fn locale(&self) -> Option { + detect_with(&HostReg) + } + + fn label(&self) -> &'static str { + "registry" + } +} + +/// 用给定 IO 读一次系统语言(`LocaleName` 优先,退 `Locale` 的 LCID)。 +pub fn detect_with(io: &dyn RegIo) -> Option { + let by_name = io + .run( + "reg.exe", + &["query", INTERNATIONAL_KEY, "/v", LOCALE_NAME_VALUE], + ) + .and_then(|text| parse_reg_sz(&text, LOCALE_NAME_VALUE)) + .filter(|value| looks_like_tag(value)); + if by_name.is_some() { + return by_name; + } + + io.run("reg.exe", &["query", INTERNATIONAL_KEY, "/v", LOCALE_VALUE]) + .and_then(|text| parse_reg_sz(&text, LOCALE_VALUE)) + .and_then(|lcid| lcid_tag(&lcid)) + .map(str::to_owned) +} + +/// 取 `reg query <键> /v <值名>` 输出里某个 `REG_SZ` 值的原文。 +/// +/// 真机输出形如(本机 Windows 11 24H2 实测,CRLF): +/// +/// ```text +/// HKEY_CURRENT_USER\Control Panel\International +/// LocaleName REG_SZ zh-CN +/// ``` +/// +/// 值名大小写不敏感(`reg.exe` 原样回显查询时给的名字);值可能含空格,所以「值名 + 类型」 +/// 之后整段取原文,不 `split_whitespace` 再拼(会吃掉连续空格)。 +pub fn parse_reg_sz(text: &str, value_name: &str) -> Option { + text.lines().find_map(|line| { + let trimmed = line.trim_start(); + let name_end = trimmed.find(char::is_whitespace)?; + if !trimmed[..name_end].eq_ignore_ascii_case(value_name) { + return None; + } + let after_name = trimmed[name_end..].trim_start(); + let kind_end = after_name.find(char::is_whitespace)?; + let value = after_name[kind_end..].trim(); + (!value.is_empty()).then(|| value.to_owned()) + }) +} + +/// LCID(十六进制原文,如 `00000804`)→ 标签(大小写不敏感)。 +pub fn lcid_tag(lcid: &str) -> Option<&'static str> { + LCID_TAGS + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(lcid.trim())) + .map(|(_, tag)| *tag) +} + +/// 像不像一个语言标签(`zh-CN` / `en` / `zh-Hans-CN`)。 +/// +/// 只拦明显不是标签的东西(空值、带空格、带路径分隔符)——`reg.exe` 在某些策略下会回 +/// `REG_SZ` 空串或一长串本地化文本,那些不该被当成语言喂给 `from_locale`。 +fn looks_like_tag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 35 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 本机 Windows 11 24H2 实测抓下来的两段原文(含 `\r\n`)。 + const REAL_LOCALE_NAME: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n LocaleName REG_SZ zh-CN\r\n\r\n"; + const REAL_LOCALE: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n Locale REG_SZ 00000804\r\n\r\n"; + + struct FakeReg { + name: Option<&'static str>, + locale: Option<&'static str>, + fail: bool, + } + + impl FakeReg { + fn both(name: Option<&'static str>, locale: Option<&'static str>) -> Self { + FakeReg { + name, + locale, + fail: false, + } + } + } + + impl RegIo for FakeReg { + fn run(&self, program: &str, args: &[&str]) -> Option { + assert_eq!(program, "reg.exe", "程序名写错就不是注册表读了"); + assert_eq!(args[0], "query"); + assert_eq!(args[1], INTERNATIONAL_KEY); + assert_eq!(args[2], "/v"); + if self.fail { + return None; + } + + let raw = match args[3] { + LOCALE_NAME_VALUE => self.name?, + LOCALE_VALUE => self.locale?, + other => panic!("没料到的值名:{other}"), + }; + Some(format!( + "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n {} REG_SZ {raw}\r\n\r\n", + args[3] + )) + } + } + + #[test] + fn real_registry_output_parses_to_the_locale_tag() { + // 抓得住:解析器与真机 `reg query` 输出格式之间的契约(CRLF、前导空白、值名与类型之间的 + // 多个空格)。这两段夹具是真机抓的原文,不是我编的。 + // 抓不住:这台机器的值本身会变(用户改区域设置)——测试只钉格式,不钉具体语言。 + assert_eq!( + parse_reg_sz(REAL_LOCALE_NAME, LOCALE_NAME_VALUE).as_deref(), + Some("zh-CN") + ); + assert_eq!( + parse_reg_sz(REAL_LOCALE, LOCALE_VALUE).as_deref(), + Some("00000804") + ); + // 值名大小写不敏感(reg.exe 会原样回显查询里给的名字) + assert_eq!( + parse_reg_sz(REAL_LOCALE_NAME, "localename").as_deref(), + Some("zh-CN") + ); + // 空值 = 没设(不是空串语言) + let empty = " LocaleName REG_SZ \r\n"; + assert_eq!(parse_reg_sz(empty, LOCALE_NAME_VALUE), None); + // 别的值名不该被误取 + assert_eq!(parse_reg_sz(REAL_LOCALE_NAME, "Locale"), None); + } + + #[test] + fn lcid_fallback_covers_chinese_and_english() { + // 抓得住:LCID 兜底表的关键格子(中文四地 + 英语三地)被改动或删掉; + // 以及「不认识就 None」这条(不许猜成某个语言)。 + // 抓不住:这 20 条之外的语言——没有 `LocaleName` 的老系统上它们会落到英文。 + assert_eq!(lcid_tag("00000804"), Some("zh-CN")); + assert_eq!(lcid_tag("00000804\r"), Some("zh-CN"), "要去空白"); + assert_eq!(lcid_tag("00000404"), Some("zh-TW")); + assert_eq!(lcid_tag("00000C04"), Some("zh-HK"), "大小写不敏感"); + assert_eq!(lcid_tag("00001004"), Some("zh-SG")); + assert_eq!(lcid_tag("00000409"), Some("en-US")); + assert_eq!(lcid_tag("00000411"), Some("ja-JP")); + assert_eq!(lcid_tag("00000437"), None, "表外语言必须 None"); + assert_eq!(lcid_tag(""), None); + } + + #[test] + fn detect_prefers_locale_name_and_falls_back_to_lcid() { + // 抓得住:两条取值路径的顺序(有 LocaleName 就不该再看 LCID),以及命令失败时的 None。 + // 抓不住:真机注册表里到底有什么(下面的 windows_live 测试才碰真机)。 + assert_eq!( + detect_with(&FakeReg::both(Some("zh-CN"), Some("00000409"))).as_deref(), + Some("zh-CN"), + "LocaleName 优先" + ); + assert_eq!( + detect_with(&FakeReg::both(None, Some("00000804"))).as_deref(), + Some("zh-CN"), + "没有 LocaleName 才退 LCID" + ); + assert_eq!(detect_with(&FakeReg::both(None, None)), None); + assert_eq!( + detect_with(&FakeReg { + name: Some("zh-CN"), + locale: Some("00000804"), + fail: true, + }), + None, + "命令失败就是 None,不许猜" + ); + // 值名对但值不像标签(策略回了一串本地化文本)→ 不许当成语言 + assert_eq!(detect_with(&FakeReg::both(Some("中文(简体)"), None)), None); + } + + #[test] + #[cfg(windows)] + fn windows_live_registry_query_returns_a_tag() { + // 抓得住:`reg.exe` 这条路在本机真的通(spawn 成功、退出码为 0、输出能被解析)。 + // 抓不住:它读到的「区域格式」是否等于用户想用的**界面语言**(见模块文档的诚实边界)。 + let tag = SystemLocale.locale().expect("本机应当读得到 LocaleName"); + assert!(!tag.is_empty()); + assert!(looks_like_tag(&tag), "解析出来的不是语言标签:{tag:?}"); + assert_eq!(SystemLocale.label(), "registry"); + } +} diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index f8baeb2..0689fbe 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -3,6 +3,7 @@ //! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; //! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。 +//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 //! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示 @@ -13,6 +14,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; use crate::Backend; +pub mod locale; pub mod win32; /// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。 diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml new file mode 100644 index 0000000..c7cbdc8 --- /dev/null +++ b/crates/i18n/Cargo.toml @@ -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 diff --git a/crates/i18n/src/bridge.rs b/crates/i18n/src/bridge.rs new file mode 100644 index 0000000..1c095dd --- /dev/null +++ b/crates/i18n/src/bridge.rs @@ -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); + } +} diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs new file mode 100644 index 0000000..6fc8b7f --- /dev/null +++ b/crates/i18n/src/catalog.rs @@ -0,0 +1,392 @@ +//! 消息目录:`Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 / 缺译诊断。 +//! +//! ## 为什么 key 是枚举而不是字符串 +//! +//! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。 +//! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接 +//! `error[E0599]`,不可能跑到运行时。 +//! +//! **这里没有守卫测试**:曾经写过一个 `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::ALL + .iter() + .copied() + .filter(|key| self.get(*key).is_none()) + .collect() + } + + /// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。 + #[must_use] + pub fn diagnose(&self) -> Option { + 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(|| "".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::>(); + 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::>(); + 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); + } +} diff --git a/crates/i18n/src/font.rs b/crates/i18n/src/font.rs new file mode 100644 index 0000000..fb04985 --- /dev/null +++ b/crates/i18n/src/font.rs @@ -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, 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), + /// 探测不出,什么都没做。 + Unknown, +} + +/// 按策略执行:需要下载时才碰 [`Fetcher`]。 +/// +/// # Errors +/// 下载器返回的错误原样透出(不吞、不改写分类)。 +pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result { + 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, + result: Result, FetchError>, + seen: Cell>, + } + + impl FakeFetcher { + fn new(result: Result, FetchError>) -> Self { + FakeFetcher { + calls: Cell::new(0), + result, + seen: Cell::new(None), + } + } + } + + impl Fetcher for FakeFetcher { + fn fetch(&self, font: &'static CjkFont) -> Result, 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"); + } +} diff --git a/crates/i18n/src/lang.rs b/crates/i18n/src/lang.rs new file mode 100644 index 0000000..a38fcc0 --- /dev/null +++ b/crates/i18n/src/lang.rs @@ -0,0 +1,387 @@ +//! 语言与系统 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。 +//! +//! ## 优先级([`resolve`]) +//! +//! **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。本 crate 只有 [`EnvLocale`] +//! 一个具体实现——平台真值源(Windows 注册表那一路)在 UI 壳层,因为读注册表要开子进程, +//! 而本 crate 的纪律是不碰外部世界。 +//! +//! ## 诚实边界(抓得住什么 / 抓不住什么) +//! +//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符); +//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);[`resolve`] 的四级优先级; +//! - **抓不住**:**Windows 上这三个变量通常都不存在**——那条路的真值源是 +//! `crates/app-iced/src/platform/locale.rs`(`reg.exe` 读注册表,零 FFI), +//! 它不在本 crate 里(本 crate 零依赖、不 spawn 进程)。所以「Windows 上拿不到语言」 +//! 这件事只在**没有接线平台源**时成立,接线后由 `resolve` 的二级兜住。 +//! 配置里的 `ui.language` 读取属于设置层接线(本 crate 只提供 [`Lang::from_setting`] 与 +//! [`resolve`] 的形状)。 + +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::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; + + /// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。 + fn label(&self) -> &'static str { + "injected" + } +} + +/// [`Detection::origin`]:结论来自 `ui.language` 的显式值。 +pub const ORIGIN_SETTING: &str = "setting"; +/// [`Detection::origin`]:结论来自环境变量([`EnvLocale`])。 +pub const ORIGIN_ENV: &str = "env"; +/// [`Detection::origin`]:一个源都没取到,落到默认英文。 +pub const ORIGIN_DEFAULT: &str = "default"; + +/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到,见模块文档)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct EnvLocale; + +impl LocaleSource for EnvLocale { + fn locale(&self) -> Option { + // 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())) + } + + fn label(&self) -> &'static str { + ORIGIN_ENV + } +} + +/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + /// 结论。 + pub lang: Lang, + /// 原始 locale 标签(`None` = 一个源都没取到)。 + pub raw: Option, + /// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。 + pub origin: &'static str, +} + +impl Detection { + /// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。 + #[must_use] + pub fn describe(&self) -> String { + format!( + "lang={} locale={} source={}", + self.lang.tag(), + self.raw.as_deref().unwrap_or("<未设置>"), + self.origin + ) + } +} + +/// 用给定来源探测语言。 +#[must_use] +pub fn detect(source: &dyn LocaleSource) -> Detection { + match source.locale() { + Some(raw) => Detection { + lang: Lang::from_locale(&raw), + raw: Some(raw), + origin: source.label(), + }, + None => Detection { + lang: Lang::En, + raw: None, + origin: ORIGIN_DEFAULT, + }, + } +} + +/// 用进程环境变量探测语言。 +#[must_use] +pub fn detect_env() -> Detection { + detect(&EnvLocale) +} + +/// 全量优先级:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。 +/// +/// - `setting`:配置里 `ui.language` 的原文(`zh_cn` / `en_us`)。给了但不认识 → **跳过** +/// (当作「没配」继续往下走,而不是把它猜成某种语言); +/// - `platform`:平台真值源(Windows 上是 `app-iced::platform::locale` 读注册表;其它平台没有); +/// - `env`:`LC_ALL > LC_MESSAGES > LANG`([`EnvLocale`]); +/// - 都不给 → [`Lang::En`],`origin = default`。 +/// +/// 这正是 D28 要的顺序:**用户手选的**压过系统,系统压过环境变量,都没有就英文。 +#[must_use] +pub fn resolve( + setting: Option<&str>, + platform: &dyn LocaleSource, + env: &dyn LocaleSource, +) -> Detection { + if let Some(value) = setting { + if let Some(lang) = Lang::from_setting(value) { + return Detection { + lang, + raw: Some(value.to_owned()), + origin: ORIGIN_SETTING, + }; + } + } + + let from_platform = detect(platform); + if from_platform.raw.is_some() { + return from_platform; + } + + detect(env) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixed(Option<&'static str>); + + impl LocaleSource for Fixed { + fn locale(&self) -> Option { + 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 上「环境变量取不到」是否等于用户真的说英文——那是平台源的活 + // (`app-iced::platform::locale`),见下面 resolve 的优先级测试。 + let detection = detect(&Fixed(None)); + assert_eq!(detection.lang, Lang::En); + assert_eq!(detection.raw, None); + assert_eq!(detection.origin, ORIGIN_DEFAULT); + assert_eq!( + detection.describe(), + "lang=en locale=<未设置> source=default" + ); + assert_eq!(Lang::default(), Lang::En); + } + + #[test] + fn detect_uses_the_injected_source_verbatim() { + // 抓得住:探测结论确实来自注入源(不会被本机环境变量污染),且 origin 取自源自报的名字。 + // 抓不住: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.origin, "injected"); + assert_eq!( + detection.describe(), + "lang=zh-CN locale=zh_CN.UTF-8 source=injected" + ); + assert_eq!(EnvLocale.label(), "env"); + } + + #[test] + fn resolve_priority_is_setting_then_platform_then_env_then_english() { + // 抓得住:四级优先级的顺序本身(用户手选压过系统,系统压过环境变量,都没有才英文); + // 以及「配置值不认识时不当成任何一种语言,继续往下走」。 + // 抓不住:这些源在真机上到底读到什么(平台源的真机行为在 app-iced 侧测)。 + struct Named(&'static str, Option<&'static str>); + + impl LocaleSource for Named { + fn locale(&self) -> Option { + self.1.map(str::to_owned) + } + fn label(&self) -> &'static str { + self.0 + } + } + + let registry_zh = Named("registry", Some("zh-CN")); + let env_en = Named("env", Some("en_US.UTF-8")); + let nothing = Named("registry", None); + + // ① 配置显式值压过一切 + let detection = resolve(Some("en_us"), ®istry_zh, &env_en); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, ORIGIN_SETTING) + ); + // ② 没配置(或配置不认识)→ 平台源 + for setting in [None, Some("zh-CN"), Some("")] { + let detection = resolve(setting, ®istry_zh, &env_en); + assert_eq!( + (detection.lang, detection.raw.as_deref(), detection.origin), + (Lang::ZhCn, Some("zh-CN"), "registry"), + "setting={setting:?}" + ); + } + // ③ 平台源没有 → 环境变量 + let detection = resolve(None, ¬hing, &env_en); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, "env"), + "环境变量说是英文,但来源必须是 env" + ); + // ④ 全没有 → 英文 + default + let detection = resolve(None, ¬hing, &Named("env", None)); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, ORIGIN_DEFAULT) + ); + } + + #[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::>(); + 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); + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs new file mode 100644 index 0000000..d6577fe --- /dev/null +++ b/crates/i18n/src/lib.rs @@ -0,0 +1,42 @@ +//! # unlucky-i18n · 国际化地基(D28) +//! +//! 本 crate 只做**地基**,不接任何页面:语言探测、两套消息目录、缺译诊断、中文字体策略、 +//! 以及「core 的中文状态串怎么进 UI」的契约。页面侧接线的 mission 用它,而不是各自硬编码英文 +//! (先硬编码再全量重写就是本 crate 要避免的那条路)。 +//! +//! | 模块 | 职责 | 不负责 | +//! |---|---|---| +//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 | +//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 | +//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 | +//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 | +//! +//! 语言从哪来的优先级写在 [`lang::resolve`]:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。 +//! 平台源(Windows 上 `reg.exe` 读注册表,零 FFI)在 UI 壳层实现并注入——本 crate 不认平台。 +//! +//! ## 三条硬规矩(细则见 [`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;