IDML: layers import and export in stacking order; font names with a format suffix (#247)

* IDML: layers import and export in stacking order

* Fonts: a family named with a format suffix such as (OTF) resolves to the installed family
This commit is contained in:
aiongg 2026-10-10 01:51:03 -04:00 committed by GitHub
parent fb68b7cc2d
commit 4d16a8ce3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 123 additions and 10 deletions

View File

@ -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::<Vec<_>>()
};
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"));
}
}

View File

@ -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<String> {
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<String> {
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<FontFace> {
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<Arc<FontFace>> {
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;
}

View File

@ -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();

View File

@ -346,7 +346,8 @@ impl<'a> Ex<'a> {
let n = format!("<?AID 001b?>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 => {

View File

@ -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 {

View File

@ -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#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<Layer Self="L1" Name="Background"/>
<Layer Self="L2" Name="Content"/>
<idPkg:Spread src="Spreads/Spread_s.xml"/>
</Document>"#;
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="s">
<Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/>
<Rectangle Self="r" ItemLayer="L1">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/>
<PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
</Rectangle>
</Spread>
</idPkg:Spread>"#;
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::<Vec<_>>();
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);
}