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

+21 -3
View File
@@ -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,
}
}