diff --git a/crates/idml/README.md b/crates/idml/README.md index a399c51bd..558bd7143 100644 --- a/crates/idml/README.md +++ b/crates/idml/README.md @@ -10,8 +10,9 @@ 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 (`model`). Stories, styles, swatches and preferences are not modelled yet: a caller -walks those elements and maps them onto its own document. +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 +story as one XML document, is read and written too (`icml`). ## Reading @@ -123,6 +124,45 @@ for spread in spreads(&doc) { - `Spread::all_items` walks nested groups without recursion, giving each item's transform into spread coordinates (`concat` of the transforms on the way). +## Stories + +```rust +use craft_idml::model::{Ace, TextNode, stories}; + +# fn main() -> Result<(), craft_xml::Error> { +let part = craft_idml::parse_part(br#" + Page
+
"#)?; +let story = stories(&part.elements().cloned().collect::>()).remove(0); +let body = story.paragraphs()[0]; +assert_eq!(body.paragraph_style(), Some("ParagraphStyle/Body")); +assert_eq!(body.characters()[0].values.num("PointSize"), Some(9.0)); +assert_eq!(story.text(|a| (a == Ace::PageNumber).then_some('#')), "Page #\n"); +# Ok(()) } +``` + +- `Story`, its `TextNode`s in document order: paragraph and character ranges (`Range`), + `Content` (text, and the special characters InDesign writes as ``: `Ace`), `Br`, + containers of text (`HyperlinkTextSource`, `Change`, `XMLElement`, `CrossReferenceSource`, + `Footnote`, `Note`, `EndnoteRange`), `Table` (rows, columns, and `Cell`s holding text), and + page items anchored in the text (the layout model's `PageItem`). +- `text` gives a story's, range's, cell's or note's text: content, `\n` for each break, and a + character for each special character the caller names. A footnote's, note's or table's text + is its own, not the text around it; a hyperlink's or a change's is. +- Content keeps its whitespace exactly; elements the model does not type are kept as written. + Containers nest as deep as the XML reader allows (256), and text is gathered without + recursion. + +## ICML + +`icml::is_icml` sniffs the `SnippetType="InCopyInterchange"` instruction in the first 4 KiB. +`icml::read_icml` reads the file as a package part is read, at most `MAX_ICML_BYTES` (512 MiB); +a root that is not `Document` or a file without a `Story` is refused, and a reference to a +package part (an ICML file has none) is left out with a warning. `Icml::stories` gives its +stories; which one to place (usually the first no text frame of the file holds) is the +caller's choice. `icml::icml_xml` writes a `Document` with the two `` instructions that +mark the file. + ## Values `craft_idml::value`: `num` and `point_str` (numbers as IDML writes them), `nums`, `matrix`, diff --git a/crates/idml/src/icml.rs b/crates/idml/src/icml.rs new file mode 100644 index 000000000..7ab300c5b --- /dev/null +++ b/crates/idml/src/icml.rs @@ -0,0 +1,86 @@ +//! InCopy Markup (ICML): one story, with what it uses, as a single XML document. +//! +//! An ICML file is what InDesign writes for File › Export › InCopy Document and InCopy saves: +//! not a package, but one XML document that holds inline what an IDML package spreads over its +//! parts. Two processing instructions mark it, `` and +//! ``, and its `Document` root holds colours and swatches, +//! fonts, the style groups, numbering lists, conditions, cross-reference formats and text +//! variables, then the `Story` (and the stories of text frames anchored in it) and the +//! hyperlinks that point into it. The elements are IDML's, so [`crate::model`] reads them. + +use craft_xml::{DECL_STANDALONE, El}; + +use crate::model::{Story, stories}; +use crate::part::parse_part; +use crate::{DOM_VERSION, Error, Result}; + +/// Most bytes an ICML file may hold: the limit one part of an IDML package has. +pub const MAX_ICML_BYTES: usize = 512 << 20; + +/// How far into the file the ICML processing instruction is looked for. +const SNIFF_BYTES: usize = 4096; + +/// Does `bytes` look like an ICML file (an XML document marked `SnippetType="InCopyInterchange"`)? +pub fn is_icml(bytes: &[u8]) -> bool { + let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(SNIFF_BYTES)).unwrap_or_default()); + head.trim_start_matches('\u{FEFF}').trim_start().starts_with('<') && head.contains("SnippetType=\"InCopyInterchange\"") +} + +/// An ICML file, read. +#[derive(Clone, Debug, PartialEq)] +pub struct Icml { + /// The `Document` element as written. + pub root: El, + /// Its children, without references to package parts (an ICML file has no package). + pub top: Vec, + /// One line per element left out. + pub warnings: Vec, +} + +impl Icml { + /// The file's stories, in order. The story to place is usually the first one no text frame + /// of the file holds; the others are the stories of text frames anchored in it. + pub fn stories(&self) -> Vec { + stories(&self.top) + } +} + +/// Reads an ICML file. More than [`MAX_ICML_BYTES`], a root that is not `Document`, and a file +/// without a `Story` are refused; the XML is read as a package part is ([`parse_part`]). +pub fn read_icml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_ICML_BYTES { + return Err(Error::TooLarge { part: "ICML".into(), limit: MAX_ICML_BYTES as u64 }); + } + let root = parse_part(bytes).map_err(|e| Error::Part { part: "ICML".into(), msg: e.to_string() })?; + if root.local() != "Document" { + return Err(Error::NotIdml(format!("the ICML root is <{}>, not ", root.name))); + } + let mut warnings = Vec::new(); + let top: Vec = root + .elements() + .filter(|c| { + let part = c.name.starts_with("idPkg:"); + if part { + warnings.push(format!("{}: an ICML file has no package parts; it was not read", c.attr("src").unwrap_or(&c.name))); + } + !part + }) + .cloned() + .collect(); + if !top.iter().any(|e| e.local() == "Story") { + return Err(Error::NotIdml("the ICML file holds no story".into())); + } + Ok(Icml { root, top, warnings }) +} + +/// An ICML file of `root` (a `Document` holding the resources and the story): the XML +/// declaration, the two instructions that mark the file, then `root`, written as a part is +/// ([`crate::part_xml`]). +pub fn icml_xml(root: &El) -> Vec { + let mut s = String::from(DECL_STANDALONE); + s.push_str("\n\n"); + root.write_xml_pretty(&mut s, "\t"); + s.into_bytes() +} diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index f101d68e8..07405a959 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -16,11 +16,12 @@ //! attribute, or a child of ``. //! - [`value`] has the codecs of attribute values: numbers, transforms, `Self` ids, style names, //! link URIs, base64 `Contents`. +//! - [`icml`] reads and writes InCopy Markup: one story and what it uses, as one XML document. //! - [`tagged`] reads and writes InDesign Tagged Text, the plain-text interchange format. -//! -//! - [`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. +//! - [`model`] types the layout (spreads and parent spreads, pages, guides, page items and the +//! graphics placed in them, with path geometry) and stories (paragraph and character ranges, +//! text, tables, notes, anchored items). 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. @@ -29,6 +30,7 @@ #[doc = include_str!("../README.md")] struct ReadmeExamples; +pub mod icml; pub mod model; mod package; mod part; diff --git a/crates/idml/src/model/mod.rs b/crates/idml/src/model/mod.rs index c3432f0e7..8ddb2a5a3 100644 --- a/crates/idml/src/model/mod.rs +++ b/crates/idml/src/model/mod.rs @@ -1,5 +1,6 @@ -//! A typed model of IDML's layout: spreads and parent (master) spreads, pages, guides, page items -//! and the graphics placed in them. +//! 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. //! //! 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,9 +13,11 @@ mod geometry; mod spread; +mod story; 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}; +pub use story::{Ace, CONTAINERS, Cell, Container, ContentPart, Range, Story, Table, TableChild, TextNode, stories}; use craft_xml::{El, Local, Node}; diff --git a/crates/idml/src/model/story.rs b/crates/idml/src/model/story.rs new file mode 100644 index 000000000..889c7b350 --- /dev/null +++ b/crates/idml/src/model/story.rs @@ -0,0 +1,405 @@ +//! Stories: paragraph and character ranges, their text, and what text holds (tables, notes, +//! anchored page items, hyperlink and change containers). + +use craft_xml::{El, Local, Node}; + +use super::{PageItem, Values, tidy}; + +/// A `Story`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Story { + pub values: Values, + pub children: Vec, +} + +/// One node of a story's text, in document order. IDML nests freely (an `XMLElement` or a +/// `HyperlinkTextSource` can hold paragraph ranges or content), so every container holds these. +#[derive(Clone, Debug, PartialEq)] +pub enum TextNode { + /// `ParagraphStyleRange`. + Paragraph(Range), + /// `CharacterStyleRange`. + Character(Range), + /// `Content`: text, with the special characters InDesign writes as ``. + Content(Vec), + /// `Br`: the end of a paragraph. + Break, + /// Text inside another element ([`CONTAINERS`]): a hyperlink's source, a tracked change, an + /// XML element, a cross-reference's source, a footnote, a note. + Container(Container), + Table(Table), + /// A page item anchored in the text. + Item(PageItem), + /// Anything else (`StoryPreference`, `HyperlinkTextDestination`, `TextVariableInstance`, …), + /// as written. + Other(El), +} + +/// The elements read as [`TextNode::Container`]. +pub const CONTAINERS: [&str; 7] = ["HyperlinkTextSource", "Change", "XMLElement", "CrossReferenceSource", "Footnote", "Note", "EndnoteRange"]; + +/// A paragraph or character range: its style and overrides ([`Values`]) and its content. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Range { + pub values: Values, + pub children: Vec, +} + +/// An element that holds text. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Container { + /// The element's name (one of [`CONTAINERS`]). + pub name: String, + pub values: Values, + pub children: Vec, +} + +/// A piece of a `Content` element. +#[derive(Clone, Debug, PartialEq)] +pub enum ContentPart { + Text(String), + /// ``: a special character. + Ace(Ace), + /// Another processing instruction, as written. + Pi { + target: String, + data: String, + }, +} + +/// The special characters InDesign writes as ``. Codes this crate does not name are +/// kept by number. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ace { + /// 4: the footnote's number, at the start of its text. + FootnoteNumber, + /// 7: Indent to Here. + IndentToHere, + /// 8: Right Indent Tab. + RightIndentTab, + /// 18: the current page's number. + PageNumber, + /// 19: the section marker. + SectionMarker, + Other(u32), +} + +impl Ace { + pub fn from_code(code: u32) -> Ace { + match code { + 4 => Ace::FootnoteNumber, + 7 => Ace::IndentToHere, + 8 => Ace::RightIndentTab, + 18 => Ace::PageNumber, + 19 => Ace::SectionMarker, + n => Ace::Other(n), + } + } + + pub fn code(self) -> u32 { + match self { + Ace::FootnoteNumber => 4, + Ace::IndentToHere => 7, + Ace::RightIndentTab => 8, + Ace::PageNumber => 18, + Ace::SectionMarker => 19, + Ace::Other(n) => n, + } + } +} + +/// A `Table`: its rows and columns as written, and its cells. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Table { + pub values: Values, + pub children: Vec, +} + +/// A child of a table, in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum TableChild { + Cell(Cell), + /// `Row`, `Column`, and anything else, as written. + Other(El), +} + +/// A table `Cell`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Cell { + pub values: Values, + pub children: Vec, +} + +/// The children of `el` other than ``, as text nodes. +fn nodes(el: &El) -> Vec { + el.elements().filter(|e| !e.is(Local("Properties"))).map(TextNode::from_el).collect() +} + +fn with_children(mut el: El, children: &[TextNode]) -> El { + el.children.extend(children.iter().map(|c| Node::El(c.to_el()))); + el +} + +impl TextNode { + pub fn from_el(el: &El) -> TextNode { + match el.local() { + "ParagraphStyleRange" => TextNode::Paragraph(Range { values: Values::of(el), children: nodes(el) }), + "CharacterStyleRange" => TextNode::Character(Range { values: Values::of(el), children: nodes(el) }), + "Content" => TextNode::Content(content_parts(el)), + "Br" => TextNode::Break, + "Table" => TextNode::Table(Table::from_el(el)), + n if CONTAINERS.contains(&n) => TextNode::Container(Container { name: n.to_string(), values: Values::of(el), children: nodes(el) }), + _ => PageItem::from_el(el).map_or_else(|| TextNode::Other(tidy(el)), TextNode::Item), + } + } + + pub fn to_el(&self) -> El { + match self { + TextNode::Paragraph(r) => with_children(r.values.element("ParagraphStyleRange"), &r.children), + TextNode::Character(r) => with_children(r.values.element("CharacterStyleRange"), &r.children), + TextNode::Content(parts) => { + let mut c = El::new("Content"); + c.children = parts + .iter() + .map(|p| match p { + ContentPart::Text(t) => Node::Text(t.clone()), + ContentPart::Ace(a) => Node::Pi { target: "ACE".into(), data: a.code().to_string() }, + ContentPart::Pi { target, data } => Node::Pi { target: target.clone(), data: data.clone() }, + }) + .collect(); + c + } + TextNode::Break => El::new("Br"), + TextNode::Container(c) => with_children(c.values.element(&c.name), &c.children), + TextNode::Table(t) => t.to_el(), + TextNode::Item(i) => i.to_el(), + TextNode::Other(e) => e.clone(), + } + } + + /// The nodes this one holds; none for content, breaks, page items and others. + pub fn children(&self) -> &[TextNode] { + match self { + TextNode::Paragraph(r) | TextNode::Character(r) => &r.children, + TextNode::Container(c) => &c.children, + _ => &[], + } + } +} + +/// A `Content` element's text and instructions, in order. CDATA is text. +fn content_parts(el: &El) -> Vec { + let mut parts: Vec = Vec::new(); + for n in &el.children { + let part = match n { + Node::Text(t) | Node::CData(t) => ContentPart::Text(t.clone()), + Node::Pi { target, data } if target == "ACE" => match data.trim().parse::() { + Ok(code) => ContentPart::Ace(Ace::from_code(code)), + Err(_) => ContentPart::Pi { target: target.clone(), data: data.clone() }, + }, + Node::Pi { target, data } => ContentPart::Pi { target: target.clone(), data: data.clone() }, + Node::El(_) | Node::Comment(_) => continue, + }; + // Adjacent text is one piece, as written. + match (parts.last_mut(), part) { + (Some(ContentPart::Text(a)), ContentPart::Text(b)) => a.push_str(&b), + (_, p) => parts.push(p), + } + } + parts +} + +impl Story { + pub fn from_el(el: &El) -> Story { + Story { values: Values::of(el), children: nodes(el) } + } + + pub fn to_el(&self) -> El { + with_children(self.values.element("Story"), &self.children) + } + + /// `StoryPreference` (`StoryDirection`, `StoryOrientation`, `OpticalMarginAlignment`, …). + pub fn preference(&self) -> Option { + self.children.iter().find_map(|c| match c { + TextNode::Other(e) if e.local() == "StoryPreference" => Some(Values::of(e)), + _ => None, + }) + } + + /// The story's paragraph ranges in reading order, those inside containers included, but not + /// those of its tables, footnotes or notes (which are text of their own). + pub fn paragraphs(&self) -> Vec<&Range> { + paragraphs(&self.children) + } + + /// The story's text: content, `\n` for each break, and `ace` for each special character + /// (left out where it gives `None`). Tables, footnotes, notes and anchored items are left + /// out: they are text of their own. + pub fn text(&self, ace: impl Fn(Ace) -> Option) -> String { + text(&self.children, &ace) + } +} + +/// Whether a container's text belongs to the text around it (a hyperlink's text) or is text of +/// its own (a footnote). +fn inline_container(name: &str) -> bool { + !matches!(name, "Footnote" | "Note" | "EndnoteRange") +} + +/// The paragraph ranges in `nodes`, depth first, without recursing. +fn paragraphs(nodes: &[TextNode]) -> Vec<&Range> { + let mut out = Vec::new(); + let mut stack: Vec<&TextNode> = nodes.iter().rev().collect(); + while let Some(n) = stack.pop() { + match n { + TextNode::Paragraph(r) => out.push(r), + TextNode::Container(c) if inline_container(&c.name) => stack.extend(c.children.iter().rev()), + _ => {} + } + } + out +} + +fn text(nodes: &[TextNode], ace: &dyn Fn(Ace) -> Option) -> String { + let mut out = String::new(); + let mut stack: Vec<&TextNode> = nodes.iter().rev().collect(); + while let Some(n) = stack.pop() { + match n { + TextNode::Content(parts) => { + for p in parts { + match p { + ContentPart::Text(t) => out.push_str(t), + ContentPart::Ace(a) => out.extend(ace(*a)), + ContentPart::Pi { .. } => {} + } + } + } + TextNode::Break => out.push('\n'), + TextNode::Paragraph(r) | TextNode::Character(r) => stack.extend(r.children.iter().rev()), + TextNode::Container(c) if inline_container(&c.name) => stack.extend(c.children.iter().rev()), + _ => {} + } + } + out +} + +impl Range { + /// `AppliedParagraphStyle` of a paragraph range. + pub fn paragraph_style(&self) -> Option<&str> { + self.values.get("AppliedParagraphStyle") + } + + /// `AppliedCharacterStyle` of a character range. + pub fn character_style(&self) -> Option<&str> { + self.values.get("AppliedCharacterStyle") + } + + /// The range's text, as [`Story::text`] gives it. + pub fn text(&self, ace: impl Fn(Ace) -> Option) -> String { + text(&self.children, &ace) + } + + /// The character ranges in this range, those inside inline containers included. + pub fn characters(&self) -> Vec<&Range> { + let mut out = Vec::new(); + let mut stack: Vec<&TextNode> = self.children.iter().rev().collect(); + while let Some(n) = stack.pop() { + match n { + TextNode::Character(r) => out.push(r), + TextNode::Container(c) if inline_container(&c.name) => stack.extend(c.children.iter().rev()), + _ => {} + } + } + out + } +} + +impl Container { + /// The container's own paragraph ranges (a footnote's, a note's). + pub fn paragraphs(&self) -> Vec<&Range> { + paragraphs(&self.children) + } + + pub fn text(&self, ace: impl Fn(Ace) -> Option) -> String { + text(&self.children, &ace) + } +} + +impl Table { + pub fn from_el(el: &El) -> Table { + let children = + el.elements() + .filter(|e| !e.is(Local("Properties"))) + .map(|c| { + if c.local() == "Cell" { + TableChild::Cell(Cell { values: Values::of(c), children: nodes(c) }) + } else { + TableChild::Other(tidy(c)) + } + }) + .collect(); + Table { values: Values::of(el), children } + } + + pub fn to_el(&self) -> El { + let mut el = self.values.element("Table"); + el.children.extend(self.children.iter().map(|c| { + Node::El(match c { + TableChild::Cell(cell) => with_children(cell.values.element("Cell"), &cell.children), + TableChild::Other(e) => e.clone(), + }) + })); + el + } + + /// The `Row` elements' values, in order. + pub fn rows(&self) -> Vec { + self.others("Row") + } + + /// The `Column` elements' values, in order. + pub fn columns(&self) -> Vec { + self.others("Column") + } + + fn others(&self, name: &str) -> Vec { + self.children + .iter() + .filter_map(|c| match c { + TableChild::Other(e) if e.local() == name => Some(Values::of(e)), + _ => None, + }) + .collect() + } + + pub fn cells(&self) -> impl DoubleEndedIterator { + self.children.iter().filter_map(|c| if let TableChild::Cell(c) = c { Some(c) } else { None }) + } +} + +impl Cell { + /// `Name`: `column:row` of the cell's top left (`2:0`). + pub fn position(&self) -> Option<(u32, u32)> { + let (c, r) = self.values.name()?.split_once(':')?; + Some((c.trim().parse().ok()?, r.trim().parse().ok()?)) + } + + /// `ColumnSpan` and `RowSpan`; 1 when absent or not a positive whole number. + pub fn span(&self) -> (u32, u32) { + let n = |k: &str| self.values.num(k).filter(|v| *v >= 1.0 && v.fract() == 0.0 && *v <= f64::from(u32::MAX)).map_or(1, |v| v as u32); + (n("ColumnSpan"), n("RowSpan")) + } + + pub fn paragraphs(&self) -> Vec<&Range> { + paragraphs(&self.children) + } + + pub fn text(&self, ace: impl Fn(Ace) -> Option) -> String { + text(&self.children, &ace) + } +} + +/// The stories among `top` (a document's or an ICML file's top-level elements), in order. +pub fn stories(top: &[El]) -> Vec { + top.iter().filter(|e| e.local() == "Story").map(Story::from_el).collect() +} diff --git a/crates/idml/tests/icml.rs b/crates/idml/tests/icml.rs new file mode 100644 index 000000000..4d54e270b --- /dev/null +++ b/crates/idml/tests/icml.rs @@ -0,0 +1,52 @@ +//! ICML: one story and what it uses, as one XML document. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_idml::icml::{MAX_ICML_BYTES, icml_xml, is_icml, read_icml}; +use craft_idml::{El, Error}; + +const ICML: &str = r#" + + + + + + + Hello + Anchored + + +"#; + +#[test] +fn an_icml_file_reads_as_its_resources_and_stories() { + assert!(is_icml(ICML.as_bytes())); + assert!(is_icml(format!("\u{FEFF} {ICML}").as_bytes())); + assert!(!is_icml(b"")); + assert!(!is_icml(b"SnippetType=\"InCopyInterchange\"")); + let icml = read_icml(ICML.as_bytes()).unwrap(); + assert_eq!(icml.top.iter().map(|e| e.name.as_str()).collect::>(), ["Color", "RootParagraphStyleGroup", "Story", "Story", "Hyperlink"]); + assert_eq!(icml.warnings, ["Resources/Graphic.xml: an ICML file has no package parts; it was not read"]); + let stories = icml.stories(); + assert_eq!(stories.iter().map(|s| s.text(|_| None)).collect::>(), ["Hello", "Anchored"]); +} + +#[test] +fn an_icml_file_written_reads_back() { + let icml = read_icml(ICML.as_bytes()).unwrap(); + let mut root = El::new("Document").with_attr("DOMVersion", craft_idml::DOM_VERSION).with_attr("Self", "d"); + root.children.extend(icml.top.iter().cloned().map(craft_idml::Node::El)); + let bytes = icml_xml(&root); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.starts_with("\n\n\n"), Err(Error::NotIdml(m)) if m.contains(""))); + assert!(matches!(read_icml(b""), Err(Error::NotIdml(m)) if m.contains("no story"))); + assert!(matches!(read_icml(b""), Err(Error::Part { .. })), "cut off"); + let huge = vec![b' '; MAX_ICML_BYTES + 1]; + assert_eq!(read_icml(&huge), Err(Error::TooLarge { part: "ICML".into(), limit: MAX_ICML_BYTES as u64 })); +} diff --git a/crates/idml/tests/story.rs b/crates/idml/tests/story.rs new file mode 100644 index 000000000..c21b1f398 --- /dev/null +++ b/crates/idml/tests/story.rs @@ -0,0 +1,151 @@ +//! The story model. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_idml::model::{Ace, ContentPart, ItemKind, Story, TextNode, stories}; +use craft_idml::{parse_part, part_root, part_xml}; + +/// A story as InDesign writes one: two paragraphs; overrides; a page number and a tab in the +/// content; a hyperlink and a tracked change around text; a footnote; a table; an anchored +/// rectangle; elements the model does not type. +const STORY: &str = r#" + + + + + + + Minion Pro + Page of two +
+
+
+ + + a link + added + & ]]> + A note. + + + + + cell +
+ + + end +
+
+
+
+"#; + +fn story() -> Story { + stories(&parse_part(STORY.as_bytes()).unwrap().elements().cloned().collect::>()).remove(0) +} + +fn ace(a: Ace) -> Option { + (a == Ace::PageNumber).then_some('#') +} + +#[test] +fn a_story_reads_as_ranges_and_content() { + let s = story(); + assert_eq!(s.values.self_id(), Some("us1")); + let pref = s.preference().unwrap(); + assert_eq!((pref.boolean("OpticalMarginAlignment"), pref.num("OpticalMarginSize")), (Some(true), Some(12.0))); + let paras = s.paragraphs(); + assert_eq!(paras.iter().map(|p| p.paragraph_style().unwrap()).collect::>(), ["ParagraphStyle/Head", "ParagraphStyle/Body"]); + let head = paras[0].characters()[0]; + assert_eq!((head.values.num("PointSize"), head.values.value("AppliedFont").as_deref()), (Some(18.0), Some("Minion Pro"))); + // Content keeps its whitespace and its special characters in place. + let TextNode::Content(parts) = &head.children[0] else { panic!() }; + assert_eq!(parts, &[ContentPart::Text("Page ".into()), ContentPart::Ace(Ace::PageNumber), ContentPart::Text("\tof two".into())]); + assert_eq!(head.children[1], TextNode::Break); + // The text of the story: hyperlink and change text belong to it, the footnote and the table + // do not. + assert_eq!(s.text(ace), "Page #\tof two\na link added & end"); + assert_eq!(s.text(|_| None), "Page \tof two\na link added & end"); + // The body paragraph's character range holds every kind of node. + let body = paras[1].characters(); + assert_eq!(body.len(), 1); + let kinds: Vec = body[0] + .children + .iter() + .map(|n| match n { + TextNode::Container(c) => c.name.clone(), + TextNode::Content(_) => "Content".into(), + TextNode::Table(_) => "Table".into(), + TextNode::Item(i) => format!("item {}", i.kind.name()), + TextNode::Other(e) => format!("other {}", e.name), + n => format!("{n:?}"), + }) + .collect(); + assert_eq!( + kinds, + ["HyperlinkTextSource", "Change", "Content", "Footnote", "Table", "item Rectangle", "other HyperlinkTextDestination", "Content"] + ); +} + +#[test] +fn footnotes_tables_and_anchored_items_are_text_and_items_of_their_own() { + let s = story(); + let body = s.paragraphs()[1].characters()[0]; + let TextNode::Container(note) = &body.children[3] else { panic!() }; + assert_eq!(note.name, "Footnote"); + assert_eq!(note.paragraphs()[0].paragraph_style(), Some("ParagraphStyle/Note")); + assert_eq!(note.text(|a| (a == Ace::FootnoteNumber).then_some('1')), "1 A note."); + let TextNode::Table(table) = &body.children[4] else { panic!() }; + assert_eq!(table.rows().len(), 1); + assert_eq!(table.columns().iter().map(|c| c.num("SingleColumnWidth").unwrap()).collect::>(), [100.0, 50.0]); + let cell = table.cells().next().unwrap(); + assert_eq!((cell.position(), cell.span()), (Some((0, 0)), (2, 1))); + assert_eq!(cell.text(|_| None), "cell"); + let TextNode::Item(item) = &body.children[5] else { panic!() }; + assert_eq!(item.kind, ItemKind::Rectangle); + assert_eq!(item.other("AnchoredObjectSetting").and_then(|a| a.attr("AnchoredPosition")), Some("InlinePosition")); + let TextNode::Container(link) = &body.children[0] else { panic!() }; + assert_eq!((link.values.name(), link.text(|_| None).as_str()), (Some("link"), "a link")); +} + +#[test] +fn a_story_written_and_read_again_is_the_same() { + let s = story(); + let bytes = part_xml(&part_root("Story").with_child(s.to_el())); + let again = stories(&parse_part(&bytes).unwrap().elements().cloned().collect::>()).remove(0); + assert_eq!(again, s); + let xml = String::from_utf8(bytes).unwrap(); + // Content is written on one line, its whitespace and instructions as they were. + assert!(xml.contains("Page \tof two"), "{xml}"); + assert!(xml.contains(" & <raw>"), "{xml}"); +} + +#[test] +fn unknown_ace_codes_and_instructions_are_kept() { + let el = parse_part(b"ab").unwrap(); + let s = Story::from_el(&el); + let TextNode::Content(parts) = &s.paragraphs()[0].characters()[0].children[0] else { panic!() }; + assert_eq!( + parts, + &[ + ContentPart::Text("a".into()), + ContentPart::Ace(Ace::Other(99)), + ContentPart::Text("b".into()), + ContentPart::Pi { target: "ACE".into(), data: "x".into() }, + ContentPart::Pi { target: "other".into(), data: "data".into() }, + ] + ); + assert_eq!(Story::from_el(&parse_part(&part_xml(&s.to_el())).unwrap()), s); + for code in [4, 7, 8, 18, 19, 3] { + assert_eq!(Ace::from_code(code).code(), code); + } +} + +/// Text containers nest as deep as the XML reader allows; reading the text does not recurse. +#[test] +fn deeply_nested_containers_are_read() { + let depth = 200; + let xml = format!("{}x{}", "".repeat(depth), "".repeat(depth)); + let s = Story::from_el(&parse_part(xml.as_bytes()).unwrap()); + assert_eq!(s.text(|_| None), "x"); +}