From a4a36e8460e68aca2e25e121f40b497305beb25b Mon Sep 17 00:00:00 2001 From: chessive Date: Wed, 21 Oct 2026 14:45:39 +0900 Subject: [PATCH] Fonts: a cell font named in another language matches its installed font (fixes #1898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korean Excel saves a cell's font under its Korean name (맑은 고딕 for Malgun Gothic), but the font scan kept one family name per face, the English one, so those cells were drawn in the default face. Each face now keeps its typographic and legacy family names in every language, and a workbook font is bound under the name the workbook uses, which is the name cell_family looks up. A name two families share goes to the family whose English name it is, else to the first face by path, so the scan's directory order no longer decides it. Names bound under two spellings share one copy of the font data. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/fonts.rs | 260 +++++++++++++++++++++++++++++++----- 1 file changed, 229 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index d5a8ea1d9..1a381b9d4 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -19,11 +19,20 @@ use std::sync::OnceLock; #[cfg(not(target_arch = "wasm32"))] const MAX_FONT_FILE: u64 = 64 << 20; +/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FAMILY_NAMES: usize = 16; + /// One face of one installed font file. #[derive(Clone, Debug)] pub struct Face { - /// Typographic family name, e.g. `"Segoe UI"`. + /// Typographic family name, e.g. `"Segoe UI"` (the English one when the font has it): what the + /// font list shows. pub family: String, + /// Every family name the face carries, `family` first, then each language's typographic and + /// legacy family names (`"Malgun Gothic"`, `"맑은 고딕"`). A workbook may name the font by any of + /// them: Korean Excel saves `맑은 고딕` (#1898). + pub names: Vec, /// The file the face lives in. pub path: PathBuf, /// Face index within the file (collections hold several, e.g. a family's Regular and Bold). @@ -88,6 +97,13 @@ fn style_weight(style: &str) -> f32 { TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) } +impl Face { + /// Whether the face goes by `name` in any language. + pub fn answers_to(&self, name: &str) -> bool { + self.names.iter().any(|n| n.eq_ignore_ascii_case(name)) + } +} + /// Whether a style name reads as italic or oblique. #[cfg(not(target_arch = "wasm32"))] fn style_italic(style: &str) -> bool { @@ -95,9 +111,10 @@ fn style_italic(style: &str) -> bool { s.contains("italic") || s.contains("oblique") } -/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic. +/// Every face in `data` as `(index, family, names, style)`, where `names` holds every family name +/// (see [`Face::names`]). Damaged data yields nothing, never a panic. #[cfg(not(target_arch = "wasm32"))] -fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { +fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec, String)> { use skrifa::raw::FileRef; use skrifa::{MetadataProvider, string::StringId}; let count = match FileRef::new(data) { @@ -108,12 +125,23 @@ fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> { let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option { ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) }; + let family_ids = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]; (0..count) .filter_map(|i| { let f = skrifa::FontRef::from_index(data, i).ok()?; - let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let family = name(&f, &family_ids)?; + let mut names = vec![family.clone()]; + for s in family_ids.iter().flat_map(|id| f.localized_strings(*id)) { + if names.len() >= MAX_FAMILY_NAMES { + break; + } + let s = s.to_string(); + if !s.is_empty() && !names.iter().any(|n| n.eq_ignore_ascii_case(&s)) { + names.push(s); + } + } let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); - Some((i, family, style)) + Some((i, family, names, style)) }) .collect() } @@ -143,8 +171,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec { continue; } let Ok(data) = std::fs::read(&path) else { continue }; - for (index, family, style) in file_faces(&data) { - out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); + for (index, family, names, style) in file_faces(&data) { + out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) }); } } } @@ -188,29 +216,50 @@ pub fn scan_done() -> bool { CATALOG.get().is_some() } -/// The faces of one installed family, regular-weight first (the order roles pick from). +/// The faces of one installed family, by any of its names, regular-weight first (the order roles +/// pick from). pub fn faces_of(family: &str) -> Vec { - let Some(cat) = catalog_now() else { return Vec::new() }; - let mut v: Vec = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect(); + catalog_now().map(|cat| faces_named(cat, family)).unwrap_or_default() +} + +/// The faces in `cat` that go by `wanted`. When two families claim one name the winner is fixed, +/// not left to the scan's directory order: a family whose own (English) name it is wins, else the +/// family of the first claiming face by path and index. Only the faces that carry the name are +/// kept, so a legacy name (`"Arial Black"`, name ID 1) picks its own face out of its family. +fn faces_named(cat: &[Face], wanted: &str) -> Vec { + let mut v: Vec = if cat.iter().any(|f| f.family.eq_ignore_ascii_case(wanted)) { + cat.iter().filter(|f| f.family.eq_ignore_ascii_case(wanted)).cloned().collect() + } else { + let owner = cat.iter().filter(|f| f.answers_to(wanted)).min_by(|a, b| a.path.cmp(&b.path).then(a.index.cmp(&b.index))); + match owner { + Some(o) => cat.iter().filter(|f| f.family.eq_ignore_ascii_case(&o.family) && f.answers_to(wanted)).cloned().collect(), + None => Vec::new(), + } + }; v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index))); v } /// The subset of `names` that is installed, sorted and deduped. These are the families whose files -/// get bound for a workbook. +/// get bound for a workbook, under the name the workbook uses: that is the name +/// [`cell_family`](crate::theme::cell_family) looks up, so `맑은 고딕` binds as `맑은 고딕`, not as +/// the catalog's `Malgun Gothic`. pub fn installed_among(names: &[String]) -> Vec { - let Some(cat) = catalog_now() else { return Vec::new() }; + catalog_now().map(|cat| installed_in(cat, names)).unwrap_or_default() +} + +/// [`installed_among`] over a given catalog. Names dedupe exactly, not case-folded: a role key is +/// built from the cell's exact string, so `Calibri` and `calibri` each need their own binding (the +/// font data behind them is shared, see [`face_key`]). +fn installed_in(cat: &[Face], names: &[String]) -> Vec { let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut seen: HashSet<&str> = HashSet::new(); for wanted in names { - if !seen.insert(wanted.to_lowercase()) { - continue; - } - if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) { - out.push(f.family.clone()); + if seen.insert(wanted.as_str()) && cat.iter().any(|f| f.answers_to(wanted)) { + out.push(wanted.clone()); } } - out.sort_by_key(|a| a.to_lowercase()); + out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); out } @@ -254,13 +303,14 @@ fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> { .or_else(|| faces.first()) } -/// A stable key for a face's font data, unique per file/index within the process. -fn face_key(family: &str, face: &Face) -> String { - let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font"); - format!("fnt-src:{family}:{file}:{}", face.index) +/// A stable key for a face's font data, one per file and index: every name a family is bound under +/// (`Malgun Gothic`, `맑은 고딕`) shares the data instead of loading the file again. +fn face_key(face: &Face) -> String { + format!("fnt-src:{}:{}", face.path.display(), face.index) } -/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the +/// Bind one installed family's faces into `fonts` under `family` (the name the workbook uses), one +/// egui family per cell role, and return the /// role keys that are now bound. A role with no exact face chains to the family's regular face, so /// no role is ever left pointing at missing data (egui panics on that). A family with no faces /// binds nothing. @@ -278,14 +328,16 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face let mut chain: Vec = Vec::new(); for face in [Some(primary), pick_face(faces, false, false)] { let Some(face) = face else { continue }; - let key = face_key(family, face); + let key = face_key(face); if chain.contains(&key) { continue; } - let Ok(bytes) = std::fs::read(&face.path) else { continue }; - let mut fd = egui::FontData::from_owned(bytes); - fd.index = face.index; - fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd)); + if !fonts.font_data.contains_key(&key) { + let Ok(bytes) = std::fs::read(&face.path) else { continue }; + let mut fd = egui::FontData::from_owned(bytes); + fd.index = face.index; + fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd)); + } chain.push(key); } if chain.is_empty() { @@ -299,7 +351,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face bound } -/// Bind every installed face of `family` (see [`bind_faces`]). +/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]). pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec { bind_faces(fonts, family, &faces_of(family)) } @@ -407,7 +459,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("Ubuntu-Light.ttf"); std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); - let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false }; + let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false }; let mut fonts = egui::FontDefinitions::default(); let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face)); @@ -420,4 +472,150 @@ mod tests { assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty()); let _ = std::fs::remove_dir_all(&dir); } + + /// A font that is just a table directory and a `name` table holding `records` as + /// `(Windows language id, name id, text)`: all [`file_faces`] reads. + #[cfg(not(target_arch = "wasm32"))] + fn font_with_names(records: &[(u16, u16, &str)]) -> Vec { + fn be(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_be_bytes()); + } + let mut name: Vec = Vec::new(); + let mut strings: Vec = Vec::new(); + for x in [0, records.len() as u16, (6 + 12 * records.len()) as u16] { + be(&mut name, x); + } + for (language, id, text) in records { + let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + // Platform 3 (Windows), encoding 1 (Unicode BMP). + for x in [3, 1, *language, *id, utf16.len() as u16, strings.len() as u16] { + be(&mut name, x); + } + strings.extend(utf16); + } + name.extend(strings); + let mut font: Vec = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [1, 16, 0, 0] { + be(&mut font, x); + } + font.extend(b"name"); + font.extend(0u32.to_be_bytes()); + font.extend(28u32.to_be_bytes()); + font.extend((name.len() as u32).to_be_bytes()); + font.extend(name); + font + } + + const EN: u16 = 0x0409; + const KO: u16 = 0x0412; + + fn face(family: &str, names: &[&str], path: &str, weight: f32) -> Face { + Face { family: family.into(), names: names.iter().map(|s| s.to_string()).collect(), path: path.into(), index: 0, weight, italic: false } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_face_keeps_its_family_names_in_every_language() { + // Korean Excel saves `맑은 고딕`, so the Korean name has to be kept next to the English one (#1898). + let font = font_with_names(&[(EN, 1, "Malgun Gothic"), (EN, 2, "Regular"), (KO, 1, "맑은 고딕"), (KO, 2, "보통")]); + let faces = file_faces(&font); + assert_eq!(faces.len(), 1); + let (_, family, names, style) = &faces[0]; + assert_eq!(family, "Malgun Gothic", "the font list keeps the English name"); + assert_eq!(names, &["Malgun Gothic", "맑은 고딕"]); + assert_eq!(style, "Regular"); + + // Both name IDs are kept: a legacy family name (ID 1) names a single face (#1606). + let font = font_with_names(&[(EN, 1, "Noto Sans KR Medium"), (EN, 2, "Regular"), (EN, 16, "Noto Sans KR"), (EN, 17, "Medium")]); + let (_, family, names, _) = &file_faces(&font)[0]; + assert_eq!(family, "Noto Sans KR"); + assert_eq!(names, &["Noto Sans KR", "Noto Sans KR Medium"]); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_name_table_full_of_names_is_capped() { + let texts: Vec = (0..100).map(|i| format!("Family {i}")).collect(); + let records: Vec<(u16, u16, &str)> = texts.iter().enumerate().map(|(i, t)| (0x0400 + i as u16, 1, t.as_str())).collect(); + let (_, _, names, _) = &file_faces(&font_with_names(&records))[0]; + assert_eq!(names.len(), MAX_FAMILY_NAMES); + } + + #[test] + fn a_family_is_found_by_any_of_its_names() { + let names = ["Malgun Gothic", "맑은 고딕"]; + let cat = [face("Malgun Gothic", &names, "malgunbd.ttf", 700.0), face("Malgun Gothic", &names, "malgun.ttf", 400.0)]; + for wanted in ["Malgun Gothic", "malgun gothic", "맑은 고딕"] { + let found: Vec = faces_named(&cat, wanted).iter().map(|f| f.weight).collect(); + assert_eq!(found, [400.0, 700.0], "{wanted:?}: both faces, regular first"); + } + assert!(faces_named(&cat, "맑은").is_empty(), "a part of a name is not a name"); + assert!(faces_named(&cat, "").is_empty()); + } + + #[test] + fn a_name_two_families_claim_has_a_fixed_owner() { + let a = face("Alpha", &["Alpha", "공유"], "a/alpha.ttf", 400.0); + let b = face("Beta", &["Beta", "공유"], "b/beta.ttf", 400.0); + // Whatever order the scan saw the files in, the first by path wins. + for cat in [[a.clone(), b.clone()], [b.clone(), a.clone()]] { + let found = faces_named(&cat, "공유"); + assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Alpha"]); + } + // A family's own name beats another family's alias, even one scanned earlier. + let c = face("Gamma", &["Gamma", "Beta"], "0/gamma.ttf", 400.0); + let found = faces_named(&[c, b], "Beta"); + assert_eq!(found.iter().map(|f| f.family.as_str()).collect::>(), ["Beta"]); + } + + #[test] + fn a_legacy_name_picks_its_own_face_out_of_the_family() { + let cat = [ + face("Noto Sans KR", &["Noto Sans KR"], "NotoSansKR-Regular.otf", 400.0), + face("Noto Sans KR", &["Noto Sans KR", "Noto Sans KR Medium"], "NotoSansKR-Medium.otf", 500.0), + ]; + assert_eq!(faces_named(&cat, "Noto Sans KR").len(), 2); + let medium = faces_named(&cat, "Noto Sans KR Medium"); + assert_eq!(medium.iter().map(|f| f.weight).collect::>(), [500.0]); + } + + #[test] + fn installed_names_are_kept_as_the_workbook_spells_them() { + // The binding is keyed on the name `cell_family` looks up, so every spelling in use is + // returned as written: the English and the Korean name both bind, over the same faces. + let cat = [face("Malgun Gothic", &["Malgun Gothic", "맑은 고딕"], "malgun.ttf", 400.0)]; + let used: Vec = ["맑은 고딕", "Malgun Gothic", "Not Installed", "맑은 고딕", "malgun gothic"].map(String::from).to_vec(); + assert_eq!(installed_in(&cat, &used), ["Malgun Gothic", "malgun gothic", "맑은 고딕"]); + assert!(installed_in(&[], &used).is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_family_bound_under_two_names_shares_its_font_data() { + let dir = std::env::temp_dir().join(format!("gridcraft-fonts-alias-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("Ubuntu-Light.ttf"); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write"); + let f = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into(), "우분투".into()], path, index: 0, weight: 300.0, italic: false }; + + let mut fonts = egui::FontDefinitions::default(); + let before = fonts.font_data.len(); + let mut bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&f)); + bound.extend(bind_faces(&mut fonts, "우분투", std::slice::from_ref(&f))); + assert!(bound.contains(&role_key("우분투", false, false)), "bound under the workbook's name"); + assert!(bound.contains(&role_key("Ubuntu", true, true))); + assert_eq!(fonts.font_data.len(), before + 1, "one file, loaded once"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// On a Windows machine with Malgun Gothic, the real file carries its Korean name. + #[cfg(windows)] + #[test] + fn malgun_gothic_answers_to_its_korean_name_when_installed() { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + let Ok(data) = std::fs::read(root.join("Fonts").join("malgun.ttf")) else { return }; + let faces = file_faces(&data); + assert!(faces.iter().any(|(_, family, names, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}"); + } }