From 9061475831df145f5d57577b6ae6c09b91a755b9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 05:14:04 +0000 Subject: [PATCH] IDML: font family names keep their spaces; FontStyle Nothing inherits (#558) --- ROADMAP.md | 1 + crates/fonts/src/fontdb.rs | 22 +++++++++++++++------- crates/fonts/src/lib.rs | 13 +++++++++++++ crates/idml/src/import.rs | 11 ++++++----- crates/idml/src/tests.rs | 23 +++++++++++++++++++++++ 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..e492c2aab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · IDML import keeps a font family name exactly as written, trailing spaces included, and font lookup falls back to the name without surrounding spaces; `FontStyle="Nothing"` leaves the style to the paragraph (#558). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..1baf294bd 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -721,6 +721,13 @@ fn without_format_suffix(family: &str) -> Option<&str> { (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) } +/// The names `family` is looked up under when no font has it as written: without surrounding +/// spaces, then without a format suffix (see [`without_format_suffix`]). +fn alternate_names(family: &str) -> impl Iterator { + let trimmed = family.trim(); + (trimmed != family && !trimmed.is_empty()).then_some(trimmed).into_iter().chain(without_format_suffix(trimmed)) +} + /// 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() @@ -1228,10 +1235,10 @@ 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, + if !v.is_empty() { + return v; } + alternate_names(family).map(|f| self.exact_styles(f)).find(|v| !v.is_empty()).unwrap_or_default() } fn exact_styles(&self, family: &str) -> Vec { @@ -1255,9 +1262,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. A - /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it. + /// family that isn't found as written is looked up without surrounding spaces, then without a + /// format suffix (`Minion Pro (OTF)` → `Minion Pro`). pub fn face(&self, family: &str, style: &str) -> Arc { - 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))) { + if let Some(f) = self.find_or_load(family, style).or_else(|| alternate_names(family).find_map(|f| self.find_or_load(f, style))) { return f; } self.find(FALLBACK_FAMILY, style) @@ -1278,9 +1286,9 @@ impl ScopedFonts<'_> { } /// Is `family` available (the document's, loaded, or installed on the system), as named or - /// without a format suffix (see [`ScopedFonts::face`])? + /// under an alternate name (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)) + self.has_exact_family(family) || alternate_names(family).any(|f| self.has_exact_family(f)) } fn has_exact_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..5419aca3a 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -437,6 +437,19 @@ mod tests { assert!(!db.has_family("DC Test Suffix")); } + #[test] + fn a_family_name_keeps_its_spaces_and_falls_back_to_the_trimmed_name() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + db.add_font(testing::font_with("DC Test Space ", &['a']).unwrap()); + db.add_font(testing::font_with("DC Test Space", &['a']).unwrap()); + assert_eq!(db.face("DC Test Space ", "Regular").family, "DC Test Space "); + assert_eq!(db.face("DC Test Space", "Regular").family, "DC Test Space"); + assert_eq!(db.face(&format!(" {DEFAULT_FAMILY}\n"), "Regular").family, DEFAULT_FAMILY); + assert!(db.has_family(&format!("{DEFAULT_FAMILY} "))); + assert_eq!(db.styles(&format!("{DEFAULT_FAMILY} ")), db.styles(DEFAULT_FAMILY)); + } + #[test] fn outlines_and_metrics() { let db = FontDb::global(); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..e5e0c7a77 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1131,14 +1131,15 @@ impl<'r> Importer<'r> { fn char_attrs(&mut self, e: &El) -> CharAttrs { let mut a = CharAttrs::default(); if let Some(f) = e.prop("AppliedFont") { - let f = f.trim(); - // Some writers append the style after a tab. - let fam = f.split('\t').next().unwrap_or(f); - if !fam.is_empty() && fam != "$ID/" { + // Some writers append the style after a tab. Spaces are part of a family's name + // ("Minion Pro " is another font than "Minion Pro"); line breaks around it aren't. + let fam = f.split('\t').next().unwrap_or(&f).trim_matches(['\n', '\r']); + if !fam.trim().is_empty() && fam.trim() != "$ID/" { a.font_family = Some(fam.to_string()); } } - a.font_style = e.prop("FontStyle"); + // "Nothing" leaves the style unset: it comes from the paragraph style. + a.font_style = e.prop("FontStyle").filter(|s| !matches!(s.trim(), "Nothing" | "$ID/Nothing")); a.size = e.num("PointSize"); a.leading = e.prop("Leading").and_then(|l| names::leading_in(l.trim())); // Automatic kerning may carry an inactive numeric value (including the diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..83742d02a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,26 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +/// A family name keeps its spaces ("Minion Pro " is a font of its own) and `FontStyle="Nothing"` +/// leaves the style to the paragraph, also after an export. +#[test] +fn applied_font_keeps_spaces_and_font_style_nothing_inherits() { + let story = r#" + + DC Space Spaced + plain + "#; + let check = |d: &Document| { + let s = d.stories.values().find(|s| s.text.contains("Spaced")).unwrap(); + let (_, base) = d.styles.resolve_para(&s.paras[0]); + let runs: Vec<_> = s.runs().map(|(_, f)| f.clone()).collect(); + assert_eq!(runs[0].over.font_family.as_deref(), Some("DC Space ")); + for f in &runs { + assert_eq!(d.styles.resolve_char(&base, f).font_style, "Bold", "the enclosing range's style"); + } + }; + let d = import_idml(&fixture_with_story(story)).unwrap(); + check(&d); + check(&import_idml(&export_idml(&d)).unwrap()); +}