pub const CJK_FONT_NAME: &str = "ek-cjk";
#[cfg(feature = "bundled-font")]
pub const BUILTIN_CJK_FONT: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/fonts/NotoSansSC-Regular.otf"
));
#[cfg(feature = "bundled-font")]
pub const BUILTIN_CJK_LABEL: &str = "(内置资产)assets/fonts/NotoSansSC-Regular.otf";
#[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",
];
#[cfg(not(feature = "bundled-font"))]
const MONO_CANDIDATES: &[&str] = &["C:/Windows/Fonts/consola.ttf", "C:/Windows/Fonts/cour.ttf"];
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);
eprintln!("[ek-ui] CJK 回退字体已装载: {BUILTIN_CJK_LABEL}");
return Some(BUILTIN_CJK_LABEL.to_string());
}
#[cfg(not(feature = "bundled-font"))]
{
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 {
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);
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::*;
#[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})"
);
}
}
#[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}"
);
}
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 兜底断)"
);
});
}
#[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"),
"内置标识不得指向集合面"
);
}
}