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.
+
+
+
+ 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");
+}