Merge idml-font-names: IDML: font family names keep their spaces; FontStyle Nothing inherits (#558)

Conflict: ROADMAP.md: both progress lines
Conflict: crates/fonts/src/fontdb.rs: next's style_traits/traits_of/linked_style, canonical_family lookup, family_kind and postscript_face; #558's alternate_names (as written, then trimmed) now chains plain_family ($ID/ prefix and format suffix) and is the fallback in style_traits, face (canonical_family, then substitute_family) and has_family
Conflict: crates/idml/src/import.rs: #558's CR/LF-only stripping (next's f.trim() dropped), next's composite_names lookup (by the trimmed name) and names::font_family_in; #558's FontStyle Nothing → unset
Conflict: crates/idml/src/tests.rs: both tests
This commit is contained in:
aiongg
2026-10-12 05:35:36 +00:00
5 changed files with 57 additions and 14 deletions
+1
View File
@@ -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.
+16 -9
View File
@@ -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<Item = &str> {
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<FontFace> {
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.
+13
View File
@@ -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();
+6 -5
View File
@@ -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
+21
View File
@@ -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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Text%3aBody" FontStyle="Bold">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" FontStyle="Nothing"><Properties><AppliedFont type="string">DC Space </AppliedFont></Properties><Content>Spaced</Content></CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" FontStyle="$ID/Nothing"><Content> plain</Content></CharacterStyleRange>
</ParagraphStyleRange></Story></idPkg:Story>"#;
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());
}