Files
designcraft/crates/idml/src/import.rs
T
Brandon Thomas a390905beb Merge branch 'pr/647' into batch1
# Conflicts:
#	crates/tools/src/lib.rs
#	crates/tools/src/tests.rs
#	crates/ui-egui/src/canvas.rs
2026-10-13 02:23:33 -04:00

2655 lines
125 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! IDML package → Document.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::sync::Arc;
use designcraft_color::cms::lab;
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch};
use designcraft_doc::{
Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document,
DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle,
Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId,
Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st,
};
use designcraft_geom::corners::{Corner, CornerOptions};
use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, unescape_id};
use crate::xml::{El, Node, parse};
use crate::{IdmlError, MIMETYPE, Result, base64_decode, sniff_image};
/// Preserve the legacy uniform field only when all four sides explicitly specify every
/// property. Partial records must stay sparse so they can inherit from a parent style.
fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<designcraft_doc::CellStroke> {
let first = edges.first()?;
if edges.iter().any(|edge| edge != first) {
return None;
}
Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? })
}
/// Import an IDML package. Linked images are read from disk when available (not on wasm).
pub fn import_idml(bytes: &[u8]) -> Result<Document> {
import_idml_with(bytes, &default_reader)
}
fn default_reader(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).ok()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
}
/// Import with a custom reader for linked files (`path` → bytes).
pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Document> {
import_idml_report(bytes, read_link).map(|r| r.document)
}
/// An imported IDML document and what the import could not bring in.
#[derive(Debug)]
pub struct Imported {
pub document: Document,
/// One line per problem that did not stop the import (a part listed in `designmap.xml` but
/// absent from the package, …).
pub warnings: Vec<String>,
}
/// Import with a custom reader for linked files, reporting the problems that did not stop it.
pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Imported> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
// Normalised part name → entry name, first entry wins (zip order, so the choice is stable).
let mut lenient: HashMap<String, String> = HashMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
if f.is_dir() {
continue;
}
let name = f.name().to_string();
// The size is what the archive claims: don't reserve more than a sane part up front.
let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24));
f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?;
lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone());
files.insert(name, buf);
}
let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n)));
if let Some(m) = part("mimetype")
&& String::from_utf8_lossy(m).trim() != MIMETYPE
{
return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim())));
}
let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?;
let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?;
if root.local() != "Document" {
return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name)));
}
// Flatten includes: every `idPkg:*` child is replaced by the children of its part's root.
let mut top: Vec<El> = Vec::new();
let mut warnings = Vec::new();
for c in root.elements() {
if c.name.starts_with("idPkg:") {
let Some(src) = c.get("src") else { continue };
let Some(data) = part(src) else {
warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported"));
continue;
};
let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?;
top.extend(el.elements().cloned());
} else {
top.push(c.clone());
}
}
let mut im = Importer::new(read_link);
im.run(&root, &top)?;
let document = im.finish(&root).map_err(|e| match e {
// Without its spread parts there is nothing to open: say which parts were missing.
IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))),
e => e,
})?;
document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;
Ok(Imported { document, warnings })
}
/// A package part name in the form the lookup compares: InDesign and other IDML writers differ
/// in percent-encoding, case, path separators and a leading `./` or `/`.
fn normalize_part_name(name: &str) -> String {
let decoded = percent_decode(&name.replace('\\', "/"));
let mut rest = decoded.as_str();
loop {
if let Some(r) = rest.strip_prefix("./") {
rest = r;
} else if let Some(r) = rest.strip_prefix('/') {
rest = r;
} else {
break;
}
}
rest.to_lowercase()
}
/// Decode `%XX` escapes; anything else (including a malformed escape) is kept as written.
pub(crate) fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
if c == b'%'
&& let (Some(h), Some(l)) = (b.get(i + 1).and_then(|h| (*h as char).to_digit(16)), b.get(i + 2).and_then(|l| (*l as char).to_digit(16)))
{
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
out.push(c);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
struct ItemCtx {
/// Self → (story self, prev self, next self)
threads: Vec<(ItemId, String, String, String)>,
}
struct Importer<'r> {
read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>,
next_id: u64,
settings: DocSettings,
default_margins: Option<(Margins, Columns)>,
swatches: Vec<Swatch>,
/// Swatch Self → our swatch name.
swatch_names: HashMap<String, String>,
/// Hidden (unnamed) colours by Self.
hidden_colors: HashMap<String, Color>,
/// Unnamed swatches (gradients) added on first use.
hidden_swatches: HashMap<String, Swatch>,
color_groups: Vec<designcraft_doc::ColorGroup>,
conditions: Vec<designcraft_doc::Condition>,
condition_names: HashMap<String, String>,
inks: designcraft_doc::InkManager,
stroke_styles: Vec<designcraft_doc::StrokeStyleDef>,
styles: Styles,
kinsoku: HashMap<String, designcraft_doc::cjk::Kinsoku>,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
lists: Vec<designcraft_doc::NumberedList>,
object_names: HashMap<String, String>,
cell_names: HashMap<String, String>,
table_names: HashMap<String, String>,
/// Object style Self → element (attribute fallback for page items).
object_els: HashMap<String, El>,
layers: Vec<Layer>,
layer_ids: HashMap<String, LayerId>,
stories: BTreeMap<StoryId, Story>,
story_ids: HashMap<String, StoryId>,
parents: Vec<Spread>,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
page_index: HashMap<String, usize>,
item_ids: HashMap<String, ItemId>,
assets: BTreeMap<AssetId, Arc<Asset>>,
/// Assets read from a link path, so later placements of that path reuse them unread.
linked_assets: HashMap<String, AssetId>,
ctx: ItemCtx,
sections: Vec<Section>,
footnote_options: designcraft_doc::FootnoteOptions,
/// Text destination Self → anchor id.
anchor_ids: HashMap<String, u64>,
/// Cross-reference source Self → (story, index in its cross-references).
xref_srcs: HashMap<String, (StoryId, usize)>,
/// Cross-reference format Self → name.
xref_format_names: HashMap<String, String>,
xref_formats: Vec<designcraft_doc::XrefFormat>,
/// Index topic Self → topic path and per-level sort keys; topic cross-references.
index_topics: HashMap<String, IndexTopic>,
index_xrefs: Vec<designcraft_doc::IndexRef>,
}
/// IDML stores a sort key on each topic, rather than on its page references.
#[derive(Clone, Default)]
struct IndexTopic {
path: Vec<String>,
sort: Vec<String>,
}
impl IndexTopic {
fn child(&self, topic: &El) -> Self {
let mut path = self.path.clone();
path.push(topic.get("Name").unwrap_or("").to_string());
let mut sort = self.sort.clone();
if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) {
// Keep empty positions before a child's key, but omit trailing defaults.
sort.resize(self.path.len(), String::new());
sort.push(key.to_string());
}
Self { path, sort }
}
}
fn lab_to_color(l: f32, a: f32, b: f32) -> Color {
let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b)));
Color::rgb(rgb[0], rgb[1], rgb[2])
}
fn nums(s: &str) -> Vec<f64> {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
fn parse_affine(s: Option<&str>) -> Affine {
match s.map(nums) {
Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]),
_ => Affine::IDENTITY,
}
}
fn parse_point(s: Option<&str>) -> Option<Point> {
let v = nums(s?);
(v.len() >= 2).then(|| Point::new(v[0], v[1]))
}
/// IDML tint (percent, `-1` = default) → 0..1.
fn tint(v: Option<f64>) -> Option<f32> {
v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 })
}
impl<'r> Importer<'r> {
fn new(read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>) -> Self {
let specials: Vec<Swatch> = designcraft_color::default_swatches().into_iter().filter(|s| s.locked).collect();
let mut swatch_names = HashMap::new();
swatch_names.insert("Swatch/None".to_string(), swatch::NONE.to_string());
swatch_names.insert("Color/Paper".to_string(), swatch::PAPER.to_string());
swatch_names.insert("Color/Black".to_string(), swatch::BLACK.to_string());
swatch_names.insert("Color/Registration".to_string(), swatch::REGISTRATION.to_string());
let mut styles = Styles::default();
// Built-ins are replaced by the file's definitions when present.
styles.paragraph.iter_mut().for_each(|s| s.based_on = None);
Importer {
read_link,
next_id: 0,
settings: DocSettings::default(),
default_margins: None,
swatches: specials,
swatch_names,
hidden_colors: HashMap::new(),
hidden_swatches: HashMap::new(),
color_groups: Vec::new(),
conditions: Vec::new(),
condition_names: HashMap::new(),
inks: Default::default(),
stroke_styles: Vec::new(),
styles,
kinsoku: HashMap::new(),
para_names: HashMap::new(),
char_names: HashMap::new(),
lists: Vec::new(),
object_names: HashMap::new(),
cell_names: HashMap::new(),
table_names: HashMap::new(),
object_els: HashMap::new(),
layers: Vec::new(),
layer_ids: HashMap::new(),
stories: BTreeMap::new(),
story_ids: HashMap::new(),
parents: Vec::new(),
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
item_ids: HashMap::new(),
assets: BTreeMap::new(),
linked_assets: HashMap::new(),
ctx: ItemCtx { threads: Vec::new() },
sections: Vec::new(),
footnote_options: Default::default(),
anchor_ids: HashMap::new(),
xref_srcs: HashMap::new(),
xref_format_names: HashMap::new(),
xref_formats: designcraft_doc::xref::default_formats(),
index_topics: HashMap::new(),
index_xrefs: Vec::new(),
}
}
fn alloc(&mut self) -> u64 {
self.next_id += 1;
self.next_id
}
fn run(&mut self, _root: &El, top: &[El]) -> Result<()> {
// Preferences first (page size, facing pages).
for e in top {
match e.local() {
"DocumentPreference" => self.doc_prefs(e),
"TransparencyPreference" => {
if let Some(v) = e.get("BlendingSpace") {
self.settings.blend_space = if v == "RGB" { designcraft_doc::BlendSpace::Rgb } else { designcraft_doc::BlendSpace::Cmyk };
}
}
"TextPreference" => {
let a = &mut self.settings.advanced_type;
for (k, v) in [
("SuperscriptSize", &mut a.superscript_size),
("SuperscriptPosition", &mut a.superscript_position),
("SubscriptSize", &mut a.subscript_size),
("SubscriptPosition", &mut a.subscript_position),
] {
if let Some(x) = e.num(k) {
*v = x;
}
}
}
"MarginPreference" => {
let (m, c) = margins_of(e);
self.default_margins = Some((m, c));
}
"ViewPreference" => {
if let Some(u) = e.get("HorizontalMeasurementUnits") {
self.settings.horizontal_units = names::unit_in(u);
}
if let Some(u) = e.get("VerticalMeasurementUnits") {
self.settings.vertical_units = names::unit_in(u);
}
if let Some(v) = e.num("CursorKeyIncrement") {
self.settings.keyboard_increment = v;
}
}
"GridPreference" => self.grid_prefs(e),
"PasteboardPreference" => {
if let Some(v) = e.get("PasteboardMargins").map(nums)
&& v.len() == 2
&& v[0] >= 0.0
&& v[1] >= 0.0
{
self.settings.pasteboard = (v[0], v[1]);
}
}
_ => {}
}
}
for e in top.iter().filter(|e| e.local() == "CompositeFont") {
let entries = e
.find_all("CompositeFontEntry")
.map(|c| designcraft_doc::cjk::CompositeFontEntry {
name: c.get("Name").unwrap_or("").into(),
characters: c.get("CustomCharacters").unwrap_or("").into(),
family: c.prop("AppliedFont").unwrap_or_default(),
style: c.get("FontStyle").unwrap_or("Regular").trim_start_matches("$ID/").into(),
relative_size: c.num("RelativeSize").unwrap_or(100.0) / 100.0,
horizontal_scale: c.num("HorizontalScale").unwrap_or(100.0) / 100.0,
vertical_scale: c.num("VerticalScale").unwrap_or(100.0) / 100.0,
baseline_shift: c.num("BaselineShift").unwrap_or(0.0) / 100.0,
scale_option: c.boolean("ScaleOption").unwrap_or(true),
})
.collect();
self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name: unescape_id(e.get("Name").unwrap_or("")), entries });
}
for e in top.iter().filter(|e| e.local() == "KinsokuTable") {
if e.get("CantBeginLineChars").is_some() || e.get("CantEndLineChars").is_some() {
self.kinsoku.insert(
e.get("Self").unwrap_or("").into(),
designcraft_doc::cjk::Kinsoku {
name: e.get("Name").unwrap_or("").into(),
no_start: e.get("CantBeginLineChars").unwrap_or("").into(),
no_end: e.get("CantEndLineChars").unwrap_or("").into(),
inseparable: e.get("CantBeSeparatedChars").unwrap_or("").into(),
hanging: e.get("HangingPunctuationChars").unwrap_or("").into(),
},
);
}
}
for e in top.iter().filter(|e| e.local() == "MojikumiTable") {
let overrides = e
.prop_el("OverrideMojikumiAkiList")
.map(|l| {
l.find_all("OverrideMojikumiAkiType")
.map(|r| designcraft_doc::cjk::MojikumiAki {
target_class: r.num("TargetMojikumiClass").unwrap_or(0.0) as i16,
side_class: r.num("SideMojikumiClass").unwrap_or(0.0) as i16,
after: r.boolean("SideIsAfterTarget").unwrap_or(false),
minimum: r.num("Minimum").unwrap_or(0.0),
desired: r.num("Desired").unwrap_or(0.0),
maximum: r.num("Maximum").unwrap_or(0.0),
priority: r.num("CompressionPriority").unwrap_or(0.0) as i16,
does_not_float: r.boolean("AkiDoesNotFloat").unwrap_or(false),
})
.collect()
})
.unwrap_or_default();
self.styles.mojikumi_tables.push(designcraft_doc::cjk::MojikumiTable {
name: unescape_id(e.get("Name").unwrap_or("")),
based_on: e.get("BasedOnMojikumiSet").unwrap_or("").into(),
overrides,
});
}
self.graphics(top);
self.styles(top);
if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") {
self.footnote_option(e);
}
for e in top.iter().filter(|e| e.local() == "Layer") {
let id = LayerId(self.alloc());
let color = match e.prop_el("LayerColor") {
Some(c) if c.get("type") == Some("list") => {
let v: Vec<f64> = c.find_all("ListItem").filter_map(|i| i.text_content().trim().parse().ok()).collect();
if v.len() == 3 { [v[0] as u8, v[1] as u8, v[2] as u8] } else { LAYER_COLORS[0].1 }
}
Some(c) => names::layer_color_in(c.text_content().trim()).unwrap_or(LAYER_COLORS[self.layers.len() % LAYER_COLORS.len()].1),
None => LAYER_COLORS[self.layers.len() % LAYER_COLORS.len()].1,
};
self.layers.push(Layer {
id,
name: e.get("Name").unwrap_or("Layer").to_string(),
color,
visible: e.get("Visible") != Some("false"),
locked: e.get("Locked") == Some("true"),
printable: e.get("Printable") != Some("false"),
show_guides: e.get("ShowGuides") != Some("false"),
suppress_wrap_when_hidden: e.get("IgnoreWrap") == Some("true"),
});
if let Some(s) = e.get("Self") {
self.layer_ids.insert(s.to_string(), id);
}
}
// IDML lists layers back to front; `Document::layers` is front to back.
self.layers.reverse();
if self.layers.is_empty() {
let id = LayerId(self.alloc());
self.layers.push(Layer {
id,
name: "Layer 1".into(),
color: LAYER_COLORS[0].1,
visible: true,
locked: false,
printable: true,
show_guides: true,
suppress_wrap_when_hidden: false,
});
}
for e in top.iter().filter(|e| e.local() == "CrossReferenceFormat") {
self.xref_format(e);
}
if let Some(ix) = top.iter().find(|e| e.local() == "Index") {
self.index_topics_of(ix, &IndexTopic::default());
}
// Stories (ids first so frames can reference them).
for e in top.iter().filter(|e| e.local() == "Story") {
let id = StoryId(self.alloc());
if let Some(s) = e.get("Self") {
self.story_ids.insert(s.to_string(), id);
}
let story = self.story(id, e);
self.stories.insert(id, story);
}
// Topic cross-references (See / See also) become markers at the start of the first story
// with text.
if !self.index_xrefs.is_empty()
&& let Some(st) = self.stories.values_mut().find(|s| !s.text.is_empty())
{
for r in std::mem::take(&mut self.index_xrefs).into_iter().rev() {
st.insert_index_ref(0, r);
}
}
// Cross-references point at their hyperlink's destination.
for h in top.iter().filter(|e| e.local() == "Hyperlink") {
let (Some(src), Some(dest)) = (h.get("Source"), h.prop("Destination")) else { continue };
let (Some(&(sid, k)), Some(&target)) = (self.xref_srcs.get(src), self.anchor_ids.get(dest.trim())) else { continue };
if let Some(x) = self.stories.get_mut(&sid).and_then(|st| st.xrefs.get_mut(k)) {
Arc::make_mut(x).target = target;
}
}
// Parent spreads: ids first (pages reference other parents).
let masters: Vec<&El> = top.iter().filter(|e| e.local() == "MasterSpread").collect();
let mut master_ids = Vec::new();
for m in &masters {
let id = SpreadId(self.alloc());
if let Some(s) = m.get("Self") {
self.parent_ids.insert(s.to_string(), id);
}
master_ids.push(id);
}
for (m, id) in masters.iter().zip(master_ids) {
let sp = self.spread(m, id, true);
self.parents.push(sp);
}
for e in top.iter().filter(|e| e.local() == "Spread") {
let id = SpreadId(self.alloc());
let sp = self.spread(e, id, false);
self.spreads.push(sp);
}
// Sections.
for e in top.iter().filter(|e| e.local() == "Section") {
let Some(start) = e.get("PageStart").and_then(|p| self.page_index.get(p)).copied() else { continue };
let cont = e.get("ContinueNumbering") != Some("false");
self.sections.push(Section {
start,
start_number: if cont && start > 0 { None } else { Some(e.num("PageNumberStart").unwrap_or(1.0).max(1.0) as u32) },
style: e.prop("PageNumberStyle").map(|s| names::number_style_in(s.trim())).unwrap_or_default(),
prefix: e.get("SectionPrefix").unwrap_or("").to_string(),
marker: e.get("Marker").unwrap_or("").to_string(),
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
self.sections.insert(
0,
Section {
start: 0,
start_number: Some(1),
style: Default::default(),
prefix: String::new(),
marker: String::new(),
include_prefix: false,
},
);
}
self.link_threads();
Ok(())
}
fn doc_prefs(&mut self, e: &El) {
let s = &mut self.settings;
if let Some(v) = e.get("OverprintBlack") {
s.overprint_black = v != "false";
}
if let Some(v) = e.num("PageWidth") {
s.page_width = v;
}
if let Some(v) = e.num("PageHeight") {
s.page_height = v;
}
if let Some(v) = e.boolean("FacingPages") {
s.facing_pages = v;
}
if let Some(v) = e.get("PageBinding") {
s.right_to_left_binding = v == "RightToLeft";
}
for (i, k) in ["DocumentBleedTopOffset", "DocumentBleedBottomOffset", "DocumentBleedInsideOrLeftOffset", "DocumentBleedOutsideOrRightOffset"]
.iter()
.enumerate()
{
if let Some(v) = e.num(k) {
s.bleed[i] = v;
}
}
for (i, k) in ["SlugTopOffset", "SlugBottomOffset", "SlugInsideOrLeftOffset", "SlugRightOrOutsideOffset"].iter().enumerate() {
if let Some(v) = e.num(k) {
s.slug[i] = v;
}
}
if let Some(i) = e.get("Intent") {
s.intent = match i {
"WebIntent" => designcraft_doc::Intent::Web,
"MobileIntent" => designcraft_doc::Intent::Mobile,
_ => designcraft_doc::Intent::Print,
};
}
if let Some(v) = e.boolean("CreatePrimaryTextFrame") {
s.primary_text_frame = v;
}
}
fn grid_prefs(&mut self, e: &El) {
let s = &mut self.settings;
let bg: &mut BaselineGrid = &mut s.baseline_grid;
if let Some(v) = e.num("BaselineStart") {
bg.start = v;
}
if let Some(v) = e.num("BaselineDivision") {
bg.increment = v;
}
if let Some(v) = e.num("BaselineViewThreshold") {
bg.view_threshold = v / 100.0;
}
if let Some(v) = e.get("BaselineGridRelativeOption") {
bg.relative_to = if v.contains("Margin") { GridRelative::TopMargin } else { GridRelative::TopOfPage };
}
if let Some(v) = e.num("HorizontalGridlineDivision") {
s.grid.horizontal = v;
}
if let Some(v) = e.num("VerticalGridlineDivision") {
s.grid.vertical = v;
}
if let Some(v) = e.num("HorizontalGridSubdivision") {
s.grid.subdivisions = v.max(1.0) as u32;
}
if let Some(v) = e.boolean("GridsInBack") {
s.grid.in_back = v;
}
}
// ---------- swatches ----------
fn color_of(e: &El) -> Option<(Color, ColorType)> {
let v = nums(e.get("ColorValue").unwrap_or(""));
let space = e.get("Space").unwrap_or("CMYK");
let c = match space {
"RGB" if v.len() >= 3 => Color::rgb((v[0] / 255.0) as f32, (v[1] / 255.0) as f32, (v[2] / 255.0) as f32),
"LAB" if v.len() >= 3 => lab_to_color(v[0] as f32, v[1] as f32, v[2] as f32),
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
_ => return None,
};
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
Some((c, ty))
}
fn graphics(&mut self, top: &[El]) {
let mut order: Vec<(String, Swatch)> = Vec::new();
// Colours.
for e in top.iter().filter(|e| e.local() == "Color") {
let Some(id) = e.get("Self") else { continue };
let Some((c, ty)) = Self::color_of(e) else { continue };
match id {
"Color/Paper" => {
if let Some(s) = self.swatches.iter_mut().find(|s| s.name == swatch::PAPER) {
s.value = SwatchValue::Paper { color: if c == Color::cmyk(0.0, 0.0, 0.0, 0.0) { Color::WHITE } else { c } };
}
continue;
}
"Color/Black" | "Color/Registration" => continue,
_ => {}
}
let name = e.get("Name").unwrap_or("");
if e.get("Visible") == Some("false") || name.is_empty() || name == "$ID/" || e.get("Model") == Some("Registration") {
self.hidden_colors.insert(id.to_string(), c);
continue;
}
let named = !name.starts_with("C=") && !name.starts_with("R=");
self.swatch_names.insert(id.to_string(), name.to_string());
order.push((
id.to_string(),
Swatch { name: name.to_string(), value: SwatchValue::Color { color: c, color_type: ty }, locked: false, named, hidden: false },
));
}
// Tints (after colours: they reference them).
for e in top.iter().filter(|e| e.local() == "Tint") {
let Some(id) = e.get("Self") else { continue };
let base = e.get("BaseColor").map(|b| self.swatch_ref(b)).unwrap_or_else(|| swatch::BLACK.to_string());
let t = e.num("TintValue").unwrap_or(100.0);
let name = match e.get("Name") {
Some(n) if !n.is_empty() && n != "$ID/" => n.to_string(),
_ => format!("{base} {}%", names::num(t)),
};
self.swatch_names.insert(id.to_string(), name.clone());
order.push((
id.to_string(),
Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true, hidden: false },
));
}
// Custom stroke styles.
for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let nums = |k: &str| e.get(k).unwrap_or("").split_whitespace().filter_map(|x| x.parse::<f64>().ok()).collect::<Vec<_>>();
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
bands: nums("StripeArray").as_chunks::<2>().0.iter().map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)).collect(),
},
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums("DashArray") },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
self.stroke_styles.push(designcraft_doc::StrokeStyleDef { name: name.to_string(), kind });
}
// Ink Manager.
for e in top.iter().filter(|e| e.local() == "Ink") {
let Some(name) = e
.get("Name")
.filter(|n| !n.starts_with("$ID/") && !["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"].contains(n))
else {
continue;
};
if e.get("ConvertToProcess") == Some("true") {
self.inks.to_process.push(name.to_string());
}
if let Some(a) = e.get("AliasInkName").filter(|a| !a.is_empty() && *a != name && !a.starts_with("$ID/")) {
self.inks.aliases.push((name.to_string(), a.to_string()));
}
}
for e in top.iter().filter(|e| e.local() == "Gradient") {
let Some(id) = e.get("Self") else { continue };
let mut stops: Vec<GradientStop> = Vec::new();
for s in e.find_all("GradientStop") {
let c = s.get("StopColor").map(|r| self.color_for_ref(r, &order)).unwrap_or(Color::BLACK);
let loc = s.num("Location").unwrap_or(0.0) / 100.0;
if let (Some(prev), Some(m)) = (stops.last_mut(), s.num("Midpoint")) {
prev.midpoint = (m / 100.0) as f32;
}
stops.push(GradientStop { offset: loc as f32, color: c, opacity: 1.0, midpoint: 0.5 });
}
let kind = if e.get("Type") == Some("Radial") { GradientKind::Radial } else { GradientKind::Linear };
let unnamed = e.get("Visible") == Some("false") || e.get("Name").is_none_or(|n| n.is_empty() || n == "$ID/");
let name = if unnamed { "Gradient".to_string() } else { e.get("Name").unwrap_or_default().to_string() };
let sw =
Swatch { name, value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, locked: false, named: !unnamed, hidden: false };
if unnamed {
// Unnamed gradients become swatches only when something uses them.
self.hidden_swatches.insert(id.to_string(), sw);
} else {
self.swatch_names.insert(id.to_string(), sw.name.clone());
order.push((id.to_string(), sw));
}
}
// Panel order from the root colour group.
let group_order: Vec<String> = top
.iter()
.filter(|e| e.local() == "ColorGroup")
.flat_map(|g| g.find_all("ColorGroupSwatch").filter_map(|s| s.get("SwatchItemRef").map(str::to_string)).collect::<Vec<_>>())
.collect();
if !group_order.is_empty() {
order.sort_by_key(|(id, _)| group_order.iter().position(|g| g == id).unwrap_or(usize::MAX));
}
let mut names: HashSet<String> = self.swatches.iter().map(|s| s.name.clone()).collect();
for (id, mut s) in order {
if names.contains(&s.name) {
let base = s.name.clone();
s.name = Styles::unique_name(|n| names.contains(n), &base);
self.swatch_names.insert(id, s.name.clone());
}
names.insert(s.name.clone());
self.swatches.push(s);
}
// Colour groups other than the root one become Swatches panel folders.
for g in top.iter().filter(|e| e.local() == "ColorGroup" && e.get("IsRootColorGroup") != Some("true")) {
let swatches: Vec<String> =
g.find_all("ColorGroupSwatch").filter_map(|s| s.get("SwatchItemRef")).filter_map(|r| self.swatch_names.get(r).cloned()).collect();
self.color_groups.push(designcraft_doc::ColorGroup { name: g.get("Name").unwrap_or("Color Group").to_string(), swatches });
}
}
fn color_for_ref(&self, r: &str, pending: &[(String, Swatch)]) -> Color {
if let Some(c) = self.hidden_colors.get(r) {
return *c;
}
match r {
"Color/Black" => return Color::cmyk(0.0, 0.0, 0.0, 1.0),
"Color/Paper" => return Color::WHITE,
"Color/Registration" => return Color::cmyk(1.0, 1.0, 1.0, 1.0),
_ => {}
}
if let Some((_, s)) = pending.iter().find(|(id, _)| id == r) {
match &s.value {
SwatchValue::Color { color, .. } => return *color,
SwatchValue::Tint { base, tint } => {
let mut all: Vec<Swatch> = self.swatches.clone();
all.extend(pending.iter().map(|(_, s)| s.clone()));
return designcraft_color::swatch::resolve(&all, base, *tint).unwrap_or(Color::BLACK);
}
_ => {}
}
}
Color::BLACK
}
/// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches.
/// A `TextFramePreference`, with its column rule colour resolved to a swatch.
fn frame_options(&mut self, e: &El) -> TextFrameOptions {
let mut o = text_frame_options(e);
if let Some(r) = e.get("ColumnRuleStrokeColor") {
o.column_rule_color = self.swatch_ref(r);
}
o
}
fn swatch_ref(&mut self, r: &str) -> String {
if let Some(n) = self.swatch_names.get(r) {
return n.clone();
}
if r == "n" || r.is_empty() {
return swatch::NONE.into();
}
if let Some(mut sw) = self.hidden_swatches.remove(r) {
sw.name = Styles::unique_name(|n| self.swatches.iter().any(|s| s.name == n), &sw.name);
self.swatch_names.insert(r.to_string(), sw.name.clone());
let name = sw.name.clone();
self.swatches.push(sw);
return name;
}
if let Some(c) = self.hidden_colors.get(r).copied() {
let name = match c {
Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()),
Color::Gray { k } => format!("K={}", (k * 100.0).round()),
};
if !self.swatches.iter().any(|s| s.name == name) {
self.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color: c, color_type: ColorType::Process },
locked: false,
named: false,
hidden: false,
});
}
self.swatch_names.insert(r.to_string(), name.clone());
return name;
}
swatch::NONE.into()
}
// ---------- styles ----------
fn styles(&mut self, top: &[El]) {
for e in top.iter().filter(|e| e.local() == "Condition") {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
// Indicator colours: "r g b" (0–255) or a UI colour name (kept as the default blue).
let nums: Vec<u8> = e
.get("IndicatorColor")
.unwrap_or("")
.split_whitespace()
.filter_map(|x| x.parse::<f64>().ok())
.map(|x| x.clamp(0.0, 255.0) as u8)
.collect();
let color = if nums.len() == 3 { [nums[0], nums[1], nums[2]] } else { [79, 153, 255] };
self.condition_names.insert(id.to_string(), name.to_string());
self.conditions.push(designcraft_doc::Condition { name: name.to_string(), color, visible: e.get("Visible") != Some("false") });
}
for e in top.iter().filter(|e| e.local() == "NumberingList") {
let name = e.get("Name").unwrap_or("").to_string();
if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) {
self.lists
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") });
}
}
let mut paras: Vec<(String, El)> = Vec::new();
let mut chars: Vec<(String, El)> = Vec::new();
let mut objects: Vec<(String, El)> = Vec::new();
let mut cell_styles: Vec<(String, El)> = Vec::new();
let mut table_styles: Vec<(String, El)> = Vec::new();
fn walk(e: &El, style: &str, group: &str, out: &mut Vec<(String, El)>) {
for c in e.elements() {
if c.local() == style {
out.push((c.get("Self").unwrap_or("").to_string(), c.clone()));
} else if c.local() == group {
walk(c, style, group, out);
}
}
}
for e in top {
match e.local() {
"RootParagraphStyleGroup" => walk(e, "ParagraphStyle", "ParagraphStyleGroup", &mut paras),
"RootCharacterStyleGroup" => walk(e, "CharacterStyle", "CharacterStyleGroup", &mut chars),
"RootObjectStyleGroup" => walk(e, "ObjectStyle", "ObjectStyleGroup", &mut objects),
"RootCellStyleGroup" => walk(e, "CellStyle", "CellStyleGroup", &mut cell_styles),
"RootTableStyleGroup" => walk(e, "TableStyle", "TableStyleGroup", &mut table_styles),
_ => {}
}
}
for (id, e) in &paras {
let n = names::style_name_in(PARA_BUILTINS, e.get("Name").unwrap_or(""));
self.para_names.insert(id.clone(), n);
}
for (id, e) in &chars {
let n = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or(""));
self.char_names.insert(id.clone(), n);
}
for (id, e) in &objects {
let n = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or(""));
self.object_names.insert(id.clone(), n);
self.object_els.insert(id.clone(), e.clone());
}
for (_, e) in &paras {
let name = names::style_name_in(PARA_BUILTINS, e.get("Name").unwrap_or(""));
let based_on = if name == designcraft_doc::NO_PARA_STYLE { None } else { self.based_on(e, true) };
let next_style = e.get("NextStyle").map(|n| self.para_style_ref(n)).filter(|n| *n != name);
let para = self.para_attrs(e);
let chars = self.char_attrs(e);
let s = ParagraphStyle { name: name.clone(), based_on, next_style, para, chars, shortcut: String::new() };
match self.styles.para_mut(&name) {
Some(slot) => *slot = s,
None => self.styles.paragraph.push(s),
}
}
for (_, e) in &chars {
let name = names::style_name_in(CHAR_BUILTINS, e.get("Name").unwrap_or(""));
let based_on = if name == st::NO_CHAR_STYLE { None } else { self.based_on(e, false) };
let chars = self.char_attrs(e);
let s = CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() };
match self.styles.char_style_mut(&name) {
Some(slot) => *slot = s,
None => self.styles.character.push(s),
}
}
// Keep built-ins as well: [Basic Table] is editable, and [No table style]
// supplies the defaults used by named styles in ordinary IDML files.
for (id, e) in &cell_styles {
self.cell_names.insert(id.clone(), names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or("")));
}
for (id, e) in &table_styles {
self.table_names.insert(id.clone(), names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or("")));
}
for (_, e) in &cell_styles {
let name = names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or(""));
let mut cs = designcraft_doc::CellStyle { name: name.clone(), ..Default::default() };
cs.based_on =
e.prop("BasedOn").map(|r| self.cell_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_CELL_STYLE.into())).filter(|n| *n != name);
cs.fill = e.prop("FillColor").map(|c| self.swatch_ref(&c));
cs.fill_tint = tint(e.num("FillTint"));
cs.inset_overrides = ["TopInset", "LeftInset", "BottomInset", "RightInset"].map(|k| e.num(k).map(|v| v.max(0.0)));
if let [Some(top), Some(left), Some(bottom), Some(right)] = cs.inset_overrides {
cs.insets = Some([top, left, bottom, right]);
cs.inset_overrides = [None; 4];
}
cs.vj = e.get("VerticalJustification").map(names::vj_in);
cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side));
if let Some(stroke) = uniform_cell_stroke(&cs.strokes) {
cs.stroke = Some(stroke);
cs.strokes = Default::default();
}
cs.paragraph_style = e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).filter(|n| n != designcraft_doc::NO_PARA_STYLE);
match self.styles.cell.iter_mut().find(|c| c.name == name) {
Some(slot) => *slot = cs,
None => self.styles.cell.push(cs),
}
}
for (_, e) in &table_styles {
let name = names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or(""));
let mut ts = designcraft_doc::TableStyle { name: name.clone(), ..Default::default() };
ts.based_on =
e.prop("BasedOn").map(|r| self.table_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_TABLE_STYLE.into())).filter(|n| *n != name);
ts.header = e.get("HeaderRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.body = e.get("BodyRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.footer = e.get("FooterRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.left_column =
e.get("LeftColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.right_column =
e.get("RightColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) });
ts.header_same_as_body = e.boolean("HeaderRegionSameAsBodyRegion");
ts.footer_same_as_body = e.boolean("FooterRegionSameAsBodyRegion");
ts.left_column_same_as_body = e.boolean("LeftColumnRegionSameAsBodyRegion");
ts.right_column_same_as_body = e.boolean("RightColumnRegionSameAsBodyRegion");
ts.borders = ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|side| self.cell_stroke_attrs(e, side));
if let Some(border) = uniform_cell_stroke(&ts.borders) {
ts.border = Some(border);
ts.borders = Default::default();
}
ts.row_fills = self.alt_fill_attrs(e, "Row");
ts.column_fills = self.alt_fill_attrs(e, "Column");
ts.row_strokes = self.alt_stroke_attrs(e, "Row");
ts.column_strokes = self.alt_stroke_attrs(e, "Column");
ts.space_before = e.num("SpaceBefore");
ts.space_after = e.num("SpaceAfter");
match self.styles.table.iter_mut().find(|c| c.name == name) {
Some(slot) => *slot = ts,
None => self.styles.table.push(ts),
}
}
for (_, e) in &objects {
let name = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or(""));
let based_on = e.prop("BasedOn").map(|b| {
self.object_names
.get(&b)
.cloned()
.unwrap_or_else(|| names::style_name_in(OBJECT_BUILTINS, &unescape_id(b.trim_start_matches("ObjectStyle/"))))
});
let based_on = based_on.filter(|b| *b != designcraft_doc::NO_OBJECT_STYLE && *b != name);
let enabled = |k: &str, attr: &str| match e.get(k) {
Some(v) => v == "true",
None => e.get(attr).is_some(),
};
let fill = enabled("EnableFill", "FillColor").then(|| {
let sw = e.get("FillColor").map(|r| self.swatch_ref(r)).unwrap_or_else(|| swatch::NONE.into());
Fill { swatch: sw, tint: tint(e.num("FillTint")).unwrap_or(1.0), ..Fill::none() }
});
let stroke = enabled("EnableStroke", "StrokeColor").then(|| self.stroke_from(e, None));
let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle")
.then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)))
.flatten();
let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") {
e.find("TextFramePreference").map(|t| self.frame_options(t))
} else {
None
};
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame };
match self.styles.object.iter_mut().find(|o| o.name == name) {
Some(slot) => *slot = s,
None => self.styles.object.push(s),
}
}
}
fn based_on(&self, e: &El, para: bool) -> Option<String> {
let b = e.prop("BasedOn")?;
let b = b.trim();
if para {
if let Some(n) = self.para_names.get(b) {
return Some(n.clone());
}
let n = names::style_name_in(PARA_BUILTINS, &unescape_id(b.trim_start_matches("ParagraphStyle/")));
Some(n)
} else {
if let Some(n) = self.char_names.get(b) {
return Some(n.clone()).filter(|n| n != st::NO_CHAR_STYLE);
}
let n = names::style_name_in(CHAR_BUILTINS, &unescape_id(b.trim_start_matches("CharacterStyle/")));
Some(n).filter(|n| n != st::NO_CHAR_STYLE)
}
}
/// Index topics (recursively); at the root, their See / See also cross-references too.
fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) {
for t in e.find_all("Topic") {
let topic = prefix.child(t);
if let Some(me) = t.get("Self") {
self.index_topics.insert(me.to_string(), topic.clone());
}
self.index_topics_of(t, &topic);
}
if !prefix.path.is_empty() {
return;
}
let mut stack = vec![(e, IndexTopic::default())];
while let Some((el, prefix)) = stack.pop() {
for t in el.find_all("Topic") {
let topic = prefix.child(t);
for x in t.find_all("CrossReference") {
let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default();
use designcraft_doc::index::IndexRange as R;
let range = match x.get("CrossReferenceType") {
Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target),
_ => R::See(target),
};
self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range });
}
stack.push((t, topic));
}
}
}
/// `CrossReferenceFormat`: building blocks back to a format definition.
fn xref_format(&mut self, e: &El) {
let name = e.get("Name").unwrap_or("Format").to_string();
let mut def = String::new();
for b in e.find_all("BuildingBlock") {
let text = |k: &str| b.get(k).filter(|v| *v != "$ID/").unwrap_or("").to_string();
match b.get("BlockType").unwrap_or("") {
"CustomStringBuildingBlock" => def.push_str(&text("CustomText")),
"FullParagraphBuildingBlock" => def.push_str("<fullPara />"),
"ParagraphTextBuildingBlock" => def.push_str("<paraText />"),
"ParagraphNumberBuildingBlock" => def.push_str("<paraNum />"),
"PageNumberBuildingBlock" => def.push_str("<pageNum />"),
"BookmarkNameBuildingBlock" => def.push_str("<txtAnchrName />"),
"ChapterNumberBuildingBlock" => def.push_str("<chapNum />"),
"FileNameBuildingBlock" => def.push_str("<fileName />"),
"PartialParagraphBuildingBlock" => def.push_str(&format!(
"<partialPara delim=\"{}\" includeDelim=\"{}\" />",
text("AppliedDelimiter"),
b.get("IncludeDelimiter") == Some("true")
)),
_ => {}
}
}
if let Some(me) = e.get("Self") {
self.xref_format_names.insert(me.to_string(), name.clone());
}
match self.xref_formats.iter_mut().find(|f| f.name == name) {
Some(f) => f.definition = def,
None => self.xref_formats.push(designcraft_doc::XrefFormat { name, definition: def }),
}
}
/// Preferences › `FootnoteOption` (after styles: it names the footnote text and marker styles).
fn footnote_option(&mut self, e: &El) {
let mut o = designcraft_doc::FootnoteOptions::default();
if let Some(v) = e.num("StartAt") {
o.start_at = v.max(0.0) as u32;
}
o.prefix = e.get("Prefix").unwrap_or("").into();
o.suffix = e.get("Suffix").unwrap_or("").into();
if let Some(r) = e.get("FootnoteTextStyle") {
o.para_style = self.para_style_ref(r);
}
if let Some(r) = e.get("FootnoteMarkerStyle") {
o.ref_char_style = self.char_style_ref(r);
}
if let Some(v) = e.get("SeparatorText") {
o.separator = v.into();
}
o.space_between = e.num("SpaceBetween").unwrap_or(0.0);
o.space_before = e.num("Spacer").unwrap_or(0.0);
if let Some(v) = e.prop("FootnoteFirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v.trim());
}
o.first_baseline_min = e.num("FootnoteMinimumFirstBaselineOffset").unwrap_or(0.0);
o.span_columns = e.boolean("EnableStraddling").unwrap_or(false);
o.rule.on = e.boolean("RuleOn").unwrap_or(true);
o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0);
o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0);
o.rule.width = e.num("RuleWidth").unwrap_or(72.0);
o.rule.offset = e.num("RuleOffset").unwrap_or(0.0);
if let Some(c) = e.prop("RuleColor") {
o.rule.color = self.swatch_ref(c.trim());
}
if let Some(v) = e.prop("FootnoteNumberingStyle") {
o.style = names::note_style_in(v.trim());
}
if let Some(v) = e.prop("RestartNumbering") {
o.restart = names::NOTE_RESTART.iter().find(|r| r.1 == v.trim()).map_or(Default::default(), |r| r.0);
}
if let Some(v) = e.prop("ShowPrefixSuffix") {
o.affix_in = names::NOTE_AFFIX.iter().find(|r| r.1 == v.trim()).map_or(Default::default(), |r| r.0);
}
if let Some(v) = e.prop("MarkerPositioning") {
o.ref_position = names::note_marker_in(v.trim());
}
self.footnote_options = o;
}
fn para_style_ref(&self, r: &str) -> String {
self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/"))))
}
fn char_style_ref(&self, r: &str) -> String {
self.char_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(CHAR_BUILTINS, &unescape_id(r.trim_start_matches("CharacterStyle/"))))
}
fn char_attrs(&mut self, e: &El) -> CharAttrs {
let mut a = CharAttrs::default();
if let Some(f) = e.prop("AppliedFont") {
let f = f.trim();
// Some writers append the style after a tab.
let fam = f.split('\t').next().unwrap_or(f);
if !fam.is_empty() && fam != "$ID/" {
a.font_family = Some(fam.to_string());
}
}
a.font_style = e.prop("FontStyle");
a.size = e.num("PointSize");
a.leading = e.prop("Leading").and_then(|l| names::leading_in(l.trim()));
// Automatic kerning may carry an inactive numeric value (including the
// 1e11 IDML sentinel). It must not become a multi-million-em advance.
a.kerning = e.prop("KerningMethod").and_then(|k| match k.trim().trim_start_matches("$ID/") {
"Optical" => Some(Kerning::Optical),
"Metrics" | "Metrics - Roman Only" => Some(Kerning::Metrics),
"None" => Some(Kerning::None),
_ => None,
});
if a.kerning.is_none() {
a.kerning = e.num("KerningValue").filter(|v| v.is_finite() && v.abs() < 1e10).map(Kerning::Manual);
}
a.tracking = e.num("Tracking");
a.leading_aki = e.num("LeadingAki").map(|v| (v >= 0.0).then_some(v));
a.trailing_aki = e.num("TrailingAki").map(|v| (v >= 0.0).then_some(v));
a.tsume = e.num("Tsume").map(|v| (v / 100.0).clamp(0.0, 1.0));
a.jidori = e.num("Jidori").map(|v| v.clamp(0.0, 10000.0) as u32);
a.leading_model = e.prop("LeadingModel").and_then(|v| crate::cjk::leading_in(&v));
a.character_alignment = e.prop("CharacterAlignment").and_then(|v| crate::cjk::alignment_in(&v));
a.h_scale = e.num("HorizontalScale").map(|v| v / 100.0);
a.v_scale = e.num("VerticalScale").map(|v| v / 100.0);
a.baseline_shift = e.num("BaselineShift");
a.skew = e.num("Skew");
if let Some(r) = e.prop("FillColor") {
a.fill = Some(self.swatch_ref(r.trim()));
}
a.fill_tint = tint(e.num("FillTint"));
if let Some(r) = e.prop("StrokeColor") {
a.stroke = Some(self.swatch_ref(r.trim()));
}
a.stroke_tint = tint(e.num("StrokeTint"));
a.stroke_weight = e.num("StrokeWeight");
a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim()));
a.position = e.prop("Position").and_then(|v| names::position_in(v.trim()));
a.underline = e.boolean("Underline");
a.strikethrough = e.boolean("StrikeThru");
a.ligatures = e.boolean("Ligatures");
// Underline / strikethrough options (-9999 = automatic).
for k in ["Underline", "StrikeThru"] {
let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0);
// An explicit Text Color resets an inherited swatch; absence still inherits.
// Resolve the sentinel before swatch_ref, which maps unknown names to [None].
let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) });
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
if k == "Underline" {
a.underline_weight = num("Weight").map(Some);
a.underline_offset = num("Offset").map(Some);
a.underline_color = color;
a.underline_tint = tint;
} else {
a.strikethrough_weight = num("Weight").map(Some);
a.strikethrough_offset = num("Offset").map(Some);
a.strikethrough_color = color;
a.strikethrough_tint = tint;
}
}
// OpenType features (only when the element sets any).
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
a.glyph_form = e.prop("GlyphForm");
a.no_break = e.boolean("NoBreak");
a.tate_chu_yoko = e.boolean("Tatechuyoko");
a.tate_chu_yoko_x_offset = e.num("TatechuyokoXOffset");
a.tate_chu_yoko_y_offset = e.num("TatechuyokoYOffset");
a.warichu = e.boolean("Warichu");
a.warichu_lines = e.num("WarichuLines").map(|v| v.max(2.0) as u32);
a.warichu_size = e.num("WarichuSize");
a.warichu_line_spacing = e.num("WarichuLineSpacing");
a.warichu_alignment = e.prop("WarichuAlignment").as_deref().and_then(crate::cjk::warichu_align_in);
a.warichu_chars_before_break = e.num("WarichuCharsBeforeBreak").map(|v| v.max(0.0) as u32);
a.warichu_chars_after_break = e.num("WarichuCharsAfterBreak").map(|v| v.max(0.0) as u32);
if let Some(on) = e.boolean("RubyFlag") {
a.ruby = Some(if on { e.prop("RubyString").unwrap_or_default() } else { String::new() });
}
if let Some(k) = e.get("KentenKind") {
a.kenten = Some(k != "None");
a.kenten_character = crate::cjk::kenten_character(k).map(str::to_string);
if k == "Custom" {
a.kenten_character = e.prop("KentenCustomCharacter");
}
}
a.digits = e.prop("DigitsType").and_then(|s| names::digits_in(s.trim()));
a.character_direction = e.prop("CharacterDirection").and_then(|s| crate::arabic::direction_in(&s));
a.allow_kashidas = e.prop("Kashidas").and_then(|s| crate::arabic::kashidas_in(&s));
a.diacritic_position = e.prop("DiacriticPosition").and_then(|s| crate::arabic::diacritic_in(&s));
a.diacritic_x_offset = e.num("XOffsetDiacritic").filter(|v| v.is_finite());
a.diacritic_y_offset = e.num("YOffsetDiacritic").filter(|v| v.is_finite());
a.positional_form = e.prop("PositionalForm");
if let Some(v) = e.get("AppliedConditions").filter(|v| !v.trim().is_empty()) {
let names: Vec<String> = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect();
if !names.is_empty() {
a.conditions = Some(names);
}
}
a.language = e.prop("AppliedLanguage").map(|l| l.trim().trim_start_matches("$ID/").to_string());
a
}
fn para_attrs(&mut self, e: &El) -> ParaAttrs {
let mut a = ParaAttrs::default();
let u = |k: &str| e.num(k).map(|v| v.max(0.0) as u32);
let frac = |k: &str| e.num(k).map(|v| v / 100.0);
a.align = e.prop("Justification").and_then(|v| names::align_in(v.trim()));
a.kinsoku_hang = e.prop("KinsokuHangType").and_then(|v| match v.as_str() {
"None" => Some(designcraft_doc::cjk::KinsokuHang::None),
"KinsokuHangRegular" => Some(designcraft_doc::cjk::KinsokuHang::Regular),
"KinsokuHangForce" => Some(designcraft_doc::cjk::KinsokuHang::Force),
_ => None,
});
a.mojikumi = e.prop("Mojikumi");
a.kinsoku_type = e.prop("KinsokuType");
a.bunri_kinshi = e.boolean("BunriKinshi");
a.rensuuji = e.boolean("Rensuuji");
a.treat_ideographic_space_as_space = e.boolean("TreatIdeographicSpaceAsSpace");
if let Some(k) = e.prop("KinsokuSet") {
if k == "Nothing" || k == "None" {
a.kinsoku = Some(Some(Default::default()));
} else if let Some(set) = self.kinsoku.get(&k) {
a.kinsoku = Some(Some(set.clone()));
} else if let Some(set) = designcraft_doc::cjk::Kinsoku::named(&k) {
a.kinsoku = Some(Some(set));
}
}
a.direction = e.prop("ParagraphDirection").map(|v| {
if v.trim() == "RightToLeftDirection" { designcraft_doc::TextDirection::RightToLeft } else { designcraft_doc::TextDirection::LeftToRight }
});
a.kashidas = e.prop("Kashidas").and_then(|s| crate::arabic::kashidas_in(&s));
a.arabic_justification = e.prop("ParagraphJustification");
a.paragraph_kashida_width = e.num("ParagraphKashidaWidth").filter(|v| v.is_finite()).map(Some);
a.left_indent = e.num("LeftIndent");
a.right_indent = e.num("RightIndent");
a.first_line_indent = e.num("FirstLineIndent");
a.last_line_indent = e.num("LastLineIndent");
a.space_before = e.num("SpaceBefore");
if let Some(r) = e.prop("AppliedNumberingList") {
let r = r.trim().trim_start_matches("NumberingList/");
a.list_name = Some(if r.starts_with("$ID/") { String::new() } else { unescape_id(r) });
}
match e.prop("NumberingContinue").as_deref().map(str::trim) {
Some("false") => a.start_at = Some(e.num("NumberingStartAt").map(|n| n.max(1.0) as u32)),
Some("true") => a.start_at = Some(None),
_ => {}
}
// Nested and GREP styles.
if let Some(l) = e.prop_el("AllNestedStyles") {
let v: Vec<designcraft_doc::NestedStyle> = l
.find_all("ListItem")
.map(|it| {
let t = |k: &str| it.find(k).map(|x| x.text_content()).unwrap_or_default();
let d = it.find("Delimiter");
designcraft_doc::NestedStyle {
style: self.char_style_ref(t("AppliedCharacterStyle").trim()),
through: t("Inclusive").trim() != "false",
count: t("Repetition").trim().parse().unwrap_or(1),
until: names::nested_until_in(d.and_then(|d| d.get("type")).unwrap_or("enumeration"), &t("Delimiter")),
}
})
.collect();
a.nested_styles = Some(v);
}
if let Some(l) = e.prop_el("AllNestedLineStyles") {
let v: Vec<designcraft_doc::NestedLineStyle> = l
.find_all("ListItem")
.map(|it| {
let t = |k: &str| it.find(k).map(|x| x.text_content()).unwrap_or_default();
designcraft_doc::NestedLineStyle {
style: self.char_style_ref(t("AppliedCharacterStyle").trim()),
lines: t("LineCount").trim().parse().unwrap_or(1),
}
})
.collect();
a.nested_line_styles = Some(v);
}
if let Some(l) = e.prop_el("AllGREPStyles") {
let v: Vec<designcraft_doc::GrepStyle> = l
.find_all("ListItem")
.map(|it| {
let t = |k: &str| it.find(k).map(|x| x.text_content()).unwrap_or_default();
designcraft_doc::GrepStyle { style: self.char_style_ref(t("AppliedCharacterStyle").trim()), pattern: t("GrepExpression") }
})
.collect();
a.grep_styles = Some(v);
}
a.space_after = e.num("SpaceAfter");
a.drop_cap_lines = u("DropCapLines");
a.drop_cap_chars = u("DropCapCharacters");
if let Some(g) = e.prop("GridAlignment") {
a.grid_align = Some(if g == "None" {
GridAlign::None
} else if e.boolean("GridAlignFirstLineOnly") == Some(true) {
GridAlign::FirstLineOnly
} else {
GridAlign::AllLines
});
}
a.composer = e
.prop("Composer")
.map(|c| if c.contains("Single") { designcraft_doc::Composer::SingleLine } else { designcraft_doc::Composer::Paragraph });
a.hyphenate = e.boolean("Hyphenation");
a.hyph_min_word = u("HyphenateWordsLongerThan");
a.hyph_after_first = u("HyphenateAfterFirst");
a.hyph_before_last = u("HyphenateBeforeLast");
a.hyph_limit = u("HyphenateLadderLimit");
a.hyph_zone = e.num("HyphenationZone");
a.hyph_capitalized = e.boolean("HyphenateCapitalizedWords");
a.hyph_last_word = e.boolean("HyphenateLastWord");
a.hyph_across_column = e.boolean("HyphenateAcrossColumns");
a.hyph_weight = e.num("HyphenWeight").map(|v| v / 10.0);
a.word_space_min = frac("MinimumWordSpacing");
a.word_space_desired = frac("DesiredWordSpacing");
a.word_space_max = frac("MaximumWordSpacing");
a.letter_space_min = frac("MinimumLetterSpacing");
a.letter_space_desired = frac("DesiredLetterSpacing");
a.letter_space_max = frac("MaximumLetterSpacing");
a.glyph_scale_min = frac("MinimumGlyphScaling");
a.glyph_scale_desired = frac("DesiredGlyphScaling");
a.glyph_scale_max = frac("MaximumGlyphScaling");
a.auto_leading = frac("AutoLeading");
a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim()));
a.keep_with_next = u("KeepWithNext");
a.keep_lines_together = e.boolean("KeepLinesTogether");
a.keep_all_lines = e.boolean("KeepAllLinesTogether");
a.keep_first = u("KeepFirstLines");
a.keep_last = u("KeepLastLines");
a.start_paragraph = e.prop("StartParagraph").and_then(|v| names::start_para_in(v.trim()));
if let Some(t) = e.prop("SpanColumnType") {
let n = e.prop("SpanSplitColumnCount").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
a.span_columns = Some(match t.trim() {
"SpanColumns" => SpanColumns::Span(n),
"SplitColumns" => SpanColumns::Split(n.max(2)),
_ => SpanColumns::Single,
});
}
a.split_inside_gutter = e.num("SplitColumnInsideGutter").filter(|v| v.is_finite());
a.split_outside_gutter = e.num("SplitColumnOutsideGutter").filter(|v| v.is_finite());
a.rule_above = self.rule(e, "RuleAbove");
a.rule_below = self.rule(e, "RuleBelow");
if let Some(tl) = e.prop_el("TabList") {
let mut tabs = Vec::new();
for li in tl.find_all("ListItem") {
let get = |k: &str| li.find(k).map(|x| x.text_content());
tabs.push(TabStop {
position: get("Position").and_then(|v| v.trim().parse().ok()).unwrap_or(0.0),
align: names::tab_align_in(get("Alignment").unwrap_or_default().trim()),
leader: get("Leader").unwrap_or_default(),
align_on: get("AlignmentCharacter").filter(|c| c != ".").unwrap_or_default(),
});
}
a.tabs = Some(tabs);
}
a.list_type = e.prop("BulletsAndNumberingListType").map(|v| match v.trim() {
"BulletList" => ListType::Bullets,
"NumberedList" => ListType::Numbers,
_ => ListType::None,
});
// A format with no counterpart (CJK, Arabic, Hebrew numbering) is left unset: decimal.
a.number_style = e.prop("NumberingFormat").and_then(|v| names::numbering_format_in(&v));
a.number_expression = e.prop("NumberingExpression");
a.list_separator = e.prop("BulletsTextAfter");
if let Some(b) = e.prop_el("BulletChar") {
// A glyph of a named font (`GlyphWithFont`) has no character to map to.
let unicode = matches!(b.get("BulletCharacterType"), Some("UnicodeOnly" | "UnicodeWithFont"));
if let Some(c) = b.get("BulletCharacterValue").and_then(|v| v.trim().parse::<u32>().ok()).and_then(char::from_u32).filter(|_| unicode) {
a.bullet_char = Some(c.to_string());
}
}
a.balance_ragged = e.prop("BalanceRaggedLines").map(|v| v.trim() != "NoBalancing" && v.trim() != "false");
a.shading_on = e.boolean("ParagraphShadingOn");
if let Some(c) = e.prop("ParagraphShadingColor") {
a.shading_color = Some(self.swatch_ref(c.trim()));
}
a.shading_tint = tint(e.num("ParagraphShadingTint"));
let sides = |f: &dyn Fn(&str) -> String| -> Option<[f64; 4]> {
let v = ["Top", "Left", "Bottom", "Right"].map(|s| e.num(&f(s)));
v.iter().any(Option::is_some).then(|| v.map(|x| x.unwrap_or(0.0)))
};
a.shading_offsets = sides(&|s| format!("ParagraphShading{s}Offset"));
a.border_on = e.boolean("ParagraphBorderOn");
if let Some(c) = e.prop("ParagraphBorderColor") {
a.border_color = Some(self.swatch_ref(c.trim()));
}
a.border_tint = tint(e.num("ParagraphBorderTint"));
a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight"));
a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset"));
a
}
fn rule(&mut self, e: &El, k: &str) -> Option<Rule> {
let on = e.boolean(k);
let weight = e.num(&format!("{k}LineWeight"));
on.or(weight.map(|_| false))?;
let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() };
if let Some(w) = weight {
r.weight = w;
}
if let Some(c) = e.prop(&format!("{k}Color")) {
let c = c.trim();
if c.contains('/') {
r.color = self.swatch_ref(c);
}
}
if let Some(t) = tint(e.num(&format!("{k}Tint"))) {
r.tint = t;
}
r.column_width = e.prop(&format!("{k}Width")).is_none_or(|w| w.trim() != "TextWidth");
r.offset = e.num(&format!("{k}Offset")).unwrap_or(0.0);
r.left_indent = e.num(&format!("{k}LeftIndent")).unwrap_or(0.0);
r.right_indent = e.num(&format!("{k}RightIndent")).unwrap_or(0.0);
Some(r)
}
fn cell_style_ref(&self, reference: &str) -> String {
let reference = reference.trim();
self.cell_names
.get(reference)
.cloned()
.unwrap_or_else(|| names::style_name_in(names::CELL_BUILTINS, &unescape_id(reference.trim_start_matches("CellStyle/"))))
}
fn table_style_ref(&self, reference: &str) -> String {
let reference = reference.trim();
self.table_names
.get(reference)
.cloned()
.unwrap_or_else(|| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(reference.trim_start_matches("TableStyle/"))))
}
fn cell_stroke_attrs(&mut self, el: &El, prefix: &str) -> designcraft_doc::CellStrokeAttrs {
designcraft_doc::CellStrokeAttrs {
weight: el.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)),
color: el.prop(&format!("{prefix}StrokeColor")).map(|v| self.swatch_ref(&v)),
tint: tint(el.num(&format!("{prefix}StrokeTint"))),
kind: el.prop(&format!("{prefix}StrokeType")).map(|v| names::stroke_type_in(&v)),
}
}
fn alt_fill_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltFillsAttrs {
designcraft_doc::AltFillsAttrs {
first: el.num(&format!("Start{kind}FillCount")).map(|v| v.max(0.0) as u32),
first_color: el.prop(&format!("Start{kind}FillColor")).map(|v| self.swatch_ref(&v)),
first_tint: tint(el.num(&format!("Start{kind}FillTint"))),
next: el.num(&format!("End{kind}FillCount")).map(|v| v.max(0.0) as u32),
next_color: el.prop(&format!("End{kind}FillColor")).map(|v| self.swatch_ref(&v)),
next_tint: tint(el.num(&format!("End{kind}FillTint"))),
skip_first: el.num(&format!("SkipFirstAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32),
skip_last: el.num(&format!("SkipLastAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32),
}
}
fn alt_stroke_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltStrokesAttrs {
let first_stroke = self.cell_stroke_attrs(el, &format!("Start{kind}"));
let mut next_stroke = self.cell_stroke_attrs(el, &format!("End{kind}"));
// IDML uses LineStyle for this one attribute (rather than StrokeType).
if kind == "Column"
&& let Some(value) = el.prop("EndColumnLineStyle")
{
next_stroke.kind = Some(names::stroke_type_in(&value));
}
designcraft_doc::AltStrokesAttrs {
first: el.num(&format!("Start{kind}StrokeCount")).map(|v| v.max(0.0) as u32),
next: el.num(&format!("End{kind}StrokeCount")).map(|v| v.max(0.0) as u32),
first_stroke,
next_stroke,
skip_first: el.num(&format!("SkipFirstAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32),
skip_last: el.num(&format!("SkipLastAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32),
}
}
// ---------- stories ----------
fn story(&mut self, id: StoryId, e: &El) -> Story {
self.story_with_para(id, e, ParaFormat::default())
}
fn story_with_para(&mut self, id: StoryId, e: &El, default_para: ParaFormat) -> Story {
let mut b = StoryBuilder {
text: String::new(),
paras: vec![default_para.clone()],
runs: Vec::new(),
fresh: true,
last: CharFormat::default(),
tables: Vec::new(),
after_table: None,
notes: Vec::new(),
anchors: Vec::new(),
xrefs: Vec::new(),
index_refs: Vec::new(),
objects: Vec::new(),
};
self.walk_story(e, &mut b, &default_para, &CharAttrs::default(), &CharFormat::default(), None);
let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b;
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
if chars.is_empty() {
chars.push(CharRun { len: 0, format: last });
}
let mut tables = BTreeMap::new();
for (pi, t) in tbls {
if let Some(p) = paras.get_mut(pi) {
p.table = Some(t.id);
tables.insert(t.id, Arc::new(t));
}
}
let notes = notes.into_iter().enumerate().map(|(i, text)| Arc::new(designcraft_doc::Footnote { id: i as u64 + 1, text })).collect();
let mut anchor_list = Vec::new();
for (k, (me, name)) in anchors.into_iter().enumerate() {
let aid = (id.0 << 24) | (k as u64 + 1);
self.anchor_ids.insert(me, aid);
anchor_list.push(Arc::new(designcraft_doc::TextAnchor { id: aid, name }));
}
let mut xref_list = Vec::new();
for (k, (me, fmt)) in xrefs.into_iter().enumerate() {
self.xref_srcs.insert(me, (id, k));
let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into());
xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format }));
}
let mut st = Story {
id,
text,
paras,
chars,
frames: vec![],
direction: if e.find("StoryPreference").and_then(|p| p.get("StoryDirection")) == Some("RightToLeftDirection") {
designcraft_doc::TextDirection::RightToLeft
} else {
designcraft_doc::TextDirection::LeftToRight
},
vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"),
rev: 0,
tables,
notes,
endnotes: Vec::new(),
anchors: anchor_list,
xrefs: xref_list,
index_refs: index_refs.into_iter().map(Arc::new).collect(),
objects: objects.into_iter().map(Arc::new).collect(),
editorial: Vec::new(),
link: None,
};
st.fix_notes();
st.fix_marks();
// Drop anything inconsistent (e.g. a table sharing a paragraph with another).
if st.check().is_err() {
st.tables.retain(|_, _| false);
for p in &mut st.paras {
p.table = None;
}
}
st
}
/// `<Table>`: rows, columns and cells (cell `Name` is `column:row`).
fn table(&mut self, e: &El) -> Table {
let id = self.alloc();
let rows: Vec<&El> = e.find_all("Row").collect();
let cols: Vec<&El> = e.find_all("Column").collect();
let nr = rows.len().max(1);
let nc = cols.len().max(1);
let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") {
designcraft_doc::TextDirection::RightToLeft
} else {
designcraft_doc::TextDirection::LeftToRight
};
for (r, re) in rows.iter().enumerate() {
let auto = re.boolean("AutoGrow").unwrap_or(true);
t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
t.rows[r].height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };
}
for (c, ce) in cols.iter().enumerate() {
t.columns[c].width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);
}
let h = e.num("HeaderRowCount").unwrap_or(0.0) as usize;
let f = e.num("FooterRowCount").unwrap_or(0.0) as usize;
if h.saturating_add(f) < nr {
t.set_header_footer(h, f);
}
let style_name = e.get("AppliedTableStyle").map(|r| self.table_style_ref(r)).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.into());
let style = self.styles.resolve_table_style(&style_name);
style.apply_to(&mut t, &self.styles.cell);
// IDML distinguishes an explicit AppliedCellStyle from the effective region style.
for cell in &mut t.cells {
cell.style.clear();
}
// Table-local options override the named table style. Cell styles are already
// materialized independently, so their edges retain their higher precedence.
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
self.cell_stroke_attrs(e, side).apply_to(border);
}
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before);
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
// A local count may enable a pattern disabled by its parent style. Merge before
// materialization so the parent's colors/tints are still available in that case.
let mut row_fills = style.row_fills.clone();
row_fills.merge(&self.alt_fill_attrs(e, "Row"));
row_fills.apply_to(&mut t.options.alt_rows);
let mut column_fills = style.column_fills.clone();
column_fills.merge(&self.alt_fill_attrs(e, "Column"));
column_fills.apply_to(&mut t.options.alt_cols);
let mut row_strokes = style.row_strokes.clone();
row_strokes.merge(&self.alt_stroke_attrs(e, "Row"));
row_strokes.apply_to(&mut t.options.row_strokes);
let mut column_strokes = style.column_strokes.clone();
column_strokes.merge(&self.alt_stroke_attrs(e, "Column"));
column_strokes.apply_to(&mut t.options.column_strokes);
let mut regions = Vec::new();
for ce in e.find_all("Cell") {
let Some((c, r)) =
ce.get("Name").and_then(|n| n.split_once(':')).and_then(|(a, b)| Some((a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)))
else {
continue;
};
if r >= nr || c >= nc {
continue;
}
let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r);
let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c);
let mut cell = t.cell(r, c).cloned().unwrap_or_default();
let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into());
if cell_style != designcraft_doc::NO_CELL_STYLE {
designcraft_doc::resolve_cell_style(&self.styles.cell, &cell_style).apply_to(&mut cell);
}
cell.style = if cell_style == designcraft_doc::NO_CELL_STYLE { String::new() } else { cell_style };
let default_para = cell.text.paras.first().cloned().unwrap_or_default();
cell.text = self.story_with_para(StoryId(0), ce, default_para);
if let Some(fc) = ce.get("FillColor") {
cell.fill = self.swatch_ref(fc);
}
if let Some(tint) = tint(ce.num("FillTint")) {
cell.fill_tint = tint;
}
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
if let Some(v) = ce.num(k) {
cell.insets[i] = v.max(0.0);
}
}
if let Some(vj) = ce.get("VerticalJustification") {
cell.vj = names::vj_in(vj);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
cell.stroke_defined[i] |= !attrs.is_empty();
let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0);
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
}
if rs > 1 || cs > 1 {
regions.push(CellRange { r0: r, c0: c, r1: (r + rs - 1).min(nr - 1), c1: (c + cs - 1).min(nc - 1) });
}
}
for rg in regions {
let _ = t.merge(rg);
}
t
}
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
for n in &e.children {
match n {
Node::El(c) => match c.local() {
"ParagraphStyleRange" => {
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| pf.style.clone());
let para = self.para_attrs(c);
let chars = self.char_attrs(c);
let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None };
if b.fresh
&& let Some(last) = b.paras.last_mut()
{
*last = npf.clone();
}
self.walk_story(c, b, &npf, &chars, cf, None);
}
"CharacterStyleRange" => {
let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into());
let mut over = pchars.clone();
over.merge(&self.char_attrs(c));
let ncf = CharFormat { style, over };
let bt = c.get("ParagraphBreakType").filter(|t| *t != "Anywhere");
b.last = ncf.clone();
self.walk_story(c, b, pf, pchars, &ncf, bt);
}
"Content" => {
for t in &c.children {
match t {
Node::Text(s) | Node::CData(s) => {
let s: String = s
.chars()
.filter(|c| *c != '\u{FEFF}')
.map(|c| if c == '\n' || c == '\r' || c == '\u{2029}' { st::FORCED_LINE_BREAK } else { c })
.collect();
b.push(&s, cf);
}
Node::Pi(t, v) if t == "ACE" => {
if let Some(ch) = ace_char(v) {
b.push(&ch.to_string(), cf);
}
}
_ => {}
}
}
}
"Br" => {
// A column, frame or page break ends its paragraph like a return does.
if let Some(t) = brk {
let ch = match t {
"NextColumn" => st::COLUMN_BREAK,
"NextFrame" => st::FRAME_BREAK,
"NextOddPage" => st::ODD_PAGE_BREAK,
"NextEvenPage" => st::EVEN_PAGE_BREAK,
_ => st::PAGE_BREAK,
};
b.push(&ch.to_string(), cf);
}
b.push("\n", cf);
b.paras.push(pf.clone());
b.fresh = true;
}
"Table" => {
let t = self.table(c);
b.push_table(t, pf, cf);
}
"HyperlinkTextDestination" | "ParagraphDestination" => {
b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string()));
b.push(&designcraft_doc::ANCHOR_MARK.to_string(), cf);
}
"PageReference" => {
use designcraft_doc::index::IndexRange as R;
let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default();
let range = match c.get("PageReferenceType") {
Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory,
Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32),
Some("SuppressPageNumbers") => R::SuppressPageRange,
_ => R::CurrentPage,
};
b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range });
b.push(&designcraft_doc::INDEX_MARK.to_string(), cf);
}
"CrossReferenceSource" => {
// The generated text is regenerated from the format.
b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string()));
let inner = c.find("CharacterStyleRange");
let f = match inner.and_then(|r| r.get("AppliedCharacterStyle")) {
Some(r) => CharFormat { style: self.char_style_ref(r), over: pchars.clone() },
None => cf.clone(),
};
b.push(&designcraft_doc::XREF_MARK.to_string(), &f);
}
"Footnote" => {
// The text starts with the number marker (ACE 4, dropped) and the separator.
let mut note = self.story(StoryId(0), c);
let sep = self.footnote_options.separator.clone();
if !sep.is_empty() && note.text.starts_with(&sep) {
note.delete(0..sep.len());
}
b.notes.push(note);
// The reference's position comes from the footnote options.
let mut rcf = cf.clone();
rcf.over.position = None;
b.push(&designcraft_doc::FOOTNOTE_REF.to_string(), &rcf);
}
// Not supported yet: skip their content entirely.
"Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" => {
// An anchored object (text frames inside stories aren't supported yet).
let has_text = c.find_all("TextFrame").next().is_some();
if !has_text && let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) {
use designcraft_doc::anchored::AnchorAlign as A;
let set = c.find("AnchoredObjectSetting");
let get = |k: &str| set.and_then(|s| s.num(k)).unwrap_or(0.0);
let position = match set.and_then(|s| s.get("AnchoredPosition")) {
Some("AboveLine") => designcraft_doc::AnchorPosition::AboveLine {
align: match set.and_then(|s| s.get("HorizontalAlignment")) {
Some("CenterAlign") => A::Center,
Some("RightAlign") => A::Right,
_ => A::Left,
},
space_before: get("AnchorSpaceAbove"),
space_after: get("AnchorYoffset"),
},
Some("Anchored") => {
let s = |k: &str| set.and_then(|x| x.get(k)).unwrap_or("");
let col = match s("HorizontalAlignment") {
"CenterAlign" => 1,
"RightAlign" => 2,
_ => 0,
};
let row = match s("VerticalAlignment") {
"CenterAlign" => 1,
"BottomAlign" => 2,
_ => 0,
};
designcraft_doc::AnchorPosition::Custom {
x_relative: names::anchor_rel_in(s("HorizontalReferencePoint")),
y_relative: names::anchor_rel_in(s("VerticalReferencePoint")),
x_offset: get("AnchorXoffset"),
y_offset: get("AnchorYoffset"),
object_point: names::anchor_in(s("AnchorPoint")),
ref_point: row * 3 + col,
keep_within_column: s("PinPosition") == "true",
}
}
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
};
b.objects.push(designcraft_doc::AnchoredObject::new(item, position));
b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf);
}
}
"Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {}
_ => self.walk_story(c, b, pf, pchars, cf, brk),
},
Node::Pi(t, v) if t == "ACE" => {
if let Some(ch) = ace_char(v) {
b.push(&ch.to_string(), cf);
}
}
_ => {}
}
}
}
// ---------- spreads and items ----------
fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread {
let facing = self.settings.facing_pages;
// Page rectangles in IDML spread coordinates.
let mut pages: Vec<(Rect, &El)> = Vec::new();
for p in e.find_all("Page") {
let gb = nums(p.get("GeometricBounds").unwrap_or(""));
let (t, l, bt, r) =
if gb.len() == 4 { (gb[0], gb[1], gb[2], gb[3]) } else { (0.0, 0.0, self.settings.page_height, self.settings.page_width) };
let xf = parse_affine(p.get("ItemTransform"));
let a = xf * Point::new(l, t);
let c = xf * Point::new(r, bt);
pages.push((Rect::from_points(a, c), p));
}
let min_x = pages.iter().map(|(r, _)| r.x0).fold(f64::INFINITY, f64::min);
let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min);
let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) };
let shift = Affine::translate((-min_x, -min_y));
let show = e.get("ShowMasterItems") != Some("false");
let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum();
let mut out_pages = Vec::new();
for (i, (r, p)) in pages.iter().enumerate() {
let pid = PageId(self.alloc());
if let Some(s) = p.get("Self")
&& !master
{
self.page_index.insert(s.to_string(), first_abs + i);
}
let (margins, columns) = match p.find("MarginPreference") {
Some(m) => margins_of(m),
None => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
};
let side = if !facing {
PageSide::Single
} else if r.x1 <= 1e-6 {
PageSide::Left
} else {
PageSide::Right
};
let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied();
out_pages.push(Page {
id: pid,
width: r.width(),
height: r.height(),
x: r.x0 - min_x,
margins,
columns,
parent,
side,
overridden: vec![],
guides: Vec::<Guide>::new(),
show_parent_items: show,
liquid: Default::default(),
transition: None,
view_rotation: 0,
});
}
let overrides: Vec<Vec<String>> =
pages.iter().map(|(_, p)| p.get("OverrideList").unwrap_or("").split_whitespace().map(str::to_string).collect()).collect();
let mut items = Vec::new();
for c in e.elements() {
if let Some(it) = self.item(c, shift) {
items.push(Arc::new(it));
}
}
for (pg, ov) in out_pages.iter_mut().zip(overrides) {
pg.overridden = ov.iter().filter_map(|s| self.item_ids.get(s)).copied().filter(|i| self.is_parent_item(*i)).collect();
}
let parent = if master {
let prefix =
e.get("NamePrefix").map(str::to_string).unwrap_or_else(|| e.get("Name").unwrap_or("A").split('-').next().unwrap_or("A").to_string());
let name = e.get("BaseName").map(str::to_string).unwrap_or_else(|| {
let n = e.get("Name").unwrap_or("Parent");
n.split_once('-').map(|(_, b)| b.to_string()).unwrap_or_else(|| n.to_string())
});
let based_on = pages.first().and_then(|(_, p)| p.get("AppliedMaster")).and_then(|m| self.parent_ids.get(m)).copied().filter(|b| *b != id);
Some(ParentInfo { prefix, name, based_on })
} else {
None
};
for p in &mut out_pages {
if master {
p.parent = None;
}
}
Spread { id, pages: out_pages, items, parent, allow_shuffle: e.get("AllowPageShuffle") != Some("false") }
}
fn is_parent_item(&self, id: ItemId) -> bool {
self.parents.iter().any(|p| {
p.items.iter().any(|it| {
let mut found = false;
it.walk(&mut |i| found |= i.id == id);
found
})
})
}
/// Resolve an item attribute, falling back to its object style chain.
fn attr_or_style(&self, e: &El, k: &str) -> Option<String> {
if let Some(v) = e.get(k) {
return Some(v.to_string());
}
let mut os = e.get("AppliedObjectStyle").map(str::to_string);
let mut depth = 0;
while let Some(id) = os {
depth += 1;
if depth > 16 {
break;
}
let Some(s) = self.object_els.get(&id) else { break };
if let Some(v) = s.get(k) {
return Some(v.to_string());
}
os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) });
}
None
}
fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke {
let g = |s: &Self, k: &str| -> Option<String> { if item.is_some() { s.attr_or_style(e, k) } else { e.get(k).map(str::to_string) } };
let color = g(self, "StrokeColor");
let mut s = Stroke::none();
if let Some(c) = color {
s.swatch = self.swatch_ref(&c);
}
if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse().ok()) {
s.weight = w;
}
if let Some(t) = g(self, "StrokeTint").and_then(|v| v.parse().ok()) {
s.tint = tint(Some(t)).unwrap_or(1.0);
}
if let Some(t) = g(self, "StrokeType") {
s.kind = names::stroke_type_in(&t);
}
if let Some(v) = g(self, "StrokeAlignment") {
s.align = names::stroke_align_in(&v);
}
if let Some(v) = g(self, "EndCap") {
s.cap = names::cap_in(&v);
}
if let Some(v) = g(self, "EndJoin") {
s.join = names::join_in(&v);
}
if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse().ok()) {
s.miter_limit = v;
}
if let Some(v) = g(self, "LeftLineEnd") {
s.start = names::arrow_in(&v);
}
if let Some(v) = g(self, "RightLineEnd") {
s.end = names::arrow_in(&v);
}
if let Some(v) = g(self, "GapColor") {
s.gap_swatch = self.swatch_ref(&v);
}
if let Some(t) = g(self, "GapTint").and_then(|v| v.parse().ok()) {
s.gap_tint = tint(Some(t)).unwrap_or(1.0);
}
s.overprint = g(self, "OverprintStroke").as_deref() == Some("true");
s.gap_overprint = g(self, "OverprintGap").as_deref() == Some("true");
s
}
fn item(&mut self, e: &El, parent: Affine) -> Option<Item> {
let tag = e.local();
let shape = match tag {
"Rectangle" | "TextFrame" => Shape::Rectangle,
"Oval" => Shape::Oval,
"Polygon" => Shape::Polygon,
"GraphicLine" => Shape::GraphicLine,
"Group" => Shape::Group,
_ => return None,
};
let id = ItemId(self.alloc());
if let Some(s) = e.get("Self") {
self.item_ids.insert(s.to_string(), id);
}
let layer = e.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied().unwrap_or_else(|| self.layers[0].id);
let path = e.prop_el("PathGeometry").map(path_of).unwrap_or_default();
let mut it = Item::new(id, layer, shape, path);
it.xf = parent * parse_affine(e.get("ItemTransform"));
it.name = e.get("Name").filter(|n| *n != "$ID/").unwrap_or("").to_string();
it.locked = e.get("Locked") == Some("true");
it.hidden = e.get("Visible") == Some("false");
it.nonprinting = e.get("Nonprinting") == Some("true");
it.object_style = e
.get("AppliedObjectStyle")
.map(|r| {
self.object_names
.get(r)
.cloned()
.unwrap_or_else(|| names::style_name_in(OBJECT_BUILTINS, &unescape_id(r.trim_start_matches("ObjectStyle/"))))
})
.unwrap_or_else(|| designcraft_doc::NO_OBJECT_STYLE.into());
// Fill and stroke.
if tag != "Group" {
if let Some(f) = self.attr_or_style(e, "FillColor") {
it.fill.swatch = self.swatch_ref(&f);
}
if let Some(t) = self.attr_or_style(e, "FillTint").and_then(|v| v.parse().ok()) {
it.fill.tint = tint(Some(t)).unwrap_or(1.0);
}
it.fill.overprint = self.attr_or_style(e, "OverprintFill").as_deref() == Some("true");
if let Some(a) = e.num("GradientFillAngle")
&& a != 0.0
{
it.fill.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
&& len > 0.0
{
let a = e.num("GradientFillAngle").unwrap_or(0.0).to_radians();
it.fill.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]);
}
it.stroke = self.stroke_from(e, Some(e));
if it.stroke.weight <= 0.0 {
it.stroke.swatch = swatch::NONE.into();
it.stroke.weight = 1.0;
}
}
// Corners.
let cn = crate::export::corner_names(&it.path);
// The legacy all-corners attributes apply only when no per-corner attribute is present.
let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some());
let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) };
let mut corners = CornerOptions::default();
for (i, n) in cn.iter().enumerate() {
let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default();
let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0);
corners.corners[i] = Corner { shape, size };
}
if !corners.is_none() {
it.corners = corners;
}
// Transparency.
if let Some(t) = e.find("TransparencySetting") {
if let Some(bs) = t.find("BlendingSetting") {
if let Some(o) = bs.num("Opacity") {
it.opacity = (o / 100.0).clamp(0.0, 1.0) as f32;
}
if let Some(m) = bs.get("BlendMode") {
it.blend = names::blend_in(m);
}
}
if let Some(ds) = t.find("DropShadowSetting")
&& ds.get("Mode") == Some("Drop")
{
let x = ds.num("XOffset").unwrap_or(4.95);
let y = ds.num("YOffset").unwrap_or(4.95);
let def = DropShadow::default();
it.effects.drop_shadow = DropShadow {
on: true,
color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color),
opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity),
angle: names_angle(x, y),
distance: x.hypot(y),
size: ds.num("Size").unwrap_or(def.size),
spread: ds.num("Spread").unwrap_or(def.spread),
global_light: false,
};
}
if let Some(f) = t.find("FeatherSetting")
&& f.get("Mode").is_some_and(|m| m != "None")
{
it.effects = Effects { feather: f.num("Width").unwrap_or(9.0), ..it.effects.clone() };
}
}
// Text wrap.
if let Some(w) = e.find("TextWrapPreference") {
let mut tw = TextWrap {
mode: names::wrap_mode_in(w.get("TextWrapMode").unwrap_or("None")),
invert: w.get("Inverse") == Some("true"),
side: names::wrap_side_in(w.get("TextWrapSide").unwrap_or("BothSides")),
..TextWrap::default()
};
if let Some(o) = w.prop_el("TextWrapOffset") {
tw.offsets =
[o.num("Top").unwrap_or(0.0), o.num("Left").unwrap_or(0.0), o.num("Bottom").unwrap_or(0.0), o.num("Right").unwrap_or(0.0)];
}
it.wrap = tw;
}
// Type on a path.
if tag != "TextFrame"
&& let Some(tp) = e.find("TextPath")
{
let story_self = tp.get("ParentStory").unwrap_or("n").to_string();
if let Some(story) = self.story_ids.get(&story_self).copied() {
let options = TextFrameOptions {
path: Some(designcraft_doc::PathType {
start: tp.num("StartBracket").unwrap_or(0.0).max(0.0),
// At (or past) the path's length it is the path's end: no end bracket.
end: tp
.num("EndBracket")
.filter(|e| e.is_finite() && *e >= 0.0)
.filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01),
flip: tp.get("FlipPathEffect") == Some("Flipped"),
align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")),
}),
..Default::default()
};
it.content = Content::Text(TextFrame { story, options });
self.ctx.threads.push((id, story_self, "n".into(), "n".into()));
}
}
// Content.
match tag {
"TextFrame" => {
let story_self = e.get("ParentStory").unwrap_or("n").to_string();
let story = match self.story_ids.get(&story_self) {
Some(s) => *s,
None => {
let sid = StoryId(self.alloc());
self.stories.insert(sid, Story::new(sid));
self.story_ids.insert(format!("#orphan{}", sid.0), sid);
sid
}
};
let mut options = e.find("TextFramePreference").map(|t| self.frame_options(t)).unwrap_or_default();
if let Some(g) = e.find("BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
options.baseline_grid =
Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0)));
}
it.content = Content::Text(TextFrame { story, options });
self.ctx.threads.push((
id,
story_self,
e.get("PreviousTextFrame").unwrap_or("n").to_string(),
e.get("NextTextFrame").unwrap_or("n").to_string(),
));
}
"Group" => {
let mut kids = Vec::new();
for c in e.elements() {
if let Some(k) = self.item(c, Affine::IDENTITY) {
kids.push(Arc::new(k));
}
}
it.content = Content::Group { items: kids };
it.fill = Fill::none();
it.stroke = Stroke::none();
}
_ => {
if let Some(g) = e.elements().find(|c| matches!(c.local(), "Image" | "PDF" | "EPS" | "ImportedPage" | "WMF" | "PICT" | "SVG")) {
it.content = self.graphic(g);
if let (Some(ff), Content::Graphic(gr)) = (e.elements().find(|c| c.local() == "FrameFittingOption"), &mut it.content) {
gr.crop = [ff.num("TopCrop"), ff.num("LeftCrop"), ff.num("BottomCrop"), ff.num("RightCrop")].map(|v| v.unwrap_or(0.0));
gr.fit_align = ff.get("FittingAlignment").map_or(4, names::anchor_in);
if ff.get("AutoFit") == Some("true") {
gr.auto_fit = ff.get("FittingOnEmptyFrame").map_or(designcraft_doc::Fitting::FillProportionally, names::fitting_in);
if gr.auto_fit == designcraft_doc::Fitting::None {
gr.auto_fit = designcraft_doc::Fitting::FillProportionally;
}
}
}
} else {
// Page items pasted into this frame (Paste Into).
let kids: Vec<Arc<Item>> = e
.elements()
.filter(|c| matches!(c.local(), "Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" | "TextFrame"))
.filter_map(|c| self.item(c, Affine::IDENTITY))
.map(Arc::new)
.collect();
if !kids.is_empty() {
it.content = Content::Group { items: kids };
}
}
}
}
Some(it)
}
fn graphic(&mut self, g: &El) -> Content {
let gxf = parse_affine(g.get("ItemTransform"));
let (l, t, r, b) = match g.prop_el("GraphicBounds") {
Some(gb) => {
(gb.num("Left").unwrap_or(0.0), gb.num("Top").unwrap_or(0.0), gb.num("Right").unwrap_or(0.0), gb.num("Bottom").unwrap_or(0.0))
}
None => (0.0, 0.0, 0.0, 0.0),
};
let size = ((r - l).abs(), (b - t).abs());
let link = g.find("Link");
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
let graphic = |asset| {
Content::Graphic(designcraft_doc::Graphic {
asset,
size,
xf: gxf * Affine::translate((l, t)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
})
};
let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default();
let mut read_from = None;
if data.is_empty()
&& let Some(p) = &uri
{
// Every placement of a linked file shares one asset; read the file once.
if let Some(id) = self.linked_assets.get(p) {
return graphic(*id);
}
if let Some(d) = (self.read_link)(p) {
data = d;
read_from = Some(p.clone());
}
}
let (mime, px) = sniff_image(&data);
let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") {
f if f.contains("JPEG") => "image/jpeg".into(),
f if f.contains("TIFF") => "image/tiff".into(),
f if f.contains("PDF") || g.local() == "PDF" => "application/pdf".into(),
f if f.contains("GIF") => "image/gif".into(),
_ => "image/png".into(),
});
let pixels = px.or_else(|| {
let ppi = nums(g.get("ActualPpi").unwrap_or(""));
(ppi.len() == 2 && size.0 > 0.0).then(|| ((size.0 * ppi[0] / 72.0).round() as u32, (size.1 * ppi[1] / 72.0).round() as u32))
});
let name =
uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into());
let fresh = AssetId(self.alloc());
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
let id =
designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels });
if let Some(p) = read_from {
self.linked_assets.insert(p, id);
}
graphic(id)
}
fn link_threads(&mut self) {
let threads = std::mem::take(&mut self.ctx.threads);
let by_self: HashMap<String, ItemId> = self.item_ids.clone();
let mut per_story: BTreeMap<StoryId, Vec<usize>> = BTreeMap::new();
for (i, (id, _, _, _)) in threads.iter().enumerate() {
let sid = self.frame_story(*id);
if let Some(sid) = sid {
per_story.entry(sid).or_default().push(i);
}
}
for (sid, idx) in per_story {
let members: HashSet<ItemId> = idx.iter().map(|i| threads[*i].0).collect();
let next_of: HashMap<ItemId, ItemId> =
idx.iter().filter_map(|i| by_self.get(&threads[*i].3).filter(|n| members.contains(n)).map(|n| (threads[*i].0, *n))).collect();
let has_prev: HashSet<ItemId> = next_of.values().copied().collect();
let mut order = Vec::new();
let mut seen = HashSet::new();
for i in &idx {
let head = threads[*i].0;
if has_prev.contains(&head) || seen.contains(&head) {
continue;
}
let mut cur = Some(head);
while let Some(c) = cur {
if !seen.insert(c) {
break;
}
order.push(c);
cur = next_of.get(&c).copied();
}
}
for i in &idx {
let f = threads[*i].0;
if seen.insert(f) {
order.push(f);
}
}
if let Some(s) = self.stories.get_mut(&sid) {
s.frames = order;
}
}
}
fn frame_story(&self, id: ItemId) -> Option<StoryId> {
fn find(items: &[Arc<Item>], id: ItemId) -> Option<StoryId> {
for it in items {
let mut r = None;
it.walk(&mut |i| {
if i.id == id {
r = i.text_frame().map(|t| t.story);
}
});
if r.is_some() {
return r;
}
}
None
}
self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id))
}
fn finish(mut self, root: &El) -> Result<Document> {
let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into());
if self.spreads.is_empty() {
return Err(IdmlError::Invalid("document has no spreads".into()));
}
self.settings.lists = std::mem::take(&mut self.lists);
// Make sure built-in paragraph styles exist and are first.
let d = Document {
title,
settings: self.settings.clone(),
spreads: std::mem::take(&mut self.spreads).into_iter().map(Arc::new).collect(),
parents: std::mem::take(&mut self.parents).into_iter().map(Arc::new).collect(),
layers: std::mem::take(&mut self.layers),
stories: std::mem::take(&mut self.stories).into_iter().map(|(k, v)| (k, Arc::new(v))).collect(),
styles: Arc::new(std::mem::take(&mut self.styles)),
swatches: std::mem::take(&mut self.swatches),
color_groups: std::mem::take(&mut self.color_groups),
conditions: std::mem::take(&mut self.conditions),
inks: std::mem::take(&mut self.inks),
stroke_styles: std::mem::take(&mut self.stroke_styles),
articles: vec![],
xml: Default::default(),
endnote_options: Default::default(),
endnote_story: None,
sections: std::mem::take(&mut self.sections),
assets: std::mem::take(&mut self.assets),
hyperlinks: vec![],
data_merge: Default::default(),
bookmarks: vec![],
user_words: vec![],
hyphenation_exceptions: vec![],
toc: None,
text_variables: designcraft_doc::vars::defaults(),
footnote_options: std::mem::take(&mut self.footnote_options),
xref_formats: std::mem::take(&mut self.xref_formats),
index: None,
created: designcraft_doc::vars::now(),
modified: 0,
next_id: self.next_id,
font_scope: 0,
};
Ok(d)
}
}
/// Drop-shadow angle (degrees) from IDML offsets (inverse of export's mapping).
fn names_angle(x: f64, y: f64) -> f64 {
if x == 0.0 && y == 0.0 {
return 135.0;
}
let a = y.atan2(-x).to_degrees();
if a < 0.0 { a + 360.0 } else { a }
}
struct StoryBuilder {
text: String,
paras: Vec<ParaFormat>,
runs: Vec<CharRun>,
/// The current paragraph has no content yet (its format can still be set by a range).
fresh: bool,
last: CharFormat,
/// Tables by anchor paragraph index.
tables: Vec<(usize, Table)>,
/// Content after a table anchor starts a new paragraph (with this format).
after_table: Option<ParaFormat>,
/// Footnote texts in reference order.
notes: Vec<Story>,
/// Text destinations (Self, name) and cross-reference sources (Self, format Self) in order.
anchors: Vec<(String, String)>,
index_refs: Vec<designcraft_doc::IndexRef>,
xrefs: Vec<(String, String)>,
objects: Vec<designcraft_doc::AnchoredObject>,
}
impl StoryBuilder {
/// A table gets an anchor paragraph of its own.
fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) {
let para_empty = self.text.rsplit('\n').next().is_none_or(str::is_empty);
if !para_empty {
self.after_table = None;
self.push("\n", cf);
self.paras.push(pf.clone());
}
self.after_table = None;
self.push(&st::TABLE_ANCHOR.to_string(), cf);
self.tables.push((self.paras.len() - 1, t));
self.after_table = Some(pf.clone());
self.fresh = false;
}
fn push(&mut self, s: &str, f: &CharFormat) {
if s.is_empty() {
return;
}
if let Some(pf) = self.after_table.take()
&& !s.starts_with('\n')
{
self.push("\n", f);
self.paras.push(pf);
}
self.text.push_str(s);
if !s.contains('\n') || s.len() > 1 {
self.fresh = false;
}
match self.runs.last_mut() {
Some(r) if r.format == *f => r.len += s.len(),
_ => self.runs.push(CharRun { len: s.len(), format: f.clone() }),
}
}
}
fn ace_char(v: &str) -> Option<char> {
match v.trim() {
"18" => Some(st::PAGE_NUMBER),
"19" => Some(st::SECTION_MARKER),
"7" => Some(st::INDENT_HERE),
"8" => Some(st::RIGHT_INDENT_TAB),
_ => None,
}
}
fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).unwrap_or(d);
(
Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) },
Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None },
)
}
fn text_frame_options(e: &El) -> TextFrameOptions {
let mut o = TextFrameOptions::default();
if let Some(v) = e.num("TextColumnCount") {
o.columns = v.max(1.0) as u32;
}
if let Some(v) = e.num("TextColumnGutter") {
o.gutter = v;
}
o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") {
ColumnsKind::FixedWidth
} else if e.get("UseFlexibleColumnWidth") == Some("true") {
ColumnsKind::FlexibleWidth
} else {
ColumnsKind::FixedNumber
};
if o.columns_kind != ColumnsKind::FixedNumber
&& let Some(v) = e.num("TextColumnFixedWidth")
{
o.column_width = v;
}
o.balance_columns = e.get("VerticalBalanceColumns") == Some("true");
if let Some(inset) = inset_spacing(e) {
o.inset = inset;
}
if let Some(v) = e.get("VerticalJustification") {
o.vertical_justification = names::vj_in(v);
}
if let Some(v) = e.num("VerticalThreshold") {
o.vj_paragraph_spacing_limit = v;
}
if let Some(v) = e.get("FirstBaselineOffset") {
o.first_baseline = names::first_baseline_in(v);
}
if let Some(v) = e.num("MinimumFirstBaselineOffset") {
o.first_baseline_min = v;
}
o.ignore_wrap = e.get("IgnoreWrap") == Some("true");
if let Some(v) = e.get("AutoSizingType") {
o.auto_size = names::auto_size_in(v);
}
if let Some(v) = e.get("AutoSizingReferencePoint") {
o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8;
}
// Column rules (the colour is a swatch reference: see `Importer::frame_options`).
o.column_rule = e.get("ColumnRuleOverride") == Some("true");
let finite = |k: &str| e.num(k).filter(|v| v.is_finite());
if let Some(v) = finite("ColumnRuleStrokeWidth") {
o.column_rule_weight = v.clamp(0.0, 1000.0);
}
if let Some(t) = tint(finite("ColumnRuleStrokeTint")) {
o.column_rule_tint = t.clamp(0.0, 1.0);
}
for (k, v) in [
("ColumnRuleOffset", &mut o.column_rule_offset),
("ColumnRuleTopInset", &mut o.column_rule_top_inset),
("ColumnRuleBottomInset", &mut o.column_rule_bottom_inset),
] {
if let Some(x) = finite(k) {
*v = x.clamp(-1440.0, 1440.0);
}
}
o
}
/// A scalar applies to every edge; a list is top, left, bottom, right. Keep the
/// existing property-element precedence over the legacy scalar attribute, even
/// when the property is malformed. Finite negative insets remain supported.
fn inset_spacing(e: &El) -> Option<[f64; 4]> {
let number = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let Some(property) = e.prop_el("InsetSpacing") else {
return e.get("InsetSpacing").and_then(number).map(|v| [v; 4]);
};
let mut items = property.elements();
if property.get("type") == Some("list") || property.find("ListItem").is_some() {
let mut inset = [0.0; 4];
for edge in &mut inset {
let item = items.next()?;
if item.local() != "ListItem" || item.elements().next().is_some() {
return None;
}
*edge = number(&item.text_content())?;
}
items.next().is_none().then_some(inset)
} else if items.next().is_none() {
number(&property.text_content()).map(|v| [v; 4])
} else {
None
}
}
fn path_of(pg: &El) -> PathData {
let mut subs = Vec::new();
for g in pg.find_all("GeometryPathType") {
let closed = g.get("PathOpen") != Some("true");
let mut anchors = Vec::new();
if let Some(arr) = g.find("PathPointArray") {
for p in arr.find_all("PathPointType") {
let Some(a) = parse_point(p.get("Anchor")) else { continue };
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
let hout = parse_point(p.get("RightDirection")).unwrap_or(a);
anchors.push(Anchor::with_handles(a, hin, hout));
}
}
if !anchors.is_empty() {
subs.push(SubPath::new(anchors, closed));
}
}
PathData::new(subs)
}
/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`).
pub(crate) fn uri_to_path(uri: &str) -> String {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
// `file:///C:/…` → `/C:/…` → `C:/…`
let rest = match rest.as_bytes() {
[b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest),
_ => rest,
};
percent_decode(rest)
}