diff --git a/ROADMAP.md b/ROADMAP.md index 6539ec17e..76d4bdbf3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Newest first. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change. +- **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 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8edb9fb49..b7e288a87 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -924,6 +924,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 an `$ID/` prefix or a format suffix (see [`plain_family`]). +fn alternate_names(family: &str) -> impl Iterator { + let trimmed = family.trim(); + (trimmed != family && !trimmed.is_empty()).then_some(trimmed).into_iter().chain(plain_family(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() @@ -1633,10 +1640,10 @@ impl ScopedFonts<'_> { /// Regular first, then by weight. fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { let v = self.exact_style_traits(family); - match plain_family(family) { - Some(base) if v.is_empty() => self.exact_style_traits(base), - _ => v, + if !v.is_empty() { + return v; } + alternate_names(family).map(|f| self.exact_style_traits(f)).find(|v| !v.is_empty()).unwrap_or_default() } fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { @@ -1662,13 +1669,13 @@ 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 InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)`) that isn't - /// found is looked up without them. + /// family that isn't found as written is looked up without surrounding spaces, then without + /// InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)` → `Minion Pro`). pub fn face(&self, family: &str, style: &str) -> Arc { let canonical = self .canonical_family(family) - .or_else(|| plain_family(family).and_then(|base| self.canonical_family(base))) - .unwrap_or_else(|| self.substitute_family(plain_family(family).unwrap_or(family))); + .or_else(|| alternate_names(family).find_map(|f| self.canonical_family(f))) + .unwrap_or_else(|| self.substitute_family(alternate_names(family).last().unwrap_or(family))); if let Some(f) = self.find_or_load(&canonical, style) { return f; } @@ -1742,9 +1749,9 @@ impl ScopedFonts<'_> { } /// Is `family` available (the document's, loaded, or installed on the system), as named or - /// without an `$ID/` prefix or a format suffix (see [`ScopedFonts::face`])? + /// under an alternate name (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { - self.canonical_family(family).is_some() || plain_family(family).is_some_and(|base| self.canonical_family(base).is_some()) + self.canonical_family(family).is_some() || alternate_names(family).any(|f| self.canonical_family(f).is_some()) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 3fa64ce73..e7214cd49 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -481,6 +481,19 @@ mod tests { assert!(!db.has_family("$ID/DC No Such Family")); } + #[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 d0cad575c..f51cdfa9a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1362,12 +1362,13 @@ 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); - a.font_family = self.composite_names.get(fam).cloned().or_else(|| names::font_family_in(fam)); + // 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']); + a.font_family = self.composite_names.get(fam.trim()).cloned().or_else(|| names::font_family_in(fam)); } - 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 c1d5e6a41..432c31fbb 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3042,4 +3042,25 @@ fn hostile_custom_wrap_boundary_is_dropped() { assert_eq!(it.wrap.path, None); assert!(first_line_x(&d) >= 100.0 - 1e-6); } +/// 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()); }