From 55fa985e3852f0f6b4f69f312f88d652e0227e63 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 16:10:26 +0000 Subject: [PATCH] Bullets and numbering: the tab position survives IDML IDML has no attribute for the list Tab Position. Export writes it as the left stop the composer adds for it, in the TabList of each style or paragraph that sets the list type, the tab position or the tabs; a level that ends an inherited list writes its tabs without the stop. Import reads it back as an ordinary tab stop, so the composed text is the same. --- crates/idml/src/export.rs | 40 ++++++++++++++++++++++++++++++++++++++- crates/idml/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index cfa3f712f..050e2bebd 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -859,6 +859,11 @@ impl<'a> Ex<'a> { } (ps.para.clone(), ps.chars.clone()) }; + let parent = match ps.based_on.as_deref() { + Some(b) if ps.name != designcraft_doc::NO_PARA_STYLE => styles.resolve_para_style(b).0, + _ => designcraft_doc::ParaProps::default(), + }; + let pa = with_list_tab(pa, || (styles.resolve_para_style(&ps.name).0, parent)); self.para_attrs(&mut el, &mut props, &pa); self.char_attrs(&mut el, &mut props, &ca); pg.insert(&ps.name, with_props(el, props)); @@ -2469,7 +2474,9 @@ impl<'a> Ex<'a> { fn psr_el(&mut self, pf: &ParaFormat) -> El { let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); let mut props = Vec::new(); - self.para_attrs(&mut el, &mut props, &pf.para); + let styles = &self.d.styles; + let pa = with_list_tab(pf.para.clone(), || (styles.resolve_para(pf).0, styles.resolve_para_style(&pf.style).0)); + self.para_attrs(&mut el, &mut props, &pa); self.char_attrs(&mut el, &mut props, &pf.chars); with_props(el, props) } @@ -2487,6 +2494,37 @@ impl<'a> Ex<'a> { } } +/// The left stop the composer adds for a list's Tab Position, if it adds one. +fn list_tab_stop(pp: &designcraft_doc::ParaProps) -> Option { + if !matches!(pp.list_type, ListType::Bullets | ListType::Numbers) { + return None; + } + pp.list_tab.filter(|t| t.is_finite() && *t >= 0.0 && !pp.tabs.iter().any(|s| (s.position - t).abs() < 1e-6)) +} + +/// IDML has no attribute for the bullets-and-numbering Tab Position, so it is written as the left +/// stop the composer adds for it, in the TabList of each level (style or paragraph) that sets the +/// list type, the tab position or the tabs. `resolved` gives this level's resolved attributes and +/// those it inherits; the stop comes back on import as an ordinary tab stop. +fn with_list_tab(mut a: ParaAttrs, resolved: impl FnOnce() -> (designcraft_doc::ParaProps, designcraft_doc::ParaProps)) -> ParaAttrs { + if a.list_tab.is_none() && a.tabs.is_none() && a.list_type.is_none() { + return a; + } + let (here, parent) = resolved(); + let stop = list_tab_stop(&here); + // An inherited TabList holds the parent's stop; a level that ends the list writes its tabs without it. + if stop.is_none() && list_tab_stop(&parent).is_none() { + return a; + } + let mut tabs = here.tabs; + if let Some(t) = stop { + tabs.push(designcraft_doc::TabStop { position: t, align: designcraft_doc::TabAlign::Left, leader: String::new(), align_on: String::new() }); + tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + a.tabs = Some(tabs); + a +} + /// Style folder tree (names `A/B/C` → groups A, B). #[derive(Default)] struct Group { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3c2b9f580..4361221e6 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1460,3 +1460,41 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn list_tab_position_round_trips_as_a_tab_stop() { + use designcraft_doc::{ListType, ParaAttrs, ParagraphStyle, TabAlign, TabStop}; + let stop = |position, align| TabStop { position, align, leader: String::new(), align_on: String::new() }; + let mut d = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut d.styles).paragraph.push(ParagraphStyle { + name: "List".into(), + based_on: None, + next_style: None, + para: ParaAttrs { list_type: Some(ListType::Bullets), list_tab: Some(Some(24.0)), ..Default::default() }, + chars: CharAttrs::default(), + shortcut: String::new(), + }); + let local = ParaAttrs { + list_type: Some(ListType::Bullets), + list_tab: Some(Some(36.0)), + tabs: Some(vec![stop(100.0, TabAlign::Right)]), + ..Default::default() + }; + let ended = ParaAttrs { list_type: Some(ListType::None), ..Default::default() }; + let lid = d.default_layer(); + for (i, (text, style, para)) in + [("Local\tx", "", local), ("Styled\ty", "List", ParaAttrs::default()), ("Ended\tz", "List", ended)].into_iter().enumerate() + { + let pf = ParaFormat { style: if style.is_empty() { ParaFormat::default().style } else { style.into() }, para, ..Default::default() }; + let y = 40.0 + 100.0 * i as f64; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, y, 300.0, y + 80.0), lid, text, pf).unwrap(); + } + let back = import_idml(&export_idml(&d)).unwrap(); + let tabs = |prefix: &str| { + let s = back.stories.values().find(|s| s.text.starts_with(prefix)).unwrap(); + back.styles.resolve_para(&s.paras[0]).0.tabs + }; + assert_eq!(tabs("Local"), vec![stop(36.0, TabAlign::Left), stop(100.0, TabAlign::Right)]); + assert_eq!(tabs("Styled"), vec![stop(24.0, TabAlign::Left)]); + assert_eq!(tabs("Ended"), vec![], "a paragraph that ends the list has no list tab stop"); +}