mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
1 parent
c89520e586
commit
b15ff4486f
6 files changed
+1093
-5
No files matched your search
+47
-3
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user