From 107f658f0ef88b6e76f586d3a21060d1850737bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 07:01:05 +0000 Subject: [PATCH] Lists: an empty paragraph gets no bullet or number A list paragraph with no characters before its return draws no label, and numbering carries on past it: in a story's own list, in named lists (also across stories) and in cross-reference paragraph numbers. --- crates/compose/src/lib.rs | 7 +++++-- crates/compose/src/tests.rs | 18 ++++++++++++++++++ crates/doc/src/notes.rs | 4 ++-- crates/doc/src/xref.rs | 13 +++++++++---- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8fbcadfc6..3324eec29 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -492,9 +492,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C story .paras .iter() - .map(|p| { + .zip(¶_ranges) + .map(|(p, r)| { let (pp, _) = doc.styles.resolve_para(p); - if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() { + if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() { return None; } let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); @@ -701,6 +702,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; match pp.list_type { + // An empty paragraph has no bullet or number, and the numbering carries on past it. + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {} designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 5f23e327d..948119bd9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2262,3 +2262,21 @@ fn numbering_from_the_largest_start_number_does_not_overflow() { let max = format!("{}.", u32::MAX); assert_eq!(list_labels(&cs, LABEL_CHARS), [max.clone(), max]); } + +#[test] +fn empty_paragraphs_get_no_bullet_or_number() { + let bullets = ParaAttrs { list_type: Some(designcraft_doc::ListType::Bullets), ..Default::default() }; + let (d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), bullets); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), ["\u{2022}", "", "\u{2022}", ""]); + // An empty paragraph doesn't use up a number, in a story's own list or a named one. + for name in ["", "Steps"] { + let attrs = ParaAttrs { list_name: Some(name.into()), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^t") }; + let (mut d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), attrs); + if !name.is_empty() { + d.settings.lists.push(designcraft_doc::NumberedList { name: name.into(), continue_across_stories: false }); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); + } +} diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index ad8b5fff6..b681542cb 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -235,9 +235,9 @@ impl crate::Document { before.sort_by_key(|st| key(st)); let mut n = 0u32; for st in before { - for p in &st.paras { + for (p, r) in st.paras.iter().zip(st.para_ranges()) { let (pp, _) = self.styles.resolve_para(p); - if pp.list_type == crate::ListType::Numbers && pp.list_name == name { + if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() { n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1)); } } diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 6bfc2bcc5..a2301b1c8 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -347,12 +347,17 @@ impl Document { let (sid, pos) = self.find_anchor(id)?; let st = self.story(sid)?; let pi = st.para_at(pos); - let r = st.para_ranges()[pi].clone(); + let ranges = st.para_ranges(); + let r = ranges[pi].clone(); let (pp, _) = self.styles.resolve_para(&st.paras[pi]); let para_num = match pp.list_type { - crate::ListType::Numbers => { - // Count the run of numbered paragraphs before this one. - let n = (0..=pi).rev().take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers).count(); + crate::ListType::Numbers if !r.is_empty() => { + // Count the run of numbered paragraphs before this one (empty ones have no number). + let n = (0..=pi) + .rev() + .take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers) + .filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty())) + .count(); pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string() } _ => String::new(),