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::>>()) + .and_then(|parts| <[f64; 2]>::try_from(parts).ok()); + GradientFeatherValues { + applied: values.boolean("Applied") == Some(true), + radial: values.get("Type") == Some("Radial"), + angle: values.num("Angle"), + length: values.num("Length"), + start, + stops: el + .elements() + .filter(|stop| stop.local() == "OpacityGradientStop") + .map(|stop| { + let values = Values::of(stop); + OpacityStopValues { location: values.num("Location"), opacity: values.num("Opacity") } + }) + .collect(), + } + }); + let directional_feather = child("DirectionalFeatherSetting").map(|el| { + let values = Values::of(el); + DirectionalFeatherValues { + applied: values.boolean("Applied") == Some(true), + widths: [values.num("TopWidth"), values.num("LeftWidth"), values.num("BottomWidth"), values.num("RightWidth")], + } + }); + Self { + blend_mode: blending.as_ref().and_then(|values| values.get("BlendMode").map(str::to_owned)), + opacity: blending.as_ref().and_then(|values| values.num("Opacity")), + drop_shadow, + feather, + gradient_feather, + directional_feather, + } + } +} + +/// Resolved object-style chain used to inherit item-level IDML settings. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ObjectStyleStack { + chain: Vec, +} + +impl ObjectStyleStack { + /// Resolves a style reference and its `BasedOn` chain (nearest first, cycle-safe, max 16). + pub fn from_styles(styles: &[El], reference: Option<&str>, include_none: bool) -> Self { + let find = |reference: &str| { + styles.iter().find(|style| Values::of(style).self_id() == Some(reference)).or_else(|| { + let normalized = if reference.starts_with("ObjectStyle/") { + reference.to_owned() + } else { + format!("ObjectStyle/{}", crate::value::escape_id(reference.trim())) + }; + styles.iter().find(|style| Values::of(style).self_id() == Some(normalized.as_str())) + }) + }; + let mut chain = Vec::new(); + let mut next = reference.map(str::to_owned); + while let Some(id) = next { + let Some(style) = find(&id) else { break }; + let self_id = Values::of(style).self_id().map(str::to_owned); + if chain.len() >= 16 || self_id.as_deref().is_some_and(|id| chain.iter().any(|entry: &El| Values::of(entry).self_id() == Some(id))) { + break; + } + chain.push(style.clone()); + next = Values::of(style).prop("BasedOn").map(|base| { + let base = base.trim(); + if base.starts_with("ObjectStyle/") { base.to_owned() } else { format!("ObjectStyle/{}", crate::value::escape_id(base)) } + }); + } + if include_none + && !chain.iter().any(|style| Values::of(style).self_id() == Some("ObjectStyle/$ID/[None]")) + && let Some(none) = find("ObjectStyle/$ID/[None]") + { + chain.push(none.clone()); + } + Self { chain } + } + + /// The resolved style elements, nearest first. + pub fn styles(&self) -> &[El] { + &self.chain + } + + /// Merges an item's child over the corresponding elements in the resolved style chain. + pub fn merged_child(&self, item: &El, name: &str) -> Option { + let category = match name { + "TransparencySetting" => Some("ObjectStyleObjectEffectsCategorySettings"), + "StrokeTransparencySetting" => Some("ObjectStyleStrokeEffectsCategorySettings"), + "FillTransparencySetting" => Some("ObjectStyleFillEffectsCategorySettings"), + "ContentTransparencySetting" => Some("ObjectStyleContentEffectsCategorySettings"), + _ => None, + }; + let enabled = |effect: &str| { + let (Some(category), Some(flag)) = (category, effect_flag(effect)) else { return true }; + self.chain.iter().find_map(|style| style.child(Local(category)).and_then(|c| Values::of(c).get(flag).map(str::to_owned))).as_deref() + != Some("false") + }; + let mut result = None; + for style in self.chain.iter().rev() { + let Some(child) = style.child(Local(name)) else { continue }; + let mut child = child.clone(); + child.children.retain(|node| !matches!(node, Node::El(element) if !enabled(element.local()))); + match &mut result { + Some(current) => overlay(current, &child, 0), + None => result = Some(child), + } + } + if let Some(child) = item.child(Local(name)) { + match &mut result { + Some(current) => overlay(current, child, 0), + None => result = Some(child.clone()), + } + } + result + } + + /// Returns the drop-shadow and feather records enabled by the style's effect category. + pub fn enabled_shadow_feather(&self) -> Option { + let category = self.chain.iter().find_map(|style| style.child(Local("ObjectStyleObjectEffectsCategorySettings"))); + let setting = |name: &str| self.chain.iter().find_map(|style| style.child(Local("TransparencySetting"))?.child(Local(name)).cloned()); + let mut result = El::new("TransparencySetting"); + let mut any = false; + for (name, flag) in [("DropShadowSetting", "EnableDropShadow"), ("FeatherSetting", "EnableFeather")] { + let element = setting(name); + let on = match category { + Some(category) => Values::of(category).get(flag) == Some("true"), + None => element.as_ref().and_then(|element| Values::of(element).get("Mode").map(str::to_owned)).is_some_and(|mode| mode != "None"), + }; + any |= on; + if let (true, Some(element)) = (on, element) { + result.push(element); + } + } + any.then_some(result) + } +} + +fn effect_flag(effect: &str) -> Option<&'static str> { + Some(match effect { + "BlendingSetting" => "EnableTransparency", + "DropShadowSetting" => "EnableDropShadow", + "FeatherSetting" => "EnableFeather", + "InnerShadowSetting" => "EnableInnerShadow", + "OuterGlowSetting" => "EnableOuterGlow", + "InnerGlowSetting" => "EnableInnerGlow", + "BevelAndEmbossSetting" => "EnableBevelEmboss", + "SatinSetting" => "EnableSatin", + "DirectionalFeatherSetting" => "EnableDirectionalFeather", + "GradientFeatherSetting" => "EnableGradientFeather", + _ => return None, + }) +} + +fn overlay(base: &mut El, top: &El, depth: usize) { + if depth > 32 { + *base = top.clone(); + return; + } + for (key, value) in &top.attrs { + base.set_attr(key, value); + if let Some(Node::El(properties)) = base.children.iter_mut().find(|node| matches!(node, Node::El(el) if el.local() == "Properties")) { + properties.children.retain(|node| !matches!(node, Node::El(el) if el.local() == key)); + } + } + if let Some(properties) = top.child(Local("Properties")) { + let names: Vec<&str> = properties.elements().map(El::local).collect(); + base.attrs.retain(|(key, _)| !names.contains(&key.as_str())); + } + let has_text = top.children.iter().any(|node| matches!(node, Node::Text(text) | Node::CData(text) if !text.trim().is_empty())); + if has_text { + base.children.retain(|node| !matches!(node, Node::Text(_) | Node::CData(_))); + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for node in &top.children { + match node { + Node::El(child) => { + let index = seen.entry(child.local()).or_insert(0); + let slot = base + .children + .iter_mut() + .filter_map(|node| match node { + Node::El(el) if el.local() == child.local() => Some(el), + _ => None, + }) + .nth(*index); + *index += 1; + match slot { + Some(slot) => overlay(slot, child, depth + 1), + None => base.children.push(node.clone()), + } + } + Node::Text(_) | Node::CData(_) if has_text => base.children.push(node.clone()), + Node::Text(_) | Node::CData(_) | Node::Comment(_) | Node::Pi { .. } => {} + } + } +} diff --git a/crates/idml/src/model/geometry.rs b/crates/idml/src/model/geometry.rs index 02b00ddaf..804774291 100644 --- a/crates/idml/src/model/geometry.rs +++ b/crates/idml/src/model/geometry.rs @@ -5,6 +5,19 @@ use craft_xml::{El, Local}; use crate::value::{nums, point_str}; +/// Why a bounded path geometry was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PathGeometryError { + #[error("too many points")] + TooManyPoints, + #[error("point without an anchor")] + MissingAnchor, + #[error("malformed point")] + MalformedPoint, + #[error("non-finite point")] + NonFinitePoint, +} + /// Most points one geometry keeps by default; points past it are dropped. pub const MAX_PATH_POINTS: usize = 1_000_000; @@ -84,6 +97,45 @@ impl PathGeometry { PathGeometry { paths } } + /// Parse a path geometry without silently discarding malformed points, keeping at most + /// `max` point elements. Missing direction handles default to the anchor. + pub fn try_from_el_capped(pg: &El, max: usize) -> Result { + let point = |value: Option<&str>, required: bool| -> Result, PathGeometryError> { + let Some(value) = value else { + return if required { Err(PathGeometryError::MissingAnchor) } else { Ok(None) }; + }; + let mut nums = value.split_whitespace(); + let Some(x) = nums.next().and_then(|n| n.parse::().ok()) else { return Err(PathGeometryError::MalformedPoint) }; + let Some(y) = nums.next().and_then(|n| n.parse::().ok()) else { return Err(PathGeometryError::MalformedPoint) }; + if nums.next().is_some() { + return Err(PathGeometryError::MalformedPoint); + } + if !(x.is_finite() && y.is_finite()) { + return Err(PathGeometryError::NonFinitePoint); + } + Ok(Some([x, y])) + }; + let mut count = 0usize; + let mut paths = Vec::new(); + for geometry in pg.children(Local("GeometryPathType")) { + let mut points = Vec::new(); + if let Some(array) = geometry.child(Local("PathPointArray")) { + for el in array.children(Local("PathPointType")) { + count = count.saturating_add(1); + if count > max { + return Err(PathGeometryError::TooManyPoints); + } + let anchor = point(el.attr("Anchor"), true)?.ok_or(PathGeometryError::MissingAnchor)?; + let left = point(el.attr("LeftDirection"), false)?.unwrap_or(anchor); + let right = point(el.attr("RightDirection"), false)?.unwrap_or(anchor); + points.push(PathPoint { anchor, left, right }); + } + } + paths.push(SubPath { open: geometry.attr("PathOpen") == Some("true"), points }); + } + Ok(PathGeometry { paths }) + } + /// The `PathGeometry` element. pub fn to_el(&self) -> El { let mut pg = El::new("PathGeometry"); diff --git a/crates/idml/src/model/mod.rs b/crates/idml/src/model/mod.rs index 049f10175..a4560b22e 100644 --- a/crates/idml/src/model/mod.rs +++ b/crates/idml/src/model/mod.rs @@ -12,20 +12,44 @@ //! Nodes are built from the elements [`crate::Package::document`] gives ([`Spread::from_el`], //! [`spreads`]) and written back with `to_el`, then [`crate::part_xml`]. +mod document; +mod effects; mod geometry; +mod preferences; mod resources; mod spread; mod story; -pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathPoint, SubPath}; -pub use resources::{ - ColorGroup, ColorSpace, GradientStop, StrokeStyle, StrokeStyleKind, Style, StyleGroup, StyleKind, StyleNode, Swatch, SwatchKind, color_groups, - named, preference, stroke_styles, style_groups, swatches, -}; -pub use spread::{Graphic, GraphicChild, GraphicKind, Guide, ItemChild, ItemKind, Link, Page, PageChild, PageItem, Spread, SpreadChild, spreads}; -pub use story::{Ace, CONTAINERS, Cell, Container, ContentPart, Range, Story, Table, TableChild, TextNode, stories}; - use craft_xml::{El, Local, Node}; +pub use document::{ + CellStyleValues, ConditionValues, CrossReferenceBuildingBlock, CrossReferenceFormatValues, EpubSplitValues, ModelDocument, NumberingListValues, + TableStyleValues, TextDateKind, TextVariableKind, TextVariableValues, TocStyleEntryValues, TocStyleValues, TopLevelKind, TopLevelNode, +}; +pub use effects::{ + DirectionalFeatherValues, DropShadowValues, FeatherValues, GradientFeatherValues, ObjectStyleStack, OpacityStopValues, TransparencySetting, +}; +pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathGeometryError, PathPoint, SubPath}; +pub use preferences::{DocumentPreferences, FootnotePreferences, GridPreferences, TextFramePreferences}; +pub use resources::{ + CharacterAppearanceValues, CharacterSpecialValues, CharacterTypographyValues, CjkCharacterValues, CjkParagraphValues, ColorDefinition, + ColorGroup, ColorGroupDefinition, ColorGroupMember, ColorSpace, CompositeFontDefinition, CompositeFontEntryDefinition, EpubExportTagMap, + FontDefinition, FontFamilyDefinition, FontsPart, GradientDefinition, GradientStop, GradientStopDefinition, GradientStopValue, GraphicPart, + GrepStyleValue, Ink, InkDefinition, MojikumiRow, NestedLineStyleValue, NestedStyleValue, NestedStyleValues, ObjectStyleValues, + OpenTypeFeatureValues, ParagraphAlignmentValues, ParagraphBoxValues, ParagraphFlowValues, ParagraphListValues, ParagraphSpanValues, RubyValues, + StrokeDefinition, StrokeStyle, StrokeStyleKind, Style, StyleGroup, StyleGroupWriter, StyleKind, StyleNode, Swatch, SwatchKind, TabStopValues, + TextDecorationValues, TintDefinition, color_groups, inks, named, preference, stroke_styles, style_groups, swatches, +}; +pub use spread::{ + BaselineFrameGridOption, BaselineFrameGridRelative, ClippingPathValues, FrameFittingOption, Graphic, GraphicChild, GraphicKind, + GraphicLayerOptions, Guide, ItemChild, ItemKind, Link, MarginPreference, Page, PageChild, PageItem, PdfAttribute, Spread, SpreadChild, TextPath, + TextWrapPreference, spreads, +}; +pub use story::{ + Ace, AnchoredObjectSetting, AnchoredObjectValues, CONTAINERS, Cell, CellOptions, Container, ContainerKind, ContentPart, CrossReferenceSource, + IndexCrossReferenceValues, IndexDocument, IndexReference, IndexTopicPath, IndexValues, PageReference, Range, SavedCrossReference, + SavedCrossReferencePart, Story, StoryAnchor, StoryPreference, Table, TableChild, TableColumn, TableLayout, TableRow, TextNode, TrackedChangeKind, + stories, +}; use crate::value::{matrix, nums}; @@ -43,6 +67,157 @@ pub struct Values { pub properties: Vec, } +/// Raw IDML stroke values before the caller maps swatches and stroke styles into its model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StrokeValues { + pub weight: Option, + pub color: Option, + pub tint: Option, + pub kind: Option, + pub alignment: Option, + pub cap: Option, + pub join: Option, + pub miter_limit: Option, + pub start: Option, + pub end: Option, + pub gap_color: Option, + pub gap_tint: Option, + pub overprint: Option, + pub gap_overprint: Option, +} + +impl StrokeValues { + /// Reads stroke properties from elements in precedence order (item first, then nearer styles). + pub fn from_elements(elements: &[&El]) -> StrokeValues { + let mut result = StrokeValues::default(); + for el in elements { + let values = Values::of(el); + let num_attr = |key: &str| values.get(key).and_then(|value| value.parse::().ok()).filter(|value| value.is_finite()); + let bool_attr = |key: &str| match values.get(key) { + Some("true") => Some(true), + Some("false") => Some(false), + _ => None, + }; + if result.weight.is_none() { + result.weight = num_attr("StrokeWeight"); + } + if result.color.is_none() { + result.color = values.get("StrokeColor").map(str::to_owned); + } + if result.tint.is_none() { + result.tint = num_attr("StrokeTint"); + } + if result.kind.is_none() { + result.kind = values.get("StrokeType").map(str::to_owned); + } + if result.alignment.is_none() { + result.alignment = values.get("StrokeAlignment").map(str::to_owned); + } + if result.cap.is_none() { + result.cap = values.get("EndCap").map(str::to_owned); + } + if result.join.is_none() { + result.join = values.get("EndJoin").map(str::to_owned); + } + if result.miter_limit.is_none() { + result.miter_limit = num_attr("MiterLimit"); + } + if result.start.is_none() { + result.start = values.get("LeftLineEnd").map(str::to_owned); + } + if result.end.is_none() { + result.end = values.get("RightLineEnd").map(str::to_owned); + } + if result.gap_color.is_none() { + result.gap_color = values.get("GapColor").map(str::to_owned); + } + if result.gap_tint.is_none() { + result.gap_tint = num_attr("GapTint"); + } + if result.overprint.is_none() { + result.overprint = bool_attr("OverprintStroke"); + } + if result.gap_overprint.is_none() { + result.gap_overprint = bool_attr("OverprintGap"); + } + } + result + } +} + +/// Raw fill properties before a caller resolves swatch names to its own colour model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FillValues { + pub color: Option, + pub tint: Option, + pub overprint: Option, +} + +impl FillValues { + /// Reads fill attributes in precedence order (item first, then nearer styles). + pub fn from_elements(elements: &[&El]) -> FillValues { + let mut result = FillValues::default(); + for el in elements { + let values = Values::of(el); + if result.color.is_none() { + result.color = values.get("FillColor").map(str::to_owned); + } + if result.tint.is_none() { + result.tint = values.get("FillTint").and_then(|value| value.parse::().ok()); + } + if result.overprint.is_none() { + result.overprint = match values.get("OverprintFill") { + Some("true") => Some(true), + Some("false") => Some(false), + _ => None, + }; + } + } + result + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AlternatingFillValues { + pub first_count: Option, + pub first_color: Option, + pub first_tint: Option, + pub next_count: Option, + pub next_color: Option, + pub next_tint: Option, + pub skip_first: Option, + pub skip_last: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AlternatingStrokeValues { + pub first_count: Option, + pub next_count: Option, + pub first: StrokeValues, + pub next: StrokeValues, + pub skip_first: Option, + pub skip_last: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableOptions { + pub direction: Option, + pub header_rows: Option, + pub footer_rows: Option, + pub applied_style: Option, + pub space_before: Option, + pub space_after: Option, + pub repeat_header: bool, + pub repeat_footer: bool, + pub borders: [StrokeValues; 4], +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CornerValues { + pub option: Option, + pub radius: Option, +} + impl Values { /// The attributes and `` of `el`. pub fn of(el: &El) -> Values { @@ -104,6 +279,13 @@ impl Values { self.prop(key).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) } + /// A two-number value (for properties such as `GradientFillStart`), bounded to three tokens. + pub fn pair(&self, key: &str) -> Option<[f64; 2]> { + let source = self.prop(key)?; + let values: Vec = source.split_whitespace().take(3).filter_map(|v| v.parse::().ok()).collect(); + <[f64; 2]>::try_from(values).ok().filter(|v| v.iter().all(|n| n.is_finite())) + } + /// `true` or `false`; anything else counts as absent. pub fn boolean(&self, key: &str) -> Option { match self.prop(key)?.trim() { @@ -154,6 +336,77 @@ impl Values { } el } + + /// Read an IDML stroke attribute family such as `TopEdgeStrokeWeight` or + /// `StartRowStrokeColor`. + pub fn stroke_values(&self, prefix: &str) -> StrokeValues { + StrokeValues { + weight: self.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)), + color: self.value(&format!("{prefix}StrokeColor")), + tint: self.num(&format!("{prefix}StrokeTint")), + kind: self.value(&format!("{prefix}StrokeType")), + gap_color: self.value(&format!("{prefix}StrokeGapColor")), + gap_tint: self.num(&format!("{prefix}StrokeGapTint")), + ..StrokeValues::default() + } + } + + pub fn alternating_fill_values(&self, kind: &str) -> AlternatingFillValues { + let num = |key: &str| self.num(key).map(|v| v.max(0.0)); + AlternatingFillValues { + first_count: num(&format!("Start{kind}FillCount")), + first_color: self.value(&format!("Start{kind}FillColor")), + first_tint: self.num(&format!("Start{kind}FillTint")), + next_count: num(&format!("End{kind}FillCount")), + next_color: self.value(&format!("End{kind}FillColor")), + next_tint: self.num(&format!("End{kind}FillTint")), + skip_first: num(&format!("SkipFirstAlternatingFill{kind}s")), + skip_last: num(&format!("SkipLastAlternatingFill{kind}s")), + } + } + + pub fn alternating_stroke_values(&self, kind: &str) -> AlternatingStrokeValues { + let num = |key: &str| self.num(key).map(|v| v.max(0.0)); + let mut next = self.stroke_values(&format!("End{kind}")); + // IDML writes this one table-column value as LineStyle rather than StrokeType. + if kind == "Column" { + next.kind = self.value("EndColumnLineStyle").or(next.kind); + } + AlternatingStrokeValues { + first_count: num(&format!("Start{kind}StrokeCount")), + next_count: num(&format!("End{kind}StrokeCount")), + first: self.stroke_values(&format!("Start{kind}")), + next, + skip_first: num(&format!("SkipFirstAlternatingStroke{kind}s")), + skip_last: num(&format!("SkipLastAlternatingStroke{kind}s")), + } + } + + pub fn table_options(&self) -> TableOptions { + TableOptions { + direction: self.value("TableDirection"), + header_rows: self.num("HeaderRowCount"), + footer_rows: self.num("FooterRowCount"), + applied_style: self.value("AppliedTableStyle"), + space_before: self.num("SpaceBefore"), + space_after: self.num("SpaceAfter"), + repeat_header: self.get("HeaderBehavior") != Some("RepeatOnce"), + repeat_footer: self.get("FooterBehavior") != Some("RepeatOnce"), + borders: [ + self.stroke_values("TopBorder"), + self.stroke_values("LeftBorder"), + self.stroke_values("BottomBorder"), + self.stroke_values("RightBorder"), + ], + } + } + + pub fn corner_values(&self, corner: &str) -> CornerValues { + CornerValues { + option: self.value(&format!("{corner}CornerOption")).or_else(|| self.value("CornerOption")), + radius: self.num(&format!("{corner}CornerRadius")).or_else(|| self.num("CornerRadius")), + } + } } /// `el` without its indentation: the whitespace-only text between the children of an element diff --git a/crates/idml/src/model/preferences.rs b/crates/idml/src/model/preferences.rs new file mode 100644 index 000000000..b32317cbd --- /dev/null +++ b/crates/idml/src/model/preferences.rs @@ -0,0 +1,420 @@ +//! Typed views of IDML's reusable text-frame preference grammar. + +use craft_xml::El; + +use super::Values; + +/// Parsed values from IDML `DocumentPreference` elements. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocumentPreferences { + pub overprint_black: Option, + pub page_width: Option, + pub page_height: Option, + pub facing_pages: Option, + pub page_binding: Option, + pub bleed: [Option; 4], + pub slug: [Option; 4], + pub intent: Option, + pub primary_text_frame: Option, + pub column_guides_locked: Option, +} + +impl DocumentPreferences { + /// Reads the document-level page, bleed, slug, and intent values. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + overprint_black: values.get("OverprintBlack").map(|value| value != "false"), + page_width: values.num("PageWidth"), + page_height: values.num("PageHeight"), + facing_pages: values.boolean("FacingPages"), + page_binding: values.prop("PageBinding"), + bleed: [ + values.num("DocumentBleedTopOffset"), + values.num("DocumentBleedBottomOffset"), + values.num("DocumentBleedInsideOrLeftOffset"), + values.num("DocumentBleedOutsideOrRightOffset"), + ], + slug: [ + values.num("SlugTopOffset"), + values.num("SlugBottomOffset"), + values.num("SlugInsideOrLeftOffset"), + values.num("SlugRightOrOutsideOffset"), + ], + intent: values.prop("Intent"), + primary_text_frame: values.boolean("CreatePrimaryTextFrame"), + column_guides_locked: values.boolean("ColumnGuideLocked"), + } + } + + /// Full canonical attributes for an exported IDML `DocumentPreference`. + pub fn to_export_attrs(&self) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + for (key, value) in [("PageHeight", self.page_height), ("PageWidth", self.page_width)] { + if let Some(value) = value { + attrs.push((key.into(), crate::value::num(value))); + } + } + attrs.push(("PagesPerDocument".into(), "1".into())); + if let Some(value) = self.facing_pages { + attrs.push(("FacingPages".into(), if value { "true" } else { "false" }.into())); + } + for (key, value) in [ + ("DocumentBleedTopOffset", self.bleed[0]), + ("DocumentBleedBottomOffset", self.bleed[1]), + ("DocumentBleedInsideOrLeftOffset", self.bleed[2]), + ("DocumentBleedOutsideOrRightOffset", self.bleed[3]), + ] { + if let Some(value) = value { + attrs.push((key.into(), crate::value::num(value))); + } + } + if self.bleed.iter().all(Option::is_some) { + attrs.push(( + "DocumentBleedUniformSize".into(), + bool_string(self.bleed.iter().flatten().all(|value| *value == self.bleed[0].unwrap_or_default())), + )); + } + for (key, value) in [ + ("SlugTopOffset", self.slug[0]), + ("SlugBottomOffset", self.slug[1]), + ("SlugInsideOrLeftOffset", self.slug[2]), + ("SlugRightOrOutsideOffset", self.slug[3]), + ] { + if let Some(value) = value { + attrs.push((key.into(), crate::value::num(value))); + } + } + if self.slug.iter().all(Option::is_some) { + attrs.push(( + "DocumentSlugUniformSize".into(), + bool_string(self.slug.iter().flatten().all(|value| *value == self.slug[0].unwrap_or_default())), + )); + } + attrs.push(("PreserveLayoutWhenShuffling".into(), "true".into())); + attrs.push(("AllowPageShuffle".into(), "true".into())); + if let Some(value) = self.overprint_black { + attrs.push(("OverprintBlack".into(), bool_string(value))); + } + if let Some(value) = &self.page_binding { + attrs.push(("PageBinding".into(), value.clone())); + } + attrs.push(("ColumnDirection".into(), "Horizontal".into())); + if let Some(value) = &self.intent { + attrs.push(("Intent".into(), value.clone())); + } + if let Some(value) = self.primary_text_frame { + attrs.push(("CreatePrimaryTextFrame".into(), bool_string(value))); + } + if let Some(value) = self.column_guides_locked { + attrs.push(("ColumnGuideLocked".into(), bool_string(value))); + } + if let Some(value) = self.primary_text_frame { + attrs.push(("MasterTextFrame".into(), bool_string(value))); + } + attrs.push(("SnippetImportUsesOriginalLocation".into(), "false".into())); + attrs + } +} + +/// Parsed document and baseline-grid preference values. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GridPreferences { + pub baseline_start: Option, + pub baseline_division: Option, + pub baseline_view_threshold: Option, + pub baseline_relative_option: Option, + pub horizontal_division: Option, + pub vertical_division: Option, + pub horizontal_subdivision: Option, + pub vertical_subdivision: Option, + pub grids_in_back: Option, +} + +/// Parsed values from IDML `FootnoteOption` elements. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FootnotePreferences { + pub start_at: Option, + pub prefix: Option, + pub suffix: Option, + pub paragraph_style: Option, + pub marker_style: Option, + pub separator: Option, + pub space_between: Option, + pub space_before: Option, + pub first_baseline: Option, + pub first_baseline_minimum: Option, + pub span_columns: Option, + pub rule_on: Option, + pub rule_weight: Option, + pub rule_tint: Option, + pub rule_left_indent: Option, + pub rule_width: Option, + pub rule_offset: Option, + pub rule_color: Option, + pub numbering_style: Option, + pub restart_numbering: Option, + pub show_prefix_suffix: Option, + pub marker_positioning: Option, +} + +impl FootnotePreferences { + /// Reads footnote preference attributes and properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + start_at: values.num("StartAt"), + prefix: values.get("Prefix").map(str::to_owned), + suffix: values.get("Suffix").map(str::to_owned), + paragraph_style: values.get("FootnoteTextStyle").map(str::to_owned), + marker_style: values.get("FootnoteMarkerStyle").map(str::to_owned), + separator: values.get("SeparatorText").map(str::to_owned), + space_between: values.num("SpaceBetween"), + space_before: values.num("Spacer"), + first_baseline: values.prop("FootnoteFirstBaselineOffset"), + first_baseline_minimum: values.num("FootnoteMinimumFirstBaselineOffset"), + span_columns: values.boolean("EnableStraddling"), + rule_on: values.boolean("RuleOn"), + rule_weight: values.num("RuleLineWeight"), + rule_tint: values.num("RuleTint"), + rule_left_indent: values.num("RuleLeftIndent"), + rule_width: values.num("RuleWidth"), + rule_offset: values.num("RuleOffset"), + rule_color: values.prop("RuleColor"), + numbering_style: values.prop("FootnoteNumberingStyle"), + restart_numbering: values.prop("RestartNumbering"), + show_prefix_suffix: values.prop("ShowPrefixSuffix"), + marker_positioning: values.prop("MarkerPositioning"), + } + } + + /// Writes one canonical IDML `FootnoteOption` using caller-resolved style, colour, and enum + /// references. + pub fn to_el(&self) -> El { + let mut el = El::new("FootnoteOption") + .with_attr("StartAt", crate::value::num(self.start_at.unwrap_or(1.0))) + .with_attr("Prefix", self.prefix.as_deref().unwrap_or("")) + .with_attr("Suffix", self.suffix.as_deref().unwrap_or("")) + .with_attr("FootnoteTextStyle", self.paragraph_style.as_deref().unwrap_or("$ID/NormalParagraphStyle")) + .with_attr("FootnoteMarkerStyle", self.marker_style.as_deref().unwrap_or("$ID/[None]")) + .with_attr("SeparatorText", self.separator.as_deref().unwrap_or("")) + .with_attr("SpaceBetween", crate::value::num(self.space_between.unwrap_or(0.0))) + .with_attr("Spacer", crate::value::num(self.space_before.unwrap_or(0.0))) + .with_attr("FootnoteFirstBaselineOffset", self.first_baseline.as_deref().unwrap_or("Ascent")) + .with_attr("FootnoteMinimumFirstBaselineOffset", crate::value::num(self.first_baseline_minimum.unwrap_or(0.0))) + .with_attr("EnableStraddling", bool_text(self.span_columns.unwrap_or(false))) + .with_attr("RuleOn", bool_text(self.rule_on.unwrap_or(true))) + .with_attr("RuleLineWeight", crate::value::num(self.rule_weight.unwrap_or(1.0))) + .with_attr("RuleTint", crate::value::num(self.rule_tint.unwrap_or(1.0))) + .with_attr("RuleLeftIndent", crate::value::num(self.rule_left_indent.unwrap_or(0.0))) + .with_attr("RuleWidth", crate::value::num(self.rule_width.unwrap_or(72.0))) + .with_attr("RuleOffset", crate::value::num(self.rule_offset.unwrap_or(0.0))); + let mut properties = El::new("Properties"); + for (name, value) in [ + ("FootnoteNumberingStyle", self.numbering_style.as_deref().unwrap_or("Arabic")), + ("RestartNumbering", self.restart_numbering.as_deref().unwrap_or("DontRestart")), + ("ShowPrefixSuffix", self.show_prefix_suffix.as_deref().unwrap_or("NoPrefixSuffix")), + ("MarkerPositioning", self.marker_positioning.as_deref().unwrap_or("Superscript")), + ] { + properties.push(El::new(name).with_attr("type", "enumeration").with_text(value)); + } + properties.push(El::new("RuleColor").with_attr("type", "object").with_text(self.rule_color.as_deref().unwrap_or("Swatch/Black"))); + el.push(properties); + el + } +} + +impl GridPreferences { + /// Reads baseline and document-grid values. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + baseline_start: values.num("BaselineStart"), + baseline_division: values.num("BaselineDivision"), + baseline_view_threshold: values.num("BaselineViewThreshold"), + baseline_relative_option: values.prop("BaselineGridRelativeOption"), + horizontal_division: values.num("HorizontalGridlineDivision"), + vertical_division: values.num("VerticalGridlineDivision"), + horizontal_subdivision: values.num("HorizontalGridSubdivision"), + vertical_subdivision: values.num("VerticalGridSubdivision"), + grids_in_back: values.boolean("GridsInBack"), + } + } + + /// IDML attributes represented by this grid preference record. + pub fn to_attrs(&self) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + for (key, value) in [ + ("HorizontalGridlineDivision", self.horizontal_division), + ("VerticalGridlineDivision", self.vertical_division), + ("HorizontalGridSubdivision", self.horizontal_subdivision), + ("VerticalGridSubdivision", self.vertical_subdivision), + ] { + if let Some(value) = value { + attrs.push((key.into(), crate::value::num(value))); + } + } + if let Some(value) = self.grids_in_back { + attrs.push(("GridsInBack".into(), if value { "true" } else { "false" }.into())); + } + for (key, value) in [ + ("BaselineStart", self.baseline_start), + ("BaselineDivision", self.baseline_division), + ("BaselineViewThreshold", self.baseline_view_threshold), + ] { + if let Some(value) = value { + attrs.push((key.into(), crate::value::num(value))); + } + } + if let Some(value) = self.baseline_relative_option.as_deref() { + attrs.push(("BaselineGridRelativeOption".into(), value.into())); + } + attrs + } +} + +fn bool_string(value: bool) -> String { + if value { "true" } else { "false" }.into() +} + +/// Parsed `TextFramePreference` values in nearest-to-farthest precedence order. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextFramePreferences { + pub column_count: Option, + pub column_gutter: Option, + pub fixed_column_width: Option, + pub fixed_width: bool, + pub flexible_width: bool, + pub balance_columns: bool, + pub inset: Option<[f64; 4]>, + pub vertical_justification: Option, + pub vertical_threshold: Option, + pub first_baseline: Option, + pub minimum_first_baseline: Option, + pub ignore_wrap: bool, + pub auto_sizing_type: Option, + pub auto_sizing_reference: Option, + pub use_minimum_height: bool, + pub minimum_height: Option, + pub use_minimum_width: bool, + pub minimum_width: Option, + pub no_line_breaks: bool, + pub column_rule_override: bool, + pub column_rule_width: Option, + pub column_rule_tint: Option, + pub column_rule_offset: Option, + pub column_rule_top_inset: Option, + pub column_rule_bottom_inset: Option, +} + +impl TextFramePreferences { + /// Reads preference levels nearest first. A malformed explicit value still masks an inherited + /// one, matching IDML's item-over-object-style precedence. + pub fn from_elements(elements: &[&El]) -> Self { + let prop = |key: &str| elements.iter().find_map(|el| Values::of(el).prop(key)); + let num = |key: &str| prop(key).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let boolean = |key: &str| matches!(prop(key).as_deref().map(str::trim), Some("true")); + Self { + column_count: num("TextColumnCount"), + column_gutter: num("TextColumnGutter"), + fixed_column_width: num("TextColumnFixedWidth"), + fixed_width: boolean("UseFixedColumnWidth"), + flexible_width: boolean("UseFlexibleColumnWidth"), + balance_columns: boolean("VerticalBalanceColumns"), + inset: inset(elements), + vertical_justification: prop("VerticalJustification"), + vertical_threshold: num("VerticalThreshold"), + first_baseline: prop("FirstBaselineOffset"), + minimum_first_baseline: num("MinimumFirstBaselineOffset"), + ignore_wrap: boolean("IgnoreWrap"), + auto_sizing_type: prop("AutoSizingType"), + auto_sizing_reference: prop("AutoSizingReferencePoint"), + use_minimum_height: boolean("UseMinimumHeightForAutoSizing"), + minimum_height: num("MinimumHeightForAutoSizing"), + use_minimum_width: boolean("UseMinimumWidthForAutoSizing"), + minimum_width: num("MinimumWidthForAutoSizing"), + no_line_breaks: boolean("UseNoLineBreaksForAutoSizing"), + column_rule_override: boolean("ColumnRuleOverride"), + column_rule_width: num("ColumnRuleStrokeWidth"), + column_rule_tint: num("ColumnRuleStrokeTint"), + column_rule_offset: num("ColumnRuleOffset"), + column_rule_top_inset: num("ColumnRuleTopInset"), + column_rule_bottom_inset: num("ColumnRuleBottomInset"), + } + } + + /// Writes one IDML `TextFramePreference`, including its inset list and column-rule + /// properties. Enumeration strings are supplied by the caller because they are model policy. + pub fn to_el(&self, column_rule_color: &str) -> El { + use crate::value::num; + + let mut inset = El::new("InsetSpacing").with_attr("type", "list"); + for value in self.inset.unwrap_or([0.0; 4]) { + inset.push(El::new("ListItem").with_attr("type", "unit").with_text(num(value))); + } + let el = El::new("TextFramePreference") + .with_attr("TextColumnCount", num(self.column_count.unwrap_or(1.0).max(1.0))) + .with_attr("TextColumnGutter", num(self.column_gutter.unwrap_or(0.0))) + .with_attr("UseFixedColumnWidth", bool_text(self.fixed_width)) + .with_attr("UseFlexibleColumnWidth", bool_text(self.flexible_width)) + .with_attr("VerticalBalanceColumns", bool_text(self.balance_columns)) + .with_attr("FirstBaselineOffset", self.first_baseline.as_deref().unwrap_or("Ascent")) + .with_attr("MinimumFirstBaselineOffset", num(self.minimum_first_baseline.unwrap_or(0.0))) + .with_attr("VerticalJustification", self.vertical_justification.as_deref().unwrap_or("TopAlign")) + .with_attr("VerticalThreshold", num(self.vertical_threshold.unwrap_or(0.0))) + .with_attr("IgnoreWrap", bool_text(self.ignore_wrap)) + .with_attr("AutoSizingType", self.auto_sizing_type.as_deref().unwrap_or("Off")) + .with_attr("AutoSizingReferencePoint", self.auto_sizing_reference.as_deref().unwrap_or("CenterPoint")) + .with_attr("UseMinimumHeightForAutoSizing", bool_text(self.use_minimum_height)) + .with_attr("MinimumHeightForAutoSizing", num(self.minimum_height.unwrap_or(0.0))) + .with_attr("UseMinimumWidthForAutoSizing", bool_text(self.use_minimum_width)) + .with_attr("MinimumWidthForAutoSizing", num(self.minimum_width.unwrap_or(0.0))) + .with_attr("UseNoLineBreaksForAutoSizing", bool_text(self.no_line_breaks)); + let el = if let Some(width) = self.fixed_column_width.filter(|width| *width > 0.0) { + el.with_attr("TextColumnFixedWidth", num(width)) + } else { + el + }; + let el = el + .with_attr("ColumnRuleOverride", bool_text(self.column_rule_override)) + .with_attr("ColumnRuleStrokeWidth", num(self.column_rule_width.unwrap_or(0.0))) + .with_attr("ColumnRuleStrokeColor", column_rule_color) + .with_attr("ColumnRuleStrokeTint", num(self.column_rule_tint.unwrap_or(0.0))) + .with_attr("ColumnRuleOffset", num(self.column_rule_offset.unwrap_or(0.0))) + .with_attr("ColumnRuleTopInset", num(self.column_rule_top_inset.unwrap_or(0.0))) + .with_attr("ColumnRuleBottomInset", num(self.column_rule_bottom_inset.unwrap_or(0.0))) + .with_attr("ColumnRuleInsetChainOverride", bool_text(self.column_rule_top_inset == self.column_rule_bottom_inset)); + el.with_child(El::new("Properties").with_child(inset)) + } +} + +fn bool_text(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn inset(elements: &[&El]) -> Option<[f64; 4]> { + let element = elements.iter().find(|el| Values::of(el).prop("InsetSpacing").is_some())?; + let values = Values::of(element); + let number = |s: &str| s.trim().parse::().ok().filter(|v| v.is_finite()); + let Some(property) = values.prop_el("InsetSpacing") else { + return values.get("InsetSpacing").and_then(number).map(|v| [v; 4]); + }; + let items: Vec<_> = property.elements().collect(); + if property.attr("type") == Some("list") || items.iter().any(|item| item.local() == "ListItem") { + if items.len() != 4 { + return None; + } + let mut result = [0.0; 4]; + for (slot, item) in result.iter_mut().zip(items) { + if item.local() != "ListItem" || item.elements().next().is_some() { + return None; + } + *slot = number(&item.deep_text())?; + } + Some(result) + } else if items.is_empty() { + number(&property.deep_text()).map(|v| [v; 4]) + } else { + None + } +} diff --git a/crates/idml/src/model/resources.rs b/crates/idml/src/model/resources.rs index 6a66f6f91..11a76b186 100644 --- a/crates/idml/src/model/resources.rs +++ b/crates/idml/src/model/resources.rs @@ -11,6 +11,1193 @@ fn others(el: &El) -> impl Iterator { el.elements().filter(|e| !e.is(Local("Properties"))) } +/// Raw bullet and numbering properties on an IDML paragraph style or range. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphListValues { + pub applied_list: Option, + pub list_type: Option, + pub numbering_format: Option, + pub expression: Option, + pub separator: Option, + pub bullet_type: Option, + pub bullet_value: Option, + pub balance_ragged: Option, + pub level: Option, + pub bullet_character_style: Option, + pub numbering_character_style: Option, + pub bullet_alignment: Option, + pub numbering_alignment: Option, + pub restart_numbers: Option, + pub restart_after_level: Option, + pub restart_after_specific_level: bool, + pub numbering_continue: Option, + pub numbering_start_at: Option, + pub bullet_font: Option, + pub bullet_font_style: Option, +} + +/// Paragraph border and shading values after the caller resolves model colours and enums. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphBoxValues { + pub shading_on: Option, + pub shading_color: Option, + pub shading_tint: Option, + pub shading_offsets: Option<[f64; 4]>, + pub shading_overprint: Option, + pub border_on: Option, + pub border_color: Option, + pub border_tint: Option, + pub border_weights: Option<[f64; 4]>, + pub border_offsets: Option<[f64; 4]>, + pub border_type: Option, + pub border_cap: Option, + pub border_join: Option, + pub border_corners: Option<[(String, f64); 4]>, + pub border_width: Option, + pub border_top: Option, + pub border_bottom: Option, + pub border_display_if_splits: Option, + pub border_merge: Option, + pub border_gap_color: Option, + pub border_gap_tint: Option, + pub border_overprint: Option, + pub border_gap_overprint: Option, + pub shading_width: Option, + pub shading_top: Option, + pub shading_bottom: Option, + pub shading_clip: Option, + pub shading_nonprinting: Option, + pub shading_corners: Option<[(String, f64); 4]>, +} + +/// CJK paragraph composition settings read from an IDML style or text range. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CjkParagraphValues { + pub kinsoku_hang: Option, + pub mojikumi: Option, + pub kinsoku_type: Option, + pub bunri_kinshi: Option, + pub rensuuji: Option, + pub treat_ideographic_space_as_space: Option, + pub kinsoku_set: Option, + pub auto_tcy: Option, + pub auto_tcy_include_roman: Option, + pub rotate_single_byte_characters: Option, + pub allow_arbitrary_hyphenation: Option, + pub grid_gyoudori: Option, + pub paragraph_gyoudori: Option, +} + +impl CjkParagraphValues { + /// Reads CJK paragraph composition properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let bounded_count = |key: &str| values.num(key).map(|value| value.clamp(0.0, 1000.0)); + Self { + kinsoku_hang: values.prop("KinsokuHangType"), + mojikumi: values.prop("Mojikumi"), + kinsoku_type: values.prop("KinsokuType"), + bunri_kinshi: values.boolean("BunriKinshi"), + rensuuji: values.boolean("Rensuuji"), + treat_ideographic_space_as_space: values.boolean("TreatIdeographicSpaceAsSpace"), + kinsoku_set: values.prop("KinsokuSet"), + auto_tcy: bounded_count("AutoTcy"), + auto_tcy_include_roman: values.boolean("AutoTcyIncludeRoman"), + rotate_single_byte_characters: values.boolean("RotateSingleByteCharacters"), + allow_arbitrary_hyphenation: values.boolean("AllowArbitraryHyphenation"), + grid_gyoudori: bounded_count("GridGyoudori"), + paragraph_gyoudori: values.boolean("ParagraphGyoudori"), + } + } + + /// Encodes a semantic kinsoku-set name as its IDML property value. + pub fn kinsoku_reference(name: &str) -> String { + if name.is_empty() { "Nothing".into() } else { format!("KinsokuTable/{}", crate::value::escape_id(name)) } + } + + /// Writes CJK paragraph attributes and properties. + pub fn to_parts(&self) -> (Vec<(String, String)>, Vec) { + let mut attrs = Vec::new(); + let mut properties = Vec::new(); + for (key, value) in [("KinsokuHangType", &self.kinsoku_hang), ("KinsokuType", &self.kinsoku_type)] { + if let Some(value) = value { + attrs.push((key.into(), value.clone())); + } + } + if let Some(value) = &self.mojikumi { + let kind = if value.starts_with("MojikumiTable/") { "object" } else { "enumeration" }; + properties.push(property("Mojikumi", kind, value.clone())); + } + for (key, value) in [ + ("BunriKinshi", self.bunri_kinshi), + ("Rensuuji", self.rensuuji), + ("TreatIdeographicSpaceAsSpace", self.treat_ideographic_space_as_space), + ("AutoTcyIncludeRoman", self.auto_tcy_include_roman), + ("RotateSingleByteCharacters", self.rotate_single_byte_characters), + ("AllowArbitraryHyphenation", self.allow_arbitrary_hyphenation), + ("ParagraphGyoudori", self.paragraph_gyoudori), + ] { + push_boolean(&mut attrs, key, value); + } + push_number(&mut attrs, "AutoTcy", self.auto_tcy); + push_number(&mut attrs, "GridGyoudori", self.grid_gyoudori); + if let Some(value) = &self.kinsoku_set { + properties.push(property("KinsokuSet", if value == "Nothing" { "enumeration" } else { "object" }, value.clone())); + } + (attrs, properties) + } +} + +/// Paragraph span and split-column properties read from IDML. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphSpanValues { + pub kind: Option, + pub split_column_count: Option, + pub inside_gutter: Option, + pub outside_gutter: Option, + pub space_before: Option, + pub space_after: Option, +} + +impl ParagraphSpanValues { + /// Reads paragraph span and split-column properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + kind: values.prop("SpanColumnType"), + split_column_count: values.prop("SpanSplitColumnCount").and_then(|value| value.trim().parse().ok()), + inside_gutter: values.num("SplitColumnInsideGutter"), + outside_gutter: values.num("SplitColumnOutsideGutter"), + space_before: values.num("SpanColumnMinSpaceBefore"), + space_after: values.num("SpanColumnMinSpaceAfter"), + } + } + + /// Writes paragraph span and split-column attributes and properties. + pub fn to_parts(&self) -> (Vec<(String, String)>, Vec) { + let mut attrs = Vec::new(); + let mut properties = Vec::new(); + if let Some(kind) = &self.kind { + attrs.push(("SpanColumnType".into(), kind.clone())); + } + if let Some(count) = self.split_column_count { + properties.push(if count == 0 { + property("SpanSplitColumnCount", "enumeration", "All") + } else { + property("SpanSplitColumnCount", "long", count.to_string()) + }); + } + for (key, value) in [ + ("SplitColumnInsideGutter", self.inside_gutter), + ("SplitColumnOutsideGutter", self.outside_gutter), + ("SpanColumnMinSpaceBefore", self.space_before), + ("SpanColumnMinSpaceAfter", self.space_after), + ] { + push_number(&mut attrs, key, value); + } + (attrs, properties) + } +} + +/// Paragraph alignment, spacing, drop cap, baseline grid and composer values after the caller +/// maps application semantics to IDML enumerations. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphAlignmentValues { + pub justification: Option, + pub direction: Option, + pub kashidas: Option, + pub paragraph_justification: Option, + pub paragraph_kashida_width: Option, + pub left_indent: Option, + pub right_indent: Option, + pub first_line_indent: Option, + pub last_line_indent: Option, + pub space_before: Option, + pub space_after: Option, + pub same_para_style_spacing: Option>, + pub drop_cap_lines: Option, + pub drop_cap_chars: Option, + pub dropcap_detail: Option, + pub grid_alignment: Option, + pub grid_align_first_line_only: Option, + pub composer: Option, +} + +/// Paragraph hyphenation, justification spacing and keep options after caller enum mapping. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphFlowValues { + pub hyphenate: Option, + pub hyphenate_min_word: Option, + pub hyphenate_after_first: Option, + pub hyphenate_before_last: Option, + pub hyphenate_ladder_limit: Option, + pub hyphenation_zone: Option, + pub hyphenate_capitalized: Option, + pub hyphenate_last_word: Option, + pub hyphenate_across_columns: Option, + pub hyphen_weight: Option, + pub word_spacing: [Option; 3], + pub letter_spacing: [Option; 3], + pub glyph_scaling: [Option; 3], + pub auto_leading: Option, + pub single_word_justification: Option, + pub keep_with_next: Option, + pub keep_lines_together: Option, + pub ignore_edge_alignment: Option, + pub keep_all_lines_together: Option, + pub keep_first_lines: Option, + pub keep_last_lines: Option, + pub start_paragraph: Option, +} + +/// Raw IDML character typography values before the caller resolves fonts and semantic enums. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CharacterTypographyValues { + pub applied_font: Option, + pub font_style: Option, + pub point_size: Option, + pub leading: Option, + pub kerning_method: Option, + pub kerning_value: Option, + pub tracking: Option, + pub leading_aki: Option, + pub trailing_aki: Option, + pub tsume: Option, + pub jidori: Option, + pub leading_model: Option, + pub character_alignment: Option, + pub horizontal_scale: Option, + pub vertical_scale: Option, + pub baseline_shift: Option, + pub skew: Option, +} + +/// One underline or strikethrough style as written in IDML. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextDecorationValues { + pub weight: Option, + pub offset: Option, + pub color: Option, + pub tint: Option, +} + +/// Character fill/stroke, decoration and toggle values before caller swatch resolution. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CharacterAppearanceValues { + pub fill_color: Option, + pub fill_tint: Option, + pub stroke_color: Option, + pub stroke_tint: Option, + pub stroke_weight: Option, + pub stroke_alignment: Option, + pub underline: Option, + pub strikethrough: Option, + pub ligatures: Option, + pub underline_options: TextDecorationValues, + pub strikethrough_options: TextDecorationValues, +} + +/// Raw Ruby attributes and properties on an IDML character range or style. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RubyValues { + pub flag: Option, + pub text: Option, + pub kind: Option, + pub alignment: Option, + pub position: Option, + pub x_offset: Option, + pub y_offset: Option, + pub font: Option, + pub font_style: Option, + pub font_size: Option, + pub x_scale: Option, + pub y_scale: Option, + pub open_type_pro: Option, + pub auto_tcy_digits: Option, + pub auto_tcy_include_roman: Option, + pub auto_tcy_auto_scale: Option, + pub overhang: Option, + pub parent_overhang: Option, + pub parent_spacing: Option, + pub auto_align: Option, + pub auto_scaling: Option, + pub parent_scaling: Option, + pub fill: Option, + pub stroke: Option, + pub fill_tint: Option, + pub stroke_tint: Option, + pub stroke_weight: Option, + pub overprint_fill: Option, + pub overprint_stroke: Option, +} + +/// Raw remaining character attributes and properties before semantic enum and condition mapping. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CharacterSpecialValues { + pub capitalization: Option, + pub position: Option, + pub glyph_form: Option, + pub no_break: Option, + pub tate_chu_yoko: Option, + pub tate_chu_yoko_x_offset: Option, + pub tate_chu_yoko_y_offset: Option, + pub warichu: Option, + pub warichu_lines: Option, + pub warichu_size: Option, + pub warichu_line_spacing: Option, + pub warichu_alignment: Option, + pub warichu_chars_before_break: Option, + pub warichu_chars_after_break: Option, + pub digits: Option, + pub character_direction: Option, + pub allow_kashidas: Option, + pub diacritic_position: Option, + pub diacritic_x_offset: Option, + pub diacritic_y_offset: Option, + pub positional_form: Option, + pub applied_conditions: Option, + pub applied_language: Option, +} + +impl CharacterSpecialValues { + /// Reads remaining character ranges/style fields from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + capitalization: values.prop("Capitalization"), + position: values.prop("Position"), + glyph_form: values.prop("GlyphForm"), + no_break: values.boolean("NoBreak"), + tate_chu_yoko: values.boolean("Tatechuyoko"), + tate_chu_yoko_x_offset: values.num("TatechuyokoXOffset"), + tate_chu_yoko_y_offset: values.num("TatechuyokoYOffset"), + warichu: values.boolean("Warichu"), + warichu_lines: values.num("WarichuLines"), + warichu_size: values.num("WarichuSize"), + warichu_line_spacing: values.num("WarichuLineSpacing"), + warichu_alignment: values.prop("WarichuAlignment"), + warichu_chars_before_break: values.num("WarichuCharsBeforeBreak"), + warichu_chars_after_break: values.num("WarichuCharsAfterBreak"), + digits: values.prop("DigitsType"), + character_direction: values.prop("CharacterDirection"), + allow_kashidas: values.prop("Kashidas"), + diacritic_position: values.prop("DiacriticPosition"), + diacritic_x_offset: values.num("XOffsetDiacritic"), + diacritic_y_offset: values.num("YOffsetDiacritic"), + positional_form: values.prop("PositionalForm"), + applied_conditions: values.prop("AppliedConditions"), + applied_language: values.prop("AppliedLanguage"), + } + } +} + +impl RubyValues { + /// Reads Ruby text and formatting values from an IDML character range/style. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + flag: values.boolean("RubyFlag").or_else(|| values.num("RubyFlag").map(|value| value != 0.0)), + text: values.prop("RubyString"), + kind: values.prop("RubyType"), + alignment: values.prop("RubyAlignment"), + position: values.prop("RubyPosition"), + x_offset: values.num("RubyXOffset"), + y_offset: values.num("RubyYOffset"), + font: values.prop("RubyFont"), + font_style: values.prop("RubyFontStyle"), + font_size: values.num("RubyFontSize"), + x_scale: values.num("RubyXScale"), + y_scale: values.num("RubyYScale"), + open_type_pro: values.boolean("RubyOpenTypePro"), + auto_tcy_digits: values.num("RubyAutoTcyDigits"), + auto_tcy_include_roman: values.boolean("RubyAutoTcyIncludeRoman"), + auto_tcy_auto_scale: values.boolean("RubyAutoTcyAutoScale"), + overhang: values.boolean("RubyOverhang"), + parent_overhang: values.prop("RubyParentOverhangAmount"), + parent_spacing: values.prop("RubyParentSpacing"), + auto_align: values.boolean("RubyAutoAlign"), + auto_scaling: values.boolean("RubyAutoScaling"), + parent_scaling: values.num("RubyParentScalingPercent"), + fill: values.prop("RubyFill"), + stroke: values.prop("RubyStroke"), + fill_tint: values.num("RubyTint"), + stroke_tint: values.num("RubyStrokeTint"), + stroke_weight: values.num("RubyWeight"), + overprint_fill: values.prop("RubyOverprintFill"), + overprint_stroke: values.prop("RubyOverprintStroke"), + } + } +} + +impl CharacterAppearanceValues { + /// Reads common character fill, stroke and decoration properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let decoration = |prefix: &str| { + let number = |suffix: &str| values.num(&format!("{prefix}{suffix}")).filter(|value| *value > -9000.0); + TextDecorationValues { + weight: number("Weight"), + offset: number("Offset"), + color: values.prop(&format!("{prefix}Color")), + tint: values.num(&format!("{prefix}Tint")), + } + }; + Self { + fill_color: values.prop("FillColor"), + fill_tint: values.num("FillTint"), + stroke_color: values.prop("StrokeColor"), + stroke_tint: values.num("StrokeTint"), + stroke_weight: values.num("StrokeWeight"), + stroke_alignment: values.prop("StrokeAlignment"), + underline: values.boolean("Underline"), + strikethrough: values.boolean("StrikeThru"), + ligatures: values.boolean("Ligatures"), + underline_options: decoration("Underline"), + strikethrough_options: decoration("StrikeThru"), + } + } +} + +impl CharacterTypographyValues { + /// Reads the common typography attributes and properties from an IDML character range/style. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + applied_font: values.prop("AppliedFont"), + font_style: values.prop("FontStyle"), + point_size: values.num("PointSize"), + leading: values.prop("Leading"), + kerning_method: values.prop("KerningMethod"), + kerning_value: values.num("KerningValue"), + tracking: values.num("Tracking"), + leading_aki: values.num("LeadingAki"), + trailing_aki: values.num("TrailingAki"), + tsume: values.num("Tsume"), + jidori: values.num("Jidori"), + leading_model: values.prop("LeadingModel"), + character_alignment: values.prop("CharacterAlignment"), + horizontal_scale: values.num("HorizontalScale"), + vertical_scale: values.num("VerticalScale"), + baseline_shift: values.num("BaselineShift"), + skew: values.num("Skew"), + } + } +} + +impl ParagraphFlowValues { + /// Reads paragraph hyphenation and flow properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + hyphenate: values.boolean("Hyphenation"), + hyphenate_min_word: values.num("HyphenateWordsLongerThan"), + hyphenate_after_first: values.num("HyphenateAfterFirst"), + hyphenate_before_last: values.num("HyphenateBeforeLast"), + hyphenate_ladder_limit: values.num("HyphenateLadderLimit"), + hyphenation_zone: values.num("HyphenationZone"), + hyphenate_capitalized: values.boolean("HyphenateCapitalizedWords"), + hyphenate_last_word: values.boolean("HyphenateLastWord"), + hyphenate_across_columns: values.boolean("HyphenateAcrossColumns"), + hyphen_weight: values.num("HyphenWeight"), + word_spacing: [values.num("MinimumWordSpacing"), values.num("DesiredWordSpacing"), values.num("MaximumWordSpacing")], + letter_spacing: [values.num("MinimumLetterSpacing"), values.num("DesiredLetterSpacing"), values.num("MaximumLetterSpacing")], + glyph_scaling: [values.num("MinimumGlyphScaling"), values.num("DesiredGlyphScaling"), values.num("MaximumGlyphScaling")], + auto_leading: values.num("AutoLeading"), + single_word_justification: values.prop("SingleWordJustification"), + keep_with_next: values.num("KeepWithNext"), + keep_lines_together: values.boolean("KeepLinesTogether"), + ignore_edge_alignment: values.boolean("IgnoreEdgeAlignment"), + keep_all_lines_together: values.boolean("KeepAllLinesTogether"), + keep_first_lines: values.num("KeepFirstLines"), + keep_last_lines: values.num("KeepLastLines"), + start_paragraph: values.prop("StartParagraph"), + } + } + + /// Writes IDML attributes for paragraph hyphenation and flow. + pub fn to_attrs(&self) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + for (key, value) in [ + ("Hyphenation", self.hyphenate), + ("HyphenateCapitalizedWords", self.hyphenate_capitalized), + ("HyphenateLastWord", self.hyphenate_last_word), + ("HyphenateAcrossColumns", self.hyphenate_across_columns), + ("KeepLinesTogether", self.keep_lines_together), + ("IgnoreEdgeAlignment", self.ignore_edge_alignment), + ("KeepAllLinesTogether", self.keep_all_lines_together), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), if value { "true" } else { "false" }.to_string())); + } + } + for (key, value) in [ + ("HyphenateWordsLongerThan", self.hyphenate_min_word), + ("HyphenateAfterFirst", self.hyphenate_after_first), + ("HyphenateBeforeLast", self.hyphenate_before_last), + ("HyphenateLadderLimit", self.hyphenate_ladder_limit), + ("HyphenationZone", self.hyphenation_zone), + ("HyphenWeight", self.hyphen_weight), + ("MinimumWordSpacing", self.word_spacing[0]), + ("DesiredWordSpacing", self.word_spacing[1]), + ("MaximumWordSpacing", self.word_spacing[2]), + ("MinimumLetterSpacing", self.letter_spacing[0]), + ("DesiredLetterSpacing", self.letter_spacing[1]), + ("MaximumLetterSpacing", self.letter_spacing[2]), + ("MinimumGlyphScaling", self.glyph_scaling[0]), + ("DesiredGlyphScaling", self.glyph_scaling[1]), + ("MaximumGlyphScaling", self.glyph_scaling[2]), + ("AutoLeading", self.auto_leading), + ("KeepWithNext", self.keep_with_next), + ("KeepFirstLines", self.keep_first_lines), + ("KeepLastLines", self.keep_last_lines), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), crate::value::num(value))); + } + } + for (key, value) in [("SingleWordJustification", &self.single_word_justification), ("StartParagraph", &self.start_paragraph)] { + if let Some(value) = value { + attrs.push((key.to_string(), value.clone())); + } + } + attrs + } +} + +impl ParagraphAlignmentValues { + /// Reads paragraph alignment and spacing properties from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + justification: values.prop("Justification"), + direction: values.prop("ParagraphDirection"), + kashidas: values.prop("Kashidas"), + paragraph_justification: values.prop("ParagraphJustification"), + paragraph_kashida_width: values.num("ParagraphKashidaWidth"), + left_indent: values.num("LeftIndent"), + right_indent: values.num("RightIndent"), + first_line_indent: values.num("FirstLineIndent"), + last_line_indent: values.num("LastLineIndent"), + space_before: values.num("SpaceBefore"), + space_after: values.num("SpaceAfter"), + same_para_style_spacing: values.prop("SameParaStyleSpacing").map(|value| value.trim().parse::().ok().filter(|v| v.is_finite())), + drop_cap_lines: values.num("DropCapLines"), + drop_cap_chars: values.num("DropCapCharacters"), + dropcap_detail: values.num("DropcapDetail").filter(|value| *value >= 0.0).map(|value| value as u32), + grid_alignment: values.prop("GridAlignment"), + grid_align_first_line_only: values.boolean("GridAlignFirstLineOnly"), + composer: values.prop("Composer"), + } + } + + /// Writes the IDML attributes for paragraph alignment and spacing. + pub fn to_attrs(&self) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + for (key, value) in [ + ("Justification", &self.justification), + ("ParagraphDirection", &self.direction), + ("Kashidas", &self.kashidas), + ("ParagraphJustification", &self.paragraph_justification), + ("GridAlignment", &self.grid_alignment), + ("Composer", &self.composer), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), value.clone())); + } + } + for (key, value) in [ + ("ParagraphKashidaWidth", self.paragraph_kashida_width), + ("LeftIndent", self.left_indent), + ("RightIndent", self.right_indent), + ("FirstLineIndent", self.first_line_indent), + ("LastLineIndent", self.last_line_indent), + ("SpaceBefore", self.space_before), + ("SpaceAfter", self.space_after), + ("DropCapLines", self.drop_cap_lines), + ("DropCapCharacters", self.drop_cap_chars), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), crate::value::num(value))); + } + } + if let Some(value) = self.same_para_style_spacing { + attrs.push(("SameParaStyleSpacing".to_string(), value.map(crate::value::num).unwrap_or_else(|| "Nothing".to_string()))); + } + if let Some(value) = self.dropcap_detail { + attrs.push(("DropcapDetail".to_string(), value.to_string())); + } + if let Some(value) = self.grid_align_first_line_only { + attrs.push(("GridAlignFirstLineOnly".to_string(), if value { "true" } else { "false" }.to_string())); + } + attrs + } +} + +impl ParagraphBoxValues { + /// Reads paragraph border and shading values from IDML. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let sides = |prefix: &str, suffix: &str| { + let values = ["Top", "Left", "Bottom", "Right"].map(|side| values.num(&format!("{prefix}{side}{suffix}"))); + values.iter().any(Option::is_some).then(|| values.map(|value| value.unwrap_or(0.0))) + }; + let corners = |prefix: &str| { + let names = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; + let shapes = names.map(|corner| values.prop(&format!("{prefix}{corner}CornerOption"))); + let sizes = names.map(|corner| values.num(&format!("{prefix}{corner}CornerRadius"))); + if shapes.iter().all(Option::is_none) && sizes.iter().all(Option::is_none) { + None + } else { + Some(std::array::from_fn(|index| { + (shapes[index].clone().unwrap_or_else(|| "None".to_string()), sizes[index].map_or(12.0, |value| value.max(0.0))) + })) + } + }; + Self { + shading_on: values.boolean("ParagraphShadingOn"), + shading_color: values.prop("ParagraphShadingColor"), + shading_tint: values.num("ParagraphShadingTint"), + shading_offsets: sides("ParagraphShading", "Offset"), + shading_overprint: values.boolean("ParagraphShadingOverprint"), + border_on: values.boolean("ParagraphBorderOn"), + border_color: values.prop("ParagraphBorderColor"), + border_tint: values.num("ParagraphBorderTint"), + border_weights: sides("ParagraphBorder", "LineWeight"), + border_offsets: sides("ParagraphBorder", "Offset"), + border_type: values.prop("ParagraphBorderType"), + border_cap: values.prop("ParagraphBorderStrokeEndCap"), + border_join: values.prop("ParagraphBorderStrokeEndJoin"), + border_corners: corners("ParagraphBorder"), + border_width: values.prop("ParagraphBorderWidth"), + border_top: values.prop("ParagraphBorderTopOrigin"), + border_bottom: values.prop("ParagraphBorderBottomOrigin"), + border_display_if_splits: values.boolean("ParagraphBorderDisplayIfSplits"), + border_merge: values.boolean("MergeConsecutiveParaBorders"), + border_gap_color: values.prop("ParagraphBorderGapColor"), + border_gap_tint: values.num("ParagraphBorderGapTint"), + border_overprint: values.boolean("ParagraphBorderOverprint"), + border_gap_overprint: values.boolean("ParagraphBorderGapOverprint"), + shading_width: values.prop("ParagraphShadingWidth"), + shading_top: values.prop("ParagraphShadingTopOrigin"), + shading_bottom: values.prop("ParagraphShadingBottomOrigin"), + shading_clip: values.boolean("ParagraphShadingClipToFrame"), + shading_nonprinting: values.boolean("ParagraphShadingSuppressPrinting"), + shading_corners: corners("ParagraphShading"), + } + } + + /// Writes IDML attributes and properties for paragraph borders and shading. + pub fn to_parts(&self) -> (Vec<(String, String)>, Vec) { + use crate::value::num; + + let mut attrs = Vec::new(); + let mut props = Vec::new(); + push_boolean(&mut attrs, "ParagraphShadingOn", self.shading_on); + if let Some(color) = &self.shading_color { + props.push(property("ParagraphShadingColor", "object", color.clone())); + } + push_number(&mut attrs, "ParagraphShadingTint", self.shading_tint); + push_boolean(&mut attrs, "ParagraphShadingOverprint", self.shading_overprint); + const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"]; + if let Some(offsets) = self.shading_offsets { + for (side, value) in SIDES.iter().zip(offsets) { + push_number(&mut attrs, &format!("ParagraphShading{side}Offset"), Some(value)); + } + } + push_boolean(&mut attrs, "ParagraphBorderOn", self.border_on); + if let Some(color) = &self.border_color { + props.push(property("ParagraphBorderColor", "object", color.clone())); + } + push_number(&mut attrs, "ParagraphBorderTint", self.border_tint); + push_boolean(&mut attrs, "ParagraphBorderOverprint", self.border_overprint); + if let Some(weights) = self.border_weights { + for (side, value) in SIDES.iter().zip(weights) { + push_number(&mut attrs, &format!("ParagraphBorder{side}LineWeight"), Some(value)); + } + } + if let Some(offsets) = self.border_offsets { + for (side, value) in SIDES.iter().zip(offsets) { + push_number(&mut attrs, &format!("ParagraphBorder{side}Offset"), Some(value)); + } + } + if let Some(kind) = &self.border_type { + props.push(property("ParagraphBorderType", "object", kind.clone())); + } + if let Some(cap) = &self.border_cap { + attrs.push(("ParagraphBorderStrokeEndCap".into(), cap.clone())); + } + if let Some(join) = &self.border_join { + attrs.push(("ParagraphBorderStrokeEndJoin".into(), join.clone())); + } + for (prefix, corners) in [("ParagraphBorder", &self.border_corners), ("ParagraphShading", &self.shading_corners)] { + if let Some(corners) = corners { + const CORNERS: [&str; 4] = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; + for ((shape, radius), corner) in corners.iter().zip(CORNERS) { + attrs.push((format!("{prefix}{corner}CornerOption"), shape.clone())); + attrs.push((format!("{prefix}{corner}CornerRadius"), num(*radius))); + } + } + } + for (key, value) in [ + ("ParagraphBorderWidth", &self.border_width), + ("ParagraphBorderTopOrigin", &self.border_top), + ("ParagraphBorderBottomOrigin", &self.border_bottom), + ("ParagraphShadingWidth", &self.shading_width), + ("ParagraphShadingTopOrigin", &self.shading_top), + ("ParagraphShadingBottomOrigin", &self.shading_bottom), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), value.clone())); + } + } + push_boolean(&mut attrs, "ParagraphBorderDisplayIfSplits", self.border_display_if_splits); + push_boolean(&mut attrs, "MergeConsecutiveParaBorders", self.border_merge); + if let Some(color) = &self.border_gap_color { + props.push(property("ParagraphBorderGapColor", "object", color.clone())); + } + push_number(&mut attrs, "ParagraphBorderGapTint", self.border_gap_tint); + push_boolean(&mut attrs, "ParagraphBorderGapOverprint", self.border_gap_overprint); + push_boolean(&mut attrs, "ParagraphShadingClipToFrame", self.shading_clip); + push_boolean(&mut attrs, "ParagraphShadingSuppressPrinting", self.shading_nonprinting); + (attrs, props) + } +} + +fn push_boolean(attrs: &mut Vec<(String, String)>, key: &str, value: Option) { + if let Some(value) = value { + attrs.push((key.to_string(), if value { "true" } else { "false" }.to_string())); + } +} + +fn push_number(attrs: &mut Vec<(String, String)>, key: &str, value: Option) { + if let Some(value) = value { + attrs.push((key.to_string(), crate::value::num(value))); + } +} + +impl ParagraphListValues { + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let restart = values.prop_el("NumberingRestartPolicies"); + let restart_after_level = restart.map(|policy| policy.attr("LowerLevel").and_then(|value| value.trim().parse::().ok()).unwrap_or(0)); + let restart_after_specific_level = restart.is_some_and(|policy| policy.attr("RestartPolicy").map(str::trim) == Some("SpecificPreviousLevel")); + let bullet = values.prop_el("BulletChar"); + Self { + applied_list: values.prop("AppliedNumberingList"), + list_type: values.prop("BulletsAndNumberingListType"), + numbering_format: values.prop("NumberingFormat"), + expression: values.prop("NumberingExpression"), + separator: values.prop("BulletsTextAfter"), + bullet_type: bullet.and_then(|value| value.attr("BulletCharacterType")).map(str::to_owned), + bullet_value: bullet.and_then(|value| value.attr("BulletCharacterValue")).map(str::to_owned), + balance_ragged: values.prop("BalanceRaggedLines"), + level: values.num("NumberingLevel"), + bullet_character_style: values.prop("BulletsCharacterStyle"), + numbering_character_style: values.prop("NumberingCharacterStyle"), + bullet_alignment: values.prop("BulletsAlignment"), + numbering_alignment: values.prop("NumberingAlignment"), + restart_numbers: values.boolean("NumberingApplyRestartPolicy"), + restart_after_level, + restart_after_specific_level, + numbering_continue: values.boolean("NumberingContinue"), + numbering_start_at: values.num("NumberingStartAt"), + bullet_font: values.prop("BulletsFont"), + bullet_font_style: values.prop("BulletsFontStyle"), + } + } + + /// Encodes a semantic paragraph-list name as its IDML object reference. + pub fn numbering_list_reference(name: &str) -> String { + if name.is_empty() { "NumberingList/$ID/[Default]".into() } else { format!("NumberingList/{}", crate::value::escape_id(name)) } + } + + /// IDML attributes represented by a paragraph's list settings. + pub fn to_attrs(&self) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + if let Some(value) = &self.applied_list { + attrs.push(("AppliedNumberingList".into(), value.clone())); + } + if let Some(value) = self.numbering_continue { + attrs.push(("NumberingContinue".into(), if value { "true" } else { "false" }.into())); + } + push_number(&mut attrs, "NumberingStartAt", self.numbering_start_at); + for (key, value) in [ + ("BulletsAndNumberingListType", &self.list_type), + ("NumberingExpression", &self.expression), + ("BulletsTextAfter", &self.separator), + ("NumberingLevel", &self.level.map(crate::value::num)), + ("BulletsAlignment", &self.bullet_alignment), + ("NumberingAlignment", &self.numbering_alignment), + ("NumberingApplyRestartPolicy", &self.restart_numbers.map(|value| if value { "true".to_string() } else { "false".to_string() })), + ] { + if let Some(value) = value { + attrs.push((key.to_string(), value.clone())); + } + } + attrs + } + + /// IDML property elements represented by a paragraph's list settings. + pub fn to_els(&self) -> Vec { + let mut properties = Vec::new(); + if let Some(format) = &self.numbering_format { + properties.push(property("NumberingFormat", "string", format.clone())); + } + if let (Some(kind), Some(value)) = (&self.bullet_type, &self.bullet_value) { + properties.push(El::new("BulletChar").with_attr("BulletCharacterType", kind).with_attr("BulletCharacterValue", value)); + } + if let Some(balance) = &self.balance_ragged { + properties.push(property("BalanceRaggedLines", "enumeration", balance.clone())); + } + for (name, style) in [("BulletsCharacterStyle", &self.bullet_character_style), ("NumberingCharacterStyle", &self.numbering_character_style)] { + if let Some(style) = style { + properties.push(property(name, "object", style.clone())); + } + } + if let Some(level) = self.restart_after_level { + let mut restart = El::new("NumberingRestartPolicies") + .with_attr("RestartPolicy", if self.restart_after_specific_level { "SpecificPreviousLevel" } else { "AnyPreviousLevel" }) + .with_attr("LowerLevel", level.to_string()); + restart.set_attr("UpperLevel", level.to_string()); + properties.push(restart); + } + if let Some(font) = &self.bullet_font { + properties.push(property("BulletsFont", "string", if font.is_empty() { "$ID/".into() } else { font.clone() })); + } + if let Some(style) = self.bullet_font_style.as_ref().filter(|style| !style.is_empty()) { + properties.push(property("BulletsFontStyle", "string", style.clone())); + } + properties + } +} + +/// One IDML nested paragraph style entry, before the caller resolves its character style. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NestedStyleValue { + pub character_style: String, + pub inclusive: bool, + pub repetition: u32, + pub delimiter_type: String, + pub delimiter: String, +} + +/// One IDML nested line style entry. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NestedLineStyleValue { + pub character_style: String, + pub line_count: u32, +} + +/// One IDML GREP style entry. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GrepStyleValue { + pub character_style: String, + pub expression: String, +} + +/// Nested style lists stored on an IDML paragraph style or text range. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NestedStyleValues { + pub nested: Option>, + pub lines: Option>, + pub grep: Option>, +} + +impl NestedStyleValues { + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let text = |item: &El, name: &str| item.find(name).map(El::deep_text).unwrap_or_default(); + let nested = values.prop_el("AllNestedStyles").map(|list| { + list.find_all("ListItem") + .map(|item| { + let delimiter = item.find("Delimiter"); + NestedStyleValue { + character_style: text(item, "AppliedCharacterStyle"), + inclusive: text(item, "Inclusive").trim() != "false", + repetition: text(item, "Repetition").trim().parse().unwrap_or(1), + delimiter_type: delimiter.and_then(|el| el.attr("type")).unwrap_or("enumeration").to_owned(), + delimiter: text(item, "Delimiter"), + } + }) + .collect() + }); + let lines = values.prop_el("AllNestedLineStyles").map(|list| { + list.find_all("ListItem") + .map(|item| NestedLineStyleValue { + character_style: text(item, "AppliedCharacterStyle"), + line_count: text(item, "LineCount").trim().parse().unwrap_or(1), + }) + .collect() + }); + let grep = values.prop_el("AllGREPStyles").map(|list| { + list.find_all("ListItem") + .map(|item| GrepStyleValue { character_style: text(item, "AppliedCharacterStyle"), expression: text(item, "GrepExpression") }) + .collect() + }); + Self { nested, lines, grep } + } + + /// Writes the nested style property lists. The caller resolves semantic style references and + /// delimiter enums into the IDML values held in this model. + pub fn to_els(&self) -> Vec { + let mut properties = Vec::new(); + if let Some(entries) = &self.nested { + let mut list = El::new("AllNestedStyles").with_attr("type", "list"); + for entry in entries { + list.push( + El::new("ListItem") + .with_attr("type", "record") + .with_child(property("AppliedCharacterStyle", "object", entry.character_style.clone())) + .with_child(property("Delimiter", &entry.delimiter_type, entry.delimiter.clone())) + .with_child(property("Repetition", "long", entry.repetition.to_string())) + .with_child(property("Inclusive", "boolean", if entry.inclusive { "true" } else { "false" })), + ); + } + properties.push(list); + } + if let Some(entries) = &self.lines { + let mut list = El::new("AllNestedLineStyles").with_attr("type", "list"); + for entry in entries { + list.push( + El::new("ListItem") + .with_attr("type", "record") + .with_child(property("AppliedCharacterStyle", "object", entry.character_style.clone())) + .with_child(property("LineCount", "long", entry.line_count.to_string())), + ); + } + properties.push(list); + } + if let Some(entries) = &self.grep { + let mut list = El::new("AllGREPStyles").with_attr("type", "list"); + for entry in entries { + list.push( + El::new("ListItem") + .with_attr("type", "record") + .with_child(property("AppliedCharacterStyle", "object", entry.character_style.clone())) + .with_child(property("GrepExpression", "string", entry.expression.clone())), + ); + } + properties.push(list); + } + properties + } +} + +fn property(name: &str, kind: &str, value: impl Into) -> El { + El::new(name).with_attr("type", kind).with_text(value) +} + +/// OpenType feature tags represented by IDML character attributes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OpenTypeFeatureValues { + pub features: Vec, + pub present: bool, +} + +impl OpenTypeFeatureValues { + /// Reads IDML OTF toggle, figure style and stylistic-set attributes into feature tags. + pub fn from_el(el: &El) -> Self { + const TOGGLES: &[(&str, &str)] = &[ + ("dlig", "OTFDiscretionaryLigature"), + ("frac", "OTFFraction"), + ("ordn", "OTFOrdinal"), + ("swsh", "OTFSwash"), + ("titl", "OTFTitling"), + ("calt", "OTFContextualAlternate"), + ("zero", "OTFSlashedZero"), + ("palt", "OTFProportionalMetrics"), + ("hkna", "OTFHVKana"), + ("locl", "OTFLocale"), + ("mark", "OTFMark"), + ("hist", "OTFHistorical"), + ("salt", "OTFStylisticAlternate"), + ("jalt", "OTFJustificationAlternate"), + ("ital", "OTFRomanItalics"), + ]; + let values = Values::of(el); + let mut result = Self::default(); + for (tag, attribute) in TOGGLES { + let Some(on) = values.boolean(attribute) else { continue }; + let default_on = matches!(*tag, "calt" | "liga" | "locl" | "mark"); + result.features.retain(|feature| feature != tag && feature.strip_prefix('-') != Some(*tag)); + if on != default_on { + result.features.push(if on { (*tag).to_owned() } else { format!("-{tag}") }); + } + result.present = true; + } + if let Some(style) = values.value("OTFFigureStyle") { + let tags: &[&str] = match style.as_str() { + "TabularLining" => &["tnum", "lnum"], + "ProportionalOldstyle" => &["pnum", "onum"], + "ProportionalLining" => &["pnum", "lnum"], + "TabularOldstyle" => &["tnum", "onum"], + "Default" => &[], + _ => &[], + }; + if matches!(style.as_str(), "TabularLining" | "ProportionalOldstyle" | "ProportionalLining" | "TabularOldstyle" | "Default") { + result.features.retain(|feature| !matches!(feature.as_str(), "tnum" | "pnum" | "lnum" | "onum")); + result.features.extend(tags.iter().map(|tag| (*tag).to_owned())); + result.present = true; + } + } + if let Some(mask) = values.num("OTFStylisticSets") { + let mask = mask.max(0.0) as u32; + result.features.retain(|feature| !(feature.len() == 4 && feature.starts_with("ss") && feature[2..].parse::().is_ok())); + result.features.extend((1..=20).filter(|number| mask & (1 << (number - 1)) != 0).map(|number| format!("ss{number:02}"))); + result.present = true; + } + result + } +} + +/// Raw character-level Japanese composition and emphasis properties from an IDML text range. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CjkCharacterValues { + pub kenten_kind: Option, + pub kenten_custom_character: Option, + pub kenten_font: Option, + pub kenten_font_style: Option, + pub kenten_size: Option, + pub kenten_x_scale: Option, + pub kenten_y_scale: Option, + pub kenten_distance: Option, + pub kenten_position: Option, + pub kenten_alignment: Option, + pub kenten_character_set: Option, + pub kenten_fill_color: Option, + pub kenten_stroke_color: Option, + pub kenten_fill_tint: Option, + pub kenten_stroke_tint: Option, + pub kenten_stroke_weight: Option, + pub kenten_overprint_fill: Option, + pub kenten_overprint_stroke: Option, + pub shatai_magnification: Option, + pub shatai_angle: Option, + pub shatai_adjust_rotation: Option, + pub shatai_adjust_tsume: Option, +} + +impl CjkCharacterValues { + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + kenten_kind: values.get("KentenKind").map(str::to_owned), + kenten_custom_character: values.prop("KentenCustomCharacter"), + kenten_font: values.prop("KentenFont"), + kenten_font_style: values.prop("KentenFontStyle"), + kenten_size: values.num("KentenFontSize"), + kenten_x_scale: values.num("KentenXScale"), + kenten_y_scale: values.num("KentenYScale"), + kenten_distance: values.num("KentenPlacement"), + kenten_position: values.prop("KentenPosition"), + kenten_alignment: values.prop("KentenAlignment"), + kenten_character_set: values.prop("KentenCharacterSet"), + kenten_fill_color: values.prop("KentenFillColor"), + kenten_stroke_color: values.prop("KentenStrokeColor"), + kenten_fill_tint: values.num("KentenTint"), + kenten_stroke_tint: values.num("KentenStrokeTint"), + kenten_stroke_weight: values.num("KentenWeight"), + kenten_overprint_fill: values.prop("KentenOverprintFill"), + kenten_overprint_stroke: values.prop("KentenOverprintStroke"), + shatai_magnification: values.num("ShataiMagnification"), + shatai_angle: values.num("ShataiDegreeAngle"), + shatai_adjust_rotation: values.boolean("ShataiAdjustRotation"), + shatai_adjust_tsume: values.boolean("ShataiAdjustTsume"), + } + } +} + +/// Numeric and boolean values in an IDML `MojikumiTable` row. +#[derive(Clone, Debug, PartialEq)] +pub struct MojikumiRow { + pub target_class: i16, + pub side_class: i16, + pub after: bool, + pub minimum: f64, + pub desired: f64, + pub maximum: f64, + pub priority: i16, + pub does_not_float: bool, +} + +impl MojikumiRow { + /// Parses the IDML mojikumi row attributes with bounded integer and finite-number checks. + pub fn from_el(el: &El) -> Result { + let values = Values::of(el); + let number = |key: &str| -> Result { + let Some(raw) = values.get(key) else { return Ok(0.0) }; + raw.trim().parse::().ok().filter(|value| value.is_finite()).ok_or_else(|| format!("Mojikumi row has invalid {key}")) + }; + let integer = |key: &str| -> Result { + let value = number(key)?; + if value.fract() != 0.0 || value < f64::from(i16::MIN) || value > f64::from(i16::MAX) { + return Err(format!("Mojikumi {key} must be an in-range integer")); + } + Ok(value as i16) + }; + let boolean = |key: &str| -> Result { + match values.get(key).map(str::trim) { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + _ => Err(format!("Mojikumi row has invalid {key}")), + } + }; + Ok(Self { + target_class: integer("TargetMojikumiClass")?, + side_class: integer("SideMojikumiClass")?, + after: boolean("SideIsAfterTarget")?, + minimum: number("Minimum")?, + desired: number("Desired")?, + maximum: number("Maximum")?, + priority: integer("CompressionPriority")?, + does_not_float: boolean("AkiDoesNotFloat")?, + }) + } +} + +/// One IDML tab stop, before the caller maps alignment names into its text model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TabStopValues { + pub position: f64, + pub alignment: String, + pub leader: String, + pub alignment_character: String, +} + +impl TabStopValues { + /// Reads `ListItem` records in a `TabList` property. Invalid or non-finite positions use 0, + /// matching IDML import fallback behavior; semantic sanitization belongs to the caller. + pub fn from_list(list: &El) -> Vec { + list.children("ListItem") + .map(|item| { + let text = |name: &str| item.child_text(name).map(|value| value.into_owned()); + Self { + position: text("Position").and_then(|value| value.trim().parse::().ok()).filter(|value| value.is_finite()).unwrap_or(0.0), + alignment: text("Alignment").unwrap_or_default(), + leader: text("Leader").unwrap_or_default(), + alignment_character: text("AlignmentCharacter").unwrap_or_default(), + } + }) + .collect() + } + + /// Writes tab-stop records in a `TabList` property. + pub fn to_list(stops: &[Self]) -> El { + let mut list = El::new("TabList").with_attr("type", "list"); + for stop in stops { + list.push( + El::new("ListItem") + .with_attr("type", "record") + .with_child(property("Alignment", "enumeration", stop.alignment.clone())) + .with_child(property("AlignmentCharacter", "string", stop.alignment_character.clone())) + .with_child(property("Leader", "string", stop.leader.clone())) + .with_child(property("Position", "unit", crate::value::num(stop.position))), + ); + } + list + } +} + /// What a style styles. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StyleKind { @@ -73,6 +1260,65 @@ pub struct StyleGroup { pub children: Vec, } +/// Builds the nested style-folder tree written under an IDML root style group. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleGroupWriter { + kind: Option, + root_self_id: String, + styles: Vec, + groups: Vec<(String, StyleGroupWriter)>, +} + +impl StyleGroupWriter { + pub fn new(kind: StyleKind, root_self_id: impl Into) -> Self { + Self { kind: Some(kind), root_self_id: root_self_id.into(), ..Self::default() } + } + + /// Adds a style at its slash-separated folder path. Built-in names in brackets remain at + /// the root, and folder nesting is capped at 32 levels. + pub fn insert(&mut self, name: &str, style: El) { + let builtin = name.starts_with('['); + let mut segments: Vec<&str> = if builtin { vec![name] } else { name.split('/').collect() }; + segments.pop(); + let mut group = self; + for segment in segments.into_iter().take(32) { + let index = match group.groups.iter().position(|(name, _)| name == segment) { + Some(index) => index, + None => { + group.groups.push((segment.to_string(), StyleGroupWriter { kind: group.kind, ..Self::default() })); + group.groups.len() - 1 + } + }; + group = &mut group.groups[index].1; + } + group.styles.push(style); + } + + /// The complete root style group and its nested groups. + pub fn to_el(self) -> El { + let Some(kind) = self.kind else { + return El::new("RootStyleGroup"); + }; + let root_self_id = self.root_self_id.clone(); + self.emit(kind.root(), &root_self_id, kind.group(), "") + } + + fn emit(self, root_name: &str, self_id: &str, group_name: &str, path: &str) -> El { + let mut element = El::new(root_name).with_attr("Self", self_id); + for style in self.styles { + element.push(style); + } + for (name, group) in self.groups { + let full = if path.is_empty() { name.clone() } else { format!("{path}:{name}") }; + let nested = group.emit(group_name, &format!("{group_name}/{}", crate::value::escape_id(&full)), group_name, &full); + let mut nested = nested; + nested.set_attr("Name", name); + element.push(nested); + } + element + } +} + /// A child of a style group, in document order. #[derive(Clone, Debug, PartialEq)] pub enum StyleNode { @@ -81,6 +1327,38 @@ pub enum StyleNode { Other(El), } +/// EPUB export tagging stored on a style in IDML. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EpubExportTagMap { + pub tag: String, + pub class: String, + pub split_document: bool, +} + +impl EpubExportTagMap { + /// Reads an EPUB `StyleExportTagMap`; other export types are not this record. + pub fn from_el(el: &El) -> Option { + let values = Values::of(el); + (el.local() == "StyleExportTagMap" && values.get("ExportType") == Some("EPUB")).then(|| Self { + tag: values.get("ExportTag").unwrap_or("").to_owned(), + class: values.get("ExportClass").unwrap_or("").to_owned(), + split_document: values.get("SplitDocument") == Some("true"), + }) + } + + /// Writes the EPUB tag map with its caller-assigned IDML `Self` identifier. + pub fn to_el(&self, self_id: &str) -> El { + El::new("StyleExportTagMap") + .with_attr("Self", self_id) + .with_attr("ExportType", "EPUB") + .with_attr("ExportTag", &self.tag) + .with_attr("ExportClass", &self.class) + .with_attr("ExportAttributes", "") + .with_attr("SplitDocument", if self.split_document { "true" } else { "false" }) + .with_attr("EmitCss", "true") + } +} + /// A style, its values as written: `Name`, `Self`, `Properties/BasedOn`, its attributes; and its /// other children (an object style's `TextFramePreference`, effect settings, …). #[derive(Clone, Debug, PartialEq)] @@ -90,6 +1368,44 @@ pub struct Style { pub rest: Vec, } +/// Parsed assignments on an IDML object style before application style-reference resolution. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ObjectStyleValues { + pub name: Option, + pub based_on: Option, + pub fill_enabled: bool, + pub fill_color: Option, + pub fill_tint: Option, + pub stroke_enabled: bool, + pub paragraph_style_enabled: bool, + pub paragraph_style: Option, + pub text_frame_general_options: bool, + pub text_frame_preference: Option, +} + +impl ObjectStyleValues { + /// Reads object-style enable flags and the values controlled by those flags. + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let enabled = |flag: &str, attribute: &str| match values.get(flag) { + Some(value) => value == "true", + None => values.get(attribute).is_some(), + }; + Self { + name: values.name().map(str::to_owned), + based_on: values.prop("BasedOn"), + fill_enabled: enabled("EnableFill", "FillColor"), + fill_color: values.get("FillColor").map(str::to_owned), + fill_tint: values.num("FillTint"), + stroke_enabled: enabled("EnableStroke", "StrokeColor"), + paragraph_style_enabled: enabled("EnableParagraphStyle", "AppliedParagraphStyle"), + paragraph_style: values.get("AppliedParagraphStyle").map(str::to_owned), + text_frame_general_options: values.get("EnableTextFrameGeneralOptions") == Some("true"), + text_frame_preference: el.child(Local("TextFramePreference")).cloned(), + } + } +} + impl StyleGroup { /// The root group `el`; `None` when `el` is no root style group. pub fn from_el(el: &El) -> Option { @@ -238,6 +1554,14 @@ pub struct GradientStop { pub midpoint: f64, } +/// A `GradientStop` in source order, including optional values as written. +#[derive(Clone, Debug, PartialEq)] +pub struct GradientStopValue { + pub color: Option, + pub location: Option, + pub midpoint: Option, +} + impl Swatch { pub fn to_el(&self) -> El { let mut el = self.values.element(self.kind.name()); @@ -296,6 +1620,22 @@ impl Swatch { }) .collect() } + + /// Every gradient stop in source order, retaining absent or malformed optional values. + pub fn stops_in_order(&self) -> Vec { + self.rest + .iter() + .filter(|e| e.local() == "GradientStop") + .map(|e| { + let values = Values::of(e); + GradientStopValue { + color: values.get("StopColor").map(str::to_string), + location: values.num("Location"), + midpoint: values.num("Midpoint"), + } + }) + .collect() + } } /// The swatches among `top`, in order. @@ -364,6 +1704,360 @@ pub struct ColorGroup { pub rest: Vec, } +/// An Ink Manager entry, preserving its source values. +#[derive(Clone, Debug, PartialEq)] +pub struct Ink { + pub values: Values, +} + +/// A neutral colour definition for writing `Graphic.xml`. +/// +/// The caller supplies colour-space values as IDML's serialized value list; this type owns the +/// element and attribute grammar so applications do not need to emit `Color` XML themselves. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorDefinition { + pub self_id: String, + pub name: String, + pub model: String, + pub space: String, + pub value: String, + pub override_kind: String, + pub editable: bool, + pub removable: bool, + pub visible: bool, +} + +impl ColorDefinition { + pub fn to_el(&self) -> El { + El::new("Color") + .with_attr("Self", &self.self_id) + .with_attr("Model", &self.model) + .with_attr("Space", &self.space) + .with_attr("ColorValue", &self.value) + .with_attr("ColorOverride", &self.override_kind) + .with_attr("AlternateSpace", "NoAlternateColor") + .with_attr("AlternateColorValue", "") + .with_attr("Name", &self.name) + .with_attr("ColorEditable", self.editable) + .with_attr("ColorRemovable", self.removable) + .with_attr("Visible", self.visible) + .with_attr("SwatchCreatorID", "7937") + } +} + +/// A neutral `Ink` definition in `Graphic.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct InkDefinition { + pub self_id: String, + pub name: String, + pub alias: Option, + pub angle: String, + pub convert_to_process: bool, + pub frequency: String, + pub neutral_density: String, + pub trap_order: usize, + pub ink_type: String, +} + +impl InkDefinition { + fn to_el(&self) -> El { + let mut el = El::new("Ink") + .with_attr("Self", &self.self_id) + .with_attr("Name", &self.name) + .with_attr("Angle", &self.angle) + .with_attr("ConvertToProcess", self.convert_to_process) + .with_attr("Frequency", &self.frequency) + .with_attr("NeutralDensity", &self.neutral_density) + .with_attr("PrintInk", "true") + .with_attr("TrapOrder", self.trap_order) + .with_attr("InkType", &self.ink_type); + if let Some(alias) = &self.alias { + el.set_attr("AliasInkName", alias); + } + el + } +} + +/// A neutral `Tint` definition in `Graphic.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct TintDefinition { + pub self_id: String, + pub value: String, + pub base_color: String, + pub name: String, +} + +impl TintDefinition { + fn to_el(&self) -> El { + El::new("Tint") + .with_attr("Self", &self.self_id) + .with_attr("TintValue", &self.value) + .with_attr("BaseColor", &self.base_color) + .with_attr("Name", &self.name) + .with_attr("ColorOverride", "Normal") + .with_attr("AlternateSpace", "NoAlternateColor") + .with_attr("AlternateColorValue", "") + .with_attr("ColorEditable", "true") + .with_attr("ColorRemovable", "true") + .with_attr("Visible", "true") + .with_attr("SwatchCreatorID", "7937") + } +} + +/// A stop in a neutral `Gradient` definition. +#[derive(Clone, Debug, PartialEq)] +pub struct GradientStopDefinition { + pub self_id: String, + pub color_id: String, + pub location: String, + pub midpoint: Option, +} + +/// A neutral `Gradient` definition in `Graphic.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct GradientDefinition { + pub self_id: String, + pub name: String, + pub radial: bool, + pub stops: Vec, +} + +impl GradientDefinition { + fn to_el(&self) -> El { + let mut el = El::new("Gradient") + .with_attr("Self", &self.self_id) + .with_attr("Type", if self.radial { "Radial" } else { "Linear" }) + .with_attr("Name", &self.name) + .with_attr("ColorEditable", "true") + .with_attr("ColorRemovable", "true") + .with_attr("Visible", "true") + .with_attr("SwatchCreatorID", "7937"); + for stop in &self.stops { + let mut child = + El::new("GradientStop").with_attr("Self", &stop.self_id).with_attr("StopColor", &stop.color_id).with_attr("Location", &stop.location); + if let Some(midpoint) = &stop.midpoint { + child.set_attr("Midpoint", midpoint); + } + el.push(child); + } + el + } +} + +/// A member reference in a neutral `ColorGroup` definition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColorGroupMember { + pub self_id: String, + pub swatch_id: String, +} + +/// A neutral `ColorGroup` definition in `Graphic.xml`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColorGroupDefinition { + pub self_id: String, + pub name: String, + pub members: Vec, +} + +impl ColorGroupDefinition { + fn to_el(&self) -> El { + let mut el = El::new("ColorGroup").with_attr("Self", &self.self_id).with_attr("Name", &self.name).with_attr("IsRootColorGroup", "false"); + for member in &self.members { + el.push(El::new("ColorGroupSwatch").with_attr("Self", &member.self_id).with_attr("SwatchItemRef", &member.swatch_id)); + } + el + } +} + +/// A custom stroke style in `Graphic.xml`. +#[derive(Clone, Debug, PartialEq)] +pub enum StrokeDefinition { + Striped { self_id: String, name: String, array: String }, + Dashed { self_id: String, name: String, array: String }, + Dotted { self_id: String, name: String, array: String }, + Named { self_id: String, name: String }, +} + +impl StrokeDefinition { + fn to_el(&self) -> El { + match self { + Self::Striped { self_id, name, array } => { + El::new("StripedStrokeStyle").with_attr("StripeArray", array).with_attr("Self", self_id).with_attr("Name", name) + } + Self::Dashed { self_id, name, array } => { + El::new("DashedStrokeStyle").with_attr("DashArray", array).with_attr("Self", self_id).with_attr("Name", name) + } + Self::Dotted { self_id, name, array } => { + El::new("DottedStrokeStyle").with_attr("DotArray", array).with_attr("Self", self_id).with_attr("Name", name) + } + Self::Named { self_id, name } => El::new("StrokeStyle").with_attr("Self", self_id).with_attr("Name", name), + } + } +} + +/// Semantic values needed to write the `Graphic.xml` part. This type owns element names, +/// attributes, child ordering and the required None swatch; callers supply their mapped values. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GraphicPart { + pub colors: Vec, + pub inks: Vec, + pub tints: Vec, + pub gradients: Vec, + pub color_groups: Vec, + pub stroke_styles: Vec, +} + +impl GraphicPart { + pub fn to_el(&self) -> El { + let mut root = crate::part_root("Graphic"); + for color in &self.colors { + root.push(color.to_el()); + } + for ink in &self.inks { + root.push(ink.to_el()); + } + for tint in &self.tints { + root.push(tint.to_el()); + } + root.push( + El::new("Swatch") + .with_attr("Self", "Swatch/None") + .with_attr("Name", "None") + .with_attr("ColorEditable", "false") + .with_attr("ColorRemovable", "false") + .with_attr("Visible", "true") + .with_attr("SwatchCreatorID", "7937"), + ); + for gradient in &self.gradients { + root.push(gradient.to_el()); + } + for group in &self.color_groups { + root.push(group.to_el()); + } + for style in &self.stroke_styles { + root.push(style.to_el()); + } + root + } +} + +/// A composite-font entry, including the applied font family stored in `Properties`. +#[derive(Clone, Debug, PartialEq)] +pub struct CompositeFontEntryDefinition { + pub self_id: String, + pub name: String, + pub characters: String, + pub font_style: String, + pub relative_size: String, + pub horizontal_scale: String, + pub vertical_scale: String, + pub baseline_shift: String, + pub scale_option: bool, + pub applied_font: String, +} + +/// A composite font in `Fonts.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct CompositeFontDefinition { + pub self_id: String, + pub name: String, + pub entries: Vec, +} + +/// A font face in a family in `Fonts.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct FontDefinition { + pub self_id: String, + pub family: String, + pub name: String, + pub postscript_name: String, + pub style: String, +} + +/// A family of font faces in `Fonts.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct FontFamilyDefinition { + pub self_id: String, + pub name: String, + pub fonts: Vec, +} + +/// Neutral data for writing the complete `Fonts.xml` part. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FontsPart { + pub composites: Vec, + pub families: Vec, +} + +impl FontsPart { + pub fn to_el(&self) -> El { + let mut root = crate::part_root("Fonts"); + for composite in &self.composites { + let mut element = El::new("CompositeFont").with_attr("Self", &composite.self_id).with_attr("Name", &composite.name); + for entry in &composite.entries { + let mut node = El::new("CompositeFontEntry") + .with_attr("Self", &entry.self_id) + .with_attr("Name", &entry.name) + .with_attr("CustomCharacters", &entry.characters) + .with_attr("FontStyle", &entry.font_style) + .with_attr("RelativeSize", &entry.relative_size) + .with_attr("HorizontalScale", &entry.horizontal_scale) + .with_attr("VerticalScale", &entry.vertical_scale) + .with_attr("BaselineShift", &entry.baseline_shift) + .with_attr("ScaleOption", entry.scale_option); + let mut properties = El::new("Properties"); + properties.push(crate::prop("AppliedFont", "string", &entry.applied_font)); + node.push(properties); + element.push(node); + } + root.push(element); + } + for family in &self.families { + let mut element = El::new("FontFamily").with_attr("Self", &family.self_id).with_attr("Name", &family.name); + for font in &family.fonts { + element.push( + El::new("Font") + .with_attr("Self", &font.self_id) + .with_attr("FontFamily", &font.family) + .with_attr("Name", &font.name) + .with_attr("PostScriptName", &font.postscript_name) + .with_attr("Status", "NotAvailable") + .with_attr("FontStyleName", &font.style) + .with_attr("FontType", "OpenTypeTT") + .with_attr("WritingScript", "0") + .with_attr("FullName", &font.name) + .with_attr("FullNameNative", &font.name) + .with_attr("FontStyleNameNative", &font.style) + .with_attr("PlatformName", "$ID/") + .with_attr("Version", "$ID/") + .with_attr("TypekitID", "$ID/"), + ); + } + root.push(element); + } + root + } +} + +impl Ink { + pub fn name(&self) -> Option<&str> { + self.values.name() + } + + pub fn convert_to_process(&self) -> bool { + self.values.get("ConvertToProcess") == Some("true") + } + + pub fn alias(&self) -> Option<&str> { + self.values.get("AliasInkName") + } +} + +/// Ink Manager entries among the document's top-level elements. +pub fn inks(top: &[El]) -> Vec { + top.iter().filter(|el| el.local() == "Ink").map(|el| Ink { values: Values::of(el) }).collect() +} + impl ColorGroup { pub fn to_el(&self) -> El { let mut el = self.values.element("ColorGroup"); diff --git a/crates/idml/src/model/spread.rs b/crates/idml/src/model/spread.rs index c4d535f89..e3f317c07 100644 --- a/crates/idml/src/model/spread.rs +++ b/crates/idml/src/model/spread.rs @@ -17,6 +17,60 @@ fn with_children(mut el: El, children: impl IntoIterator) -> El { el } +/// Raw clip-path settings attached to a placed graphic, before the caller maps type and +/// coordinates into its scene model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ClippingPathValues { + pub clipping_type: Option, + pub geometry: PathGeometry, + pub invert: Option, + pub include_inside_edges: Option, + pub restrict_to_frame: Option, + pub use_high_resolution: Option, + pub threshold: Option, + pub tolerance: Option, + pub inset: Option, + pub source_name: Option, + pub index: Option, +} + +impl ClippingPathValues { + /// Reads a graphic's `ClippingPathSettings` and saved geometry, with the caller's point cap. + pub fn from_graphic(graphic: &El, max_points: usize) -> Option { + let settings = graphic.find("ClippingPathSettings")?; + let settings_values = Values::of(settings); + let graphic_values = Values::of(graphic); + let clipping_type = settings_values.get("ClippingType").map(str::to_owned); + let invert = settings_values.boolean("InvertPath"); + let include_inside_edges = settings_values.boolean("IncludeInsideEdges"); + let restrict_to_frame = settings_values.boolean("RestrictToFrame"); + let use_high_resolution = settings_values.boolean("UseHighResolutionImage"); + let threshold = settings_values.num("Threshold"); + let tolerance = settings_values.num("Tolerance"); + let inset = settings_values.num("InsetFrame"); + let source_name = settings_values.get("AppliedPathName").map(str::to_owned); + let index = settings_values.num("Index"); + let geometry = settings_values + .prop_el("ClippingPathGeometry") + .or_else(|| settings.find("ClippingPathGeometry")) + .or_else(|| graphic_values.prop_el("ClippingPathGeometry"))?; + let geometry = PathGeometry::from_el_capped(geometry, max_points); + Some(Self { + clipping_type, + geometry, + invert, + include_inside_edges, + restrict_to_frame, + use_high_resolution, + threshold, + tolerance, + inset, + source_name, + index, + }) + } +} + /// A spread (`Spread`) or a parent spread (`MasterSpread`). #[derive(Clone, Debug, Default, PartialEq)] pub struct Spread { @@ -86,6 +140,52 @@ impl Spread { pub fn parent_name(&self) -> Option<(&str, &str)> { Some((self.values.get("NamePrefix")?, self.values.get("BaseName")?)) } + + /// Parent spread's prefix, using the prefix of `Name` or `A` when absent. + pub fn parent_prefix(&self) -> String { + self.values.get("NamePrefix").map(str::to_string).unwrap_or_else(|| self.name().split('-').next().unwrap_or("A").to_string()) + } + + /// Parent spread's base name, using the suffix of `Name` or `Parent` when absent. + pub fn base_name(&self) -> String { + self.values + .get("BaseName") + .map(str::to_string) + .unwrap_or_else(|| self.name().split_once('-').map(|(_, b)| b.to_string()).unwrap_or_else(|| self.name().to_string())) + } + + pub fn name(&self) -> &str { + self.values.name().unwrap_or("Parent") + } + + /// Whether master page items are shown. + pub fn show_master_items(&self) -> bool { + self.values.boolean("ShowMasterItems") != Some(false) + } + + /// Whether page shuffle is allowed. + pub fn allow_shuffle(&self) -> bool { + self.values.boolean("AllowPageShuffle") != Some(false) + } + + /// Exact quarter-turn view rotation encoded by the spread transform, clockwise on screen. + pub fn view_rotation(&self) -> u8 { + if self.master { + return 0; + } + let [a, b, c, d, _, _] = self.values.item_transform(); + let is = |x: f64, y: f64| (x - y).abs() < 1e-6; + let quarter = |m: [f64; 4]| is(a, m[0]) && is(b, m[1]) && is(c, m[2]) && is(d, m[3]); + if quarter([0.0, 1.0, -1.0, 0.0]) { + 1 + } else if quarter([-1.0, 0.0, 0.0, -1.0]) { + 2 + } else if quarter([0.0, -1.0, 1.0, 0.0]) { + 3 + } else { + 0 + } + } } /// The spreads and parent spreads of a document, in its order. @@ -100,6 +200,44 @@ pub struct Page { pub children: Vec, } +/// Parsed layout values of an IDML `MarginPreference`. +#[derive(Clone, Debug, PartialEq)] +pub struct MarginPreference { + pub values: Values, +} + +impl MarginPreference { + pub fn from_el(el: &El) -> Self { + Self { values: Values::of(el) } + } + pub fn top(&self) -> Option { + self.values.num("Top") + } + pub fn left(&self) -> Option { + self.values.num("Left") + } + pub fn bottom(&self) -> Option { + self.values.num("Bottom") + } + pub fn right(&self) -> Option { + self.values.num("Right") + } + pub fn column_count(&self) -> Option { + self.values.num("ColumnCount") + } + pub fn column_gutter(&self) -> Option { + self.values.num("ColumnGutter") + } + + /// Parse at most `limit` whitespace-separated `ColumnsPositions` values; malformed or + /// non-finite values invalidate the list. + pub fn column_positions(&self, limit: usize) -> Option> { + let source = self.values.get("ColumnsPositions")?; + let values: Option> = source.split_whitespace().take(limit).map(|v| v.parse::().ok().filter(|v| v.is_finite())).collect(); + values.filter(|values| !values.is_empty()) + } +} + /// A child of a page, in document order. #[derive(Clone, Debug, PartialEq)] pub enum PageChild { @@ -142,6 +280,20 @@ impl Page { self.values.get("AppliedMaster").filter(|m| *m != "n") } + pub fn self_id(&self) -> Option<&str> { + self.values.self_id() + } + + /// `ItemTransform`; the identity when absent or malformed. + pub fn item_transform(&self) -> Matrix { + self.values.item_transform() + } + + /// `LayoutRule`, defaulting to `Off`. + pub fn layout_rule(&self) -> &str { + self.values.get("LayoutRule").unwrap_or("Off") + } + /// `OverrideList`: the `Self`s of the parent items overridden on this page. pub fn override_list(&self) -> Vec<&str> { self.values.get("OverrideList").map(|l| l.split_whitespace().collect()).unwrap_or_default() @@ -155,6 +307,10 @@ impl Page { }) } + pub fn margin_settings(&self) -> Option { + self.margin_preference().map(|values| MarginPreference { values }) + } + pub fn guides(&self) -> impl DoubleEndedIterator { self.children.iter().filter_map(|c| if let PageChild::Guide(g) = c { Some(g) } else { None }) } @@ -188,6 +344,16 @@ impl Guide { pub fn liquid(&self) -> bool { self.values.get("GuideType") == Some("Liquid") } + + /// `Locked` is true. + pub fn locked(&self) -> bool { + self.values.boolean("Locked") == Some(true) + } + + /// `ItemLayer`: the identifier of the layer the guide belongs to. + pub fn item_layer(&self) -> Option<&str> { + self.values.item_layer() + } } /// What a page item is. @@ -297,6 +463,61 @@ impl PageItem { self.values.prop_el("PathGeometry").map(PathGeometry::from_el).unwrap_or_default() } + pub fn self_id(&self) -> Option<&str> { + self.values.self_id() + } + + pub fn item_layer(&self) -> Option<&str> { + self.values.item_layer() + } + + pub fn item_transform(&self) -> Matrix { + self.values.item_transform() + } + + pub fn name(&self) -> Option<&str> { + self.values.name() + } + + pub fn locked(&self) -> bool { + self.values.locked() + } + + pub fn hidden(&self) -> bool { + !self.values.visible() + } + + pub fn nonprinting(&self) -> bool { + self.values.boolean("Nonprinting") == Some(true) + } + + pub fn gradient_fill_angle(&self) -> Option { + self.values.num("GradientFillAngle") + } + pub fn gradient_fill_start(&self) -> Option<[f64; 2]> { + self.values.pair("GradientFillStart") + } + pub fn gradient_fill_length(&self) -> Option { + self.values.num("GradientFillLength") + } + pub fn gradient_stroke_angle(&self) -> Option { + self.values.num("GradientStrokeAngle") + } + pub fn gradient_stroke_start(&self) -> Option<[f64; 2]> { + self.values.pair("GradientStrokeStart") + } + pub fn gradient_stroke_length(&self) -> Option { + self.values.num("GradientStrokeLength") + } + + pub fn text_path(&self) -> Option { + self.other("TextPath").map(|el| TextPath { values: Values::of(el) }) + } + + pub fn text_wrap(&self) -> Option { + self.other("TextWrapPreference").map(TextWrapPreference::from_el) + } + /// Replaces the outline. pub fn set_geometry(&mut self, g: &PathGeometry) { self.values.set_prop_el("PathGeometry", Some(g.to_el())); @@ -346,6 +567,169 @@ impl PageItem { } } +/// Parsed IDML `TextWrapPreference` values, including the nested offset and contour elements. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextWrapPreference { + values: Values, + contour_type: Option, + offsets: Option<[f64; 4]>, +} + +/// Parsed IDML frame fitting options. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FrameFittingOption { + pub crop: [Option; 4], + pub alignment: Option, + pub auto_fit: bool, + pub fitting_on_empty_frame: Option, +} + +impl FrameFittingOption { + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + Self { + crop: [values.num("TopCrop"), values.num("LeftCrop"), values.num("BottomCrop"), values.num("RightCrop")], + alignment: values.get("FittingAlignment").map(str::to_owned), + auto_fit: values.boolean("AutoFit") == Some(true), + fitting_on_empty_frame: values.get("FittingOnEmptyFrame").map(str::to_owned), + } + } +} + +/// IDML baseline-grid position relative to a page, frame, or inset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BaselineFrameGridRelative { + TopMargin, + TopOfFrame, + TopInset, + #[default] + TopOfPage, +} + +/// Parsed IDML custom baseline-grid options on an item or object style. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BaselineFrameGridOption { + pub use_custom: bool, + pub start: f64, + pub increment: f64, + pub relative_to: BaselineFrameGridRelative, +} + +impl BaselineFrameGridOption { + pub fn from_el(el: &El) -> Self { + let values = Values::of(el); + let relative = values.get("BaselineFrameGridRelativeOption").unwrap_or(""); + let relative = relative.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(relative); + let relative_to = match relative { + "TopOfMargin" | "TopMargin" => BaselineFrameGridRelative::TopMargin, + "TopOfFrame" => BaselineFrameGridRelative::TopOfFrame, + "TopOfInset" | "TopInset" => BaselineFrameGridRelative::TopInset, + _ => BaselineFrameGridRelative::TopOfPage, + }; + Self { + use_custom: values.boolean("UseCustomBaselineFrameGrid") == Some(true), + start: values.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), + increment: values.num("BaselineFrameGridIncrement").unwrap_or(12.0), + relative_to, + } + } +} + +impl TextWrapPreference { + pub fn from_el(el: &El) -> Self { + let contour_type = el.child(Local("ContourOption")).and_then(|c| Values::of(c).get("ContourType").map(str::to_string)); + let offsets = el.child(Local("Properties")).and_then(|p| p.child(Local("TextWrapOffset"))).map(|o| { + let values = Values::of(o); + [ + values.num("Top").unwrap_or(0.0), + values.num("Left").unwrap_or(0.0), + values.num("Bottom").unwrap_or(0.0), + values.num("Right").unwrap_or(0.0), + ] + }); + Self { values: Values::of(el), contour_type, offsets } + } + + pub fn mode(&self) -> Option<&str> { + self.values.get("TextWrapMode") + } + pub fn inverse(&self) -> bool { + self.values.boolean("Inverse") == Some(true) + } + pub fn side(&self) -> Option<&str> { + self.values.get("TextWrapSide") + } + pub fn contour_type(&self) -> Option<&str> { + self.contour_type.as_deref() + } + pub fn offsets(&self) -> Option<[f64; 4]> { + self.offsets + } + + /// Writes the IDML preference structure around caller-supplied enum values and path geometry. + pub fn to_el(mode: &str, inverse: bool, side: &str, contour: &str, offsets: [f64; 4], paths: &[PathGeometry]) -> El { + use crate::value::num; + + let mut properties = El::new("Properties"); + for path in paths { + properties.push(path.to_el()); + } + properties.push( + El::new("TextWrapOffset") + .with_attr("Top", num(offsets[0])) + .with_attr("Left", num(offsets[1])) + .with_attr("Bottom", num(offsets[2])) + .with_attr("Right", num(offsets[3])), + ); + El::new("TextWrapPreference") + .with_attr("Inverse", if inverse { "true" } else { "false" }) + .with_attr("ApplyToMasterPageOnly", "false") + .with_attr("TextWrapSide", side) + .with_attr("TextWrapMode", mode) + .with_child(properties) + .with_child(El::new("ContourOption").with_attr("ContourType", contour).with_attr("IncludeInsideEdges", "false")) + } +} + +/// Type-on-a-path settings. +#[derive(Clone, Debug, PartialEq)] +pub struct TextPath { + pub values: Values, +} + +impl TextPath { + /// Writes IDML type-on-a-path settings for a frame and its story. + pub fn to_el(frame_self: &str, parent_story: &str, alignment: &str, flipped: bool, start: f64, end: f64) -> El { + El::new("TextPath") + .with_attr("Self", format!("{frame_self}tp")) + .with_attr("ParentStory", parent_story) + .with_attr("PreviousTextFrame", "n") + .with_attr("NextTextFrame", "n") + .with_attr("PathEffect", "RainbowPathEffect") + .with_attr("PathAlignment", "CenterPathAlignment") + .with_attr("TextAlignment", alignment) + .with_attr("FlipPathEffect", if flipped { "Flipped" } else { "NotFlipped" }) + .with_attr("StartBracket", crate::value::num(start)) + .with_attr("EndBracket", crate::value::num(end)) + } + + pub fn parent_story(&self) -> Option<&str> { + self.values.get("ParentStory").filter(|s| *s != "n") + } + pub fn start_bracket(&self) -> Option { + self.values.num("StartBracket") + } + pub fn end_bracket(&self) -> Option { + self.values.num("EndBracket") + } + pub fn flipped(&self) -> bool { + self.values.get("FlipPathEffect") == Some("Flipped") + } + pub fn alignment(&self) -> Option<&str> { + self.values.get("TextAlignment") + } +} + /// What a placed graphic is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GraphicKind { @@ -420,6 +804,119 @@ impl Link { } } +/// A placed graphic's layer choices, independent of the document model that stores them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GraphicLayerOptions { + pub attributes: Vec<(String, String)>, + pub layers: Vec>, + /// Parent indices in pre-order. Missing entries and invalid parents are treated as top-level. + pub parents: Vec>, +} + +impl GraphicLayerOptions { + /// Reads a `GraphicLayerOption` subtree while preserving layer order and hierarchy. + pub fn from_el(option: &El) -> Self { + let attributes = option.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); + let mut roots: Vec<_> = option.elements().collect(); + roots.reverse(); + let mut pending: Vec<_> = roots.into_iter().map(|element| (element, None)).collect(); + let mut layers = Vec::new(); + let mut parents = Vec::new(); + while let Some((element, parent)) = pending.pop() { + let layer_parent = if element.local() == "GraphicLayer" { + let index = layers.len(); + layers.push(element.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect()); + parents.push(parent); + Some(index) + } else { + parent + }; + let mut children: Vec<_> = element.elements().collect(); + children.reverse(); + pending.extend(children.into_iter().map(|child| (child, layer_parent))); + } + Self { attributes, layers, parents } + } + + /// Serializes this layer tree, generating fresh IDs for the option and each layer. + pub fn to_el(&self, self_id: &str, mut fresh: impl FnMut() -> String) -> El { + let mut option = El::new("GraphicLayerOption").with_attr("Self", self_id); + for (key, value) in &self.attributes { + if valid_layer_attribute(key) { + option.set_attr(key, value); + } + } + // A parent must still be open in the pre-order walk. IDML layer groups have a depth cap. + let mut open = Vec::new(); + let mut parents = Vec::with_capacity(self.layers.len()); + for index in 0..self.layers.len() { + let parent = self.parents.get(index).copied().flatten(); + if let Some(at) = parent.and_then(|parent| open.iter().position(|id| *id == parent)).filter(|at| *at < 239) { + open.truncate(at + 1); + parents.push(parent); + } else { + open.clear(); + parents.push(None); + } + open.push(index); + } + let mut children: Vec> = (0..self.layers.len()).map(|_| Vec::new()).collect(); + let mut roots = Vec::new(); + for (index, attributes) in self.layers.iter().enumerate().rev() { + let mut layer = El::new("GraphicLayer").with_attr("Self", fresh()); + for (key, value) in attributes { + if valid_layer_attribute(key) { + layer.set_attr(key, value); + } + } + if let Some(nested) = children.get_mut(index) { + for child in std::mem::take(nested).into_iter().rev() { + layer.push(child); + } + } + if let Some(nested) = parents.get(index).copied().flatten().and_then(|parent| children.get_mut(parent)) { + nested.push(layer); + } else { + roots.push(layer); + } + } + for layer in roots.into_iter().rev() { + option.push(layer); + } + option + } +} + +fn valid_layer_attribute(key: &str) -> bool { + !key.is_empty() + && key != "Self" + && key != "xmlns" + && key + .bytes() + .enumerate() + .all(|(index, byte)| byte.is_ascii_alphabetic() || byte == b'_' || (index > 0 && (byte.is_ascii_digit() || matches!(byte, b'.' | b'-')))) +} + +/// A placed PDF's import options. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PdfAttribute { + values: Values, +} + +impl PdfAttribute { + pub fn page_index(&self) -> u32 { + self.values + .get("PageNumber") + .and_then(|value| value.trim().parse::().ok()) + .and_then(|page| u32::try_from(page.saturating_sub(1)).ok()) + .unwrap_or(0) + } + + pub fn crop(&self) -> Option<&str> { + self.values.get("PDFCrop") + } +} + impl Graphic { pub fn from_el(el: &El) -> Option { let kind = GraphicKind::of(el.local())?; @@ -449,6 +946,83 @@ impl Graphic { self.children.iter().find_map(|c| if let GraphicChild::Link(l) = c { Some(l) } else { None }) } + pub fn self_id(&self) -> Option<&str> { + self.values.self_id() + } + + pub fn applied_object_style(&self) -> Option<&str> { + self.values.get("AppliedObjectStyle") + } + + pub fn item_transform(&self) -> Matrix { + self.values.item_transform() + } + + /// GraphicBounds in IDML's `Left`, `Top`, `Right`, `Bottom` order. + pub fn bounds(&self) -> [f64; 4] { + self.values + .prop_el("GraphicBounds") + .map(Values::of) + .map(|values| ["Left", "Top", "Right", "Bottom"].map(|key| values.num(key).unwrap_or(0.0))) + .unwrap_or([0.0; 4]) + } + + /// Two finite values from InDesign's `ActualPpi` field. + pub fn actual_ppi(&self) -> Option<[f64; 2]> { + let source = self.values.get("ActualPpi")?; + let numbers: Vec = source.split_whitespace().take(3).filter_map(|value| value.parse().ok()).collect(); + <[f64; 2]>::try_from(numbers).ok().filter(|values| values.iter().all(|value| value.is_finite())) + } + + pub fn fill_color(&self) -> Option<&str> { + self.values.get("FillColor") + } + + pub fn fill_tint(&self) -> Option { + self.values.num("FillTint") + } + + /// The MIME fallback when a linked graphic's bytes cannot be sniffed. + pub fn fallback_mime(&self) -> &'static str { + let format = self.link().and_then(Link::format).unwrap_or(""); + match format { + value if value.contains("EPS") || self.kind == GraphicKind::EPS => "application/postscript", + value if value.contains("JPEG") => "image/jpeg", + value if value.contains("TIFF") => "image/tiff", + value if value.contains("PDF") || self.kind == GraphicKind::PDF => "application/pdf", + value if value.contains("GIF") => "image/gif", + _ => "image/png", + } + } + + pub fn layer_options(&self) -> Option { + self.children + .iter() + .find_map(|child| match child { + GraphicChild::Other(el) if el.local() == "GraphicLayerOption" => Some(el), + GraphicChild::Other(el) => el.find("GraphicLayerOption"), + GraphicChild::Link(link) => { + link.rest.iter().find_map(|el| if el.local() == "GraphicLayerOption" { Some(el) } else { el.find("GraphicLayerOption") }) + } + }) + .or_else(|| self.values.prop_el("GraphicLayerOption")) + .map(GraphicLayerOptions::from_el) + } + + pub fn pdf_attribute(&self) -> Option { + self.children + .iter() + .find_map(|child| match child { + GraphicChild::Other(el) if el.local() == "PDFAttribute" => Some(el), + GraphicChild::Other(el) => el.find("PDFAttribute"), + GraphicChild::Link(link) => { + link.rest.iter().find_map(|el| if el.local() == "PDFAttribute" { Some(el) } else { el.find("PDFAttribute") }) + } + }) + .or_else(|| self.values.prop_el("PDFAttribute")) + .map(|el| PdfAttribute { values: Values::of(el) }) + } + /// `Properties/GraphicBounds`: `top left bottom right` in the graphic's inner coordinates. pub fn graphic_bounds(&self) -> Option<[f64; 4]> { let b = self.values.prop_el("GraphicBounds")?; diff --git a/crates/idml/src/model/story.rs b/crates/idml/src/model/story.rs index 889c7b350..38839b0d3 100644 --- a/crates/idml/src/model/story.rs +++ b/crates/idml/src/model/story.rs @@ -2,9 +2,116 @@ //! anchored page items, hyperlink and change containers). use craft_xml::{El, Local, Node}; +use std::collections::{BTreeMap, HashMap}; use super::{PageItem, Values, tidy}; +/// Resolved IDML anchored-object options from an item and its inherited style levels. +#[derive(Clone, Debug, PartialEq)] +pub struct AnchoredObjectValues { + pub position: String, + pub anchor_point: String, + pub horizontal_alignment: String, + pub horizontal_reference: String, + pub vertical_alignment: String, + pub vertical_reference: String, + pub pin_position: bool, + pub spine_relative: bool, + pub space_above: f64, + pub x_offset: f64, + pub y_offset: f64, +} + +/// An IDML `AnchoredObjectSetting` element. +#[derive(Clone, Debug, PartialEq)] +pub struct AnchoredObjectSetting { + attributes: Vec<(String, String)>, +} + +impl AnchoredObjectSetting { + pub fn inline_position(y_offset: f64) -> Self { + Self { attributes: vec![("AnchoredPosition".into(), "InlinePosition".into()), ("AnchorYoffset".into(), crate::value::num(y_offset))] } + } + + pub fn above_line(horizontal_alignment: &str, space_above: f64, y_offset: f64) -> Self { + Self { + attributes: vec![ + ("AnchoredPosition".into(), "AboveLine".into()), + ("HorizontalAlignment".into(), horizontal_alignment.into()), + ("AnchorSpaceAbove".into(), crate::value::num(space_above)), + ("AnchorYoffset".into(), crate::value::num(y_offset)), + ], + } + } + + #[allow(clippy::too_many_arguments)] + pub fn anchored( + anchor_point: &str, + horizontal_reference: &str, + vertical_reference: &str, + horizontal_alignment: &str, + vertical_alignment: &str, + x_offset: f64, + y_offset: f64, + pin_position: bool, + spine_relative: bool, + ) -> Self { + Self { + attributes: vec![ + ("AnchoredPosition".into(), "Anchored".into()), + ("AnchorPoint".into(), anchor_point.into()), + ("HorizontalReferencePoint".into(), horizontal_reference.into()), + ("VerticalReferencePoint".into(), vertical_reference.into()), + ("HorizontalAlignment".into(), horizontal_alignment.into()), + ("VerticalAlignment".into(), vertical_alignment.into()), + ("AnchorXoffset".into(), crate::value::num(x_offset)), + ("AnchorYoffset".into(), crate::value::num(y_offset)), + ("PinPosition".into(), bool_string(pin_position)), + ("SpineRelative".into(), bool_string(spine_relative)), + ], + } + } + + pub fn to_el(&self) -> El { + let mut element = El::new("AnchoredObjectSetting"); + for (key, value) in &self.attributes { + element.set_attr(key, value); + } + element + } +} + +fn bool_string(value: bool) -> String { + if value { "true" } else { "false" }.into() +} + +impl AnchoredObjectValues { + /// Resolves attributes item-first from `AnchoredObjectSetting` children. + pub fn from_elements(elements: &[&El]) -> Self { + let get = |key: &str, default: &str| { + elements + .iter() + .filter_map(|element| element.child(Local("AnchoredObjectSetting"))) + .find_map(|setting| Values::of(setting).get(key).map(str::to_owned)) + .unwrap_or_else(|| default.to_owned()) + }; + let number = |key: &str| get(key, "0").parse::().ok().filter(|value| value.is_finite()).unwrap_or(0.0); + Self { + position: get("AnchoredPosition", "InlinePosition"), + anchor_point: get("AnchorPoint", "BottomRightAnchor"), + horizontal_alignment: get("HorizontalAlignment", "LeftAlign"), + horizontal_reference: get("HorizontalReferencePoint", "TextFrame"), + vertical_alignment: get("VerticalAlignment", "BottomAlign"), + vertical_reference: get("VerticalReferencePoint", "LineBaseline"), + pin_position: get("PinPosition", "true") == "true", + spine_relative: get("SpineRelative", "false") == "true", + space_above: number("AnchorSpaceAbove"), + x_offset: number("AnchorXoffset"), + y_offset: number("AnchorYoffset"), + } + } +} + /// A `Story`. #[derive(Clone, Debug, Default, PartialEq)] pub struct Story { @@ -12,6 +119,26 @@ pub struct Story { pub children: Vec, } +/// The IDML `StoryPreference` element written for a story. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StoryPreference { + pub optical_margin_alignment: bool, + pub optical_margin_size: f64, + pub vertical: bool, + pub right_to_left: bool, +} + +impl StoryPreference { + pub fn to_el(self) -> El { + El::new("StoryPreference") + .with_attr("OpticalMarginAlignment", self.optical_margin_alignment) + .with_attr("OpticalMarginSize", self.optical_margin_size) + .with_attr("FrameType", "TextFrameType") + .with_attr("StoryOrientation", if self.vertical { "Vertical" } else { "Horizontal" }) + .with_attr("StoryDirection", if self.right_to_left { "RightToLeftDirection" } else { "LeftToRightDirection" }) + } +} + /// 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)] @@ -24,10 +151,19 @@ pub enum TextNode { Content(Vec), /// `Br`: the end of a paragraph. Break, + /// A processing instruction at a structural story position. The parent serializer preserves + /// it in place; `to_el` represents it as a one-instruction `Content` fragment. + ProcessingInstruction { + target: String, + data: String, + }, /// 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), + Anchor(StoryAnchor), + PageReference(PageReference), + TextVariable(Values), /// A page item anchored in the text. Item(PageItem), /// Anything else (`StoryPreference`, `HyperlinkTextDestination`, `TextVariableInstance`, …), @@ -35,6 +171,264 @@ pub enum TextNode { Other(El), } +#[derive(Clone, Debug, PartialEq)] +pub struct StoryAnchor { + pub name: String, + pub values: Values, +} + +impl StoryAnchor { + /// Builds the IDML `Self` value for a hyperlink text destination. + pub fn destination_self(id: u64) -> String { + format!("HyperlinkTextDestination/a{id}") + } + + /// Writes an IDML `HyperlinkTextDestination` from a DesignCraft-independent anchor identity. + pub fn destination_el(id: u64, name: &str) -> El { + El::new("HyperlinkTextDestination") + .with_attr("Self", Self::destination_self(id)) + .with_attr("Name", name) + .with_attr("Hidden", "false") + .with_attr("DestinationUniqueKey", id & 0xFFFF_FFFF) + } + + pub fn self_id(&self) -> Option<&str> { + self.values.self_id() + } + pub fn label(&self) -> Option<&str> { + self.values.name() + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PageReference { + pub values: Values, +} + +/// Nested index topics and their See / See also references for one IDML `Index`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct IndexValues { + pub topics: BTreeMap, String>, + pub cross_references: Vec, +} + +/// An index cross-reference attached to a topic path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexCrossReferenceValues { + pub from: Vec, + pub reference_type: String, + pub target: String, +} + +/// A topic path and its sort keys, as they are saved on nested IDML `Topic` elements. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IndexTopicPath { + pub path: Vec, + pub sort: Vec, +} + +/// An IDML index cross-reference as stored under an index topic. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IndexReference { + pub from: IndexTopicPath, + pub referenced_topic: Option, + pub reference_type: Option, +} + +/// Parsed IDML index topics and cross-references. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IndexDocument { + pub topics: HashMap, + pub cross_references: Vec, +} + +impl IndexDocument { + pub fn from_el(element: &El) -> Self { + let mut result = Self::default(); + let mut stack: Vec<(&El, IndexTopicPath)> = + element.elements().filter(|child| child.local() == "Topic").map(|topic| (topic, IndexTopicPath::default())).collect(); + while let Some((topic, prefix)) = stack.pop() { + let name = topic.attr("Name").unwrap_or_default().to_string(); + let mut current = prefix.clone(); + current.path.push(name); + if let Some(key) = topic.attr("SortOrder").filter(|key| !key.is_empty()) { + current.sort.resize(prefix.path.len(), String::new()); + current.sort.push(key.to_string()); + } + if let Some(self_id) = topic.attr("Self") { + result.topics.insert(self_id.to_string(), current.clone()); + } + for child in topic.elements() { + if child.local() == "CrossReference" { + result.cross_references.push(IndexReference { + from: current.clone(), + referenced_topic: child.attr("ReferencedTopic").map(str::to_string), + reference_type: child.attr("CrossReferenceType").map(str::to_string), + }); + } else if child.local() == "Topic" { + stack.push((child, current.clone())); + } + } + } + result + } +} + +impl IndexValues { + /// Builds the IDML `Self` value for a topic path. + pub fn topic_self(path: &[String]) -> String { + let mut id = String::from("ix"); + for part in path { + id.push_str("Topicn"); + id.push_str(&crate::value::escape_id(part)); + } + id + } + + /// Writes the nested IDML index grammar, assigning a fresh ID to each cross-reference. + pub fn to_el(&self, mut fresh: impl FnMut() -> String) -> El { + let mut topics = self.topics.clone(); + for reference in &self.cross_references { + topics.entry(vec![reference.target.clone()]).or_default(); + } + + fn build( + topics: &BTreeMap, String>, + references: &[IndexCrossReferenceValues], + prefix: &[String], + fresh: &mut impl FnMut() -> String, + ) -> Vec { + let mut out = Vec::new(); + for (path, sort_order) in topics.iter().filter(|(path, _)| path.len() == prefix.len() + 1 && path.starts_with(prefix)) { + let mut element = El::new("Topic") + .with_attr("Self", IndexValues::topic_self(path)) + .with_attr("SortOrder", sort_order) + .with_attr("Name", path.last().map_or("", String::as_str)); + for reference in references.iter().filter(|reference| reference.from == *path) { + element.push( + El::new("CrossReference") + .with_attr("Self", fresh()) + .with_attr("ReferencedTopic", IndexValues::topic_self(std::slice::from_ref(&reference.target))) + .with_attr("CrossReferenceType", &reference.reference_type) + .with_attr("CustomTypeString", ""), + ); + } + element.children.extend(build(topics, references, path, fresh).into_iter().map(Node::El)); + out.push(element); + } + out + } + + let mut element = El::new("Index").with_attr("Self", "ix"); + element.children = build(&topics, &self.cross_references, &[], &mut fresh).into_iter().map(Node::El).collect(); + element + } +} + +/// Text saved inside an IDML `CrossReferenceSource` before the application regenerates it from +/// the cross-reference format. Page-number variables are separate segments so callers can retain +/// the last displayed page while mapping to their own text model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SavedCrossReference { + pub self_id: Option, + pub applied_format: Option, + pub parts: Vec, + /// The first directly nested character range, which supplies the marker's formatting. + pub character_range: Option, +} + +/// The IDML cross-reference source wrapper around its saved character range. +pub struct CrossReferenceSource; + +impl CrossReferenceSource { + pub fn to_el(self_id: &str, applied_format: &str, name: &str, character_range: El) -> El { + El::new("CrossReferenceSource") + .with_attr("Self", self_id) + .with_attr("AppliedFormat", applied_format) + .with_attr("Name", name) + .with_attr("Hidden", "false") + .with_child(character_range) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SavedCrossReferencePart { + Text(String), + PageNumber { result_text: Option }, +} + +impl SavedCrossReference { + /// Reads the bounded saved-text grammar. `page_variables` contains IDs whose variable type is + /// the cross-reference page number; when the document omits variable definitions, InDesign's + /// conventional `TV XRefPageNumber` suffix is recognized. + pub fn from_el(el: &El, page_variables: &[String]) -> SavedCrossReference { + let mut parsed = SavedCrossReference { + self_id: el.attr("Self").map(str::to_string), + applied_format: el.attr("AppliedFormat").map(str::to_string), + parts: Vec::new(), + character_range: el.find(Local("CharacterStyleRange")).and_then(|range| match TextNode::from_el(range) { + TextNode::Character(range) => Some(range), + _ => None, + }), + }; + saved_xref_parts(el, page_variables, &mut parsed.parts, 0); + parsed + } + + pub fn from_container(container: &Container, page_variables: &[String]) -> SavedCrossReference { + Self::from_el(&container.to_el(), page_variables) + } +} + +fn saved_xref_parts(el: &El, page_variables: &[String], parts: &mut Vec, depth: usize) { + if depth > 16 { + return; + } + for child in el.elements() { + match child.local() { + "Content" => parts.push(SavedCrossReferencePart::Text(child.deep_text())), + "TextVariableInstance" => { + let id = child.attr("AssociatedTextVariable").unwrap_or_default(); + let page_number = + page_variables.iter().any(|variable| variable == id) || (page_variables.is_empty() && id.ends_with("TV XRefPageNumber")); + if page_number { + parts.push(SavedCrossReferencePart::PageNumber { result_text: child.attr("ResultText").map(str::to_string) }); + } else { + parts.push(SavedCrossReferencePart::Text(child.attr("ResultText").unwrap_or_default().to_string())); + } + } + "Change" if child.attr("ChangeType") == Some("DeletedText") => {} + "CharacterStyleRange" | "Change" => saved_xref_parts(child, page_variables, parts, depth + 1), + _ => {} + } + } +} + +impl PageReference { + pub fn referenced_topic(&self) -> Option<&str> { + self.values.get("ReferencedTopic") + } + pub fn reference_type(&self) -> Option<&str> { + self.values.get("PageReferenceType") + } + pub fn limit(&self) -> Option { + self.values.num("PageReferenceLimit") + } + + /// Writes a page-reference record from the shared index model's type and target. + pub fn to_el(self_id: &str, reference_type: &str, topic: &str, id: usize, limit: Option) -> El { + let mut el = El::new("PageReference") + .with_attr("Self", self_id) + .with_attr("PageReferenceType", reference_type) + .with_attr("ReferencedTopic", topic) + .with_attr("Id", id); + if let Some(limit) = limit { + el.set_attr("PageReferenceLimit", limit); + } + el + } +} + /// The elements read as [`TextNode::Container`]. pub const CONTAINERS: [&str; 7] = ["HyperlinkTextSource", "Change", "XMLElement", "CrossReferenceSource", "Footnote", "Note", "EndnoteRange"]; @@ -54,6 +448,54 @@ pub struct Container { pub children: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContainerKind { + HyperlinkTextSource, + Change, + XMLElement, + CrossReferenceSource, + Footnote, + Note, + EndnoteRange, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrackedChangeKind { + Deleted, + Inserted, + Other, +} + +impl Container { + pub fn to_el(&self) -> El { + with_children(self.values.element(&self.name), &self.children) + } + + pub fn kind(&self) -> ContainerKind { + match self.name.as_str() { + "HyperlinkTextSource" => ContainerKind::HyperlinkTextSource, + "Change" => ContainerKind::Change, + "XMLElement" => ContainerKind::XMLElement, + "CrossReferenceSource" => ContainerKind::CrossReferenceSource, + "Footnote" => ContainerKind::Footnote, + "Note" => ContainerKind::Note, + _ => ContainerKind::EndnoteRange, + } + } + + pub fn self_id(&self) -> Option<&str> { + self.values.self_id() + } + + pub fn change_kind(&self) -> Option { + (self.kind() == ContainerKind::Change).then(|| match self.values.get("ChangeType") { + Some("DeletedText") => TrackedChangeKind::Deleted, + Some("InsertedText" | "MovedText") => TrackedChangeKind::Inserted, + _ => TrackedChangeKind::Other, + }) + } +} + /// A piece of a `Content` element. #[derive(Clone, Debug, PartialEq)] pub enum ContentPart { @@ -85,6 +527,11 @@ pub enum Ace { } impl Ace { + /// Parses an IDML ACE instruction's decimal code. + pub fn from_decimal(value: &str) -> Option { + value.trim().parse::().ok().map(Ace::from_code) + } + pub fn from_code(code: u32) -> Ace { match code { 4 => Ace::FootnoteNumber, @@ -115,6 +562,63 @@ pub struct Table { pub children: Vec, } +/// The row and column grid of an IDML table, read without allocating cell models. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableLayout { + pub rows: Vec, + pub columns: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableRow { + pub values: Values, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableColumn { + pub values: Values, +} + +impl TableLayout { + pub fn from_el(el: &El) -> TableLayout { + Self::from_table(&Table::from_el(el)) + } + + pub fn from_table(table: &Table) -> TableLayout { + let mut layout = TableLayout::default(); + for child in &table.children { + if let TableChild::Other(element) = child { + match element.local() { + "Row" => layout.rows.push(TableRow { values: Values::of(element) }), + "Column" => layout.columns.push(TableColumn { values: Values::of(element) }), + _ => {} + } + } + } + layout + } +} + +impl TableRow { + pub fn auto_grow(&self) -> Option { + self.values.boolean("AutoGrow") + } + + pub fn minimum_height(&self) -> Option { + self.values.num("MinimumHeight") + } + + pub fn single_row_height(&self) -> Option { + self.values.num("SingleRowHeight") + } +} + +impl TableColumn { + pub fn single_column_width(&self) -> Option { + self.values.num("SingleColumnWidth") + } +} + /// A child of a table, in document order. #[derive(Clone, Debug, PartialEq)] pub enum TableChild { @@ -130,13 +634,34 @@ pub struct Cell { pub children: Vec, } +/// Format-level options on an IDML table cell, before DesignCraft maps them into its model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CellOptions { + pub fill_color: Option, + pub fill_tint: Option, + pub insets: [Option; 4], + pub vertical_justification: Option, + pub first_baseline_offset: Option, + pub minimum_first_baseline_offset: Option, + pub rotation_angle: Option, + pub edge_stroke_priorities: [i32; 4], + pub edge_strokes: [super::StrokeValues; 4], +} + /// 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() + el.children + .iter() + .filter_map(|node| match node { + Node::El(e) if !e.is(Local("Properties")) => Some(TextNode::from_el(e)), + Node::Pi { target, data } => Some(TextNode::ProcessingInstruction { target: target.clone(), data: data.clone() }), + _ => None, + }) + .collect() } fn with_children(mut el: El, children: &[TextNode]) -> El { - el.children.extend(children.iter().map(|c| Node::El(c.to_el()))); + el.children.extend(children.iter().map(TextNode::to_node)); el } @@ -148,6 +673,11 @@ impl TextNode { "Content" => TextNode::Content(content_parts(el)), "Br" => TextNode::Break, "Table" => TextNode::Table(Table::from_el(el)), + "HyperlinkTextDestination" | "ParagraphDestination" => { + TextNode::Anchor(StoryAnchor { name: el.local().to_string(), values: Values::of(el) }) + } + "PageReference" => TextNode::PageReference(PageReference { values: Values::of(el) }), + "TextVariableInstance" => TextNode::TextVariable(Values::of(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), } @@ -170,13 +700,30 @@ impl TextNode { c } TextNode::Break => El::new("Br"), + TextNode::ProcessingInstruction { target, data } => { + let mut content = El::new("Content"); + content.children.push(Node::Pi { target: target.clone(), data: data.clone() }); + content + } TextNode::Container(c) => with_children(c.values.element(&c.name), &c.children), TextNode::Table(t) => t.to_el(), + TextNode::Anchor(a) => a.values.element(&a.name), + TextNode::PageReference(r) => r.values.element("PageReference"), + TextNode::TextVariable(v) => v.element("TextVariableInstance"), TextNode::Item(i) => i.to_el(), TextNode::Other(e) => e.clone(), } } + /// Serializes this node in its parent position, preserving processing instructions as XML + /// instructions rather than wrapping them in an element. + pub fn to_node(&self) -> Node { + match self { + TextNode::ProcessingInstruction { target, data } => Node::Pi { target: target.clone(), data: data.clone() }, + _ => Node::El(self.to_el()), + } + } + /// The nodes this one holds; none for content, breaks, page items and others. pub fn children(&self) -> &[TextNode] { match self { @@ -226,6 +773,26 @@ impl Story { }) } + /// Whether `StoryDirection` selects right-to-left order. + pub fn right_to_left(&self) -> bool { + self.preference().and_then(|values| values.get("StoryDirection").map(str::to_string)).as_deref() == Some("RightToLeftDirection") + } + + /// Whether `StoryOrientation` selects vertical composition. + pub fn vertical(&self) -> bool { + self.preference().and_then(|values| values.get("StoryOrientation").map(str::to_string)).as_deref() == Some("Vertical") + } + + /// Whether optical margin alignment is enabled; false when absent. + pub fn optical_margin_enabled(&self) -> bool { + self.preference().and_then(|values| values.boolean("OpticalMarginAlignment")) == Some(true) + } + + /// The `OpticalMarginSize`, if finite. + pub fn optical_margin_size(&self) -> Option { + self.preference().and_then(|values| values.num("OpticalMarginSize")) + } + /// 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> { @@ -275,6 +842,11 @@ fn text(nodes: &[TextNode], ace: &dyn Fn(Ace) -> Option) -> String { } } TextNode::Break => out.push('\n'), + TextNode::ProcessingInstruction { target, data } if target == "ACE" => { + if let Ok(code) = data.trim().parse::() { + out.extend(ace(Ace::from_code(code))); + } + } 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()), _ => {} @@ -284,6 +856,11 @@ fn text(nodes: &[TextNode], ace: &dyn Fn(Ace) -> Option) -> String { } impl Range { + /// `ParagraphBreakType`, except `Anywhere` which means no forced break. + pub fn paragraph_break_type(&self) -> Option<&str> { + self.values.get("ParagraphBreakType").filter(|kind| *kind != "Anywhere") + } + /// `AppliedParagraphStyle` of a paragraph range. pub fn paragraph_style(&self) -> Option<&str> { self.values.get("AppliedParagraphStyle") @@ -378,6 +955,14 @@ impl Table { } impl Cell { + pub fn from_el(el: &El) -> Cell { + Cell { values: Values::of(el), children: nodes(el) } + } + + pub fn to_el(&self) -> El { + with_children(self.values.element("Cell"), &self.children) + } + /// `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(':')?; @@ -390,6 +975,27 @@ impl Cell { (n("ColumnSpan"), n("RowSpan")) } + pub fn options(&self) -> CellOptions { + let inset = |current: &str, legacy: &str| self.values.num(current).or_else(|| self.values.num(legacy)); + let priority = |side: &str| self.values.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse().ok()).unwrap_or(0); + CellOptions { + fill_color: self.values.value("FillColor"), + fill_tint: self.values.num("FillTint"), + insets: [ + inset("TextTopInset", "TopInset"), + inset("TextLeftInset", "LeftInset"), + inset("TextBottomInset", "BottomInset"), + inset("TextRightInset", "RightInset"), + ], + vertical_justification: self.values.value("VerticalJustification"), + first_baseline_offset: self.values.value("FirstBaselineOffset"), + minimum_first_baseline_offset: self.values.num("MinimumFirstBaselineOffset"), + rotation_angle: self.values.num("RotationAngle"), + edge_stroke_priorities: [priority("TopEdge"), priority("LeftEdge"), priority("BottomEdge"), priority("RightEdge")], + edge_strokes: ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.values.stroke_values(side)), + } + } + pub fn paragraphs(&self) -> Vec<&Range> { paragraphs(&self.children) } diff --git a/crates/idml/src/part.rs b/crates/idml/src/part.rs index e8fd72d6b..d0d3661c3 100644 --- a/crates/idml/src/part.rs +++ b/crates/idml/src/part.rs @@ -22,6 +22,25 @@ pub const fn xml_limits() -> craft_xml::Limits { /// `META-INF/container.xml` of a package. pub const CONTAINER_XML: &str = "\n\n\t\n\t\t\n\t\n\n"; +/// The standard root element for `designmap.xml`, including its required namespace and core +/// document identity fields. The caller supplies DesignCraft-independent values; children are +/// appended after construction. +pub fn designmap_root(self_id: &str, story_list: &str, name: &str, zero_point: &str, active_layer: &str) -> El { + El::new("Document") + .with_attr("xmlns:idPkg", PACKAGING_NS) + .with_attr("DOMVersion", DOM_VERSION) + .with_attr("Self", self_id) + .with_attr("StoryList", story_list) + .with_attr("Name", name) + .with_attr("ZeroPoint", zero_point) + .with_attr("ActiveLayer", active_layer) +} + +/// An `idPkg` reference in `designmap.xml` to a part stored at `src`. +pub fn designmap_include(kind: &str, src: &str) -> El { + El::new(format!("idPkg:{kind}")).with_attr("src", src) +} + /// Parses a part into its root element with [`xml_limits`]. /// /// Names are kept as written (`idPkg:Story`), text is kept exactly (it is story content), CDATA @@ -112,10 +131,15 @@ pub fn part_xml(root: &El) -> Vec { /// `designmap.xml` as bytes: [`part_xml`] with the `` instruction that tells InDesign /// what the file is. pub fn designmap_xml(root: &El) -> Vec { + let pi = format!("", DOM_VERSION); + designmap_xml_with_pi(root, &pi) +} + +/// A designmap part with the caller's `` metadata instruction. +pub fn designmap_xml_with_pi(root: &El, pi: &str) -> Vec { let mut s = String::from(DECL_STANDALONE); - s.push_str("\n"); + s.push_str(pi); + s.push('\n'); root.write_xml_pretty(&mut s, "\t"); s.into_bytes() } diff --git a/crates/idml/tests/story.rs b/crates/idml/tests/story.rs index c21b1f398..fe2642edc 100644 --- a/crates/idml/tests/story.rs +++ b/crates/idml/tests/story.rs @@ -69,6 +69,10 @@ fn a_story_reads_as_ranges_and_content() { // The body paragraph's character range holds every kind of node. let body = paras[1].characters(); assert_eq!(body.len(), 1); + let TextNode::Anchor(destination) = &body[0].children[6] else { panic!() }; + assert_eq!(destination.name, "HyperlinkTextDestination"); + assert_eq!(destination.values.self_id(), Some("ud1")); + assert_eq!(destination.values.value("Name").as_deref(), Some("here")); let kinds: Vec = body[0] .children .iter() @@ -77,13 +81,14 @@ fn a_story_reads_as_ranges_and_content() { TextNode::Content(_) => "Content".into(), TextNode::Table(_) => "Table".into(), TextNode::Item(i) => format!("item {}", i.kind.name()), + TextNode::Anchor(anchor) => format!("anchor {}", anchor.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"] + ["HyperlinkTextSource", "Change", "Content", "Footnote", "Table", "item Rectangle", "anchor HyperlinkTextDestination", "Content"] ); } diff --git a/crates/image-codecs/Cargo.toml b/crates/image-codecs/Cargo.toml index 30f7f7046..b893c347a 100644 --- a/crates/image-codecs/Cargo.toml +++ b/crates/image-codecs/Cargo.toml @@ -30,6 +30,7 @@ zune-jpeg = "0.5" zune-core = "0.5" jpeg-encoder = "0.6" craft-jpeg = { workspace = true } +craft-metadata = { workspace = true } zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } craft-heif = { workspace = true, optional = true } diff --git a/crates/image-codecs/src/resolution.rs b/crates/image-codecs/src/resolution.rs index a787cacbc..6dbc9d5ee 100644 --- a/crates/image-codecs/src/resolution.rs +++ b/crates/image-codecs/src/resolution.rs @@ -1,575 +1,57 @@ -//! Image resolution (pixels per inch) recorded in metadata blocks: EXIF / TIFF IFD0 -//! (XResolution 282, YResolution 283, ResolutionUnit 296), XMP (`tiff:XResolution`, -//! `tiff:YResolution`, `tiff:ResolutionUnit`) and Photoshop's image resource -//! ResolutionInfo (`8BIM` resource 0x03ED); reading them, and rewriting the EXIF and XMP -//! copies on export so they agree with the resolution the file is written at. +//! Resolution metadata edits delegated to the shared [`craft_metadata::edit`] implementation. //! -//! Implemented from TIFF 6.0 (section 8, "Baseline fields": XResolution / YResolution are -//! RATIONAL, ResolutionUnit is SHORT, 1 = no absolute unit, 2 = inch, 3 = centimetre, -//! default 2), EXIF 2.3 (same tags in the 0th IFD, same default) and the Adobe Photoshop File -//! Formats Specification (ResolutionInfo: Fixed 16.16 hRes in pixels per inch, then the -//! display units; the vertical resolution likewise). -//! -//! **Precedence** when a file carries several, as Photoshop resolves them: Photoshop's own -//! ResolutionInfo, then the XMP `tiff:` properties, then EXIF, and only then the format's own -//! density field when it is the generic JFIF one (see the JPEG decoder). JFIF density is last -//! because cameras record the real resolution in EXIF while many tools and libraries write a -//! default JFIF density of 72 (or a bare aspect ratio) without knowing it; Photoshop shows a -//! camera JPEG at its EXIF resolution whatever its JFIF header says. Formats whose own field is -//! written deliberately (PNG `pHYs`, TIFF tags) keep it first; the metadata blocks only fill in -//! when it is missing. -//! -//! Everything read here is untrusted: offsets and counts are bounds-checked, both byte orders -//! and BigTIFF-style EXIF are handled, and anything malformed reads as "no resolution" rather -//! than as a wrong one. +//! Image codecs keep these wrappers so their public API and format-specific callers remain +//! stable while EXIF, XMP, and Photoshop ResolutionInfo parsing and editing live in one library. use std::borrow::Cow; -use crate::orientation::{Order, ifd0_entries, tiff_body, upright_exif, upright_xmp}; - -const TAG_X_RESOLUTION: u16 = 282; -const TAG_Y_RESOLUTION: u16 = 283; -const TAG_RESOLUTION_UNIT: u16 = 296; -const TYPE_SHORT: u16 = 3; -const TYPE_RATIONAL: u16 = 5; -const CM_PER_INCH: f64 = 2.54; -/// Photoshop's ResolutionInfo image resource. -const RESOLUTION_INFO: u16 = 0x03ED; -/// Resolutions above this (pixels per inch) are treated as damage, not data. -const MAX_PPI: f64 = 1_000_000.0; -/// Two resolutions this close (pixels per inch) are the same. -const SAME_PPI: f64 = 1e-3; - -/// A usable resolution pair, or `None`. -fn ppi_pair(x: f64, y: f64) -> Option<(f32, f32)> { - let ok = |v: f64| v.is_finite() && v > 0.0 && v <= MAX_PPI; - (ok(x) && ok(y)).then_some((x as f32, y as f32)) -} - -fn same(a: (f32, f32), b: (f32, f32)) -> bool { - (f64::from(a.0) - f64::from(b.0)).abs() < SAME_PPI && (f64::from(a.1) - f64::from(b.1)).abs() < SAME_PPI -} - -/// `ppi` as a TIFF RATIONAL: a whole number over 1 when it is one, else over 1000. -fn rational(ppi: f32) -> (u32, u32) { - let p = f64::from(ppi); - if (p - p.round()).abs() < SAME_PPI { (p.round() as u32, 1) } else { ((p * 1000.0).round() as u32, 1000) } -} - -/// One of the three resolution entries of IFD0. -#[derive(Clone, Copy)] -struct Field { - /// Offset of the 12- (BigTIFF: 20-) byte entry. - entry: usize, - tag: u16, - ty: u16, - count: u64, - /// Where the value bytes are, when the type is one we read and they lie inside the block. - value: Option, -} - -impl Field { - fn short(&self, order: Order, b: &[u8]) -> Option { - (self.ty == TYPE_SHORT && self.count == 1).then_some(())?; - order.u16(b, self.value?) - } - - fn rational(&self, order: Order, b: &[u8]) -> Option { - (self.ty == TYPE_RATIONAL && self.count == 1).then_some(())?; - let at = self.value?; - let (n, d) = (order.u32(b, at)?, order.u32(b, at.checked_add(4)?)?); - (d != 0).then(|| f64::from(n) / f64::from(d)) - } -} - -/// IFD0's resolution entries. -struct Ifd0 { - order: Order, - big: bool, - first: usize, - count: usize, - entry_bytes: usize, - fields: Vec, -} - -impl Ifd0 { - fn parse(b: &[u8]) -> Option { - let (order, first, count, entry_bytes, big) = ifd0_entries(b)?; - let mut fields = Vec::new(); - for i in 0..count { - let e = first.checked_add(i.checked_mul(entry_bytes)?)?; - let tag = order.u16(b, e)?; - if !matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) { - continue; - } - let ty = order.u16(b, e.checked_add(2)?)?; - let count = if big { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) }; - let value = value_at(order, b, e, big, ty, count); - fields.push(Field { entry: e, tag, ty, count, value }); - } - Some(Ifd0 { order, big, first, count, entry_bytes, fields }) - } - - fn first_of(&self, tag: u16) -> Option<&Field> { - self.fields.iter().find(|f| f.tag == tag) - } -} - -/// Where an entry's SHORT or RATIONAL value bytes are: inline in the entry when they fit, -/// else at the offset it holds. `None` for other types or when they leave the block. -fn value_at(order: Order, b: &[u8], e: usize, big: bool, ty: u16, count: u64) -> Option { - let size = match ty { - TYPE_SHORT => 2u64, - TYPE_RATIONAL => 8, - _ => return None, - } - .checked_mul(count)?; - let (field, inline) = if big { (e.checked_add(12)?, 8) } else { (e.checked_add(8)?, 4) }; - let at = if size <= inline { - field - } else if big { - usize::try_from(order.u64(b, field)?).ok()? - } else { - usize::try_from(order.u32(b, field)?).ok()? - }; - (at.checked_add(usize::try_from(size).ok()?)? <= b.len()).then_some(at) -} - /// The resolution (pixels per inch, horizontal and vertical) recorded in IFD0 of an EXIF block -/// (with or without the JPEG `Exif\0\0` prefix) or a TIFF structure. -/// -/// Both XResolution and YResolution must be one RATIONAL each with a non-zero denominator. -/// ResolutionUnit 2 (inch, also when the tag is absent) is used as is, 3 (centimetre) is -/// converted; 1 (no absolute unit), any other value or a malformed unit entry gives `None`. -/// The first of duplicated entries wins. +/// or TIFF structure. pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> { - let b = tiff_body(exif); - let ifd = Ifd0::parse(b)?; - let unit = match ifd.first_of(TAG_RESOLUTION_UNIT) { - None => 2, - Some(f) => f.short(ifd.order, b)?, - }; - let scale = match unit { - 2 => 1.0, - 3 => CM_PER_INCH, - _ => return None, - }; - let x = ifd.first_of(TAG_X_RESOLUTION)?.rational(ifd.order, b)?; - let y = ifd.first_of(TAG_Y_RESOLUTION)?.rational(ifd.order, b)?; - ppi_pair(x * scale, y * scale) + craft_metadata::edit::exif_resolution(exif) } -/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no -/// resolution entries, already records that resolution, or cannot be parsed. -/// -/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place, -/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are -/// removed from IFD0 instead, so no stale value survives; all other bytes are kept. +/// `exif` with its IFD0 resolution set to `ppi`, borrowed when no edit is needed. pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> { - exif_with_resolution_inner(exif, ppi, false) + craft_metadata::edit::exif_with_resolution(exif, ppi) } -/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none. -/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which -/// cannot carry a JFIF density marker. -pub(crate) fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> { - exif_with_resolution_inner(exif, ppi, true) -} - -/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed. -fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> { - if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() { - return Cow::Borrowed(exif); - } - if exif.is_empty() { - if !add_missing { - return Cow::Borrowed(exif); - } - // An image with DPI but no source EXIF still needs a valid EXIF block when the output - // format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker). - return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned()); - } - let body = tiff_body(exif); - let prefix = exif.len() - body.len(); - let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) }; - if exif_resolution(body).is_some_and(|r| same(r, ppi)) { - return Cow::Borrowed(exif); - } - let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none() - || ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none(); - if add_missing && missing_xy { - let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) }; - let mut full = exif.get(..prefix).unwrap_or_default().to_vec(); - full.extend_from_slice(&body); - return Cow::Owned(full); - } - if ifd.fields.is_empty() { - return Cow::Borrowed(exif); - } - let order = ifd.order; - let mut out = body.to_vec(); - let mut removed = Vec::new(); - for f in &ifd.fields { - let bytes = match f.tag { - TAG_RESOLUTION_UNIT if f.short(order, body).is_some() => put_u16(order, 2).to_vec(), - TAG_X_RESOLUTION | TAG_Y_RESOLUTION if f.rational(order, body).is_some() => { - let (n, d) = rational(if f.tag == TAG_X_RESOLUTION { ppi.0 } else { ppi.1 }); - [put_u32(order, n), put_u32(order, d)].concat() - } - _ => { - removed.push(f.entry); - continue; - } - }; - let slot = f.value.and_then(|at| out.get_mut(at..at.checked_add(bytes.len())?)); - match slot { - Some(slot) => slot.copy_from_slice(&bytes), - None => removed.push(f.entry), - } - } - if !removed.is_empty() { - remove_entries(&mut out, &ifd, &removed); - } - let mut full = exif.get(..prefix).unwrap_or_default().to_vec(); - full.extend_from_slice(&out); - if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) } -} - -/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original -/// bytes and offsets, since the TIFF block's existing data remains where it was. -fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option> { - let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) }; - let mut entries = Vec::with_capacity(ifd.count.saturating_add(3)); - for index in 0..ifd.count { - let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?; - let tag = ifd.order.u16(body, entry)?; - if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) { - continue; - } - entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec())); - } - let count = entries.len().checked_add(3)?; - if !ifd.big && count > usize::from(u16::MAX) { - return None; - } - - let new_ifd = align_up(body.len(), alignment)?; - let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?; - let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?; - let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?; - let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?; - let y_rational_at = rational_at.checked_add(8)?; - if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) { - return None; - } - - entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?)); - entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?)); - entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?)); - entries.sort_by_key(|(tag, _)| *tag); - - let mut out = body.to_vec(); - out.resize(new_ifd, 0); - if ifd.big { - out.extend_from_slice(&put_u64(ifd.order, count as u64)); - } else { - out.extend_from_slice(&put_u16(ifd.order, count as u16)); - } - for (_, entry) in entries { - out.extend_from_slice(&entry); - } - out.extend_from_slice(next_ifd); - out.resize(rational_at, 0); - let (xn, xd) = rational(ppi.0); - let (yn, yd) = rational(ppi.1); - out.extend_from_slice(&put_u32(ifd.order, xn)); - out.extend_from_slice(&put_u32(ifd.order, xd)); - out.extend_from_slice(&put_u32(ifd.order, yn)); - out.extend_from_slice(&put_u32(ifd.order, yd)); - - let offset_at: usize = if ifd.big { 8 } else { 4 }; - if ifd.big { - out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64)); - } else { - out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?)); - } - Some(out) -} - -/// One IFD entry. Rational fields store an offset; the SHORT unit is inline. -fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option> { - let entry_bytes = if big { 20 } else { 12 }; - let count_at = 4; - let value_at = if big { 12 } else { 8 }; - let mut entry = vec![0; entry_bytes]; - entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag)); - entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL })); - if big { - entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1)); - if tag == TAG_RESOLUTION_UNIT { - entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16)); - } else { - entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value)); - } - } else { - entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1)); - if tag == TAG_RESOLUTION_UNIT { - entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16)); - } else { - entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?)); - } - } - Some(entry) -} - -/// Round `value` up to the next `alignment` boundary, returning `None` on overflow. -fn align_up(value: usize, alignment: usize) -> Option { - value.checked_add((alignment - value % alignment) % alignment) -} - -fn put_u16(order: Order, v: u16) -> [u8; 2] { - match order { - Order::Little => v.to_le_bytes(), - Order::Big => v.to_be_bytes(), - } -} - -fn put_u32(order: Order, v: u32) -> [u8; 4] { - match order { - Order::Little => v.to_le_bytes(), - Order::Big => v.to_be_bytes(), - } -} - -fn put_u64(order: Order, v: u64) -> [u8; 8] { - match order { - Order::Little => v.to_le_bytes(), - Order::Big => v.to_be_bytes(), - } -} - -/// Removes the IFD0 entries at offsets `removed` (see [`exif_with_resolution`]). -fn remove_entries(b: &mut [u8], ifd: &Ifd0, removed: &[usize]) { - let eb = ifd.entry_bytes; - let next_bytes = if ifd.big { 8 } else { 4 }; - let count_bytes = if ifd.big { 8 } else { 2 }; - let Some(table_end) = ifd.count.checked_mul(eb).and_then(|n| ifd.first.checked_add(n)) else { return }; - let Some(old) = b.get(ifd.first..table_end.saturating_add(next_bytes)).map(<[u8]>::to_vec) else { return }; - let mut table = Vec::with_capacity(old.len()); - let mut kept = 0usize; - for (i, entry) in old.chunks_exact(eb).take(ifd.count).enumerate() { - if !removed.contains(&(ifd.first + i * eb)) { - table.extend_from_slice(entry); - kept += 1; - } - } - table.extend_from_slice(old.get(ifd.count * eb..).unwrap_or_default()); - table.resize(old.len(), 0); - let count: Vec = if ifd.big { put_u64(ifd.order, kept as u64).to_vec() } else { put_u16(ifd.order, kept as u16).to_vec() }; - let Some(count_at) = ifd.first.checked_sub(count_bytes) else { return }; - if let Some(slot) = b.get_mut(count_at..ifd.first) { - slot.copy_from_slice(&count); - } - if let Some(slot) = b.get_mut(ifd.first..ifd.first + table.len()) { - slot.copy_from_slice(&table); - } -} - -/// The resolution in a Photoshop image resource block (a sequence of `8BIM` resources, as in a -/// JPEG's APP13 "Photoshop 3.0" segment or TIFF tag 34377): its ResolutionInfo (0x03ED), whose -/// Fixed 16.16 hRes / vRes are pixels per inch whatever display unit follows them. +/// The resolution (pixels per inch) recorded by Photoshop's ResolutionInfo image resource. pub fn photoshop_resolution(resources: &[u8]) -> Option<(f32, f32)> { - let be16 = |at: usize| resources.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])); - let be32 = |at: usize| resources.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])); - let mut i = 0usize; - while i < resources.len() { - let signature = resources.get(i..i.checked_add(4)?)?; - let id = be16(i.checked_add(4)?)?; - // Pascal name: length byte plus text, padded to an even size. - let name_len = usize::from(*resources.get(i.checked_add(6)?)?); - let name = (name_len + 1).next_multiple_of(2); - let size_at = i.checked_add(6)?.checked_add(name)?; - let size = usize::try_from(be32(size_at)?).ok()?; - let data = size_at.checked_add(4)?; - if signature == b"8BIM" && id == RESOLUTION_INFO { - let info = resources.get(data..data.checked_add(size)?)?; - if info.len() < 16 { - return None; - } - let fixed = |at: usize| be32(data + at).map(|v| f64::from(v) / 65536.0); - return ppi_pair(fixed(0)?, fixed(8)?); - } - i = data.checked_add(size)?.checked_add(size % 2)?; - } - None + craft_metadata::edit::photoshop_resolution(resources) } -/// The text ranges of every value of XMP property `key` (`key="v"`, `key='v'` or -/// `v`), in order. -fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> { - let mut out = Vec::new(); - let mut from = 0; - while let Some(k) = xmp.get(from..).and_then(|s| s.find(key)).map(|k| k + from) { - from = k + key.len(); - // An attribute or an opening tag of exactly this name (not ``, not `xkey`). - if !xmp.get(..k).and_then(|h| h.chars().next_back()).is_some_and(|c| c == '<' || c.is_ascii_whitespace()) { - continue; - } - let rest = xmp.get(from..).unwrap_or_default(); - let after_key = rest.trim_start(); - let (value, close) = if let Some(r) = after_key.strip_prefix('=') { - let r = r.trim_start(); - match r.chars().next() { - Some(q @ ('"' | '\'')) => (r.get(1..).unwrap_or_default(), q), - _ => continue, - } - } else if let Some(r) = rest.strip_prefix('>') { - (r, '<') - } else { - continue; - }; - let start = xmp.len() - value.len(); - // Advance past this value before searching for the next key. Without this, `from` - // only moved past the key text, so property-like text repeated inside an unrelated - // attribute re-ran this full-suffix `find(close)` for every occurrence, which is - // quadratic in the packet size. A missing close means the value is unterminated, so - // no later value can parse either; stop scanning in that case. - let Some(len) = value.find(close) else { break }; - out.push((start, start + len)); - from = start + len + close.len_utf8(); - } - out -} - -/// An XMP rational (`300/1`) or plain number. -fn xmp_number(s: &str) -> Option { - let s = s.trim(); - match s.split_once('/') { - Some((n, d)) => { - let (n, d) = (n.trim().parse::().ok()?, d.trim().parse::().ok()?); - (d != 0.0).then(|| n / d) - } - None => s.parse().ok(), - } -} - -const XMP_X: &str = "tiff:XResolution"; -const XMP_Y: &str = "tiff:YResolution"; -const XMP_UNIT: &str = "tiff:ResolutionUnit"; - -/// The resolution (pixels per inch) in an XMP packet's `tiff:XResolution` / `tiff:YResolution` -/// (with `tiff:ResolutionUnit`, 2 when absent, 3 converted from centimetres), or `None`. +/// The resolution (pixels per inch, horizontal and vertical) recorded in an XMP packet. pub fn xmp_resolution(xmp: &str) -> Option<(f32, f32)> { - let first = |key: &str| xmp_values(xmp, key).first().and_then(|&(a, b)| xmp.get(a..b)); - let unit = match first(XMP_UNIT) { - None => 2.0, - Some(u) => xmp_number(u)?, - }; - let scale = if unit == 2.0 { - 1.0 - } else if unit == 3.0 { - CM_PER_INCH - } else { - return None; - }; - ppi_pair(xmp_number(first(XMP_X)?)? * scale, xmp_number(first(XMP_Y)?)? * scale) + craft_metadata::edit::xmp_resolution(xmp) } -/// `xmp` with its `tiff:` resolution properties set to `ppi` (and the unit to 2, inch), -/// borrowed when it has none or already says so. +/// `xmp` with its resolution set to `ppi`, borrowed when no edit is needed. pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> { - if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() || xmp_resolution(xmp).is_some_and(|r| same(r, ppi)) { - return Cow::Borrowed(xmp); - } - let text = |v: f32| { - let (n, d) = rational(v); - format!("{n}/{d}") - }; - let mut edits: Vec<(usize, usize, String)> = Vec::new(); - for (key, value) in [(XMP_X, text(ppi.0)), (XMP_Y, text(ppi.1)), (XMP_UNIT, "2".to_owned())] { - edits.extend(xmp_values(xmp, key).into_iter().map(|(a, b)| (a, b, value.clone()))); - } - if edits.is_empty() { - return Cow::Borrowed(xmp); - } - edits.sort_by_key(|e| e.0); - let mut out = String::with_capacity(xmp.len() + 16); - let mut at = 0; - for (a, b, v) in edits { - // Overlapping ranges can only come from malformed markup; keep the first edit. - if a < at { - continue; - } - out.push_str(xmp.get(at..a).unwrap_or_default()); - out.push_str(&v); - at = b; - } - out.push_str(xmp.get(at..).unwrap_or_default()); - if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) } + craft_metadata::edit::xmp_with_resolution(xmp, ppi) } -/// `exif` without its embedded thumbnail, or `None` when it has none (or doesn't parse). -/// -/// EXIF keeps the thumbnail in IFD1, the IFD IFD0's next-IFD pointer names. Finder, Android's -/// gallery and upload pickers show that thumbnail instead of decoding the image, so a copied one -/// keeps showing the photo as it was before it was edited (#2147, #2373). The pointer is cleared; -/// nothing else moves, so every other offset in the block stays valid. -fn exif_without_thumbnail(exif: &[u8]) -> Option> { - let body = tiff_body(exif); - let prefix = exif.len() - body.len(); - let (_, first, count, entry_bytes, big_tiff) = ifd0_entries(body)?; - let next = prefix.checked_add(first)?.checked_add(count.checked_mul(entry_bytes)?)?; - let width = if big_tiff { 8 } else { 4 }; - // In bounds: `ifd0_entries` checked that the whole pointer is present. - if exif.get(next..next.checked_add(width)?)?.iter().all(|b| *b == 0) { - return None; - } - let mut out = exif.to_vec(); - out.get_mut(next..next + width)?.fill(0); - Some(out) -} - -/// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see -/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and, -/// when `ppi` is known, the resolution (see [`exif_with_resolution`]). +/// The EXIF to write next to upright pixels at resolution `ppi`. pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> { - export_exif_inner(exif, ppi, false) + craft_metadata::edit::export_exif(exif, ppi) } -/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution -/// entries to valid metadata when necessary. +/// Export EXIF, adding missing resolution entries when `ppi` is supplied. pub(crate) fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> { - export_exif_inner(exif, ppi, true) + craft_metadata::edit::export_exif_adding_missing_resolution(exif, ppi) } -/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags. -fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> { - let up = upright_exif(exif); - let up = match exif_without_thumbnail(&up) { - Some(v) => Cow::Owned(v), - None => up, - }; - let Some(ppi) = ppi else { return up }; - let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) }; - if let Cow::Owned(v) = result { - return Cow::Owned(v); - } - up -} - -/// The XMP to write next to upright pixels at resolution `ppi` (see [`upright_xmp`] and -/// [`xmp_with_resolution`]). +/// The XMP to write next to upright pixels at resolution `ppi`. pub fn export_xmp(xmp: &str, ppi: Option<(f32, f32)>) -> Cow<'_, str> { - let up = upright_xmp(xmp); - let Some(ppi) = ppi else { return up }; - if let Cow::Owned(v) = xmp_with_resolution(&up, ppi) { - return Cow::Owned(v); - } - up + craft_metadata::edit::export_xmp(xmp, ppi) } -/// The resolution a decoded file's metadata blocks record, in precedence order: XMP, then -/// EXIF (Photoshop's ResolutionInfo is format-specific and read by the decoder). +/// Resolution recorded by XMP or EXIF when a format-specific resolution field is absent. +pub fn resolution(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> { + craft_metadata::edit::resolution(exif, xmp) +} + +/// Resolution a decoded file's EXIF and XMP blocks record, in precedence order. pub(crate) fn from_metadata(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> { - xmp.and_then(xmp_resolution).or_else(|| exif.and_then(exif_resolution)) + craft_metadata::edit::resolution(exif, xmp) } diff --git a/crates/svg/src/tree/tests.rs b/crates/svg/src/tree/tests.rs index 751e7726b..59b42dacc 100644 --- a/crates/svg/src/tree/tests.rs +++ b/crates/svg/src/tree/tests.rs @@ -81,7 +81,8 @@ fn every_limit_refuses_the_file_past_it() { let chain: String = (0..20).map(|i| format!("", i + 1)).collect(); let uses = svg(&format!("{chain}")); assert!(!guard::deeper_than(&uses, 24)); - assert_eq!(try_parse(&uses, &shallow()).err(), Some(Error::TooDeep(24))); + let referenced_depth = Limits { tree_depth: 24, ..shallow() }; + assert_eq!(try_parse(&uses, &referenced_depth).err(), Some(Error::TooDeep(24))); // Attributes on one element. let wide = svg(&format!("", (0..40).map(|i| format!(" a{i}='1'")).collect::())); assert!(try_parse(&wide, &Limits { attributes: 40, ..shallow() }).is_ok());