feat(m168): 中文字体 CDN 下载真接线(D28 后半)
M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。
判据 → 下载 → 注册(一条链,四个 crate 各管一段):
* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
`FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
(英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
`install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
视图模型,所以那一行会自己刷新。
配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。
守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。
测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
1 parent
2a004a5754
commit
449ded2cb7
22 files changed
+2191
-184
No files matched your search
@@ -46,6 +46,7 @@ use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::LangChoice;
|
||||
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
use crate::pages::{frame, Language, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -71,6 +72,10 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stacked = column![].spacing(0.0).width(Length::Fill);
|
||||
stacked = stacked.push(language_row(ui, language));
|
||||
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
|
||||
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(font_row(ui, language));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
@@ -85,6 +90,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
|
||||
///
|
||||
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
|
||||
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
|
||||
///
|
||||
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
|
||||
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
|
||||
pub(super) fn font_status(
|
||||
catalog: &unlucky_i18n::catalog::Catalog,
|
||||
status: &CjkInstall,
|
||||
) -> (String, Option<String>) {
|
||||
match status {
|
||||
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
|
||||
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
|
||||
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
|
||||
CjkInstall::System { family } => (
|
||||
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
|
||||
None,
|
||||
),
|
||||
CjkInstall::Downloading { bytes } => (
|
||||
catalog.render(
|
||||
Key::FontCjkStatusDownloading,
|
||||
&[("size", &format_size(*bytes))],
|
||||
),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Ready { family, .. } => (
|
||||
catalog.render(Key::FontCjkReady, &[("family", family)]),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Failed { class, .. } => {
|
||||
let reason = catalog.text(match class {
|
||||
FetchClass::Network => Key::FontCjkReasonNetwork,
|
||||
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
|
||||
FetchClass::Disk => Key::FontCjkReasonDisk,
|
||||
});
|
||||
(
|
||||
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人话体积(`17.0 MB`)。只用于「≈」级别的量,不追求精确位数。
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const MIB: f64 = 1024.0 * 1024.0;
|
||||
let mib = bytes as f64 / MIB;
|
||||
if mib >= 1.0 {
|
||||
format!("{mib:.1} MB")
|
||||
} else {
|
||||
format!("{:.0} KB", bytes as f64 / 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
|
||||
///
|
||||
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
|
||||
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
|
||||
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
|
||||
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let status = crate::fonts::install::status();
|
||||
let (value, hint) = font_status(&catalog, &status);
|
||||
let color = match status {
|
||||
CjkInstall::Failed { .. } => ui.color(Role::Error),
|
||||
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
|
||||
_ => ui.color(Role::OnSurfaceVariant),
|
||||
};
|
||||
|
||||
let mut stacked = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
catalog.text(Key::FontCjkStatusLabel),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
value,
|
||||
color
|
||||
))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]));
|
||||
|
||||
if let Some(hint) = hint {
|
||||
stacked = stacked.push(container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)));
|
||||
}
|
||||
|
||||
stacked.into()
|
||||
}
|
||||
|
||||
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
|
||||
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
|
||||
@@ -35,6 +35,82 @@ fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **M168(D28 后半)**:中文字体下载状态行的四种状态都得有话说,且两套目录都不许 `⟪missing⟫`。
|
||||
///
|
||||
/// 抓得住:①只画「下载成功」一种(失败的用户会以为程序坏了);②失败态的文案写成「保持英文」
|
||||
/// (D28 的实际行为是**继续用系统字体**,界面语言不变);③占位符没被替换(用户会看到 `{family}`);
|
||||
/// ④新 key 只进了英文表(中文界面上出现 `⟪missing⟫`)。
|
||||
/// 抓不住:真下载时的样子(那要联网)、字体有没有真的出字(截图 + 真机,见报告)。
|
||||
#[test]
|
||||
fn the_font_status_row_speaks_in_four_states_and_two_languages() {
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
|
||||
let states = [
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "timeout".to_owned(),
|
||||
},
|
||||
CjkInstall::System {
|
||||
family: "Microsoft YaHei".to_owned(),
|
||||
},
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Unknown,
|
||||
];
|
||||
|
||||
for lang in Lang::ALL {
|
||||
let catalog = lang.catalog();
|
||||
assert!(
|
||||
catalog.get(Key::FontCjkStatusLabel).is_some(),
|
||||
"{} 目录里缺中文字体那一行的标签",
|
||||
lang.tag()
|
||||
);
|
||||
for state in &states {
|
||||
let (value, hint) = general::font_status(&catalog, state);
|
||||
assert!(!value.is_empty(), "{state:?} 没话说");
|
||||
assert!(!value.contains('{'), "{state:?} 占位符没替换:{value}");
|
||||
assert!(
|
||||
!value.contains(unlucky_i18n::catalog::MISSING),
|
||||
"{state:?} 在 {} 目录里缺译:{value}",
|
||||
lang.tag()
|
||||
);
|
||||
if let Some(hint) = hint {
|
||||
assert!(!hint.contains('{'), "{state:?} 的提示没替换:{hint}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败态必须说清三类原因之一(而不是一句「失败了」)。
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
for (class, reason) in [
|
||||
(FetchClass::Network, "网络不通"),
|
||||
(FetchClass::Integrity, "镜像内容校验不通过"),
|
||||
(FetchClass::Disk, "写不进数据目录"),
|
||||
] {
|
||||
let (value, _) = general::font_status(
|
||||
&zh,
|
||||
&CjkInstall::Failed {
|
||||
class,
|
||||
message: "x".to_owned(),
|
||||
},
|
||||
);
|
||||
assert!(value.contains(reason), "失败原因没进文案:{value}");
|
||||
}
|
||||
|
||||
// 下载中写的是**真实**字节数(17.0 MB),不是假百分比。
|
||||
let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 });
|
||||
assert!(
|
||||
value.contains("16.9 MB"),
|
||||
"17 772 300 B ≈ 16.9 MiB:{value}"
|
||||
);
|
||||
assert!(!value.contains('%'), "没有真进度就不许写百分比:{value}");
|
||||
}
|
||||
|
||||
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
|
||||
#[test]
|
||||
fn the_general_group_keeps_its_icon() {
|
||||
|
||||
Reference in new issue
Block a user