From 40eaaea9db06307872fa787168d7ce4a930c76c9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 9 Oct 2026 20:49:09 +0000 Subject: [PATCH] Fonts: a family named with a format suffix such as (OTF) resolves to the installed family --- crates/engine/src/cmd/fonts.rs | 35 ++++++++++++++++++++++++++ crates/fonts/src/fontdb.rs | 46 +++++++++++++++++++++++++++------- crates/fonts/src/lib.rs | 12 +++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb9313..3b173c199 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -190,4 +190,39 @@ mod tests { assert_eq!(r["changed"], 1); assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str())); } + + /// InDesign names a family installed in several formats `Family (OTF)`; the plain family + /// stands for it, and a family that isn't available under either name is still missing. + #[test] + fn a_family_with_a_format_suffix_uses_the_plain_family() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 15})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4 (OTF)", "fontStyle": "Bold"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + let entry = l.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4 (OTF)").unwrap(); + assert_eq!((&entry["missing"], &entry["styleMissing"], &entry["source"]), (&json!(false), &json!(false), &json!("bundled")), "{l}"); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let faces: Vec<_> = + cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0).map(|g| g.face.get()).collect(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.family == "Source Serif 4" && f.style == "Bold")); + let kinds = |s: &mut Session| { + s.execute("preflight.run", &json!({})).unwrap()["issues"] + .as_array() + .unwrap() + .iter() + .map(|i| i["kind"].as_str().unwrap().to_string()) + .collect::>() + }; + assert!(!kinds(&mut s).iter().any(|k| k == "missingFont")); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Nope (OTF)"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}"); + assert!(kinds(&mut s).iter().any(|k| k == "missingFont")); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 4177f941c..14441e144 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -712,6 +712,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// `family` without the font-format suffix layout apps append when a family is installed in +/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. +fn without_format_suffix(family: &str) -> Option<&str> { + const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"]; + let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?; + let base = base.trim_end(); + (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() @@ -1218,6 +1227,14 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + let v = self.exact_styles(family); + match without_format_suffix(family) { + Some(base) if v.is_empty() => self.exact_styles(base), + _ => v, + } + } + + fn exact_styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1237,15 +1254,10 @@ impl ScopedFonts<'_> { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A + /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it. pub fn face(&self, family: &str, style: &str) -> Arc { - if let Some(f) = self.find(family, style) { - return f; - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.load_cataloged(family) - && let Some(f) = self.find(family, style) - { + if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) { return f; } self.find(FALLBACK_FAMILY, style) @@ -1254,8 +1266,24 @@ impl ScopedFonts<'_> { .unwrap_or_else(last_resort_face) } - /// Is `family` available (the document's, loaded, or installed on the system)? + fn find_or_load(&self, family: &str, style: &str) -> Option> { + if let Some(f) = self.find(family, style) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.load_cataloged(family) { + return self.find(family, style); + } + None + } + + /// Is `family` available (the document's, loaded, or installed on the system), as named or + /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { + self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) + } + + fn has_exact_family(&self, family: &str) -> bool { if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { return true; } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8f901e96d..c1d744780 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -425,6 +425,18 @@ mod tests { assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings"); } + #[test] + fn an_exact_family_name_wins_over_the_name_without_its_format_suffix() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + db.add_font(testing::font_with("DC Test Suffix (OTF)", &['a']).unwrap()); + assert_eq!(db.face("DC Test Suffix (OTF)", "Regular").family, "DC Test Suffix (OTF)"); + assert_eq!(db.face(&format!("{DEFAULT_FAMILY} (TT)"), "Regular").family, DEFAULT_FAMILY); + assert!(db.has_family(&format!("{DEFAULT_FAMILY} (T1)"))); + assert!(!db.has_family(&format!("{DEFAULT_FAMILY} (Bold)"))); + assert!(!db.has_family("DC Test Suffix")); + } + #[test] fn outlines_and_metrics() { let db = FontDb::global();