//! The layout model: spreads, pages, guides, page items, graphics and geometry. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use craft_idml::model::{ GraphicKind, IDENTITY, ItemChild, ItemKind, MAX_PATH_POINTS, PageChild, PathGeometry, PathPoint, Spread, SpreadChild, SubPath, Values, apply, concat, spreads, }; use craft_idml::{El, Package, designmap_xml, parse_part, part_root, part_xml, write_package}; /// A spread shaped as InDesign writes one: a page with margins and guides, then the page items /// back to front (an image frame, a threaded text frame, a group, an open line), with elements the /// model does not type in between. const SPREAD: &str = r#" UseMasterColor "#; fn spread() -> Spread { let part = parse_part(SPREAD.as_bytes()).unwrap(); Spread::from_el(part.child("Spread").unwrap()) } #[test] fn a_spread_reads_as_pages_and_items_in_order() { let s = spread(); assert!(!s.master); assert_eq!(s.values.self_id(), Some("ud3")); assert_eq!(s.values.item_transform(), IDENTITY); let kinds: Vec = s .children .iter() .map(|c| match c { SpreadChild::Page(_) => "Page".into(), SpreadChild::Item(i) => i.kind.name().to_string(), SpreadChild::Other(e) => format!("other {}", e.name), }) .collect(); assert_eq!(kinds, ["other FlattenerPreference", "Page", "Rectangle", "TextFrame", "Group", "GraphicLine", "MultiStateObject"]); let page = s.pages().next().unwrap(); assert_eq!(page.values.name(), Some("1")); assert_eq!(page.geometric_bounds(), Some([0.0, 0.0, 792.0, 612.0])); assert_eq!(page.values.item_transform(), [1.0, 0.0, 0.0, 1.0, -306.0, -396.0]); assert_eq!(page.applied_master(), Some("ud1")); assert_eq!(page.override_list(), ["ue1", "ue2"]); assert_eq!(page.values.value("PageColor").as_deref(), Some("UseMasterColor")); let margins = page.margin_preference().unwrap(); assert_eq!((margins.num("ColumnCount"), margins.num("Top"), margins.get("ColumnsPositions")), (Some(2.0), Some(36.0), Some("0 264 276 540"))); let guides: Vec<_> = page.guides().collect(); assert_eq!(guides.len(), 2); assert!(guides[0].horizontal() && !guides[0].fit_to_page() && guides[0].values.locked() && !guides[0].liquid()); assert_eq!((guides[0].location(), guides[0].values.item_layer()), (Some(100.0), Some("ua"))); assert!(!guides[1].horizontal() && guides[1].fit_to_page() && guides[1].liquid()); assert_eq!(guides[1].location(), Some(50.5)); } #[test] fn page_items_give_their_geometry_links_and_threads() { let s = spread(); let items: Vec<_> = s.items().collect(); let frame = items[0]; assert_eq!(frame.kind, ItemKind::Rectangle); assert_eq!(frame.applied_object_style(), Some("ObjectStyle/$ID/[Normal Graphics Frame]")); assert_eq!(frame.values.get("FillColor"), Some("Swatch/None")); assert_eq!(frame.geometry().bounds(), Some([0.0, 0.0, 100.0, 200.0])); assert_eq!(frame.other("TextWrapPreference").and_then(|w| w.attr("TextWrapMode")), Some("None")); let image = frame.graphics().next().unwrap(); assert_eq!(image.kind, GraphicKind::Image); assert_eq!(image.graphic_bounds(), Some([0.0, 0.0, 200.0, 400.0])); assert_eq!(image.contents().as_deref(), Some(&b"hello world"[..])); let link = image.link().unwrap(); assert_eq!((link.uri(), link.format()), (Some("file:/Users/a/My%20Pictures/p.jpg"), Some("$ID/JPEG"))); assert_eq!(craft_idml::value::uri_to_path(link.uri().unwrap()), "/Users/a/My Pictures/p.jpg"); let text = items[1]; assert_eq!(text.kind, ItemKind::TextFrame); assert_eq!((text.parent_story(), text.previous_text_frame(), text.next_text_frame()), (Some("us1"), None, Some("ut2"))); assert!(!text.values.visible() && !text.values.locked()); assert_eq!(text.geometry().paths[0].points[1], PathPoint::corner([0.0, 50.0]), "a missing direction point is the anchor"); let group = items[2]; assert_eq!(group.overridden_page_item(), Some("ue1")); let oval = group.items().next().unwrap(); let g = oval.geometry(); assert_eq!(g.paths[0].points[0], PathPoint { anchor: [0.0, -1.0], left: [-0.55, -1.0], right: [0.55, -1.0] }); assert_eq!(g.bounds(), Some([-1.0, -0.55, 0.55, 1.0]), "direction points count"); let line = items[3]; assert!(line.geometry().paths[0].open); // A page item kind the model does not type keeps its content as written. assert_eq!(items[4].kind, ItemKind::Other("MultiStateObject".into())); assert!(matches!(&items[4].children[..], [ItemChild::Other(e)] if e.name == "State")); } #[test] fn nested_items_carry_their_transform_into_the_spread() { let s = spread(); let all: Vec<(&str, [f64; 6])> = s.all_items().into_iter().map(|(i, m)| (i.values.self_id().unwrap(), m)).collect(); let ids: Vec<&str> = all.iter().map(|(id, _)| *id).collect(); assert_eq!(ids, ["ur1", "ut1", "ug9", "uo1", "ug10", "ul2", "ul3", "um1"]); let oval = all[3].1; assert_eq!(oval, [2.0, 0.0, 0.0, 2.0, 105.0, 105.0]); assert_eq!(apply(&oval, [1.0, 0.0]), [107.0, 105.0]); assert_eq!(all[5].1, [1.0, 0.0, 0.0, 1.0, 101.0, 101.0]); assert_eq!(concat(&IDENTITY, &oval), oval); } #[test] fn a_spread_written_and_read_again_is_the_same() { let s = spread(); let bytes = part_xml(&part_root("Spread").with_child(s.to_el())); let again = Spread::from_el(parse_part(&bytes).unwrap().child("Spread").unwrap()); assert_eq!(again, s); let xml = String::from_utf8(bytes).unwrap(); // `` stays the first child; the embedded contents and unknown elements survive. assert!(xml.contains("\n"), "{xml}"); assert!( xml.contains("\n\t\t\t\t\n\t\t\t\t\t"), "{xml}"); assert!(xml.contains(""), "{xml}"); } #[test] fn a_package_gives_its_spreads_and_parent_spreads() { let s = spread(); let mut parent = Spread { master: true, ..Spread::default() }; parent.values.set("Self", "ud1"); parent.values.set("NamePrefix", "A"); parent.values.set("BaseName", "Parent"); let designmap = El::new("Document") .with_attr("xmlns:idPkg", craft_idml::PACKAGING_NS) .with_child(El::new("idPkg:MasterSpread").with_attr("src", "MasterSpreads/MasterSpread_ud1.xml")) .with_child(El::new("idPkg:Spread").with_attr("src", "Spreads/Spread_ud3.xml")); let bytes = write_package(&[ ("designmap.xml", designmap_xml(&designmap)), ("MasterSpreads/MasterSpread_ud1.xml", part_xml(&part_root("MasterSpread").with_child(parent.to_el()))), ("Spreads/Spread_ud3.xml", SPREAD.as_bytes().to_vec()), ]) .unwrap(); let doc = Package::open(&bytes).unwrap().document().unwrap(); let found = spreads(&doc); assert_eq!(found.len(), 2); assert!(found[0].master); assert_eq!(found[0].parent_name(), Some(("A", "Parent"))); assert_eq!(found[1], s); } #[test] fn geometry_reads_writes_and_keeps_only_usable_points() { let rect = PathGeometry::rectangle([10.0, 20.0, 30.0, 40.0]); assert_eq!(rect.bounds(), Some([10.0, 20.0, 30.0, 40.0])); assert_eq!(rect.paths[0].points[0], PathPoint::corner([20.0, 10.0])); assert_eq!(PathGeometry::from_el(&rect.to_el()), rect); assert_eq!(PathGeometry::default().bounds(), None); let pg = parse_part( br#" "#, ) .unwrap(); let g = PathGeometry::from_el(&pg); // Subpaths with a non-finite number are dropped whole; points without a usable anchor are // dropped; an unusable direction point is the anchor. assert_eq!(g, PathGeometry { paths: vec![SubPath { open: false, points: vec![PathPoint::corner([2.0, 3.0])] }] }); } #[test] fn geometry_keeps_at_most_the_point_limit() { let many = format!( "{}", "".repeat(4) ); let pg = parse_part(many.as_bytes()).unwrap(); assert_eq!(PathGeometry::from_el(&pg).point_count(), 12); let capped = PathGeometry::from_el_capped(&pg, 7); assert_eq!(capped.point_count(), 7); assert_eq!(capped.paths.iter().map(|p| p.points.len()).collect::>(), [3, 3, 1]); assert_eq!(PathGeometry::from_el_capped(&pg, 0), PathGeometry::default()); assert_eq!(MAX_PATH_POINTS, 1_000_000); } #[test] fn values_set_and_replace_their_properties() { let mut s = spread(); let SpreadChild::Item(frame) = &mut s.children[2] else { panic!() }; frame.set_geometry(&PathGeometry::rectangle([0.0, 0.0, 1.0, 1.0])); assert_eq!(frame.geometry().bounds(), Some([0.0, 0.0, 1.0, 1.0])); assert_eq!(frame.values.properties.len(), 1, "replaced, not added"); frame.values.set("FillColor", "Color/Black"); assert_eq!(frame.values.get("FillColor"), Some("Color/Black")); let mut v = Values::default(); v.set_prop_el("Leading", Some(El::new("Leading").with_attr("type", "unit").with_text("14"))); assert_eq!(v.num("Leading"), Some(14.0)); v.set_prop_el("Leading", None); assert_eq!(v.prop("Leading"), None); assert!(v.element("Thing").children.is_empty()); // A page's own children other than guides stay where they were. let page = s.pages().next().unwrap(); assert!(matches!(&page.children[0], PageChild::Other(e) if e.name == "MarginPreference")); } /// Groups nest as deep as the XML reader allows; reading and walking them does not recurse /// without bound. #[test] fn deeply_nested_groups_are_read_and_walked() { let depth = 250; let xml = format!("{}{}", "".repeat(depth), "".repeat(depth)); let s = Spread::from_el(&parse_part(xml.as_bytes()).unwrap()); let all = s.all_items(); assert_eq!(all.len(), depth + 1); let (deepest, m) = all.last().unwrap(); assert_eq!(deepest.values.self_id(), Some("deep")); assert_eq!(m[4], depth as f64); let again = Spread::from_el(&parse_part(&part_xml(&s.to_el())).unwrap()); assert_eq!(again, s); }