From dfd096babff2c2bb02fe88d4088fee4bacf6b078 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:59:52 -0500 Subject: [PATCH] Parse IDML text frame preferences into typed model --- crates/idml/src/model/mod.rs | 2 + crates/idml/src/model/preferences.rs | 99 ++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 crates/idml/src/model/preferences.rs diff --git a/crates/idml/src/model/mod.rs b/crates/idml/src/model/mod.rs index c53fa2b4d..f0815f780 100644 --- a/crates/idml/src/model/mod.rs +++ b/crates/idml/src/model/mod.rs @@ -15,6 +15,7 @@ mod document; mod effects; mod geometry; +mod preferences; mod resources; mod spread; mod story; @@ -23,6 +24,7 @@ use craft_xml::{El, Local, Node}; pub use document::{ModelDocument, TopLevelKind, TopLevelNode}; pub use effects::{DirectionalFeatherValues, DropShadowValues, FeatherValues, GradientFeatherValues, OpacityStopValues, TransparencySetting}; pub use geometry::{MAX_PATH_POINTS, PathGeometry, PathGeometryError, PathPoint, SubPath}; +pub use preferences::TextFramePreferences; pub use resources::{ ColorDefinition, ColorGroup, ColorGroupDefinition, ColorGroupMember, ColorSpace, CompositeFontDefinition, CompositeFontEntryDefinition, FontDefinition, FontFamilyDefinition, FontsPart, GradientDefinition, GradientStop, GradientStopDefinition, GradientStopValue, GraphicPart, Ink, diff --git a/crates/idml/src/model/preferences.rs b/crates/idml/src/model/preferences.rs new file mode 100644 index 000000000..48e5dc4ed --- /dev/null +++ b/crates/idml/src/model/preferences.rs @@ -0,0 +1,99 @@ +//! Typed views of IDML's reusable text-frame preference grammar. + +use craft_xml::El; + +use super::Values; + +/// 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"), + } + } +} + +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 + } +}