From 24903d320de2d2fe55033b39cc02d225d3c117ea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:35:11 -0500 Subject: [PATCH] craft-idml: resources model (styles, swatches, stroke styles, colour groups, preferences) (#1017) * craft-idml: a typed model of the layout (spreads, pages, guides, page items, graphics, geometry) 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. * craft-idml: the story model (ranges, content, tables, notes, anchored items) Stories as IDML writes them: paragraph and character ranges, Content with the special characters InDesign writes as , breaks, the containers text sits in (hyperlink sources, tracked changes, XML elements, cross-reference sources, footnotes, notes), tables with their cells, and page items anchored in the text. Each node keeps its values as written; text is gathered without recursion. * craft-idml: read and write ICML (InCopy Markup) The format layer of storytold/designcraft#2248: an ICML file told by its InCopyInterchange instruction, read within the size of one package part, its references to package parts left out with a warning, a file without a story refused, and written with the two instructions that mark it. Choosing the story to place and mapping it stay with the application. * craft-idml: the resources model (style groups, swatches, stroke styles, colour groups, preferences) Style groups as trees of groups and styles for each kind, each style with its group path, BasedOn and NextStyle; colours, tints, gradients with their stops, mixed inks and the None swatch; inks, custom stroke styles, colour groups; and the preference elements by name. Values are kept as written and read through accessors. --------- Co-authored-by: ljkx <177671075+ljkx@users.noreply.github.com> --- crates/idml/README.md | 18 +- crates/idml/src/model/mod.rs | 8 +- crates/idml/src/model/resources.rs | 401 +++++++++++++++++++++++++++++ crates/idml/tests/resources.rs | 179 +++++++++++++ 4 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 crates/idml/src/model/resources.rs create mode 100644 crates/idml/tests/resources.rs 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"))); +}