diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 700c9f36c..73bea978b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1667,6 +1667,7 @@ impl<'a> Ex<'a> { .attr("Self", uid(pg.id.0)) .attr("Name", name) .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) + .attr("LayoutRule", names::liquid_rule_out(pg.liquid)) .attr("OverrideList", overrides.join(" ")) .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 517e3ee80..80669a49c 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2168,7 +2168,7 @@ impl<'r> Importer<'r> { overridden: vec![], guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine), show_parent_items: show, - liquid: Default::default(), + liquid: names::liquid_rule_in(p.get("LayoutRule").unwrap_or("Off")), transition: None, view_rotation: 0, show_parent_overlay: false, diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ed0daaf40..d83a55884 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -74,6 +74,31 @@ pub fn fitting_in(s: &str) -> designcraft_doc::Fitting { } } +pub fn liquid_rule_out(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Recenter", + R::GuideBased => "GuideBased", + R::ObjectBased => "ObjectBased", + R::ControlledByParent => "UseMaster", + } +} + +pub fn liquid_rule_in(s: &str) -> designcraft_doc::LiquidRule { + use designcraft_doc::LiquidRule as R; + match s { + "Off" => R::Off, + "Scale" => R::Scale, + "Recenter" => R::ReCenter, + "GuideBased" => R::GuideBased, + "ObjectBased" => R::ObjectBased, + "UseMaster" => R::ControlledByParent, + _ => R::Off, + } +} + pub fn escape_id(s: &str) -> String { s.replace('%', "%25").replace(':', "%3a") } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ba0bad4eb..d11a3db6b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2387,3 +2387,159 @@ fn the_default_cjk_leading_model_applies_only_with_a_japanese_composer() { assert_eq!(model("$ID/HL Composer J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); assert_eq!(model("$ID/HL Single J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); } + +mod liquid_page_rules { + use super::*; + use designcraft_doc::LiquidRule as R; + use std::sync::Arc; + + // Literal XML expectations, independent of names.rs and serde spellings. + const CASES: [(&str, R); 6] = [ + ("Scale", R::Scale), + ("Off", R::Off), + ("Recenter", R::ReCenter), + ("GuideBased", R::GuideBased), + ("ObjectBased", R::ObjectBased), + ("UseMaster", R::ControlledByParent), + ]; + + fn fixture(rule: Option<&str>, parent_rule: Option<&str>) -> Vec { + let attribute = |value: Option<&str>| value.map(|v| format!(r#" LayoutRule="{v}""#)).unwrap_or_default(); + let (rule, parent_rule) = (attribute(rule), attribute(parent_rule)); + let map = r#" + + + + +"#; + let parent = format!( + r#" + + + + +"# + ); + // Based-on belongs to the whole derived master: both pages apply the same master. + let derived = format!( + r#" + + + + +"# + ); + let spread = format!( + r#" + + + + + + +"# + ); + zip_files(&[ + ("designmap.xml", map), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/A.xml", &parent), + ("MasterSpreads/B.xml", &derived), + ("Spreads/S.xml", &spread), + ]) + } + + #[test] + fn import_literal_xml() { + let baseline = import_idml(&fixture(None, None)).unwrap(); + assert_eq!((baseline.spreads.len(), baseline.parents.len()), (1, 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).all(|sp| sp.pages.len() == 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).flat_map(|sp| &sp.pages).all(|pg| pg.liquid == R::Off)); + let cases = CASES.into_iter().map(|(literal, rule)| (Some(literal), rule)).chain([ + (None, R::Off), + (Some("ReCenter"), R::Off), + (Some("controlledByParent"), R::Off), + (Some("PreserveExisting"), R::Off), + ]); + for (literal, rule) in cases { + let actual = import_idml(&fixture(literal, Some("Recenter"))).unwrap(); + assert_eq!(actual.spreads[0].pages[1].liquid, rule, "ordinary Page: {literal:?}"); + assert_eq!(actual.parents[1].pages[0].liquid, rule, "master Page: {literal:?}"); + // Same XML without rule attributes supplies the complete allowed-delta oracle. + let mut expected = baseline.clone(); + Arc::make_mut(&mut expected.spreads[0]).pages[1].liquid = rule; + Arc::make_mut(&mut expected.parents[1]).pages[0].liquid = rule; + Arc::make_mut(&mut expected.parents[0]).pages[1].liquid = R::ReCenter; + // Import assigns a fresh creation timestamp; all other document fields are compared. + expected.created = actual.created; + assert_eq!(actual, expected, "{literal:?}: only the three explicit rule fields may change apart from the import timestamp"); + assert_eq!(actual.spreads[0].pages[0].liquid, R::Off, "omitted does not infer inheritance"); + assert_eq!(actual.spreads[0].pages[0].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parents[1].parent.as_ref().unwrap().based_on, Some(actual.parents[0].id)); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + if rule == R::ControlledByParent { + // The right document page resolves to A's right page, not B's controlled page. + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert_eq!(actual.parents[0].pages[1].liquid, R::ReCenter); + } + } + } + + #[test] + fn export_and_round_trip() { + use std::io::Read; + + let baseline = import_idml(&fixture(None, None)).unwrap(); + for (literal, rule) in CASES { + // Set native enums directly; the literal importer under test does not build this input. + let mut source = baseline.clone(); + let pages = &mut Arc::make_mut(&mut source.spreads[0]).pages; + pages[0].parent = None; + pages[0].liquid = R::ControlledByParent; + pages[1].liquid = rule; + Arc::make_mut(&mut source.parents[0]).pages[1].liquid = R::ReCenter; + Arc::make_mut(&mut source.parents[1]).pages[0].liquid = rule; + source.check().unwrap(); + let bytes = export_idml(&source); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + for (directory, tag, spread, rules) in [ + ("Spreads", "Spread", &source.spreads[0], ["UseMaster", literal]), + ("MasterSpreads", "MasterSpread", &source.parents[0], ["Off", "Recenter"]), + ("MasterSpreads", "MasterSpread", &source.parents[1], [literal, "Off"]), + ] { + let path = format!("{directory}/{tag}_u{:x}.xml", spread.id.0); + let mut part = Vec::new(); + archive.by_name(&path).unwrap().read_to_end(&mut part).unwrap(); + let root = xml::parse(&part).unwrap(); + let body = root.find(tag).unwrap(); + let pages: Vec<_> = body.find_all("Page").collect(); + assert_eq!(pages.len(), spread.pages.len(), "{path}"); + for (page, expected) in spread.pages.iter().zip(rules) { + let id = format!("u{:x}", page.id.0); + let matches: Vec<_> = pages.iter().filter(|p| p.get("Self") == Some(id.as_str())).collect(); + assert_eq!(matches.len(), 1, "{path}: {id}"); + assert_eq!(matches[0].get("LayoutRule"), Some(expected), "{path}: {id}"); + } + } + let actual = import_idml(&bytes).unwrap(); + assert_eq!((actual.spreads.len(), actual.parents.len()), (1, 2)); + assert_eq!(actual.spreads[0].pages[0].parent, None); + assert_eq!(actual.spreads[0].pages[1].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + for (index, prefix, name, based_on) in [(0, "A", "Base", None), (1, "B", "Derived", Some(actual.parents[0].id))] { + let info = actual.parents[index].parent.as_ref().unwrap(); + assert_eq!((&info.prefix[..], &info.name[..], info.based_on), (prefix, name, based_on)); + } + for (got, want) in actual.spreads.iter().chain(&actual.parents).zip(source.spreads.iter().chain(&source.parents)) { + assert_eq!(got.pages.len(), want.pages.len()); + for (got, want) in got.pages.iter().zip(&want.pages) { + // IDs are allocated afresh; parent associations were checked explicitly above. + let mut normalized = got.clone(); + normalized.id = want.id; + normalized.parent = want.parent; + assert_eq!(&normalized, want, "{literal}: all other Page fields, including the stored rule"); + } + } + } + } +}