diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb93..3b173c1 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 4177f94..14441e1 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 8f901e9..c1d7447 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(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 62f50d0..09fa22f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -346,7 +346,8 @@ impl<'a> Ex<'a> { let n = format!("TV {name}"); root.push(El::new("TextVariable").attr("Self", format!("dTextVariablen{n}")).attr("Name", &n).attr("VariableType", ty)); } - for l in &d.layers { + // IDML lists layers back to front. + for l in d.layers.iter().rev() { let color = match names::layer_color_out(l.color) { Some(n) => p("LayerColor", "enumeration", n), None => { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f6e711d..a958e65 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -356,6 +356,8 @@ impl<'r> Importer<'r> { self.layer_ids.insert(s.to_string(), id); } } + // IDML lists layers back to front; `Document::layers` is front to back. + self.layers.reverse(); if self.layers.is_empty() { let id = LayerId(self.alloc()); self.layers.push(Layer { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 910c89a..e6308e8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -859,3 +859,38 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +/// InDesign's designmap lists layers back to front; `Document::layers[0]` is the front layer. +#[test] +fn layers_import_and_export_in_stacking_order() { + let designmap = r#" + + + + "#; + let spread = r#" + + + + + + + + + + "#; + let d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread)])).unwrap(); + let names = |d: &Document| d.layers.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(&d), ["Content", "Background"]); + assert_eq!(d.spreads[0].items[0].layer, d.layers[1].id, "the rectangle stays on Background"); + + let bytes = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut map = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("designmap.xml").unwrap(), &mut map).unwrap(); + let (bg, content) = (map.find(r#"Name="Background""#).unwrap(), map.find(r#"Name="Content""#).unwrap()); + assert!(bg < content, "designmap lists the back layer first"); + let back = import_idml(&bytes).unwrap(); + assert_eq!(names(&back), ["Content", "Background"]); + assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); +}