diff --git a/Cargo.lock b/Cargo.lock
index 7b965e3f7..722a8f1f9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1848,6 +1848,7 @@ version = "0.1.0"
dependencies = [
"craft-heif",
"craft-jpeg",
+ "craft-metadata",
"exr",
"flate2",
"half",
diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs
index 07405a959..3b2cf45f7 100644
--- a/crates/idml/src/lib.rs
+++ b/crates/idml/src/lib.rs
@@ -39,7 +39,10 @@ pub mod value;
pub use craft_xml::{El, Local, Node};
pub use package::{Document, Limits, Package, write_package};
-pub use part::{CONTAINER_XML, Props, designmap_xml, parse_part, part_root, part_xml, prop, with_props, xml_limits};
+pub use part::{
+ CONTAINER_XML, Props, designmap_include, designmap_root, designmap_xml, designmap_xml_with_pi, parse_part, part_root, part_xml, prop, with_props,
+ xml_limits,
+};
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
@@ -48,6 +51,14 @@ pub const DOM_VERSION: &str = "16.0";
/// The namespace of the `idPkg` prefix.
pub const PACKAGING_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging";
+/// Writes the bounded XMP metadata packet used by an IDML package. Dates are already normalized
+/// ISO-8601 values supplied by the caller; this function owns the XML vocabulary and envelope.
+pub fn xmp_metadata(create_date: &str, modify_date: &str) -> String {
+ format!(
+ "\n\n \n \n {create_date}\n {modify_date}\n \n \n\n"
+ )
+}
+
/// Why a package was not read or not written.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
diff --git a/crates/idml/src/model/document.rs b/crates/idml/src/model/document.rs
new file mode 100644
index 000000000..3937a4501
--- /dev/null
+++ b/crates/idml/src/model/document.rs
@@ -0,0 +1,524 @@
+//! The `Document` tree: root values and included designmap nodes in source order.
+
+use craft_xml::El;
+
+use super::{AlternatingFillValues, AlternatingStrokeValues, Spread, Story, StrokeValues, Values};
+
+/// One IDML table-of-contents style entry before document style names are resolved.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TocStyleEntryValues {
+ pub style: Option,
+ pub level: Option,
+ pub page_number_position: Option,
+ pub separator: Option,
+ pub entry_style: Option,
+ pub page_number_style: Option,
+ pub separator_style: Option,
+ pub sort: Option,
+}
+
+/// An IDML table-of-contents style and its entry grammar.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TocStyleValues {
+ pub self_id: Option,
+ pub title: Option,
+ pub title_style: Option,
+ pub create_bookmarks: Option,
+ pub entries: Vec,
+}
+
+/// Date-based IDML text variable kinds.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum TextDateKind {
+ Creation,
+ Modification,
+ Output,
+}
+
+/// Semantically classified `TextVariable` definitions before caller model mapping.
+#[derive(Clone, Debug, PartialEq)]
+pub enum TextVariableKind {
+ CustomText { text: String },
+ LastPageNumber { section: bool },
+ ChapterNumber,
+ FileName { extension: bool },
+ Date { kind: TextDateKind, format: String },
+ RunningHeader { style: String, character: bool, last_on_page: bool },
+ CrossReference { page_number: bool },
+ Imported { variable_type: String, settings_element: String, settings: Vec<(String, String)> },
+}
+
+/// Parsed values from an IDML `TextVariable` definition.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextVariableValues {
+ pub self_id: Option,
+ pub name: String,
+ pub before: String,
+ pub after: String,
+ pub kind: TextVariableKind,
+}
+
+/// A recognized IDML cross-reference format building block.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum CrossReferenceBuildingBlock {
+ CustomText(String),
+ FullParagraph,
+ ParagraphText,
+ ParagraphNumber,
+ PageNumber,
+ BookmarkName,
+ ChapterNumber,
+ FileName,
+ PartialParagraph { delimiter: String, include_delimiter: bool },
+}
+
+/// Parsed IDML cross-reference format definition.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct CrossReferenceFormatValues {
+ pub self_id: Option,
+ pub name: String,
+ pub blocks: Vec,
+}
+
+impl CrossReferenceFormatValues {
+ /// Reads a `CrossReferenceFormat` and recognizes its building-block grammar.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ let mut result =
+ Self { self_id: values.self_id().map(str::to_owned), name: values.get("Name").unwrap_or("Format").to_owned(), blocks: Vec::new() };
+ for block in el.elements().filter(|child| child.local() == "BuildingBlock") {
+ let values = Values::of(block);
+ let text = |key: &str| values.get(key).filter(|value| *value != "$ID/").unwrap_or("").to_owned();
+ let parsed = match values.get("BlockType").unwrap_or("") {
+ "CustomStringBuildingBlock" => Some(CrossReferenceBuildingBlock::CustomText(text("CustomText"))),
+ "FullParagraphBuildingBlock" => Some(CrossReferenceBuildingBlock::FullParagraph),
+ "ParagraphTextBuildingBlock" => Some(CrossReferenceBuildingBlock::ParagraphText),
+ "ParagraphNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::ParagraphNumber),
+ "PageNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::PageNumber),
+ "BookmarkNameBuildingBlock" => Some(CrossReferenceBuildingBlock::BookmarkName),
+ "ChapterNumberBuildingBlock" => Some(CrossReferenceBuildingBlock::ChapterNumber),
+ "FileNameBuildingBlock" => Some(CrossReferenceBuildingBlock::FileName),
+ "PartialParagraphBuildingBlock" => Some(CrossReferenceBuildingBlock::PartialParagraph {
+ delimiter: text("AppliedDelimiter"),
+ include_delimiter: values.get("IncludeDelimiter") == Some("true"),
+ }),
+ _ => None,
+ };
+ if let Some(parsed) = parsed {
+ result.blocks.push(parsed);
+ }
+ }
+ result
+ }
+
+ /// Builds an IDML cross-reference format from DesignCraft's stored token definition.
+ pub fn to_el(self_id: &str, name: &str, definition: &str) -> El {
+ let mut root = El::new("CrossReferenceFormat").with_attr("Self", self_id).with_attr("Name", name).with_attr("AppliedCharacterStyle", "n");
+ let mut index = 0;
+ let mut block = |root: &mut El, kind: &str, text: &str, delimiter: &str, include: bool| {
+ root.push(
+ El::new("BuildingBlock")
+ .with_attr("Self", format!("{self_id}BuildingBlock{index}"))
+ .with_attr("BlockType", kind)
+ .with_attr("AppliedCharacterStyle", "n")
+ .with_attr("CustomText", if text.is_empty() { "$ID/" } else { text })
+ .with_attr("AppliedDelimiter", if delimiter.is_empty() { "$ID/" } else { delimiter })
+ .with_attr("IncludeDelimiter", if include { "true" } else { "false" }),
+ );
+ index += 1;
+ };
+ let mut rest = definition;
+ while let Some(start) = rest.find('<') {
+ let Some(end) = rest[start..].find('>') else { break };
+ if start > 0 {
+ block(&mut root, "CustomStringBuildingBlock", &rest[..start], "", false);
+ }
+ let tag = rest[start + 1..start + end].trim().trim_end_matches('/').trim();
+ let attr =
+ |key: &str| tag.find(&format!("{key}=\"")).and_then(|position| tag[position + key.len() + 2..].split('"').next()).unwrap_or("");
+ match tag.split_whitespace().next().unwrap_or("") {
+ "fullPara" => block(&mut root, "FullParagraphBuildingBlock", "", "", false),
+ "paraText" => block(&mut root, "ParagraphTextBuildingBlock", "", "", false),
+ "paraNum" => block(&mut root, "ParagraphNumberBuildingBlock", "", "", false),
+ "pageNum" => block(&mut root, "PageNumberBuildingBlock", "", "", false),
+ "txtAnchrName" => block(&mut root, "BookmarkNameBuildingBlock", "", "", false),
+ "chapNum" => block(&mut root, "ChapterNumberBuildingBlock", "", "", false),
+ "fileName" => block(&mut root, "FileNameBuildingBlock", "", "", false),
+ "partialPara" => block(&mut root, "PartialParagraphBuildingBlock", "", attr("delim"), attr("includeDelim") == "true"),
+ _ => block(&mut root, "CustomStringBuildingBlock", &rest[start..start + end + 1], "", false),
+ }
+ rest = &rest[start + end + 1..];
+ }
+ if !rest.is_empty() {
+ block(&mut root, "CustomStringBuildingBlock", rest, "", false);
+ }
+ root
+ }
+}
+
+/// Parsed EPUB document-splitting preference.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct EpubSplitValues {
+ pub break_document: bool,
+ pub paragraph_style: Option,
+}
+
+/// Parsed IDML condition metadata.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ConditionValues {
+ pub self_id: Option,
+ pub name: Option,
+ pub indicator_rgb: Option<[u8; 3]>,
+ pub visible: bool,
+}
+
+impl ConditionValues {
+ /// Reads condition fields and its optional three-channel indicator color.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ let rgb: Vec = values
+ .get("IndicatorColor")
+ .unwrap_or("")
+ .split_whitespace()
+ .filter_map(|value| value.parse::().ok())
+ .map(|value| value.clamp(0.0, 255.0) as u8)
+ .collect();
+ Self {
+ self_id: values.self_id().map(str::to_owned),
+ name: values.get("Name").map(str::to_owned),
+ indicator_rgb: (rgb.len() == 3).then(|| [rgb[0], rgb[1], rgb[2]]),
+ visible: values.get("Visible") != Some("false"),
+ }
+ }
+}
+
+/// Parsed IDML numbering-list metadata.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct NumberingListValues {
+ pub name: String,
+ pub continue_across_stories: bool,
+}
+
+/// Parsed cell-style assignments that are independent of DesignCraft's style resolution.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct CellStyleValues {
+ pub self_id: Option,
+ pub name: Option,
+ pub based_on: Option,
+ pub fill: Option,
+ pub fill_tint: Option,
+ pub insets: [Option; 4],
+ pub vertical_justification: Option,
+ pub first_baseline: Option,
+ pub first_baseline_min: Option,
+ pub paragraph_style: Option,
+ pub edge_strokes: [StrokeValues; 4],
+}
+
+impl CellStyleValues {
+ /// Reads the cell-style value assignments and supports the older inset property names.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ let inset = |modern, legacy| values.num(modern).or_else(|| values.num(legacy)).map(|v| v.max(0.0));
+ Self {
+ self_id: values.self_id().map(str::to_owned),
+ name: values.name().map(str::to_owned),
+ based_on: values.prop("BasedOn"),
+ fill: values.prop("FillColor"),
+ fill_tint: values.num("FillTint"),
+ insets: [
+ inset("TextTopInset", "TopInset"),
+ inset("TextLeftInset", "LeftInset"),
+ inset("TextBottomInset", "BottomInset"),
+ inset("TextRightInset", "RightInset"),
+ ],
+ vertical_justification: values.get("VerticalJustification").map(str::to_owned),
+ first_baseline: values.get("FirstBaselineOffset").map(str::to_owned),
+ first_baseline_min: values.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0)),
+ paragraph_style: values.get("AppliedParagraphStyle").map(str::to_owned),
+ edge_strokes: ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|edge| values.stroke_values(edge)),
+ }
+ }
+}
+
+/// Parsed table-style assignments that are independent of DesignCraft's style resolution.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TableStyleValues {
+ pub name: Option,
+ pub based_on: Option,
+ pub header_cell_style: Option,
+ pub body_cell_style: Option,
+ pub footer_cell_style: Option,
+ pub left_column_cell_style: Option,
+ pub right_column_cell_style: Option,
+ pub header_same_as_body: Option,
+ pub footer_same_as_body: Option,
+ pub left_column_same_as_body: Option,
+ pub right_column_same_as_body: Option,
+ pub space_before: Option,
+ pub space_after: Option,
+ pub border_strokes: [StrokeValues; 4],
+ pub row_fills: AlternatingFillValues,
+ pub column_fills: AlternatingFillValues,
+ pub row_strokes: AlternatingStrokeValues,
+ pub column_strokes: AlternatingStrokeValues,
+}
+
+impl TableStyleValues {
+ /// Reads table-style regions, inheritance, spacing, and region reuse values.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ Self {
+ name: values.name().map(str::to_owned),
+ based_on: values.prop("BasedOn"),
+ header_cell_style: values.get("HeaderRegionCellStyle").map(str::to_owned),
+ body_cell_style: values.get("BodyRegionCellStyle").map(str::to_owned),
+ footer_cell_style: values.get("FooterRegionCellStyle").map(str::to_owned),
+ left_column_cell_style: values.get("LeftColumnRegionCellStyle").map(str::to_owned),
+ right_column_cell_style: values.get("RightColumnRegionCellStyle").map(str::to_owned),
+ header_same_as_body: values.boolean("HeaderRegionSameAsBodyRegion"),
+ footer_same_as_body: values.boolean("FooterRegionSameAsBodyRegion"),
+ left_column_same_as_body: values.boolean("LeftColumnRegionSameAsBodyRegion"),
+ right_column_same_as_body: values.boolean("RightColumnRegionSameAsBodyRegion"),
+ space_before: values.num("SpaceBefore"),
+ space_after: values.num("SpaceAfter"),
+ border_strokes: ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|border| values.stroke_values(border)),
+ row_fills: values.alternating_fill_values("Row"),
+ column_fills: values.alternating_fill_values("Column"),
+ row_strokes: values.alternating_stroke_values("Row"),
+ column_strokes: values.alternating_stroke_values("Column"),
+ }
+ }
+}
+
+impl NumberingListValues {
+ /// Reads a `NumberingList` element.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ Self {
+ name: values.get("Name").unwrap_or("").to_owned(),
+ continue_across_stories: values.boolean("ContinueNumbersAcrossStories") == Some(true),
+ }
+ }
+}
+
+impl EpubSplitValues {
+ /// Reads an `EPubExportPreference` element.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ Self { break_document: values.boolean("BreakDocument") == Some(true), paragraph_style: values.get("ParagraphStyleName").map(str::to_owned) }
+ }
+}
+
+impl TextVariableValues {
+ /// Reads a `TextVariable` and its preference record.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ let preference = el.elements().find(|child| child.name.ends_with("Preference"));
+ let attr = |key: &str| preference.and_then(|child| child.attr(key)).unwrap_or("");
+ let variable_type = values.get("VariableType").unwrap_or("");
+ let date = |kind| TextVariableKind::Date { kind, format: if attr("Format").is_empty() { "MM/dd/yy".into() } else { attr("Format").into() } };
+ let kind = match variable_type {
+ "CustomTextType" => TextVariableKind::CustomText { text: attr("Contents").into() },
+ "LastPageNumberType" => TextVariableKind::LastPageNumber { section: attr("Scope") == "SectionScope" },
+ "ChapterNumberType" => TextVariableKind::ChapterNumber,
+ "FileNameType" if attr("IncludePath") != "true" => TextVariableKind::FileName { extension: attr("IncludeExtension") == "true" },
+ "CreationDateType" => date(TextDateKind::Creation),
+ "ModificationDateType" => date(TextDateKind::Modification),
+ "OutputDateType" => date(TextDateKind::Output),
+ "MatchParagraphStyleType" | "MatchCharacterStyleType" => {
+ let character = variable_type == "MatchCharacterStyleType";
+ let style = attr(if character { "AppliedCharacterStyle" } else { "AppliedParagraphStyle" });
+ if style.is_empty() {
+ TextVariableKind::Imported {
+ variable_type: variable_type.into(),
+ settings_element: preference.map(|child| child.name.clone()).unwrap_or_default(),
+ settings: preference.map(|child| child.attrs.clone()).unwrap_or_default(),
+ }
+ } else {
+ TextVariableKind::RunningHeader { style: style.into(), character, last_on_page: attr("SearchStrategy") == "LastOnPage" }
+ }
+ }
+ _ if variable_type.starts_with("Xref") => TextVariableKind::CrossReference { page_number: variable_type == "XrefPageNumberType" },
+ _ => TextVariableKind::Imported {
+ variable_type: variable_type.into(),
+ settings_element: preference.map(|child| child.name.clone()).unwrap_or_default(),
+ settings: preference.map(|child| child.attrs.clone()).unwrap_or_default(),
+ },
+ };
+ Self {
+ self_id: values.self_id().map(str::to_owned),
+ name: values.get("Name").unwrap_or("").to_owned(),
+ before: values.get("TextBefore").unwrap_or("").to_owned(),
+ after: values.get("TextAfter").unwrap_or("").to_owned(),
+ kind,
+ }
+ }
+}
+
+impl TocStyleValues {
+ /// Reads a `TOCStyle` element and its bounded `TOCStyleEntry` children.
+ pub fn from_el(el: &El) -> Self {
+ let values = Values::of(el);
+ let mut result = Self {
+ self_id: values.self_id().map(str::to_owned),
+ title: values.get("Title").map(str::to_owned),
+ title_style: values.get("TitleStyle").map(str::to_owned),
+ create_bookmarks: values.get("CreateBookmarks").or_else(|| values.get("CreatePdfBookmarks")).map(|value| value == "true"),
+ entries: Vec::new(),
+ };
+ result.entries.extend(el.elements().filter(|entry| entry.local() == "TOCStyleEntry").take(64).map(|entry| {
+ let values = Values::of(entry);
+ TocStyleEntryValues {
+ style: values.get("Name").map(str::to_owned),
+ level: values.get("Level").and_then(|value| value.trim().parse().ok()),
+ page_number_position: values.get("PageNumberPosition").map(str::to_owned),
+ separator: values.get("Separator").map(str::to_owned),
+ entry_style: values.prop("FormatStyle"),
+ page_number_style: values.prop("PageNumberStyle"),
+ separator_style: values.prop("SeparatorStyle"),
+ sort: values.get("Sort").map(|value| value.trim() == "true"),
+ }
+ }));
+ result
+ }
+}
+
+/// The top-level IDML elements consumed by document importers.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum TopLevelKind {
+ DocumentPreference,
+ TransparencyPreference,
+ TextPreference,
+ MarginPreference,
+ ViewPreference,
+ GridPreference,
+ PasteboardPreference,
+ CompositeFont,
+ KinsokuTable,
+ MojikumiTable,
+ FootnoteOption,
+ EpubExportPreference,
+ Layer,
+ CrossReferenceFormat,
+ Index,
+ TextVariable,
+ Story,
+ Hyperlink,
+ HyperlinkUrlDestination,
+ HyperlinkPageDestination,
+ HyperlinkPageItemSource,
+ HyperlinkOtherDestination,
+ MasterSpread,
+ Spread,
+ Section,
+ TocStyle,
+ Condition,
+ NumberingList,
+ StyleGroup,
+ Other,
+}
+
+/// One included node in `designmap.xml`, classified by the shared IDML grammar.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TopLevelNode {
+ pub kind: TopLevelKind,
+ data: TopLevelData,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum TopLevelData {
+ Story(Story),
+ Spread(Spread),
+ Raw(El),
+}
+
+impl TopLevelNode {
+ fn from_el(el: El) -> TopLevelNode {
+ let kind = top_level_kind(el.local());
+ let data = match kind {
+ TopLevelKind::Story => TopLevelData::Story(Story::from_el(&el)),
+ TopLevelKind::Spread | TopLevelKind::MasterSpread => TopLevelData::Spread(Spread::from_el(&el)),
+ _ => TopLevelData::Raw(el),
+ };
+ TopLevelNode { kind, data }
+ }
+
+ /// The typed story or spread tree when this is one of those parts.
+ pub fn story(&self) -> Option<&Story> {
+ if let TopLevelData::Story(story) = &self.data { Some(story) } else { None }
+ }
+
+ pub fn spread(&self) -> Option<&Spread> {
+ if let TopLevelData::Spread(spread) = &self.data { Some(spread) } else { None }
+ }
+
+ /// The original element, reconstructed from the typed story/spread model where applicable.
+ pub fn to_el(&self) -> El {
+ match &self.data {
+ TopLevelData::Story(story) => story.to_el(),
+ TopLevelData::Spread(spread) => spread.to_el(),
+ TopLevelData::Raw(el) => el.clone(),
+ }
+ }
+
+ pub fn values(&self) -> Values {
+ Values::of(&self.to_el())
+ }
+}
+
+/// A neutral view of an IDML package's designmap and included top-level parts.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ModelDocument {
+ pub root: Values,
+ pub top: Vec,
+ pub warnings: Vec,
+ pub metadata: Option,
+}
+
+impl ModelDocument {
+ /// Converts the shared bounded package reader's parsed document into typed document nodes.
+ pub fn from_package(document: crate::Document) -> ModelDocument {
+ ModelDocument {
+ root: Values::of(&document.root),
+ top: document.top.into_iter().map(TopLevelNode::from_el).collect(),
+ warnings: document.warnings,
+ metadata: document.metadata,
+ }
+ }
+}
+
+fn top_level_kind(name: &str) -> TopLevelKind {
+ match name {
+ "DocumentPreference" => TopLevelKind::DocumentPreference,
+ "TransparencyPreference" => TopLevelKind::TransparencyPreference,
+ "TextPreference" => TopLevelKind::TextPreference,
+ "MarginPreference" => TopLevelKind::MarginPreference,
+ "ViewPreference" => TopLevelKind::ViewPreference,
+ "GridPreference" => TopLevelKind::GridPreference,
+ "PasteboardPreference" => TopLevelKind::PasteboardPreference,
+ "CompositeFont" => TopLevelKind::CompositeFont,
+ "KinsokuTable" => TopLevelKind::KinsokuTable,
+ "MojikumiTable" => TopLevelKind::MojikumiTable,
+ "FootnoteOption" => TopLevelKind::FootnoteOption,
+ "EPubExportPreference" => TopLevelKind::EpubExportPreference,
+ "Layer" => TopLevelKind::Layer,
+ "CrossReferenceFormat" => TopLevelKind::CrossReferenceFormat,
+ "Index" => TopLevelKind::Index,
+ "TextVariable" => TopLevelKind::TextVariable,
+ "Story" => TopLevelKind::Story,
+ "Hyperlink" => TopLevelKind::Hyperlink,
+ "HyperlinkURLDestination" => TopLevelKind::HyperlinkUrlDestination,
+ "HyperlinkPageDestination" => TopLevelKind::HyperlinkPageDestination,
+ "HyperlinkPageItemSource" => TopLevelKind::HyperlinkPageItemSource,
+ "MasterSpread" => TopLevelKind::MasterSpread,
+ "Spread" => TopLevelKind::Spread,
+ "Section" => TopLevelKind::Section,
+ "TOCStyle" => TopLevelKind::TocStyle,
+ "Condition" => TopLevelKind::Condition,
+ "NumberingList" => TopLevelKind::NumberingList,
+ name if name.starts_with("Root") && name.ends_with("StyleGroup") => TopLevelKind::StyleGroup,
+ name if name.starts_with("Hyperlink") && name.ends_with("Destination") => TopLevelKind::HyperlinkOtherDestination,
+ _ => TopLevelKind::Other,
+ }
+}
diff --git a/crates/idml/src/model/effects.rs b/crates/idml/src/model/effects.rs
new file mode 100644
index 000000000..8f5fa9530
--- /dev/null
+++ b/crates/idml/src/model/effects.rs
@@ -0,0 +1,404 @@
+//! The IDML transparency/effect settings found on page items and object styles.
+
+use craft_xml::{El, Local, Node};
+
+use super::Values;
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct DropShadowValues {
+ pub mode: Option,
+ pub x_offset: Option,
+ pub y_offset: Option,
+ pub color: Option,
+ pub opacity: Option,
+ pub size: Option,
+ pub spread: Option,
+ pub blend_mode: Option,
+}
+
+impl DropShadowValues {
+ pub fn to_el(&self) -> El {
+ let mut element = El::new("DropShadowSetting");
+ if let Some(value) = &self.mode {
+ element.set_attr("Mode", value);
+ }
+ for (key, value) in
+ [("XOffset", self.x_offset), ("YOffset", self.y_offset), ("Size", self.size), ("Spread", self.spread), ("Opacity", self.opacity)]
+ {
+ if let Some(value) = value {
+ element.set_attr(key, crate::value::num(value));
+ }
+ }
+ if let Some(value) = &self.blend_mode {
+ element.set_attr("BlendMode", value);
+ }
+ if let Some(value) = &self.color {
+ element.set_attr("EffectColor", value);
+ }
+ element
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct FeatherValues {
+ pub mode: Option,
+ pub width: Option,
+ pub choke_amount: Option,
+ pub corner_type: Option,
+}
+
+impl FeatherValues {
+ pub fn to_el(&self) -> El {
+ let mut element = El::new("FeatherSetting");
+ if let Some(value) = &self.mode {
+ element.set_attr("Mode", value);
+ }
+ for (key, value) in [("Width", self.width), ("ChokeAmount", self.choke_amount)] {
+ if let Some(value) = value {
+ element.set_attr(key, crate::value::num(value));
+ }
+ }
+ if let Some(value) = &self.corner_type {
+ element.set_attr("CornerType", value);
+ }
+ element
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct OpacityStopValues {
+ pub location: Option,
+ pub opacity: Option,
+}
+
+impl OpacityStopValues {
+ pub fn to_el(&self, self_id: &str, midpoint: bool) -> El {
+ let mut element = El::new("OpacityGradientStop").with_attr("Self", self_id);
+ if let Some(value) = self.opacity {
+ element.set_attr("Opacity", crate::value::num(value));
+ }
+ if let Some(value) = self.location {
+ element.set_attr("Location", crate::value::num(value));
+ }
+ if midpoint {
+ element.set_attr("Midpoint", "50");
+ }
+ element
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct GradientFeatherValues {
+ pub applied: bool,
+ pub radial: bool,
+ pub angle: Option,
+ pub length: Option,
+ pub start: Option<[f64; 2]>,
+ pub stops: Vec,
+}
+
+impl GradientFeatherValues {
+ pub fn to_el(&self, id_prefix: &str) -> El {
+ let mut element =
+ El::new("GradientFeatherSetting").with_attr("Applied", "true").with_attr("Type", if self.radial { "Radial" } else { "Linear" });
+ if let Some(value) = self.angle {
+ element.set_attr("Angle", crate::value::num(value));
+ }
+ if let Some(value) = self.length {
+ element.set_attr("Length", crate::value::num(value));
+ }
+ if let Some([x, y]) = self.start {
+ element.set_attr("GradientStart", format!("{} {}", crate::value::num(x), crate::value::num(y)));
+ }
+ for (index, stop) in self.stops.iter().enumerate() {
+ let stop_id = format!("{id_prefix}TransparencySetting1GradientFeatherSetting1OpacityGradientStop{index}");
+ element.push(stop.to_el(&stop_id, index > 0));
+ }
+ element
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct DirectionalFeatherValues {
+ pub applied: bool,
+ pub widths: [Option; 4],
+}
+
+impl DirectionalFeatherValues {
+ pub fn to_el(&self) -> El {
+ let mut element = El::new("DirectionalFeatherSetting").with_attr("Applied", "true");
+ for (key, value) in
+ [("LeftWidth", self.widths[1]), ("RightWidth", self.widths[3]), ("TopWidth", self.widths[0]), ("BottomWidth", self.widths[2])]
+ {
+ if let Some(value) = value {
+ element.set_attr(key, crate::value::num(value));
+ }
+ }
+ element
+ }
+}
+
+/// A typed view of an IDML `TransparencySetting` and its effect elements.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TransparencySetting {
+ pub blend_mode: Option,
+ pub opacity: Option,
+ pub drop_shadow: Option,
+ pub feather: Option,
+ pub gradient_feather: Option,
+ pub directional_feather: Option,
+}
+
+impl TransparencySetting {
+ pub fn to_el(&self, id_prefix: &str) -> El {
+ let mut element = El::new("TransparencySetting");
+ if self.blend_mode.is_some() || self.opacity.is_some() {
+ let mut blending = El::new("BlendingSetting");
+ if let Some(value) = &self.blend_mode {
+ blending.set_attr("BlendMode", value);
+ }
+ if let Some(value) = self.opacity {
+ blending.set_attr("Opacity", crate::value::num(value));
+ }
+ element.push(blending);
+ }
+ if let Some(effect) = &self.drop_shadow {
+ element.push(effect.to_el());
+ }
+ if let Some(effect) = &self.feather {
+ element.push(effect.to_el());
+ }
+ if let Some(effect) = &self.directional_feather {
+ element.push(effect.to_el());
+ }
+ if let Some(effect) = &self.gradient_feather {
+ element.push(effect.to_el(id_prefix));
+ }
+ element
+ }
+
+ pub fn from_el(el: &El) -> Self {
+ let child = |name: &str| el.child(Local(name));
+ let blending = child("BlendingSetting").map(Values::of);
+ let drop_shadow = child("DropShadowSetting").map(|el| {
+ let values = Values::of(el);
+ DropShadowValues {
+ mode: values.get("Mode").map(str::to_owned),
+ x_offset: values.num("XOffset"),
+ y_offset: values.num("YOffset"),
+ color: values.get("EffectColor").map(str::to_owned),
+ opacity: values.num("Opacity"),
+ size: values.num("Size"),
+ spread: values.num("Spread"),
+ blend_mode: values.get("BlendMode").map(str::to_owned),
+ }
+ });
+ let feather = child("FeatherSetting").map(|el| {
+ let values = Values::of(el);
+ FeatherValues {
+ mode: values.get("Mode").map(str::to_owned),
+ width: values.num("Width"),
+ choke_amount: values.num("ChokeAmount"),
+ corner_type: values.get("CornerType").map(str::to_owned),
+ }
+ });
+ let gradient_feather = child("GradientFeatherSetting").map(|el| {
+ let values = Values::of(el);
+ let start = values
+ .prop("GradientStart")
+ .and_then(|value| value.split_whitespace().map(|part| part.parse::().ok().filter(|n| n.is_finite())).collect::