mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 21:26:48 -06:00
Review fix-up: dedupe CJK fallback load, wider Linux/macOS font paths
- Load each fallback font file once: the region-merged Noto Sans CJK .ttc found for Han also covers Hangul (and Tahoma covers Thai and Hebrew), so a later script whose candidate file is already loaded reuses it instead of loading tens of MB again. New test: no two fallback faces share a file. - Fold in the Linux CJK paths from #103: Fedora google-noto-cjk/, Arch noto-cjk/ and wenquanyi/, Debian truetype/wqy/wqy-microhei.ttc, the SC .otf builds, /usr/local, plus SimSun on Windows. Document the .ttc face indices (JP 0, KR 1, SC 2, TC 3, HK 4); Hangul uses the KR face. - macOS: Hiragino Sans (ヒラギノ角ゴシック W3) and Hiragino Sans GB come before PingFang.ttc, which recent macOS no longer ships in /System. - Comments promise glyphs instead of tofu, not correct rendering. - fallback_covers_its_script compared has_glyphs, which is a false negative when the owning face also draws the replacement glyph (DejaVu Sans as UI font owns Hebrew); compare laid-out glyphs with the tofu glyph instead. Co-authored-by: eyouhi <47080137+eyouhi@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
eyouhi
Claude Opus 5.5
parent
c607fe1627
commit
c4fd49afc2
+76
-18
@@ -322,17 +322,27 @@ pub fn font_definitions() -> FontDefinitions {
|
||||
|
||||
/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so
|
||||
/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so
|
||||
/// without this a Thai, Hebrew, Japanese or Chinese workbook renders as tofu boxes even though the
|
||||
/// text was read correctly. epaint shapes the complex scripts itself (via `harfrust`); it only needs
|
||||
/// a face that has the glyphs.
|
||||
/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text
|
||||
/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing
|
||||
/// is bundled, and a machine without the face keeps the old behaviour.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_fallback(fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
// Files already loaded as a fallback face. A file serves at most one key: the region-merged
|
||||
// Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and
|
||||
// Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
||||
let mut loaded: Vec<String> = Vec::new();
|
||||
for (key, cands) in fallback_faces() {
|
||||
let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect();
|
||||
if let Some(fd) = try_load(&refs) {
|
||||
fonts.font_data.insert(key.to_string(), Arc::new(fd));
|
||||
keys.push(key.to_string());
|
||||
for (path, index) in &cands {
|
||||
if loaded.contains(path) {
|
||||
break; // a face loaded for an earlier script already covers this one
|
||||
}
|
||||
if let Some(fd) = try_load(&[(path.as_str(), *index)]) {
|
||||
fonts.font_data.insert(key.to_string(), Arc::new(fd));
|
||||
keys.push(key.to_string());
|
||||
loaded.push(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
@@ -351,40 +361,66 @@ fn fallback_faces() -> Vec<(&'static str, Vec<(String, u32)>)> {
|
||||
vec![("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())]
|
||||
}
|
||||
|
||||
/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`,
|
||||
/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and
|
||||
/// flat layouts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LINUX_CJK_DIRS: [&str; 10] = [
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/google-noto-cjk",
|
||||
"/usr/share/fonts/noto-cjk",
|
||||
"/usr/share/fonts/truetype/noto",
|
||||
"/usr/share/fonts/noto",
|
||||
"/usr/share/fonts/truetype",
|
||||
"/usr/share/fonts/TTF",
|
||||
"/usr/share/fonts",
|
||||
"/usr/local/share/fonts",
|
||||
"/usr/local/share/fonts/noto-cjk",
|
||||
];
|
||||
|
||||
/// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render
|
||||
/// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries
|
||||
/// kana, so any of them renders Japanese; the order only decides kanji glyph style.
|
||||
///
|
||||
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
|
||||
/// HK 4) with the same glyph coverage; index 0 picks the Japanese glyph shapes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cjk_han() -> Vec<(String, u32)> {
|
||||
let mac = "/System/Library/Fonts";
|
||||
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||
let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
|
||||
let mut v: Vec<(String, u32)> = Vec::new();
|
||||
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic (Japanese)
|
||||
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic (Japanese)
|
||||
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (Chinese)
|
||||
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0));
|
||||
v.push((format!("{mac}/PingFang.ttc"), 0));
|
||||
for d in lin {
|
||||
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun (Chinese, older installs)
|
||||
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans (Japanese)
|
||||
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
|
||||
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
|
||||
for d in LINUX_CJK_DIRS {
|
||||
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0));
|
||||
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
|
||||
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
|
||||
}
|
||||
for d in LINUX_CJK_DIRS {
|
||||
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
|
||||
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
|
||||
v.push((format!("{d}/wqy-microhei.ttc"), 0));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Hangul faces (Korean; not covered by the Han fonts).
|
||||
/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file
|
||||
/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cjk_hangul() -> Vec<(String, u32)> {
|
||||
let mac = "/System/Library/Fonts";
|
||||
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype"];
|
||||
let mut v: Vec<(String, u32)> = Vec::new();
|
||||
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
|
||||
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
|
||||
for d in lin {
|
||||
for d in LINUX_CJK_DIRS {
|
||||
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
|
||||
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0));
|
||||
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -497,6 +533,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_faces_load_each_file_once() {
|
||||
// The region-merged Noto Sans CJK .ttc serves Han and Hangul; it must be loaded once, not
|
||||
// once per script (tens of MB each).
|
||||
let fonts = font_definitions();
|
||||
let present: Vec<&Arc<FontData>> = fallback_faces().into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect();
|
||||
for (i, a) in present.iter().enumerate() {
|
||||
for b in present.iter().skip(i + 1) {
|
||||
assert!(a.font != b.font, "the same font file is loaded for two fallback scripts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_covers_its_script() {
|
||||
// Real render check, per script: the proportional family (what cells and the UI use) must
|
||||
@@ -512,12 +561,21 @@ mod tests {
|
||||
ctx.set_fonts(font_definitions());
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
|
||||
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
|
||||
// `has_glyphs` is a false negative when the face that owns the script is also the face that
|
||||
// draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare the laid
|
||||
// out glyphs with the tofu glyph itself instead.
|
||||
let font = FontId::proportional(14.0);
|
||||
let glyphs = |text: &str| {
|
||||
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK));
|
||||
galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect)).collect::<Vec<_>>()
|
||||
};
|
||||
let tofu = glyphs("\u{25FB}");
|
||||
for (key, cands, sample) in cases {
|
||||
if cands.iter().all(|(p, _)| std::fs::read(p).is_err()) {
|
||||
if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) {
|
||||
continue; // no face for this script on this machine; nothing to assert
|
||||
}
|
||||
let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), sample));
|
||||
assert!(covered, "{key} is present but {sample:?} still renders as tofu");
|
||||
let drawn = glyphs(sample);
|
||||
assert!(!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)), "{key} is present but {sample:?} still renders as tofu");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user