From 2a2d20e0c2b854aa8b89887bab5337a3862dd12f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 06:57:28 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Lists:=20numbering=20formats=20from=20IDML?= =?UTF-8?q?=20(A,=20B,=20C;=20i,=20ii,=20iii;=2001,=2002=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph's numbering format, number expression, bullet character and text after the bullet import from IDML and export back. Numbered labels come from the expression ("^#.^t", "Tabel ^#^t") with its metacharacters expanded, in place of a fixed "n." label. Adds 001 and 0001 number styles, keeps letter and roman labels short for huge numbers, and counts saturate instead of overflowing. --- crates/compose/src/lib.rs | 10 ++-- crates/compose/src/tests.rs | 53 ++++++++++++++++++ crates/doc/src/attrs.rs | 106 +++++++++++++++++++++++++++++++++++- crates/doc/src/notes.rs | 2 +- crates/doc/src/xref.rs | 2 +- crates/idml/src/export.rs | 12 ++++ crates/idml/src/import.rs | 11 ++++ crates/idml/src/names.rs | 42 +++++++++++++- crates/idml/src/tests.rs | 43 +++++++++++++++ 9 files changed, 271 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6e745d8cb..8fbcadfc6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -498,7 +498,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C return None; } let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); - *c = pp.start_at.map_or(*c + 1, |s| s.max(1)); + *c = pp.start_at.map_or(c.saturating_add(1), |s| s.max(1)); Some(*c) }) .collect() @@ -704,16 +704,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 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); - let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator); + let label = pp.number_label(n); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::Numbers => { - list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1)); - let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator); + list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1)); + let label = pp.number_label(list_counter); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::Bullets => { - let label = format!("{}{}", pp.bullet_char, pp.list_separator); + let label = pp.bullet_label(); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::None => list_counter = 0, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1f11dcdfe..5f23e327d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2209,3 +2209,56 @@ fn spanning_paragraph_in_rtl_and_vertical_frames() { assert_no_overlap(&cs.frames[0]); assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); } + +/// Each paragraph's list label as text: its label glyphs (laid before the paragraph's own text) +/// matched back to characters of the label font; tabs and other invisible glyphs are skipped. +fn list_labels(cs: &ComposedStory, alphabet: &str) -> Vec { + all_lines(cs) + .iter() + .filter(|l| l.first_in_para) + .map(|l| { + l.glyphs + .iter() + .take_while(|g| g.len == 0) + .filter(|g| g.visible) + .map(|g| alphabet.chars().find(|c| g.face.glyph_for(*c) == g.gid).unwrap_or('?')) + .collect() + }) + .collect() +} + +const LABEL_CHARS: &str = "0123456789ABCDIVXabcdivx.\u{2022} Tabel"; + +fn numbered(style: designcraft_doc::NumberStyle, expression: &str) -> ParaAttrs { + ParaAttrs { + list_type: Some(designcraft_doc::ListType::Numbers), + number_style: Some(style), + number_expression: Some(expression.into()), + ..Default::default() + } +} + +#[test] +fn numbered_lists_follow_their_format_and_expression() { + use designcraft_doc::NumberStyle as N; + let cases: [(N, &str, [&str; 3]); 4] = [ + (N::UpperLetters, "^#.^t", ["A.", "B.", "C."]), + (N::LowerRoman, "^#.^t", ["i.", "ii.", "iii."]), + (N::ArabicThreeDigits, "^#.^t", ["001.", "002.", "003."]), + (N::Arabic, "Tabel ^#^t", ["Tabel 1", "Tabel 2", "Tabel 3"]), + ]; + for (style, expression, want) in cases { + let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(style, expression)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), want, "{style:?} {expression}"); + } +} + +#[test] +fn numbering_from_the_largest_start_number_does_not_overflow() { + let (mut d, sid, _) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(designcraft_doc::NumberStyle::Arabic, "^#.")); + d.story_mut(sid).unwrap().paras[0].para.start_at = Some(Some(u32::MAX)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let max = format!("{}.", u32::MAX); + assert_eq!(list_labels(&cs, LABEL_CHARS), [max.clone(), max]); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ce658e907..bd714a818 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -571,15 +571,33 @@ pub enum NumberStyle { LowerLetters, /// 01, 02, … ArabicLeadingZero, + /// 001, 002, … + ArabicThreeDigits, + /// 0001, 0002, … + ArabicFourDigits, /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. Symbols, } +/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman +/// numerals, letters, symbols), so a hostile start number can't build a huge label. +const MAX_REPEATED_FORM: u32 = 3999; + impl NumberStyle { pub fn format(self, n: u32) -> String { + if n > MAX_REPEATED_FORM + && matches!( + self, + NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols + ) + { + return n.to_string(); + } match self { NumberStyle::Arabic => n.to_string(), NumberStyle::ArabicLeadingZero => format!("{n:02}"), + NumberStyle::ArabicThreeDigits => format!("{n:03}"), + NumberStyle::ArabicFourDigits => format!("{n:04}"), NumberStyle::UpperRoman => roman(n), NumberStyle::LowerRoman => roman(n).to_lowercase(), NumberStyle::UpperLetters => letters(n), @@ -593,6 +611,58 @@ impl NumberStyle { } } +/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), +/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no +/// meaning in a label are dropped; a label never holds a paragraph break. +pub fn expand_list_text(s: &str, number: &str) -> String { + let mut out = String::with_capacity(s.len() + number.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + if c != '\n' && c != '\r' { + out.push(c); + } + continue; + } + let Some(m) = it.next() else { + out.push('^'); + break; + }; + let r = match m { + '#' => { + out.push_str(number); + continue; + } + '^' => '^', + 't' => '\t', + 'y' => crate::story::RIGHT_INDENT_TAB, + 'i' => crate::story::INDENT_HERE, + 'n' => crate::story::FORCED_LINE_BREAK, + 'm' => '\u{2003}', + '>' => '\u{2002}', + 'f' => '\u{2001}', + '|' => '\u{200A}', + '<' => '\u{2009}', + '/' => '\u{2007}', + '.' => '\u{2008}', + 's' => '\u{A0}', + 'S' => '\u{202F}', + '_' => '\u{2014}', + '=' => '\u{2013}', + '~' => '\u{2011}', + '{' => '\u{201C}', + '}' => '\u{201D}', + '[' => '\u{2018}', + ']' => '\u{2019}', + '"' => '"', + '\'' => '\'', + _ => continue, + }; + out.push(r); + } + out +} + fn roman(mut n: u32) -> String { if n == 0 { return "0".into(); @@ -788,7 +858,10 @@ attr_set! { start_at: Option = None, bullet_char: String = "\u{2022}".into(), number_style: NumberStyle = NumberStyle::Arabic, - /// Text after the number/bullet (`^t` = tab). + /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in + /// [`expand_list_text`]. + number_expression: String = "^#.^t".into(), + /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). list_separator: String = "\t".into(), balance_ragged: bool = false, optical_margin: bool = false, @@ -840,6 +913,20 @@ impl ParaAttrs { } } +impl ParaProps { + /// The label of a numbered paragraph with number `n`. + pub fn number_label(&self, n: u32) -> String { + expand_list_text(&self.number_expression, &self.number_style.format(n)) + } + + /// The label of a bulleted paragraph. + pub fn bullet_label(&self) -> String { + let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); + s.push_str(&expand_list_text(&self.list_separator, "")); + s + } +} + #[cfg(test)] mod tests { use super::*; @@ -1023,5 +1110,22 @@ mod tests { assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); + assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); + assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); + // Repeating forms stay small for huge numbers. + assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); + } + + #[test] + fn list_labels_expand_metacharacters() { + let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; + assert_eq!(p.number_label(2), "B.\t"); + let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; + assert_eq!(p.number_label(3), "Tabel 3\t"); + assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); + assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); + assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); } } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index 0b0162d2c..ad8b5fff6 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -238,7 +238,7 @@ impl crate::Document { for p in &st.paras { let (pp, _) = self.styles.resolve_para(p); if pp.list_type == crate::ListType::Numbers && pp.list_name == name { - n = pp.start_at.map_or(n + 1, |s| s.max(1)); + 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 a76a6473a..6bfc2bcc5 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -353,7 +353,7 @@ impl Document { 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(); - format!("{}.", pp.number_style.format(n as u32)) + pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string() } _ => String::new(), }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c63815b21..aa1861ff2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1404,6 +1404,18 @@ impl<'a> Ex<'a> { }, ); } + if let Some(n) = a.number_style { + props.push(p("NumberingFormat", "string", names::numbering_format_out(n))); + } + if let Some(x) = &a.number_expression { + el.set("NumberingExpression", names::list_text_out(x)); + } + if let Some(x) = &a.list_separator { + el.set("BulletsTextAfter", names::list_text_out(x)); + } + if let Some(c) = a.bullet_char.as_deref().and_then(|s| s.chars().next()) { + props.push(El::new("BulletChar").attr("BulletCharacterType", "UnicodeOnly").attr("BulletCharacterValue", u32::from(c).to_string())); + } if let Some(b) = a.balance_ragged { props.push(p("BalanceRaggedLines", "enumeration", if b { "FullyBalanced" } else { "NoBalancing" })); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a8f7e7ae9..51f7edc11 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1334,6 +1334,17 @@ impl<'r> Importer<'r> { "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::().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") { diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ae2805bda..afb0534d2 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -462,7 +462,7 @@ pub const REF_POINTS: [&str; 9] = [ pub fn number_style_out(n: NumberStyle) -> &'static str { match n { - NumberStyle::Arabic | NumberStyle::ArabicLeadingZero => "Arabic", + NumberStyle::Arabic | NumberStyle::ArabicLeadingZero | NumberStyle::ArabicThreeDigits | NumberStyle::ArabicFourDigits => "Arabic", NumberStyle::UpperRoman => "UpperRoman", NumberStyle::LowerRoman => "LowerRoman", NumberStyle::UpperLetters => "UpperLetters", @@ -541,17 +541,55 @@ pub fn pt(x: f64, y: f64) -> String { format!("{} {}", num(x), num(y)) } +// ---------- list numbering ---------- + +/// A paragraph's `NumberingFormat` ("A, B, C, D...", "001, 002, 003...", …), told apart by its +/// first item. None for formats with no counterpart (CJK, Arabic, Hebrew, …). +pub fn numbering_format_in(s: &str) -> Option { + Some(match s.split(',').next().unwrap_or("").trim() { + "1" => NumberStyle::Arabic, + "01" => NumberStyle::ArabicLeadingZero, + "001" => NumberStyle::ArabicThreeDigits, + "0001" => NumberStyle::ArabicFourDigits, + "I" => NumberStyle::UpperRoman, + "i" => NumberStyle::LowerRoman, + "A" => NumberStyle::UpperLetters, + "a" => NumberStyle::LowerLetters, + _ => return None, + }) +} +pub fn numbering_format_out(n: NumberStyle) -> &'static str { + match n { + NumberStyle::Arabic | NumberStyle::Symbols => "1, 2, 3, 4...", + NumberStyle::ArabicLeadingZero => "01, 02, 03...", + NumberStyle::ArabicThreeDigits => "001, 002, 003...", + NumberStyle::ArabicFourDigits => "0001, 0002, 0003...", + NumberStyle::UpperRoman => "I, II, III, IV...", + NumberStyle::LowerRoman => "i, ii, iii, iv...", + NumberStyle::UpperLetters => "A, B, C, D...", + NumberStyle::LowerLetters => "a, b, c, d...", + } +} +/// Label text in IDML metacharacters (a tab is `^t`). +pub fn list_text_out(s: &str) -> String { + s.replace('\t', "^t") +} + // ---------- footnote options ---------- pub fn note_style_out(n: NumberStyle) -> &'static str { match n { NumberStyle::ArabicLeadingZero => "SingleLeadingZeros", + NumberStyle::ArabicThreeDigits => "DoubleLeadingZeros", + NumberStyle::ArabicFourDigits => "TripleLeadingZeros", n => number_style_out(n), } } pub fn note_style_in(s: &str) -> NumberStyle { match s { - "SingleLeadingZeros" | "DoubleLeadingZeros" => NumberStyle::ArabicLeadingZero, + "SingleLeadingZeros" => NumberStyle::ArabicLeadingZero, + "DoubleLeadingZeros" => NumberStyle::ArabicThreeDigits, + "TripleLeadingZeros" => NumberStyle::ArabicFourDigits, "Asterisks" => NumberStyle::Symbols, s => number_style_in(s), } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 850726ba3..fda15449d 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -940,3 +940,46 @@ fn layers_import_and_export_in_stacking_order() { assert_eq!(names(&back), ["Content", "Background"]); assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); } + +#[test] +fn list_numbering_format_expression_and_bullet_import_and_round_trip() { + let styles = STYLES.replace( + " ", + r#" + A, B, C, D... + + + 001, 002, 003... + + + 一, 二, 三, 四... + + + + + "#, + ); + let bytes = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let check = |d: &Document| { + let (head, _) = d.styles.resolve_para_style("Text/Head"); + assert_eq!(head.number_style, designcraft_doc::NumberStyle::UpperLetters); + assert_eq!(head.number_label(2), "B.\t"); + let (table, _) = d.styles.resolve_para_style("Text/Table"); + assert_eq!(table.number_label(7), "Tabel 007\t"); + let (kanji, _) = d.styles.resolve_para_style("Text/Kanji"); + assert_eq!(kanji.number_style, designcraft_doc::NumberStyle::Arabic); + let (point, _) = d.styles.resolve_para_style("Text/Point"); + assert_eq!(point.bullet_label(), "\u{25A0}\u{2002}"); + }; + let d = import_idml(&bytes).unwrap(); + check(&d); + check(&import_idml(&export_idml(&d)).unwrap()); +} From 107f658f0ef88b6e76f586d3a21060d1850737bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 07:01:05 +0000 Subject: [PATCH 2/2] 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(),