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.
This commit is contained in:
aiongg
2026-10-10 16:10:26 +00:00
parent 29a75b3607
commit 55fa985e38
2 changed files with 77 additions and 1 deletions
+39 -1
View File
@@ -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<f64> {
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 {
+38
View File
@@ -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");
}