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
@@ -0,0 +1,154 @@
|
||||
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
|
||||
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
|
||||
//! core 的 FontSpec(值)
|
||||
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
|
||||
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
|
||||
//! ```
|
||||
//!
|
||||
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
|
||||
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
|
||||
//! 单测)。
|
||||
//!
|
||||
//! ## 「不联网」的三条路
|
||||
//!
|
||||
//! | 情形 | 谁拦住 |
|
||||
//! |---|---|
|
||||
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
|
||||
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
|
||||
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
|
||||
//!
|
||||
//! ## 设置改写只换 URL,不换哈希
|
||||
//!
|
||||
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
||||
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Source};
|
||||
|
||||
use crate::platform::data;
|
||||
|
||||
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdnFetcher {
|
||||
cache_root: PathBuf,
|
||||
spec: FontSpec,
|
||||
}
|
||||
|
||||
impl CdnFetcher {
|
||||
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
|
||||
///
|
||||
/// # Errors
|
||||
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
|
||||
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
|
||||
Ok(Self {
|
||||
cache_root,
|
||||
spec: spec_of(font)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 缓存是不是已经在盘上(只 stat,不做校验)。
|
||||
///
|
||||
/// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**:
|
||||
/// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。
|
||||
#[must_use]
|
||||
pub fn has_cached(&self) -> bool {
|
||||
core_font::cache_path(&self.cache_root, &self.spec).is_file()
|
||||
}
|
||||
|
||||
/// 缓存文件落点(诊断日志用)。
|
||||
#[must_use]
|
||||
pub fn cache_file(&self) -> PathBuf {
|
||||
core_font::cache_path(&self.cache_root, &self.spec)
|
||||
}
|
||||
}
|
||||
|
||||
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
||||
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
core_font::fetch_blocking(&self.cache_root, &self.spec)
|
||||
.map(|fetch| fetch.bytes)
|
||||
.map_err(|error| translate(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
|
||||
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = font.source;
|
||||
|
||||
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
|
||||
if let Some(override_url) = data::font_cdn_override() {
|
||||
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
|
||||
candidates.insert(0, override_url);
|
||||
}
|
||||
|
||||
FontSpec::new(candidates, sha256, bytes)
|
||||
.map_err(|error| FetchError::Integrity(error.to_string()))
|
||||
}
|
||||
|
||||
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
|
||||
fn translate(error: FontFetchError) -> FetchError {
|
||||
match error {
|
||||
FontFetchError::Network(message) => FetchError::Network(message),
|
||||
FontFetchError::Integrity(message) => FetchError::Integrity(message),
|
||||
FontFetchError::Disk(message) => FetchError::Disk(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_i18n::font::NOTO_SANS_SC;
|
||||
|
||||
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
|
||||
///
|
||||
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
|
||||
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
|
||||
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
|
||||
#[test]
|
||||
fn the_pin_survives_the_trip_to_core() {
|
||||
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
|
||||
assert!(
|
||||
spec.urls.len() >= urls.len(),
|
||||
"候选不该变少:{:?}",
|
||||
spec.urls
|
||||
);
|
||||
assert_eq!(spec.sha256, sha256);
|
||||
assert_eq!(spec.bytes, bytes);
|
||||
assert_eq!(
|
||||
spec.urls[spec.urls.len() - urls.len()],
|
||||
urls[0],
|
||||
"内置首选不许被挤掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
|
||||
#[test]
|
||||
fn an_empty_cache_root_is_not_a_hit() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
||||
assert!(!fetcher.has_cached());
|
||||
assert!(fetcher
|
||||
.cache_file()
|
||||
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user