craft-idml: a typed model of the layout (spreads, pages, guides, page items, graphics, geometry) (#1003)

The first step of the typed IDML model. Spreads and parent spreads, pages, guides, page items
(nested), placed graphics and their links are typed; each keeps its attributes and Properties
as written and reads them through accessors, path geometry among them. Elements the model
does not type are kept where they stood, so a node read and written again reads the same.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:49:28 -05:00
1 parent c89520e586
commit b15ff4486f
6 files changed
+1093 -5

No files matched your search

+47 -3
View File
@@ -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<u8> = 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 `<Properties>` 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`,
+4 -2
View File
@@ -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;
+126
View File
@@ -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<PathPoint>,
}
/// A `PathGeometry` element.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PathGeometry {
pub paths: Vec<SubPath>,
}
/// 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::<f64>().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()
}
}
+184
View File
@@ -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 `<Properties>`
//! 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 `<Properties>` children, as written.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Values {
pub attrs: Vec<(String, String)>,
/// The children of `<Properties>`, in order.
pub properties: Vec<El>,
}
impl Values {
/// The attributes and `<Properties>` 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<String>) {
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 `<Properties>` child `key`.
pub fn prop_el(&self, key: &str) -> Option<&El> {
self.properties.iter().find(|e| e.local() == key)
}
/// Replaces the `<Properties>` child of `el`'s name, or adds it; `None` removes it.
pub fn set_prop_el(&mut self, key: &str, el: Option<El>) {
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 `<Properties>` child.
pub fn prop(&self, key: &str) -> Option<String> {
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<String> {
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<f64> {
self.prop(key).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
}
/// `true` or `false`; anything else counts as absent.
pub fn boolean(&self, key: &str) -> Option<bool> {
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 `<Properties>` (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]
}
+464
View File
@@ -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 `<Properties>`.
fn others(el: &El) -> impl Iterator<Item = &El> {
el.elements().filter(|e| !e.is(Local("Properties")))
}
/// `el` (built by [`Values::element`]) with `children` after its `<Properties>`.
fn with_children(mut el: El, children: impl IntoIterator<Item = El>) -> 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<SpreadChild>,
}
/// 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<Item = &Page> {
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<Item = &PageItem> {
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<Spread> {
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<PageChild>,
}
/// 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<Values> {
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<Item = &Guide> {
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 `<Properties>`, as written.
pub rest: Vec<El>,
}
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<f64> {
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<ItemKind> {
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<ItemChild>,
}
/// 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<PageItem> {
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<Item = &PageItem> {
self.children.iter().filter_map(|c| if let ItemChild::Item(i) = c { Some(i) } else { None })
}
pub fn graphics(&self) -> impl DoubleEndedIterator<Item = &Graphic> {
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<GraphicKind> {
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<GraphicChild>,
}
/// 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<El>,
}
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<Graphic> {
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::<f64>().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<Vec<u8>> {
self.values.prop_el("Contents").map(|c| base64_decode(&c.deep_text())).filter(|d| !d.is_empty())
}
}
+268
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Spread Self="ud3" PageCount="1" BindingLocation="0" AllowPageShuffle="true" ItemTransform="1 0 0 1 0 0" ShowMasterItems="true">
<FlattenerPreference LineArtAndTextResolution="300" />
<Page Self="ud8" Name="1" AppliedMaster="ud1" OverrideList="ue1 ue2" GeometricBounds="0 0 792 612" ItemTransform="1 0 0 1 -306 -396">
<Properties><PageColor type="enumeration">UseMasterColor</PageColor></Properties>
<MarginPreference ColumnCount="2" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="36" ColumnsPositions="0 264 276 540" />
<Guide Self="ug1" Orientation="Horizontal" Location="100" FitToPage="false" ItemLayer="ua" Locked="true" />
<Guide Self="ug2" Orientation="Vertical" Location="50.5" GuideType="Liquid" />
</Page>
<Rectangle Self="ur1" ItemLayer="ua" ItemTransform="1 0 0 1 10 20" AppliedObjectStyle="ObjectStyle/$ID/[Normal Graphics Frame]" FillColor="Swatch/None">
<Properties>
<PathGeometry>
<GeometryPathType PathOpen="false">
<PathPointArray>
<PathPointType Anchor="0 0" LeftDirection="0 0" RightDirection="0 0" />
<PathPointType Anchor="0 100" LeftDirection="0 100" RightDirection="0 100" />
<PathPointType Anchor="200 100" LeftDirection="200 100" RightDirection="200 100" />
<PathPointType Anchor="200 0" LeftDirection="200 0" RightDirection="200 0" />
</PathPointArray>
</GeometryPathType>
</PathGeometry>
</Properties>
<TextWrapPreference TextWrapMode="None" />
<Image Self="ui1" ItemTransform="0.5 0 0 0.5 0 0" ActualPpi="72 72">
<Properties>
<GraphicBounds Left="0" Top="0" Right="400" Bottom="200" />
<Contents><![CDATA[aGVsbG8gd29y
bGQ=]]></Contents>
</Properties>
<TextWrapPreference TextWrapMode="None" />
<Link Self="ul1" LinkResourceURI="file:/Users/a/My%20Pictures/p.jpg" LinkResourceFormat="$ID/JPEG" StoredState="Normal" />
<ClippingPathSettings ClippingType="None" />
</Image>
</Rectangle>
<TextFrame Self="ut1" ParentStory="us1" PreviousTextFrame="n" NextTextFrame="ut2" ItemLayer="ua" ItemTransform="1 0 0 1 0 300" Visible="false">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray><PathPointType Anchor="0 0" /><PathPointType Anchor="0 50" /><PathPointType Anchor="100 50" /><PathPointType Anchor="100 0" /></PathPointArray></GeometryPathType></PathGeometry></Properties>
<TextFramePreference TextColumnCount="2" />
</TextFrame>
<Group Self="ug9" ItemTransform="1 0 0 1 100 100" OverriddenPageItem="ue1">
<Oval Self="uo1" ItemTransform="2 0 0 2 5 5"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 -1" LeftDirection="-0.55 -1" RightDirection="0.55 -1" />
<PathPointType Anchor="1 0" LeftDirection="1 -0.55" RightDirection="1 0.55" />
</PathPointArray></GeometryPathType></PathGeometry></Properties></Oval>
<Group Self="ug10" ItemTransform="1 0 0 1 1 1"><GraphicLine Self="ul2" /></Group>
</Group>
<GraphicLine Self="ul3"><Properties><PathGeometry><GeometryPathType PathOpen="true"><PathPointArray><PathPointType Anchor="0 0" /><PathPointType Anchor="10 10" /></PathPointArray></GeometryPathType></PathGeometry></Properties></GraphicLine>
<MultiStateObject Self="um1"><State Self="us9"><Rectangle Self="ur9" /></State></MultiStateObject>
</Spread>
</idPkg:Spread>
"#;
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<String> = 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();
// `<Properties>` stays the first child; the embedded contents and unknown elements survive.
assert!(xml.contains("<Image Self=\"ui1\" ItemTransform=\"0.5 0 0 0.5 0 0\" ActualPpi=\"72 72\">\n"), "{xml}");
assert!(
xml.contains("<Image Self=\"ui1\" ItemTransform=\"0.5 0 0 0.5 0 0\" ActualPpi=\"72 72\">\n\t\t\t\t<Properties>\n\t\t\t\t\t<GraphicBounds"),
"{xml}"
);
assert!(xml.contains("<FlattenerPreference LineArtAndTextResolution=\"300\"/>"), "{xml}");
assert!(xml.contains("<State Self=\"us9\">"), "{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#"<PathGeometry>
<GeometryPathType PathOpen="false"><PathPointArray><PathPointType Anchor="0 NaN"/><PathPointType Anchor="1 1"/></PathPointArray></GeometryPathType>
<GeometryPathType PathOpen="true"><PathPointArray><PathPointType Anchor="0 0" RightDirection="inf 0"/></PathPointArray></GeometryPathType>
<GeometryPathType><PathPointArray><PathPointType/><PathPointType Anchor="1"/><PathPointType Anchor="2 3" LeftDirection="x"/></PathPointArray></GeometryPathType>
</PathGeometry>"#,
)
.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!(
"<PathGeometry>{}</PathGeometry>",
"<GeometryPathType><PathPointArray><PathPointType Anchor=\"1 1\"/><PathPointType Anchor=\"2 2\"/><PathPointType Anchor=\"3 3\"/></PathPointArray></GeometryPathType>".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::<Vec<_>>(), [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!("<Spread>{}<Rectangle Self=\"deep\"/>{}</Spread>", "<Group ItemTransform=\"1 0 0 1 1 0\">".repeat(depth), "</Group>".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);
}