mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-idml: story model and ICML (InCopy Markup) (#1008)
* 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 <?ACE n?>, 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. --------- Co-authored-by: ljkx <177671075+ljkx@users.noreply.github.com>
This commit is contained in:
1 parent
06aee09c5c
commit
c6ded901fb
7 files changed
+747
-8
No files matched your search
+42
-2
@@ -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#"<idPkg:Story><Story Self="u1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body">
|
||||
<CharacterStyleRange PointSize="9"><Content>Page <?ACE 18?></Content><Br/></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#)?;
|
||||
let story = stories(&part.elements().cloned().collect::<Vec<_>>()).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 n?>`: `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 `<?aid?>` instructions that
|
||||
mark the file.
|
||||
|
||||
## Values
|
||||
|
||||
`craft_idml::value`: `num` and `point_str` (numbers as IDML writes them), `nums`, `matrix`,
|
||||
|
||||
@@ -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, `<?aid style="50" type="snippet" …?>` and
|
||||
//! `<?aid SnippetType="InCopyInterchange"?>`, 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<El>,
|
||||
/// One line per element left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
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<Story> {
|
||||
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<Icml> {
|
||||
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 <Document>", root.name)));
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let top: Vec<El> = 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<u8> {
|
||||
let mut s = String::from(DECL_STANDALONE);
|
||||
s.push_str("<?aid style=\"50\" type=\"snippet\" readerVersion=\"6.0\" featureSet=\"513\" product=\"");
|
||||
s.push_str(DOM_VERSION);
|
||||
s.push_str("(1)\" ?>\n<?aid SnippetType=\"InCopyInterchange\"?>\n");
|
||||
root.write_xml_pretty(&mut s, "\t");
|
||||
s.into_bytes()
|
||||
}
|
||||
@@ -16,11 +16,12 @@
|
||||
//! attribute, or a child of `<Properties>`.
|
||||
//! - [`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;
|
||||
|
||||
@@ -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 `<Properties>`
|
||||
//! 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};
|
||||
|
||||
|
||||
@@ -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<TextNode>,
|
||||
}
|
||||
|
||||
/// 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 `<?ACE n?>`.
|
||||
Content(Vec<ContentPart>),
|
||||
/// `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<TextNode>,
|
||||
}
|
||||
|
||||
/// 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<TextNode>,
|
||||
}
|
||||
|
||||
/// A piece of a `Content` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ContentPart {
|
||||
Text(String),
|
||||
/// `<?ACE n?>`: a special character.
|
||||
Ace(Ace),
|
||||
/// Another processing instruction, as written.
|
||||
Pi {
|
||||
target: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The special characters InDesign writes as `<?ACE n?>`. 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<TableChild>,
|
||||
}
|
||||
|
||||
/// 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<TextNode>,
|
||||
}
|
||||
|
||||
/// The children of `el` other than `<Properties>`, as text nodes.
|
||||
fn nodes(el: &El) -> Vec<TextNode> {
|
||||
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<ContentPart> {
|
||||
let mut parts: Vec<ContentPart> = 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::<u32>() {
|
||||
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<Values> {
|
||||
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<char>) -> 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<char>) -> 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<char>) -> 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<char>) -> 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<Values> {
|
||||
self.others("Row")
|
||||
}
|
||||
|
||||
/// The `Column` elements' values, in order.
|
||||
pub fn columns(&self) -> Vec<Values> {
|
||||
self.others("Column")
|
||||
}
|
||||
|
||||
fn others(&self, name: &str) -> Vec<Values> {
|
||||
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<Item = &Cell> {
|
||||
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<char>) -> 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<Story> {
|
||||
top.iter().filter(|e| e.local() == "Story").map(Story::from_el).collect()
|
||||
}
|
||||
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<?aid style="50" type="snippet" readerVersion="6.0" featureSet="513" product="16.0(1)" ?>
|
||||
<?aid SnippetType="InCopyInterchange"?>
|
||||
<Document DOMVersion="16.0" Self="d">
|
||||
<Color Self="Color/Black" Model="Process" Space="CMYK" ColorValue="0 0 0 100" />
|
||||
<RootParagraphStyleGroup Self="u1"><ParagraphStyle Self="ParagraphStyle/Body" Name="Body" /></RootParagraphStyleGroup>
|
||||
<idPkg:Graphic src="Resources/Graphic.xml" />
|
||||
<Story Self="us1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange><Content>Hello</Content></CharacterStyleRange></ParagraphStyleRange></Story>
|
||||
<Story Self="us2"><ParagraphStyleRange><CharacterStyleRange><Content>Anchored</Content></CharacterStyleRange></ParagraphStyleRange></Story>
|
||||
<Hyperlink Self="uh1" Source="uh9" />
|
||||
</Document>
|
||||
"#;
|
||||
|
||||
#[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"<Document/>"));
|
||||
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?aid style=\"50\" type=\"snippet\" readerVersion=\"6.0\" featureSet=\"513\" product=\"16.0(1)\" ?>\n<?aid SnippetType=\"InCopyInterchange\"?>\n<Document "), "{text}");
|
||||
assert!(is_icml(&bytes));
|
||||
assert_eq!(read_icml(&bytes).unwrap().stories(), icml.stories());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_no_icml_story_is_refused() {
|
||||
assert!(matches!(read_icml(b"<Workbook/>"), Err(Error::NotIdml(m)) if m.contains("<Workbook>")));
|
||||
assert!(matches!(read_icml(b"<Document><Color/></Document>"), Err(Error::NotIdml(m)) if m.contains("no story")));
|
||||
assert!(matches!(read_icml(b"<Document><Story>"), 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 }));
|
||||
}
|
||||
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Story Self="us1" AppliedTOCStyle="n" TrackChanges="false">
|
||||
<StoryPreference OpticalMarginAlignment="true" OpticalMarginSize="12" StoryDirection="LeftToRightDirection" />
|
||||
<InCopyExportOption IncludeGraphicProxies="true" />
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Head">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" PointSize="18">
|
||||
<Properties><AppliedFont type="string">Minion Pro</AppliedFont></Properties>
|
||||
<Content>Page <?ACE 18?> of two</Content>
|
||||
<Br />
|
||||
</CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/Em">
|
||||
<HyperlinkTextSource Self="uh1" Name="link"><Content>a link</Content></HyperlinkTextSource>
|
||||
<Change ChangeType="InsertedText"><Content> added</Content></Change>
|
||||
<Content> & <![CDATA[<raw>]]></Content>
|
||||
<Footnote><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Note"><CharacterStyleRange><Content><?ACE 4?> A note.</Content></CharacterStyleRange></ParagraphStyleRange></Footnote>
|
||||
<Table Self="ut1" BodyRowCount="1" ColumnCount="2">
|
||||
<Row Self="ut1Row0" Name="0" SingleRowHeight="20" />
|
||||
<Column Self="ut1Column0" Name="0" SingleColumnWidth="100" />
|
||||
<Column Self="ut1Column1" Name="1" SingleColumnWidth="50" />
|
||||
<Cell Self="ut1i0" Name="0:0" ColumnSpan="2" RowSpan="1"><ParagraphStyleRange><CharacterStyleRange><Content>cell</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
|
||||
</Table>
|
||||
<Rectangle Self="ur1" ItemTransform="1 0 0 1 0 0"><AnchoredObjectSetting AnchoredPosition="InlinePosition" /></Rectangle>
|
||||
<HyperlinkTextDestination Self="ud1" Name="here" />
|
||||
<Content>end</Content>
|
||||
</CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
</Story>
|
||||
</idPkg:Story>
|
||||
"#;
|
||||
|
||||
fn story() -> Story {
|
||||
stories(&parse_part(STORY.as_bytes()).unwrap().elements().cloned().collect::<Vec<_>>()).remove(0)
|
||||
}
|
||||
|
||||
fn ace(a: Ace) -> Option<char> {
|
||||
(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::<Vec<_>>(), ["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 & <raw>end");
|
||||
assert_eq!(s.text(|_| None), "Page \tof two\na link added & <raw>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<String> = 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::<Vec<_>>(), [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::<Vec<_>>()).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("<Content>Page <?ACE 18?>\tof two</Content>"), "{xml}");
|
||||
assert!(xml.contains("<Content> & <raw></Content>"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_ace_codes_and_instructions_are_kept() {
|
||||
let el = parse_part(b"<Story><ParagraphStyleRange><CharacterStyleRange><Content>a<?ACE 99?>b<?ACE x?><?other data?></Content></CharacterStyleRange></ParagraphStyleRange></Story>").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!("<Story>{}<Content>x</Content>{}</Story>", "<XMLElement>".repeat(depth), "</XMLElement>".repeat(depth));
|
||||
let s = Story::from_el(&parse_part(xml.as_bytes()).unwrap());
|
||||
assert_eq!(s.text(|_| None), "x");
|
||||
}
|
||||
Reference in new issue
Block a user