diff --git a/crates/idml/README.md b/crates/idml/README.md index 558bd7143..5d9a9d39c 100644 --- a/crates/idml/README.md +++ b/crates/idml/README.md @@ -10,8 +10,8 @@ Implemented clean-room from the public IDML File Format Specification and from i packages exported from synthetic documents. This crate is the layer between the bytes and IDML's own element tree, with a typed model of -the layout and the stories (`model`). Styles, swatches and preferences are not modelled yet: a -caller walks those elements and maps them onto its own document. InCopy Markup (ICML), one +the layout, the stories and the resources (`model`): a caller reads it and maps it onto its own +document. InCopy Markup (ICML), one story as one XML document, is read and written too (`icml`). ## Reading @@ -153,6 +153,20 @@ assert_eq!(story.text(|a| (a == Ace::PageNumber).then_some('#')), "Page #\n"); Containers nest as deep as the XML reader allows (256), and text is gathered without recursion. +## Resources + +- `style_groups` gives the root style groups (`RootParagraphStyleGroup` and the character, + object, cell and table ones) as trees of `StyleGroup` and `Style`, written back exactly. + `StyleGroup::styles` lists every style with the names of the groups it is in; `Style` has + `based_on`, `next_style` and its other children (`other`: an object style's + `TextFramePreference`, its effect settings). +- `swatches` gives `Color`, `Tint`, `Gradient` (with `stops`), `MixedInk`, `MixedInkGroup` and the + None `Swatch`: `spot`, `space`, `color_value`, `base_color`, `tint_value`, `radial`. +- `stroke_styles` (dashed with `dash_array`, dotted, striped with `stripe_array`) and + `color_groups` (with `members`). +- `preference` finds a preference element (`DocumentPreference`, `GridPreference`, …) and + `named` every element of a name (`Ink`, `Layer`, `Section`, `Condition`, `NumberingList`). + ## ICML `icml::is_icml` sniffs the `SnippetType="InCopyInterchange"` instruction in the first 4 KiB. diff --git a/crates/idml/src/model/mod.rs b/crates/idml/src/model/mod.rs index 8ddb2a5a3..049f10175 100644 --- a/crates/idml/src/model/mod.rs +++ b/crates/idml/src/model/mod.rs @@ -1,6 +1,7 @@ //! A typed model of IDML: spreads and parent (master) spreads, pages, guides, page items and the //! graphics placed in them; stories with their paragraph and character ranges, text, tables, -//! notes and anchored items. +//! notes and anchored items; style groups and styles, swatches, inks, stroke styles, colour +//! groups and preferences. //! //! 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 @@ -12,10 +13,15 @@ //! [`spreads`]) and written back with `to_el`, then [`crate::part_xml`]. mod geometry; +mod resources; mod spread; mod story; pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathPoint, SubPath}; +pub use resources::{ + ColorGroup, ColorSpace, GradientStop, StrokeStyle, StrokeStyleKind, Style, StyleGroup, StyleKind, StyleNode, Swatch, SwatchKind, color_groups, + named, preference, stroke_styles, style_groups, swatches, +}; pub use spread::{Graphic, GraphicChild, GraphicKind, Guide, ItemChild, ItemKind, Link, Page, PageChild, PageItem, Spread, SpreadChild, spreads}; pub use story::{Ace, CONTAINERS, Cell, Container, ContentPart, Range, Story, Table, TableChild, TextNode, stories}; diff --git a/crates/idml/src/model/resources.rs b/crates/idml/src/model/resources.rs new file mode 100644 index 000000000..6a66f6f91 --- /dev/null +++ b/crates/idml/src/model/resources.rs @@ -0,0 +1,401 @@ +//! Resources: style groups and styles, swatches, inks, stroke styles, colour groups, and the +//! preference elements. + +use craft_xml::{El, Local, Node}; + +use super::{Values, tidy}; +use crate::value::nums; + +/// The children of `el` other than ``. +fn others(el: &El) -> impl Iterator { + el.elements().filter(|e| !e.is(Local("Properties"))) +} + +/// What a style styles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StyleKind { + Paragraph, + Character, + Object, + Cell, + Table, +} + +impl StyleKind { + pub const ALL: [StyleKind; 5] = [StyleKind::Paragraph, StyleKind::Character, StyleKind::Object, StyleKind::Cell, StyleKind::Table]; + + /// The element name of a style: `ParagraphStyle`, …. + pub fn style(self) -> &'static str { + match self { + StyleKind::Paragraph => "ParagraphStyle", + StyleKind::Character => "CharacterStyle", + StyleKind::Object => "ObjectStyle", + StyleKind::Cell => "CellStyle", + StyleKind::Table => "TableStyle", + } + } + + /// The element name of a group: `ParagraphStyleGroup`, …. + pub fn group(self) -> &'static str { + match self { + StyleKind::Paragraph => "ParagraphStyleGroup", + StyleKind::Character => "CharacterStyleGroup", + StyleKind::Object => "ObjectStyleGroup", + StyleKind::Cell => "CellStyleGroup", + StyleKind::Table => "TableStyleGroup", + } + } + + /// The element name of the root group: `RootParagraphStyleGroup`, …. + pub fn root(self) -> &'static str { + match self { + StyleKind::Paragraph => "RootParagraphStyleGroup", + StyleKind::Character => "RootCharacterStyleGroup", + StyleKind::Object => "RootObjectStyleGroup", + StyleKind::Cell => "RootCellStyleGroup", + StyleKind::Table => "RootTableStyleGroup", + } + } + + /// The kind whose root group is called `name`. + pub fn of_root(name: &str) -> Option { + StyleKind::ALL.into_iter().find(|k| k.root() == name) + } +} + +/// A style group: a root group (`RootParagraphStyleGroup`, …) or a group in one. +#[derive(Clone, Debug, PartialEq)] +pub struct StyleGroup { + pub kind: StyleKind, + /// The root group of its kind. + pub root: bool, + pub values: Values, + pub children: Vec, +} + +/// A child of a style group, in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum StyleNode { + Group(StyleGroup), + Style(Style), + Other(El), +} + +/// A style, its values as written: `Name`, `Self`, `Properties/BasedOn`, its attributes; and its +/// other children (an object style's `TextFramePreference`, effect settings, …). +#[derive(Clone, Debug, PartialEq)] +pub struct Style { + pub kind: StyleKind, + pub values: Values, + pub rest: Vec, +} + +impl StyleGroup { + /// The root group `el`; `None` when `el` is no root style group. + pub fn from_el(el: &El) -> Option { + let kind = StyleKind::of_root(el.local())?; + Some(Self::read(kind, true, el)) + } + + fn read(kind: StyleKind, root: bool, el: &El) -> StyleGroup { + let children = others(el) + .map(|c| match c.local() { + n if n == kind.group() => StyleNode::Group(Self::read(kind, false, c)), + n if n == kind.style() => StyleNode::Style(Style { kind, values: Values::of(c), rest: others(c).map(tidy).collect() }), + _ => StyleNode::Other(tidy(c)), + }) + .collect(); + StyleGroup { kind, root, values: Values::of(el), children } + } + + pub fn to_el(&self) -> El { + let mut el = self.values.element(if self.root { self.kind.root() } else { self.kind.group() }); + el.children.extend(self.children.iter().map(|c| { + Node::El(match c { + StyleNode::Group(g) => g.to_el(), + StyleNode::Style(s) => s.to_el(), + StyleNode::Other(e) => e.clone(), + }) + })); + el + } + + /// Every style in this group and the groups in it, in document order, with the names of + /// the groups it is in (outermost first, this group's own name left out for a root group). + pub fn styles(&self) -> Vec<(Vec<&str>, &Style)> { + let mut out = Vec::new(); + let start: Vec<&str> = if self.root { Vec::new() } else { self.values.name().into_iter().collect() }; + // Iterative: nesting is bounded by the XML reader, but there is no need to recurse. + let mut stack: Vec<(Vec<&str>, &StyleNode)> = self.children.iter().rev().map(|c| (start.clone(), c)).collect(); + while let Some((path, node)) = stack.pop() { + match node { + StyleNode::Style(s) => out.push((path, s)), + StyleNode::Group(g) => { + let mut inner = path.clone(); + inner.extend(g.values.name()); + stack.extend(g.children.iter().rev().map(|c| (inner.clone(), c))); + } + StyleNode::Other(_) => {} + } + } + out + } +} + +impl Style { + pub fn to_el(&self) -> El { + let mut el = self.values.element(self.kind.style()); + el.children.extend(self.rest.iter().cloned().map(Node::El)); + el + } + + /// `Properties/BasedOn`: the `Self` of the style this one is based on (`$ID/[No paragraph + /// style]` and the like for none). + pub fn based_on(&self) -> Option { + self.values.value("BasedOn") + } + + /// `NextStyle` of a paragraph style. + pub fn next_style(&self) -> Option { + self.values.value("NextStyle") + } + + /// The child element `name` (`TextFramePreference`, `TransparencySetting`, …). + pub fn other(&self, name: &str) -> Option<&El> { + self.rest.iter().find(|e| e.local() == name) + } +} + +/// The root style groups among `top`, in order. +pub fn style_groups(top: &[El]) -> Vec { + top.iter().filter_map(StyleGroup::from_el).collect() +} + +/// What a swatch is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwatchKind { + Color, + Tint, + Gradient, + MixedInk, + MixedInkGroup, + /// `Swatch`: the None swatch. + Swatch, +} + +impl SwatchKind { + pub fn of(name: &str) -> Option { + Some(match name { + "Color" => SwatchKind::Color, + "Tint" => SwatchKind::Tint, + "Gradient" => SwatchKind::Gradient, + "MixedInk" => SwatchKind::MixedInk, + "MixedInkGroup" => SwatchKind::MixedInkGroup, + "Swatch" => SwatchKind::Swatch, + _ => return None, + }) + } + + pub fn name(self) -> &'static str { + match self { + SwatchKind::Color => "Color", + SwatchKind::Tint => "Tint", + SwatchKind::Gradient => "Gradient", + SwatchKind::MixedInk => "MixedInk", + SwatchKind::MixedInkGroup => "MixedInkGroup", + SwatchKind::Swatch => "Swatch", + } + } +} + +/// A swatch (`Color`, `Tint`, `Gradient`, …), its values as written and its other children +/// (a gradient's `GradientStop`s). +#[derive(Clone, Debug, PartialEq)] +pub struct Swatch { + pub kind: SwatchKind, + pub values: Values, + pub rest: Vec, +} + +/// A colour's space, as `Space` names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorSpace { + Cmyk, + Rgb, + Lab, + /// `MixedInk`. + MixedInk, +} + +/// A stop of a gradient. +#[derive(Clone, Debug, PartialEq)] +pub struct GradientStop { + /// `StopColor`: the `Self` of a colour. + pub color: String, + /// `Location`, 0 to 100. + pub location: f64, + /// `Midpoint`, 0 to 100 (50 when absent). + pub midpoint: f64, +} + +impl Swatch { + pub fn to_el(&self) -> El { + let mut el = self.values.element(self.kind.name()); + el.children.extend(self.rest.iter().cloned().map(Node::El)); + el + } + + /// `Model` is `Spot`. + pub fn spot(&self) -> bool { + self.values.get("Model") == Some("Spot") + } + + /// `Space`; CMYK when absent or unknown. + pub fn space(&self) -> ColorSpace { + match self.values.get("Space") { + Some("RGB") => ColorSpace::Rgb, + Some("LAB") => ColorSpace::Lab, + Some("MixedInk") => ColorSpace::MixedInk, + _ => ColorSpace::Cmyk, + } + } + + /// `ColorValue`: CMYK in percent, RGB 0 to 255, Lab as L a b. Empty when absent or when a + /// number is not finite. + pub fn color_value(&self) -> Vec { + self.values.get("ColorValue").map(nums).unwrap_or_default() + } + + /// `BaseColor` of a tint: the `Self` of its colour. + pub fn base_color(&self) -> Option<&str> { + self.values.get("BaseColor") + } + + /// `TintValue` of a tint, in percent. + pub fn tint_value(&self) -> Option { + self.values.num("TintValue") + } + + /// `Type` of a gradient is `Radial`; linear otherwise. + pub fn radial(&self) -> bool { + self.values.get("Type") == Some("Radial") + } + + /// A gradient's stops in order; a stop without a colour or a finite location is left out. + pub fn stops(&self) -> Vec { + self.rest + .iter() + .filter(|e| e.local() == "GradientStop") + .filter_map(|s| { + let v = Values::of(s); + Some(GradientStop { + color: v.get("StopColor")?.to_string(), + location: v.num("Location")?, + midpoint: v.num("Midpoint").unwrap_or(50.0), + }) + }) + .collect() + } +} + +/// The swatches among `top`, in order. +pub fn swatches(top: &[El]) -> Vec { + top.iter().filter_map(|e| Some(Swatch { kind: SwatchKind::of(e.local())?, values: Values::of(e), rest: others(e).map(tidy).collect() })).collect() +} + +/// What a stroke style is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StrokeStyleKind { + Dashed, + Dotted, + Striped, +} + +/// A custom stroke style. +#[derive(Clone, Debug, PartialEq)] +pub struct StrokeStyle { + pub kind: StrokeStyleKind, + pub values: Values, + pub rest: Vec, +} + +impl StrokeStyle { + pub fn to_el(&self) -> El { + let name = match self.kind { + StrokeStyleKind::Dashed => "DashedStrokeStyle", + StrokeStyleKind::Dotted => "DottedStrokeStyle", + StrokeStyleKind::Striped => "StripedStrokeStyle", + }; + let mut el = self.values.element(name); + el.children.extend(self.rest.iter().cloned().map(Node::El)); + el + } + + /// `DashArray` of a dashed style: dash, gap, dash, gap, … in points. + pub fn dash_array(&self) -> Vec { + self.values.get("DashArray").map(nums).unwrap_or_default() + } + + /// `StripeArray` of a striped style: each stripe's start and end, in percent of the weight. + pub fn stripe_array(&self) -> Vec { + self.values.get("StripeArray").map(nums).unwrap_or_default() + } +} + +/// The custom stroke styles among `top`, in order. +pub fn stroke_styles(top: &[El]) -> Vec { + top.iter() + .filter_map(|e| { + let kind = match e.local() { + "DashedStrokeStyle" => StrokeStyleKind::Dashed, + "DottedStrokeStyle" => StrokeStyleKind::Dotted, + "StripedStrokeStyle" => StrokeStyleKind::Striped, + _ => return None, + }; + Some(StrokeStyle { kind, values: Values::of(e), rest: others(e).map(tidy).collect() }) + }) + .collect() +} + +/// A colour group (`ColorGroup`): its values and the swatches it lists. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorGroup { + pub values: Values, + pub rest: Vec, +} + +impl ColorGroup { + pub fn to_el(&self) -> El { + let mut el = self.values.element("ColorGroup"); + el.children.extend(self.rest.iter().cloned().map(Node::El)); + el + } + + /// `IsRootColorGroup`. + pub fn is_root(&self) -> bool { + self.values.boolean("IsRootColorGroup") == Some(true) + } + + /// The `Self`s of the swatches the group lists (`ColorGroupSwatch`'s `SwatchItemRef`), in + /// order. + pub fn members(&self) -> Vec<&str> { + self.rest.iter().filter(|e| e.local() == "ColorGroupSwatch").filter_map(|e| e.attr("SwatchItemRef")).collect() + } +} + +/// The colour groups among `top`, in order. +pub fn color_groups(top: &[El]) -> Vec { + top.iter().filter(|e| e.local() == "ColorGroup").map(|e| ColorGroup { values: Values::of(e), rest: others(e).map(tidy).collect() }).collect() +} + +/// The values of every element named `name` among `top`, in order: `Ink`, `Layer`, `Section`, +/// `Condition`, `NumberingList`, …. +pub fn named(top: &[El], name: &str) -> Vec { + top.iter().filter(|e| e.local() == name).map(Values::of).collect() +} + +/// The first element named `name` among `top` (a preference: `DocumentPreference`, +/// `MarginPreference`, `TextFramePreference`, `GridPreference`, …), its values. +pub fn preference(top: &[El], name: &str) -> Option { + top.iter().find(|e| e.local() == name).map(Values::of) +} diff --git a/crates/idml/tests/resources.rs b/crates/idml/tests/resources.rs new file mode 100644 index 000000000..0d2e5e622 --- /dev/null +++ b/crates/idml/tests/resources.rs @@ -0,0 +1,179 @@ +//! Styles, swatches, stroke styles, colour groups and preferences. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_idml::model::{ + ColorSpace, GradientStop, StrokeStyleKind, StyleGroup, StyleKind, StyleNode, SwatchKind, color_groups, named, preference, stroke_styles, + style_groups, swatches, +}; +use craft_idml::{El, parse_part, part_root, part_xml}; + +const STYLES: &str = r#" + + + + CharacterStyle/$ID/[No character style] + + + + + + + + + $ID/[No paragraph style]22 + + + + + + + + + + + + + +"#; + +fn top(xml: &str) -> Vec { + parse_part(xml.as_bytes()).unwrap().elements().cloned().collect() +} + +#[test] +fn style_groups_give_their_styles_with_their_group_path() { + let groups = style_groups(&top(STYLES)); + let kinds: Vec = groups.iter().map(|g| g.kind).collect(); + assert_eq!(kinds, [StyleKind::Character, StyleKind::Paragraph, StyleKind::Object, StyleKind::Cell, StyleKind::Table]); + assert!(groups.iter().all(|g| g.root)); + let chars = groups[0].styles(); + assert_eq!( + chars.iter().map(|(path, s)| (path.join("/"), s.values.name().unwrap())).collect::>(), + [(String::new(), "$ID/[No character style]"), (String::new(), "Em"), ("Inline/Code".to_string(), "Inline:Code:Mono"),] + ); + let em = chars[1].1; + assert_eq!( + (em.kind, em.based_on().as_deref(), em.values.get("FontStyle")), + (StyleKind::Character, Some("CharacterStyle/$ID/[No character style]"), Some("Italic")) + ); + let head = groups[1].styles()[0].1; + assert_eq!((head.based_on().as_deref(), head.next_style().as_deref()), (Some("$ID/[No paragraph style]"), Some("ParagraphStyle/Body"))); + assert_eq!((head.values.num("PointSize"), head.values.num("Leading")), (Some(18.0), Some(22.0))); + let frame = groups[2].styles()[0].1; + assert_eq!(frame.other("TextFramePreference").and_then(|p| p.attr("TextColumnCount")), Some("3")); + assert!(frame.other("TransparencySetting").is_some()); + // A group inside a group is its own node, with its own values. + let StyleNode::Group(inline) = &groups[0].children[2] else { panic!() }; + assert!(!inline.root); + assert_eq!(inline.styles()[0].0, ["Inline", "Code"], "a group's own name starts its path"); + // Not a root style group. + assert_eq!(StyleGroup::from_el(&El::new("TOCStyle")), None); +} + +#[test] +fn style_groups_written_and_read_again_are_the_same() { + let groups = style_groups(&top(STYLES)); + let mut part = part_root("Styles"); + for g in &groups { + part.push(g.to_el()); + } + assert_eq!(style_groups(&top(&String::from_utf8(part_xml(&part)).unwrap())), groups); +} + +const GRAPHIC: &str = r#" + + + + + + + + + + + + + + + + + + + + + + +"#; + +#[test] +fn swatches_give_their_colours_tints_and_gradients() { + let t = top(GRAPHIC); + let sw = swatches(&t); + assert_eq!( + sw.iter().map(|s| s.kind).collect::>(), + [SwatchKind::Color, SwatchKind::Color, SwatchKind::Color, SwatchKind::Color, SwatchKind::Tint, SwatchKind::Gradient, SwatchKind::Swatch] + ); + assert_eq!((sw[0].spot(), sw[0].space(), sw[0].color_value()), (false, ColorSpace::Cmyk, vec![0.0, 0.0, 0.0, 100.0])); + assert_eq!((sw[1].spot(), sw[1].space(), sw[1].color_value()), (true, ColorSpace::Rgb, vec![255.0, 128.0, 0.0])); + assert_eq!(sw[2].space(), ColorSpace::Lab); + assert_eq!(sw[3].color_value(), Vec::::new(), "a value that is not finite is no value"); + assert_eq!((sw[4].base_color(), sw[4].tint_value()), (Some("Color/Brand"), Some(40.0))); + assert!(sw[5].radial()); + assert_eq!( + sw[5].stops(), + [ + GradientStop { color: "Color/Black".into(), location: 0.0, midpoint: 50.0 }, + GradientStop { color: "Color/Brand".into(), location: 100.0, midpoint: 30.0 }, + ] + ); + let again: Vec = top(&String::from_utf8(part_xml(&sw.iter().fold(part_root("Graphic"), |p, s| p.with_child(s.to_el())))).unwrap()); + assert_eq!(swatches(&again), sw); +} + +#[test] +fn inks_stroke_styles_and_colour_groups() { + let t = top(GRAPHIC); + let inks = named(&t, "Ink"); + assert_eq!(inks.len(), 2); + assert_eq!((inks[1].boolean("ConvertToProcess"), inks[1].get("AliasInkName")), (Some(true), Some("Other"))); + let strokes = stroke_styles(&t); + assert_eq!(strokes.iter().map(|s| s.kind).collect::>(), [StrokeStyleKind::Dashed, StrokeStyleKind::Striped, StrokeStyleKind::Dotted]); + assert_eq!(strokes[0].dash_array(), [4.0, 2.0, 1.0, 2.0]); + assert_eq!(strokes[1].stripe_array(), [0.0, 25.0, 75.0, 100.0]); + let groups = color_groups(&t); + assert!(groups[0].is_root()); + assert_eq!(groups[0].members(), ["Swatch/None", "Color/Black"]); + let again = top(&String::from_utf8(part_xml(&part_root("Graphic").with_child(groups[0].to_el()).with_child(strokes[1].to_el()))).unwrap()); + assert_eq!(color_groups(&again), groups); + assert_eq!(stroke_styles(&again), [strokes[1].clone()]); +} + +#[test] +fn preferences_are_found_by_name() { + let prefs = top( + r#"Horizontal"#, + ); + let doc = preference(&prefs, "DocumentPreference").unwrap(); + assert_eq!( + (doc.num("PageWidth"), doc.boolean("FacingPages"), doc.value("ColumnDirection").as_deref()), + (Some(612.0), Some(true), Some("Horizontal")) + ); + assert_eq!(preference(&prefs, "GridPreference").and_then(|g| g.num("BaselineDivision")), Some(12.0)); + assert_eq!(preference(&prefs, "MarginPreference"), None); + assert_eq!(named(&prefs, "DocumentPreference").len(), 2); +} + +/// Style groups nest as deep as the XML reader allows; their styles are gathered without +/// recursion. +#[test] +fn deeply_nested_style_groups_are_read() { + let depth = 200; + let xml = format!( + "{}{}", + "".repeat(depth), + "".repeat(depth) + ); + let g = StyleGroup::from_el(&parse_part(xml.as_bytes()).unwrap()).unwrap(); + let styles = g.styles(); + assert_eq!((styles.len(), styles[0].0.len(), styles[0].1.values.name()), (1, depth, Some("deep"))); +}