diff --git a/crates/idml/README.md b/crates/idml/README.md index 16435a361..a399c51bd 100644 --- a/crates/idml/README.md +++ b/crates/idml/README.md @@ -9,9 +9,9 @@ An IDML package is a ZIP whose first entry is an uncompressed `mimetype`, follow Implemented clean-room from the public IDML File Format Specification and from inspecting IDML packages exported from synthetic documents. -This crate is the layer between the bytes and IDML's own element tree. What the elements mean -(spreads, page items, stories, styles, swatches) is not modelled here yet: a caller walks the -tree and maps it onto its own document. +This crate is the layer between the bytes and IDML's own element tree, with a typed model of +the layout (`model`). Stories, styles, swatches and preferences are not modelled yet: a caller +walks those elements and maps them onto its own document. ## Reading @@ -79,6 +79,50 @@ assert!(craft_idml::is_idml(&bytes)); Parts are written with an element that has only element children one child per line, and any other element on one line, so story text is never changed by indentation. +## The layout model + +```rust +# fn main() -> craft_idml::Result<()> { +# let bytes: Vec = Vec::new(); +# if bytes.is_empty() { return Ok(()); } +use craft_idml::model::{apply, spreads}; + +let doc = craft_idml::Package::open(&bytes)?.document()?; +for spread in spreads(&doc) { + for page in spread.pages() { + let _bounds = page.geometric_bounds(); // top left bottom right + let _parent = page.applied_master(); + } + for (item, to_spread) in spread.all_items() { // nested items too, with their transform + let outline = item.geometry(); // subpaths of anchor and direction points + if let Some([top, left, _, _]) = outline.bounds() { + let _corner = apply(&to_spread, [left, top]); + } + for graphic in item.graphics() { + let _file = graphic.link().and_then(|l| l.uri()); + let _embedded = graphic.contents(); + } + } +} +# Ok(()) } +``` + +- `Spread` (and parent spreads, `master`), `Page`, `Guide`, `PageItem` (`ItemKind`: text frames, + rectangles, ovals, polygons, lines, groups, buttons, EPS text, and other page item elements by + name), `Graphic` (`GraphicKind`: images, EPS, PDF, imported pages, WMF, PICT, SVG) and `Link`. + Children keep their document order: page items stack back to front. +- Each node keeps its attributes and `` as written (`Values`) and reads them through + accessors: `item_transform`, `geometric_bounds`, `applied_master`, `override_list`, + `parent_story`, `next_text_frame`, `graphic_bounds`, `contents`, and `num`, `boolean`, + `value` for any other property. Anything the model does not type is kept as XML where it + stood, without the part's indentation (`tidy`). `to_el` writes a node back: what is read and + written again reads the same. +- `PathGeometry` (`geometry`, `set_geometry`) holds subpaths of points. A point without a usable + anchor is left out, a missing direction point is the anchor, and a subpath holding `NaN` or an + infinity is left out whole. At most `MAX_PATH_POINTS` (1,000,000) points are kept. +- `Spread::all_items` walks nested groups without recursion, giving each item's transform into + spread coordinates (`concat` of the transforms on the way). + ## Values `craft_idml::value`: `num` and `point_str` (numbers as IDML writes them), `nums`, `matrix`, diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 96d051f0d..f101d68e8 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -18,8 +18,9 @@ //! link URIs, base64 `Contents`. //! - [`tagged`] reads and writes InDesign Tagged Text, the plain-text interchange format. //! -//! What the elements mean (spreads, page items, stories, styles) is not modelled here yet: a -//! caller walks the tree. +//! - [`model`] types the layout: spreads and parent spreads, pages, guides, page items and the +//! graphics placed in them, with path geometry. Stories, styles, swatches and preferences are +//! walked as elements for now. //! //! Implemented clean-room from the public IDML File Format Specification and from inspecting //! IDML packages exported from synthetic documents. @@ -28,6 +29,7 @@ #[doc = include_str!("../README.md")] struct ReadmeExamples; +pub mod model; mod package; mod part; pub mod tagged; diff --git a/crates/idml/src/model/geometry.rs b/crates/idml/src/model/geometry.rs new file mode 100644 index 000000000..02b00ddaf --- /dev/null +++ b/crates/idml/src/model/geometry.rs @@ -0,0 +1,126 @@ +//! `PathGeometry`: the outline of a page item, as subpaths of anchor points with their +//! direction (Bézier control) points, in the item's inner coordinates. + +use craft_xml::{El, Local}; + +use crate::value::{nums, point_str}; + +/// Most points one geometry keeps by default; points past it are dropped. +pub const MAX_PATH_POINTS: usize = 1_000_000; + +/// An anchor point and its two direction points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PathPoint { + pub anchor: [f64; 2], + /// `LeftDirection`: the control point before the anchor. + pub left: [f64; 2], + /// `RightDirection`: the control point after the anchor. + pub right: [f64; 2], +} + +impl PathPoint { + /// A corner point: both direction points on the anchor. + pub fn corner(anchor: [f64; 2]) -> PathPoint { + PathPoint { anchor, left: anchor, right: anchor } + } +} + +/// One `GeometryPathType`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SubPath { + /// `PathOpen`: an open path is not closed back to its first point. + pub open: bool, + pub points: Vec, +} + +/// A `PathGeometry` element. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PathGeometry { + pub paths: Vec, +} + +/// A point `x y`; `None` when the value is not two finite numbers. +fn point(s: &str) -> Option<[f64; 2]> { + match nums(s).as_slice() { + [x, y] => Some([*x, *y]), + _ => None, + } +} + +impl PathGeometry { + /// The geometry of `pg` (a `PathGeometry` element), at most [`MAX_PATH_POINTS`] points. + pub fn from_el(pg: &El) -> PathGeometry { + Self::from_el_capped(pg, MAX_PATH_POINTS) + } + + /// [`PathGeometry::from_el`] keeping at most `max` points over all subpaths. + /// + /// A point without a usable anchor is left out; a missing or unusable direction point is + /// the anchor. A subpath with a value that is a number but not a finite one (`NaN`, `inf`) + /// is left out whole: dropping only those points would change its shape. + pub fn from_el_capped(pg: &El, max: usize) -> PathGeometry { + let mut left = max; + let mut paths = Vec::new(); + for g in pg.children(Local("GeometryPathType")) { + if left == 0 { + break; + } + let points_el: Vec<&El> = + g.child(Local("PathPointArray")).map(|a| a.children(Local("PathPointType")).take(left).collect()).unwrap_or_default(); + let non_finite = |v: Option<&str>| v.is_some_and(|s| s.split_whitespace().any(|t| t.parse::().is_ok_and(|x| !x.is_finite()))); + if points_el.iter().any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.attr(k)))) { + continue; + } + let mut points = Vec::with_capacity(points_el.len()); + for p in points_el { + let Some(anchor) = p.attr("Anchor").and_then(point) else { continue }; + let left_dir = p.attr("LeftDirection").and_then(point).unwrap_or(anchor); + let right_dir = p.attr("RightDirection").and_then(point).unwrap_or(anchor); + points.push(PathPoint { anchor, left: left_dir, right: right_dir }); + } + left = left.saturating_sub(points.len()); + paths.push(SubPath { open: g.attr("PathOpen") == Some("true"), points }); + } + PathGeometry { paths } + } + + /// The `PathGeometry` element. + pub fn to_el(&self) -> El { + let mut pg = El::new("PathGeometry"); + for sp in &self.paths { + let mut arr = El::new("PathPointArray"); + for p in &sp.points { + arr.push( + El::new("PathPointType") + .with_attr("Anchor", point_str(p.anchor[0], p.anchor[1])) + .with_attr("LeftDirection", point_str(p.left[0], p.left[1])) + .with_attr("RightDirection", point_str(p.right[0], p.right[1])), + ); + } + pg.push(El::new("GeometryPathType").with_attr("PathOpen", if sp.open { "true" } else { "false" }).with_child(arr)); + } + pg + } + + /// A closed rectangle from `top left bottom right`, corners clockwise from the top left. + pub fn rectangle([top, left, bottom, right]: [f64; 4]) -> PathGeometry { + let points = [[left, top], [left, bottom], [right, bottom], [right, top]].map(PathPoint::corner).to_vec(); + PathGeometry { paths: vec![SubPath { open: false, points }] } + } + + /// The box around the anchors and direction points: `top left bottom right`. `None` without + /// points. + pub fn bounds(&self) -> Option<[f64; 4]> { + let mut it = self.paths.iter().flat_map(|p| &p.points).flat_map(|p| [p.anchor, p.left, p.right]); + let first = it.next()?; + let [mut x0, mut y0, mut x1, mut y1] = [first[0], first[1], first[0], first[1]]; + for [x, y] in it { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + Some([y0, x0, y1, x1]) + } + + pub fn point_count(&self) -> usize { + self.paths.iter().map(|p| p.points.len()).sum() + } +} diff --git a/crates/idml/src/model/mod.rs b/crates/idml/src/model/mod.rs new file mode 100644 index 000000000..c3432f0e7 --- /dev/null +++ b/crates/idml/src/model/mod.rs @@ -0,0 +1,184 @@ +//! A typed model of IDML's layout: spreads and parent (master) spreads, pages, guides, page items +//! and the graphics placed in them. +//! +//! The model types what contains what. Each node keeps its attributes and `` +//! children as written ([`Values`]) and reads them through typed accessors, path geometry among +//! them; anything the model does not type is kept as XML where it stood ([`tidy`]: without the +//! part's indentation). Parsing and writing a node therefore loses nothing, and a caller reads +//! the properties it maps and leaves the rest. +//! +//! Nodes are built from the elements [`crate::Package::document`] gives ([`Spread::from_el`], +//! [`spreads`]) and written back with `to_el`, then [`crate::part_xml`]. + +mod geometry; +mod spread; + +pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathPoint, SubPath}; +pub use spread::{Graphic, GraphicChild, GraphicKind, Guide, ItemChild, ItemKind, Link, Page, PageChild, PageItem, Spread, SpreadChild, spreads}; + +use craft_xml::{El, Local, Node}; + +use crate::value::{matrix, nums}; + +/// An IDML transform: `a b c d tx ty`, inner coordinates to parent coordinates. +pub type Matrix = [f64; 6]; + +/// The identity transform. +pub const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// A node's own values: its attributes and its `` children, as written. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Values { + pub attrs: Vec<(String, String)>, + /// The children of ``, in order. + pub properties: Vec, +} + +impl Values { + /// The attributes and `` of `el`. + pub fn of(el: &El) -> Values { + let properties = el.child(Local("Properties")).map(|p| p.elements().map(tidy).collect()).unwrap_or_default(); + Values { attrs: el.attrs.clone(), properties } + } + + /// An attribute as written. + pub fn get(&self, key: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + /// Sets an attribute, replacing one of that name. + pub fn set(&mut self, key: &str, value: impl Into) { + let value = value.into(); + match self.attrs.iter_mut().find(|(k, _)| k == key) { + Some(slot) => slot.1 = value, + None => self.attrs.push((key.to_string(), value)), + } + } + + /// The `` child `key`. + pub fn prop_el(&self, key: &str) -> Option<&El> { + self.properties.iter().find(|e| e.local() == key) + } + + /// Replaces the `` child of `el`'s name, or adds it; `None` removes it. + pub fn set_prop_el(&mut self, key: &str, el: Option) { + let at = self.properties.iter().position(|e| e.local() == key); + match (at, el) { + (Some(i), Some(el)) => { + if let Some(slot) = self.properties.get_mut(i) { + *slot = el; + } + } + (Some(i), None) => { + self.properties.remove(i); + } + (None, Some(el)) => self.properties.push(el), + (None, None) => {} + } + } + + /// A property in either spelling: the attribute, else the text of the `` child. + pub fn prop(&self, key: &str) -> Option { + match self.get(key) { + Some(v) => Some(v.to_string()), + None => self.prop_el(key).map(El::deep_text), + } + } + + /// [`Values::prop`] trimmed; `None` when blank. + pub fn value(&self, key: &str) -> Option { + self.prop(key).map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) + } + + /// A finite number; `NaN` and infinities count as absent. + pub fn num(&self, key: &str) -> Option { + self.prop(key).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + } + + /// `true` or `false`; anything else counts as absent. + pub fn boolean(&self, key: &str) -> Option { + match self.prop(key)?.trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } + } + + /// `Self`, the node's id. + pub fn self_id(&self) -> Option<&str> { + self.get("Self") + } + + pub fn name(&self) -> Option<&str> { + self.get("Name") + } + + /// `ItemTransform`; the identity when absent or not six finite numbers. + pub fn item_transform(&self) -> Matrix { + self.get("ItemTransform").and_then(matrix).unwrap_or(IDENTITY) + } + + /// `ItemLayer`: the `Self` of the layer the node is on. + pub fn item_layer(&self) -> Option<&str> { + self.get("ItemLayer") + } + + /// `Visible`; visible when absent. + pub fn visible(&self) -> bool { + self.boolean("Visible") != Some(false) + } + + /// `Locked`; unlocked when absent. + pub fn locked(&self) -> bool { + self.boolean("Locked") == Some(true) + } + + /// The element `name` with these values: attributes, then `` (when there are + /// any) as its first child. + pub fn element(&self, name: &str) -> El { + let mut el = El::new(name); + el.attrs = self.attrs.clone(); + if !self.properties.is_empty() { + let mut p = El::new("Properties"); + p.children = self.properties.iter().cloned().map(Node::El).collect(); + el.push(p); + } + el + } +} + +/// `el` without its indentation: the whitespace-only text between the children of an element +/// that has child elements. Text that is not only whitespace, and an element's only text, is kept. +/// A part's indentation means nothing, and the writer makes its own. +pub fn tidy(el: &El) -> El { + let blank = |n: &Node| matches!(n, Node::Text(t) if t.chars().all(|c| matches!(c, ' ' | '\t' | '\n' | '\r'))); + let element_only = el.children.iter().any(|n| matches!(n, Node::El(_))) && el.children.iter().all(|n| matches!(n, Node::El(_)) || blank(n)); + let mut out = El::new(el.name.clone()); + out.attrs = el.attrs.clone(); + for n in &el.children { + match n { + Node::El(c) => out.push(tidy(c)), + n if element_only && blank(n) => {} + n => out.children.push(n.clone()), + } + } + out +} + +/// Four numbers `top left bottom right` (`GeometricBounds`, `VisibleBounds`), finite. +pub fn bounds(s: &str) -> Option<[f64; 4]> { + <[f64; 4]>::try_from(nums(s)).ok() +} + +/// `p` through `m`. +pub fn apply(m: &Matrix, p: [f64; 2]) -> [f64; 2] { + [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]] +} + +/// `outer · inner`: `inner` first, then `outer` (a child's transform into its parent's space, +/// then the parent's into its own parent's). +pub fn concat(outer: &Matrix, inner: &Matrix) -> Matrix { + let [a, b, c, d, e, f] = *outer; + let [p, q, r, s, t, u] = *inner; + [a * p + c * q, b * p + d * q, a * r + c * s, b * r + d * s, a * t + c * u + e, b * t + d * u + f] +} diff --git a/crates/idml/src/model/spread.rs b/crates/idml/src/model/spread.rs new file mode 100644 index 000000000..c4d535f89 --- /dev/null +++ b/crates/idml/src/model/spread.rs @@ -0,0 +1,464 @@ +//! Spreads, pages, guides, page items and graphics. + +use craft_xml::{El, Local, Node}; + +use super::{Matrix, PathGeometry, Values, bounds, concat, tidy}; +use crate::Document; +use crate::value::base64_decode; + +/// The children of `el` other than ``. +fn others(el: &El) -> impl Iterator { + el.elements().filter(|e| !e.is(Local("Properties"))) +} + +/// `el` (built by [`Values::element`]) with `children` after its ``. +fn with_children(mut el: El, children: impl IntoIterator) -> El { + el.children.extend(children.into_iter().map(Node::El)); + el +} + +/// A spread (`Spread`) or a parent spread (`MasterSpread`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Spread { + /// A parent ("master") spread. + pub master: bool, + pub values: Values, + pub children: Vec, +} + +/// A child of a spread, in document order: the order of its page items is their stacking order, +/// back to front. +#[derive(Clone, Debug, PartialEq)] +pub enum SpreadChild { + Page(Page), + Item(PageItem), + /// Anything else (`FlattenerPreference`, …), as written. + Other(El), +} + +impl Spread { + pub fn from_el(el: &El) -> Spread { + let children = others(el) + .map(|c| match c.local() { + "Page" => SpreadChild::Page(Page::from_el(c)), + n if ItemKind::of(n).is_some() => PageItem::from_el(c).map_or_else(|| SpreadChild::Other(tidy(c)), SpreadChild::Item), + _ => SpreadChild::Other(tidy(c)), + }) + .collect(); + Spread { master: el.local() == "MasterSpread", values: Values::of(el), children } + } + + pub fn to_el(&self) -> El { + let name = if self.master { "MasterSpread" } else { "Spread" }; + with_children( + self.values.element(name), + self.children.iter().map(|c| match c { + SpreadChild::Page(p) => p.to_el(), + SpreadChild::Item(i) => i.to_el(), + SpreadChild::Other(e) => e.clone(), + }), + ) + } + + pub fn pages(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let SpreadChild::Page(p) = c { Some(p) } else { None }) + } + + /// The top-level page items, back to front. + pub fn items(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let SpreadChild::Item(i) = c { Some(i) } else { None }) + } + + /// Every page item, nested ones included, in document order, each with its transform into + /// spread coordinates. + pub fn all_items(&self) -> Vec<(&PageItem, Matrix)> { + let mut out = Vec::new(); + // Iterative: nesting is bounded by the XML reader, but there is no need to recurse. + let mut stack: Vec<(&PageItem, Matrix)> = self.items().rev().map(|i| (i, i.values.item_transform())).collect(); + while let Some((item, m)) = stack.pop() { + out.push((item, m)); + stack.extend(item.items().rev().map(|c| (c, concat(&m, &c.values.item_transform())))); + } + out + } + + /// `BaseName` and `NamePrefix` of a parent spread (`A` and `Parent`). + pub fn parent_name(&self) -> Option<(&str, &str)> { + Some((self.values.get("NamePrefix")?, self.values.get("BaseName")?)) + } +} + +/// The spreads and parent spreads of a document, in its order. +pub fn spreads(doc: &Document) -> Vec { + doc.top.iter().filter(|e| matches!(e.local(), "Spread" | "MasterSpread")).map(Spread::from_el).collect() +} + +/// A page. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub values: Values, + pub children: Vec, +} + +/// A child of a page, in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum PageChild { + Guide(Guide), + /// `MarginPreference`, `GridDataInformation`, … as written. + Other(El), +} + +impl Page { + pub fn from_el(el: &El) -> Page { + let children = others(el) + .map(|c| { + if c.local() == "Guide" { + PageChild::Guide(Guide { values: Values::of(c), rest: others(c).map(tidy).collect() }) + } else { + PageChild::Other(tidy(c)) + } + }) + .collect(); + Page { values: Values::of(el), children } + } + + pub fn to_el(&self) -> El { + with_children( + self.values.element("Page"), + self.children.iter().map(|c| match c { + PageChild::Guide(g) => with_children(g.values.element("Guide"), g.rest.iter().cloned()), + PageChild::Other(e) => e.clone(), + }), + ) + } + + /// `GeometricBounds`: `top left bottom right` in the page's inner coordinates. + pub fn geometric_bounds(&self) -> Option<[f64; 4]> { + self.values.get("GeometricBounds").and_then(bounds) + } + + /// `AppliedMaster`: the `Self` of the parent spread, or `None` for none (`n`). + pub fn applied_master(&self) -> Option<&str> { + self.values.get("AppliedMaster").filter(|m| *m != "n") + } + + /// `OverrideList`: the `Self`s of the parent items overridden on this page. + pub fn override_list(&self) -> Vec<&str> { + self.values.get("OverrideList").map(|l| l.split_whitespace().collect()).unwrap_or_default() + } + + /// `MarginPreference`: margins, columns and their positions. + pub fn margin_preference(&self) -> Option { + self.children.iter().find_map(|c| match c { + PageChild::Other(e) if e.local() == "MarginPreference" => Some(Values::of(e)), + _ => None, + }) + } + + pub fn guides(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let PageChild::Guide(g) = c { Some(g) } else { None }) + } +} + +/// A ruler guide. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Guide { + pub values: Values, + /// Its children other than ``, as written. + pub rest: Vec, +} + +impl Guide { + /// `Orientation` is `Horizontal`; a guide is vertical otherwise. + pub fn horizontal(&self) -> bool { + self.values.get("Orientation") == Some("Horizontal") + } + + /// `Location`: from the ruler origin, along the axis across the guide. + pub fn location(&self) -> Option { + self.values.num("Location") + } + + /// `FitToPage`: across the page only (else across the spread). True when absent. + pub fn fit_to_page(&self) -> bool { + self.values.boolean("FitToPage") != Some(false) + } + + /// `GuideType` is `Liquid`. + pub fn liquid(&self) -> bool { + self.values.get("GuideType") == Some("Liquid") + } +} + +/// What a page item is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ItemKind { + TextFrame, + Rectangle, + Oval, + Polygon, + GraphicLine, + Group, + Button, + EPSText, + /// Another page item element (`MultiStateObject`, form fields, `HtmlItem`), by name; its + /// children are kept as written. + Other(String), +} + +impl ItemKind { + /// The kind of a page item element, or `None` for an element that is no page item. + pub fn of(name: &str) -> Option { + Some(match name { + "TextFrame" => ItemKind::TextFrame, + "Rectangle" => ItemKind::Rectangle, + "Oval" => ItemKind::Oval, + "Polygon" => ItemKind::Polygon, + "GraphicLine" => ItemKind::GraphicLine, + "Group" => ItemKind::Group, + "Button" => ItemKind::Button, + "EPSText" => ItemKind::EPSText, + "MultiStateObject" | "HtmlItem" | "CheckBox" | "ComboBox" | "ListBox" | "RadioButton" | "TextBox" | "SignatureField" => { + ItemKind::Other(name.to_string()) + } + _ => return None, + }) + } + + pub fn name(&self) -> &str { + match self { + ItemKind::TextFrame => "TextFrame", + ItemKind::Rectangle => "Rectangle", + ItemKind::Oval => "Oval", + ItemKind::Polygon => "Polygon", + ItemKind::GraphicLine => "GraphicLine", + ItemKind::Group => "Group", + ItemKind::Button => "Button", + ItemKind::EPSText => "EPSText", + ItemKind::Other(n) => n, + } + } +} + +/// A page item. +#[derive(Clone, Debug, PartialEq)] +pub struct PageItem { + pub kind: ItemKind, + pub values: Values, + pub children: Vec, +} + +/// A child of a page item, in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum ItemChild { + /// A nested page item: a group's members, a frame's content. + Item(PageItem), + /// A placed graphic. + Graphic(Graphic), + /// Anything else (`TextFramePreference`, `TextWrapPreference`, `TextPath`, …), as written. + Other(El), +} + +impl PageItem { + /// The page item `el`; `None` when it is no page item element. + pub fn from_el(el: &El) -> Option { + let kind = ItemKind::of(el.local())?; + let children = if let ItemKind::Other(_) = kind { + others(el).map(tidy).map(ItemChild::Other).collect() + } else { + others(el) + .map(|c| { + if let Some(g) = Graphic::from_el(c) { + ItemChild::Graphic(g) + } else if let Some(i) = PageItem::from_el(c) { + ItemChild::Item(i) + } else { + ItemChild::Other(tidy(c)) + } + }) + .collect() + }; + Some(PageItem { kind, values: Values::of(el), children }) + } + + pub fn to_el(&self) -> El { + with_children( + self.values.element(self.kind.name()), + self.children.iter().map(|c| match c { + ItemChild::Item(i) => i.to_el(), + ItemChild::Graphic(g) => g.to_el(), + ItemChild::Other(e) => e.clone(), + }), + ) + } + + /// The outline (`Properties/PathGeometry`) in inner coordinates; empty when it has none. + pub fn geometry(&self) -> PathGeometry { + self.values.prop_el("PathGeometry").map(PathGeometry::from_el).unwrap_or_default() + } + + /// Replaces the outline. + pub fn set_geometry(&mut self, g: &PathGeometry) { + self.values.set_prop_el("PathGeometry", Some(g.to_el())); + } + + /// The nested page items, back to front. + pub fn items(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let ItemChild::Item(i) = c { Some(i) } else { None }) + } + + pub fn graphics(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let ItemChild::Graphic(g) = c { Some(g) } else { None }) + } + + /// The child element `name` other than a page item or graphic (`TextFramePreference`, + /// `TextWrapPreference`, `FrameFittingOption`, …). + pub fn other(&self, name: &str) -> Option<&El> { + self.children.iter().find_map(|c| match c { + ItemChild::Other(e) if e.local() == name => Some(e), + _ => None, + }) + } + + /// `ParentStory` of a text frame: the `Self` of its story. + pub fn parent_story(&self) -> Option<&str> { + self.values.get("ParentStory").filter(|s| *s != "n") + } + + /// `PreviousTextFrame` of a threaded text frame. + pub fn previous_text_frame(&self) -> Option<&str> { + self.values.get("PreviousTextFrame").filter(|s| *s != "n") + } + + /// `NextTextFrame` of a threaded text frame. + pub fn next_text_frame(&self) -> Option<&str> { + self.values.get("NextTextFrame").filter(|s| *s != "n") + } + + /// `AppliedObjectStyle`. + pub fn applied_object_style(&self) -> Option<&str> { + self.values.get("AppliedObjectStyle") + } + + /// `OverriddenPageItem`: the `Self` of the parent item this item overrides. + pub fn overridden_page_item(&self) -> Option<&str> { + self.values.get("OverriddenPageItem").filter(|s| *s != "n") + } +} + +/// What a placed graphic is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraphicKind { + Image, + EPS, + PDF, + ImportedPage, + WMF, + PICT, + SVG, +} + +impl GraphicKind { + pub fn of(name: &str) -> Option { + Some(match name { + "Image" => GraphicKind::Image, + "EPS" => GraphicKind::EPS, + "PDF" => GraphicKind::PDF, + "ImportedPage" => GraphicKind::ImportedPage, + "WMF" => GraphicKind::WMF, + "PICT" => GraphicKind::PICT, + "SVG" => GraphicKind::SVG, + _ => return None, + }) + } + + pub fn name(self) -> &'static str { + match self { + GraphicKind::Image => "Image", + GraphicKind::EPS => "EPS", + GraphicKind::PDF => "PDF", + GraphicKind::ImportedPage => "ImportedPage", + GraphicKind::WMF => "WMF", + GraphicKind::PICT => "PICT", + GraphicKind::SVG => "SVG", + } + } +} + +/// A graphic placed in a frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Graphic { + pub kind: GraphicKind, + pub values: Values, + pub children: Vec, +} + +/// A child of a graphic, in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum GraphicChild { + Link(Link), + /// `ClippingPathSettings`, `GraphicLayerOption`, `PDFAttribute`, … as written. + Other(El), +} + +/// Where a graphic's file is (`Link`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Link { + pub values: Values, + pub rest: Vec, +} + +impl Link { + /// `LinkResourceURI` (`file:…`; see [`crate::value::uri_to_path`]). + pub fn uri(&self) -> Option<&str> { + self.values.get("LinkResourceURI") + } + + /// `LinkResourceFormat` (`$ID/JPEG`, `$ID/Portable Document Format (PDF)`, …). + pub fn format(&self) -> Option<&str> { + self.values.get("LinkResourceFormat") + } +} + +impl Graphic { + pub fn from_el(el: &El) -> Option { + let kind = GraphicKind::of(el.local())?; + let children = others(el) + .map(|c| { + if c.local() == "Link" { + GraphicChild::Link(Link { values: Values::of(c), rest: others(c).map(tidy).collect() }) + } else { + GraphicChild::Other(tidy(c)) + } + }) + .collect(); + Some(Graphic { kind, values: Values::of(el), children }) + } + + pub fn to_el(&self) -> El { + with_children( + self.values.element(self.kind.name()), + self.children.iter().map(|c| match c { + GraphicChild::Link(l) => with_children(l.values.element("Link"), l.rest.iter().cloned()), + GraphicChild::Other(e) => e.clone(), + }), + ) + } + + pub fn link(&self) -> Option<&Link> { + self.children.iter().find_map(|c| if let GraphicChild::Link(l) = c { Some(l) } else { None }) + } + + /// `Properties/GraphicBounds`: `top left bottom right` in the graphic's inner coordinates. + pub fn graphic_bounds(&self) -> Option<[f64; 4]> { + let b = self.values.prop_el("GraphicBounds")?; + let n = |k: &str| b.attr(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + Some([n("Top")?, n("Left")?, n("Bottom")?, n("Right")?]) + } + + /// The embedded file (`Properties/Contents`, base64), decoded; `None` for a graphic that + /// is only linked. + pub fn contents(&self) -> Option> { + self.values.prop_el("Contents").map(|c| base64_decode(&c.deep_text())).filter(|d| !d.is_empty()) + } +} diff --git a/crates/idml/tests/model.rs b/crates/idml/tests/model.rs new file mode 100644 index 000000000..98c94df1a --- /dev/null +++ b/crates/idml/tests/model.rs @@ -0,0 +1,268 @@ +//! 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); +}