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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user