ek-ui 0.9.46

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
//! CJK 字体回退(BD-UI-26):epaint 0.33 缺省字体组(Hack / Ubuntu-Light /
//! NotoEmoji-Regular / emoji-icon-font)**没有任何中文字形** —— 界面中文
//! 直接渲染成豆腐块/乱码(2026-10-18 用户实窗报障)。
//!
//! 两条装载路径(0.9.0 打包案定稿,判据 f0 对两条同形):
//! - **内置资产路径(`--features bundled-font`,安装器构建用)**:Noto Sans SC
//!   Regular(OTF,SIL OFL 1.1,许可文本随 `assets/fonts/OFL-NotoSansSC-LICENSE.txt`)。
//!   独立安装版落在**没装过任何中文字体**的干净机器上也能读中文(2026-10-28 用户
//!   裁定「安装后不依赖我们这个环境」)—— 运行期零读盘,安装面不存在"字体缺失"
//!   这一类环境故障;
//! - **系统候选路径(默认,库消费方与本仓开发/判据面)**:按候选表从系统字体目录
//!   取一枚纯 .ttf/.otf 追加到两族末尾。
//!
//! 为什么内置资产**不进 crates.io 发布包**:`[features] bundled-font` 缺省关 +
//! `[package] exclude` 排除该资产 —— 单资产 15.7MB 会撞 crates.io 单包上限,
//! 而链上没有任何 crate 需要界面字体(ek-ui 是叶子)。安装器的构建命令显式开这条
//! 特性,发布面的构建(含 docs.rs)永远不碰它。
//!
//! 解析纪律(复审钉):epaint 0.33 走 **ab_glyph**(ttf-parser 底),对解析失败的
//! 文件是 **panic**(`Error parsing … TTF/OTF font file`),且**不吃 .ttc 集合**。
//! 两条路径都只收**纯 .otf/.ttf 单字体表**;内置资产取 Noto Sans SC 的 OTF
//! (magic `OTTO`,s64 仓已核)。

/// 回退字体在 font_data 里的登记名。
pub const CJK_FONT_NAME: &str = "ek-cjk";

/// 内置 CJK 字体(M3;0.9.0 打包案)—— 只在 `bundled-font` 特性下编译。
///
/// Noto Sans SC Regular(OTF;SIL OFL 1.1)。为什么缺省关:见模块头
/// (15.7MB 资产不进 crates.io 包;链上零消费者需要界面字体)。
#[cfg(feature = "bundled-font")]
pub const BUILTIN_CJK_FONT: &[u8] = include_bytes!(concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/assets/fonts/NotoSansSC-Regular.otf"
));

/// 内置字体在装载证据行里的标识(排障时一眼分清来源:内置 vs 系统)。
#[cfg(feature = "bundled-font")]
pub const BUILTIN_CJK_LABEL: &str = "(内置资产)assets/fonts/NotoSansSC-Regular.otf";

/// 系统 CJK 字体候选(纯 TTF/OTF;Windows 优先;Linux/macOS 兜底)。
/// 只在**未开** `bundled-font` 时使用(默认构建面)。
#[cfg(not(feature = "bundled-font"))]
const CJK_CANDIDATES: &[&str] = &[
    "C:/Windows/Fonts/simhei.ttf",
    "C:/Windows/Fonts/simkai.ttf",
    "C:/Windows/Fonts/simfang.ttf",
    "C:/Windows/Fonts/simsunb.ttf",
    "C:/Windows/Fonts/NotoSansSC-VF.ttf",
    "/usr/share/fonts/truetype/unifont/unifont.ttf",
    "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
];

/// mono 族换头候选(0.9.10 用户实看:「数字里的 0 中间有个点很怪异」)。
/// 病根:epaint 缺省 mono 头 = **Hack** —— 它的零是**点零**(0 中实心点),
/// 表格里价格/账号一水数字 = 满屏怪零。换 **Consolas**(Windows 标准随系统装,
/// 普通椭圆零,等宽常用);缺位退 **Courier New**;都没有 = 维持缺省(不硬装)。
/// CJK 不进 mono 头:汉字由族尾的 `ek-cjk` 兜底(下方装载),拉丁/数字/符号
/// Consolas 全覆盖 = 中英同屏正常。
#[cfg(not(feature = "bundled-font"))]
const MONO_CANDIDATES: &[&str] = &["C:/Windows/Fonts/consola.ttf", "C:/Windows/Fonts/cour.ttf"];

/// 给 egui 上下文装 CJK 回退字体。返回命中的字体标识;全空返回 None。
/// 必须在建窗后、首帧渲染前调用(eframe 取帧闭包里做)。
pub fn apply_cjk_fonts(ctx: &egui::Context) -> Option<String> {
    let mut fonts = egui::FontDefinitions::default();
    #[cfg(feature = "bundled-font")]
    {
        fonts.font_data.insert(
            CJK_FONT_NAME.to_owned(),
            std::sync::Arc::new(egui::FontData::from_owned(BUILTIN_CJK_FONT.to_vec())),
        );
        for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
            fonts
                .families
                .entry(family)
                .or_default()
                .push(CJK_FONT_NAME.to_owned());
        }
        ctx.set_fonts(fonts);
        // 装载证据行:窗口进程 stderr 可 grep 到本行 = 实窗已装字体(无多模态
        // 验收口径;标注「内置资产」,装机排障时一眼分清来源)。
        eprintln!("[ek-ui] CJK 回退字体已装载: {BUILTIN_CJK_LABEL}");
        return Some(BUILTIN_CJK_LABEL.to_string());
    }
    #[cfg(not(feature = "bundled-font"))]
    {
        // ── mono 换头(点零退役):Hack → Consolas/Cour。装成功 = families 头替换;
        // 候选全缺 = 不硬装(维持缺省 mono,中文面仍由下方 CJK 兜底)。
        for path in MONO_CANDIDATES {
            let lower = path.to_ascii_lowercase();
            if !(lower.ends_with(".ttf") || lower.ends_with(".otf")) {
                continue;
            }
            let Ok(bytes) = std::fs::read(path) else {
                continue;
            };
            let name = if lower.contains("consola") {
                "ek-mono"
            } else {
                "ek-mono-alt"
            };
            fonts.font_data.insert(
                name.to_owned(),
                std::sync::Arc::new(egui::FontData::from_owned(bytes)),
            );
            let mono = fonts
                .families
                .entry(egui::FontFamily::Monospace)
                .or_default();
            mono.retain(|f| f != "Hack");
            mono.insert(0, name.to_owned());
            eprintln!("[ek-ui] mono 族头换装(点零退役): {path}");
            break;
        }
        for path in CJK_CANDIDATES {
            // 扩展名闸(双保险):ab_glyph 只吃 sfnt 单字体表;喂 .ttc = 渲染期 panic。
            let lower = path.to_ascii_lowercase();
            if !(lower.ends_with(".ttf") || lower.ends_with(".otf")) {
                continue;
            }
            let Ok(bytes) = std::fs::read(path) else {
                continue;
            };
            fonts.font_data.insert(
                CJK_FONT_NAME.to_owned(),
                std::sync::Arc::new(egui::FontData::from_owned(bytes)),
            );
            for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
                fonts
                    .families
                    .entry(family)
                    .or_default()
                    .push(CJK_FONT_NAME.to_owned());
            }
            ctx.set_fonts(fonts);
            // 装载证据行(无多模态验收口径:窗口进程 stderr 可 grep 到本行 = 实窗已装字体)。
            eprintln!("[ek-ui] CJK 回退字体已装载: {path}");
            return Some(path.to_string());
        }
        ctx.set_fonts(fonts);
        eprintln!(
            "[ek-ui] 未找到系统 CJK 字体候选,中文可能显示为豆腐块(环境侧;安装版请开 bundled-font)"
        );
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    /// 装字体不 panic;命中与否随环境(Windows 候选在位则必命中;
    /// 开 `bundled-font` 时必命中内置资产)。
    /// 验收钉在**字形面**:回退字体必须真带 CJK 字形(乱码根因的直接反证)。
    /// 走 `Harness::new(|ctx|)`(Context 直给)+ 真实 run 一帧(fonts 就位)后断言。
    #[test]
    fn f0_apply_cjk_fonts_registers_fallback() {
        let hit_cell = std::cell::RefCell::new(None);
        let recorder = &hit_cell;
        let mut harness = egui_kittest::Harness::new(move |ctx: &egui::Context| {
            let mut slot = recorder.borrow_mut();
            if slot.is_none() {
                *slot = apply_cjk_fonts(ctx);
            }
            drop(slot);
            egui::CentralPanel::default().show(ctx, |ui| {
                ui.label("观测台 插件 行情 委托");
            });
        });
        harness.run();
        let hit = hit_cell.borrow();
        if let Some(path) = &*hit {
            assert!(!path.is_empty());
            let probe = "观测台插件状态版本依赖行情委托成交持仓资金冻结订阅授权密码回灌撤单";
            let font_id = egui::FontId::proportional(16.0);
            let mut missing: Vec<char> = Vec::new();
            harness.ctx.fonts_mut(|f| {
                for ch in probe.chars() {
                    if !f.has_glyph(&font_id, ch) {
                        missing.push(ch);
                    }
                }
            });
            assert!(
                missing.is_empty(),
                "回退字体缺字形: {missing:?} (font: {path})"
            );
        }
    }

    /// mono 换头钉(0.9.10 点零退役):Windows 候选在位时,mono 族头必须是换装件
    /// (Consolas 优先 = `ek-mono`),**Hack 不得再在 mono 族里**(点零 0 的携带者);
    /// 数字 `0` 字形必须可寻(表格里价格/账号列的字源就位)。
    #[cfg(not(feature = "bundled-font"))]
    #[test]
    fn f2_mono_head_has_no_dot_zero_carrier() {
        let mut harness = egui_kittest::Harness::new(|ctx: &egui::Context| {
            apply_cjk_fonts(ctx);
            egui::CentralPanel::default().show(ctx, |ui| {
                ui.label("0.00 123456789");
            });
        });
        harness.run();
        let consolas = std::path::Path::new("C:/Windows/Fonts/consola.ttf").exists();
        let cour = std::path::Path::new("C:/Windows/Fonts/cour.ttf").exists();
        harness.ctx.fonts_mut(|f| {
            let fams = f.definitions();
            let mono = fams
                .families
                .get(&egui::FontFamily::Monospace)
                .expect("mono 族存在");
            if consolas || cour {
                assert!(
                    !mono.iter().any(|n| n == "Hack"),
                    "Hack(点零携带者)仍在 mono 族: {mono:?}"
                );
                let head = mono.first().expect("mono 族非空");
                assert!(
                    head == "ek-mono" || head == "ek-mono-alt",
                    "mono 族头不是换装件: {head}"
                );
            }
            // 数字零必须可从 mono 族取到字形(换装件/回退链任一覆盖即算就位)。
            let fid = egui::FontId::monospace(14.0);
            assert!(f.has_glyph(&fid, '0'), "mono 族无 '0' 字形");
            assert!(
                f.has_glyph(&egui::FontId::proportional(14.0), '账'),
                "proportional 链缺中文 '账'(CJK 兜底断)"
            );
        });
    }

    /// 内置资产钉(开 `bundled-font` 时生效):字节必为 sfnt 单字体表 magic
    /// (`OTTO`/`true`/`\0\1\0\0` 之一),且容量量级合理(>1MB,<20MB)。
    /// 独立安装版的字体面 = 这条 + f0 字形面。
    #[cfg(feature = "bundled-font")]
    #[test]
    fn f1_builtin_asset_is_sfnt_single_font() {
        let magic = &BUILTIN_CJK_FONT[0..4];
        assert!(
            magic == b"OTTO" || magic == b"true" || magic == [0, 1, 0, 0],
            "内置资产 magic 非 sfnt 单字体表: {:?}",
            &magic[..]
        );
        assert!(
            BUILTIN_CJK_FONT.len() > 1_000_000 && BUILTIN_CJK_FONT.len() < 20_000_000,
            "内置资产容量异常: {} 字节",
            BUILTIN_CJK_FONT.len()
        );
        assert!(
            !BUILTIN_CJK_LABEL.contains(".ttc"),
            "内置标识不得指向集合面"
        );
    }
}