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"); +}