feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)

M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。

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

测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
This commit is contained in:
AskaEth committed 2026-11-02 16:13:22 +08:00
1 parent 1331d90ff4
commit e77a5df6e8
12 files changed
+1418 -3

No files matched your search

+39
View File
@@ -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;