From e2ec4c915884a0feaf468b50a631a1e52dcab842 Mon Sep 17 00:00:00 2001 From: huanghuang <13652790703@163.com> Date: Tue, 11 Aug 2026 13:43:05 +0800 Subject: [PATCH 1/3] fix(docx): strip hardcoded numbering prefix from list item text OOXML numbered lists store the rendered prefix (e.g. "1. ") in w:t text runs while also carrying the numbering model in w:numPr. AnyDoc previously kept both, causing render_list to output "1. 1. text" instead of "1. text". Add strip_list_num_prefix() called in emit_paragraph's ParaKind::ListItem branch to remove the redundant prefix from paragraph inlines before they enter the ListEntry. The function: - Skips bullets (unordered lists never hardcode a prefix) - Matches the expected label or marker.label(number) against the text - Requires a separator (space/tab) after the prefix to avoid false matches like "1.text" as content - Handles prefix text split across multiple inline runs All existing 222 tests pass with no regression on snapshot fixtures. --- src/formats/docx/content.rs | 155 +++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index c53c1ec..4cba9b4 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -13,7 +13,7 @@ use crate::shared::blockstyle::{BlockStyle, StyledRun}; use crate::shared::delta::rebase_emphasis; use crate::shared::fields::{FieldFrame, field_result}; use crate::shared::header::resolve_header_rows; -use crate::shared::list::{ListEntry, ListKey, flush_list}; +use crate::shared::list::{ListEntry, ListKey, MarkerKind, flush_list}; use crate::shared::text::{clean_text, is_xml_space}; use std::cell::RefCell; use std::collections::HashMap; @@ -172,11 +172,93 @@ fn collect_blocks( Ok(()) } +fn strip_list_num_prefix( + blocks: &mut Vec, + marker: MarkerKind, + number: u64, + label: &Option, +) { + // Bullets don't carry hardened number prefixes in OOXML. + if !marker.ordered() { + return; + } + + // The text the renderer will place before the paragraph. + let num_text = label.clone().unwrap_or_else(|| marker.label(number)); + let Some(Block::Paragraph(inlines)) = blocks.first_mut() else { + return; + }; + + // Flatten enough leading text inlines to decide whether the prefix matches. + let mut flat = String::new(); + let mut lead = 0usize; + for inv in inlines.iter() { + match inv { + Inline::Text { text, .. } => { + flat.push_str(text); + lead += 1; + } + Inline::LineBreak | Inline::Anchor(_) => lead += 1, + _ => break, + } + if flat.len() >= num_text.len() + 1 { + break; + } + } + + // Must start with `num_text` followed by a separator or end-of-string. + let sep = match flat.strip_prefix(&num_text) { + Some("") => 0, + Some(r) if r.starts_with(' ') || r.starts_with('\t') => 1, + _ => return, + }; + let mut strip = num_text.len() + sep; + if strip == 0 { + return; + } + + // Drain the leading inlines and re-insert the survivors. + let tail: Vec = inlines.split_off(lead); + let mut head = Vec::with_capacity(inlines.len()); + for inv in std::mem::take(inlines) { + if strip == 0 { + head.push(inv); + continue; + } + match inv { + Inline::Text { text, style } => { + if text.len() <= strip { + strip -= text.len(); + } else { + let t = text[strip..].trim_start().to_string(); + strip = 0; + if !t.is_empty() { + head.push(Inline::Text { text: t, style }); + } + } + } + Inline::LineBreak | Inline::Anchor(_) => {} // consume + other => { + strip = 0; + head.push(other); + } + } + } + head.extend(tail); + *inlines = head; + + // Don't leave a ghost paragraph with no visible content. + if inlines.is_empty() { + blocks.remove(0); + } +} + fn emit_paragraph(kind: ParaKind, pieces: Vec, blocks: &mut Vec, runs: &mut Runs) { match kind { ParaKind::ListItem { ilvl, key, number, label } => { runs.styled.flush(blocks); - let item = pieces_into_blocks(pieces); + let mut item = pieces_into_blocks(pieces); + strip_list_num_prefix(&mut item, key.marker, number, &label); runs.list.push(ListEntry { level: ilvl, key, number, label, blocks: item }); } ParaKind::Styled(style) => { @@ -986,4 +1068,73 @@ mod tests { assert_eq!(before, "before"); assert_eq!(after, "after"); } + + // -- strip_list_num_prefix ------------------------------------------- + + fn strip(blocks: &mut Vec, marker: MarkerKind, n: u64, label: &Option) -> String { + strip_list_num_prefix(blocks, marker, n, label); + match blocks.first() { + Some(Block::Paragraph(inlines)) => crate::model::inlines_to_plain_text(inlines), + _ => String::new(), + } + } + + #[test] + fn strip_prefix_ordered() { + let cases = [ + (vec![Inline::plain("1. text")], MarkerKind::Decimal, 1, None, "text"), + (vec![Inline::plain("1.\ttext")], MarkerKind::Decimal, 1, None, "text"), + (vec![Inline::plain("a. text")], MarkerKind::LowerAlpha, 1, None, "text"), + (vec![Inline::plain("iv. text")], MarkerKind::LowerRoman, 4, None, "text"), + ]; + for (inlines, marker, n, label, expected) in cases { + let mut blocks = vec![Block::Paragraph(inlines)]; + assert_eq!(strip(&mut blocks, marker, n, &label), expected); + } + } + + #[test] + fn strip_prefix_composite_label() { + let mut blocks = vec![Block::Paragraph(vec![Inline::plain("1.1) text")])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 1, &Some("1.1)".into())), "text"); + } + + #[test] + fn strip_prefix_split_inlines() { + let mut blocks = vec![Block::Paragraph(vec![ + Inline::plain("1."), Inline::plain(" "), Inline::plain("text"), + ])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 1, &None), "text"); + + let mut blocks = vec![Block::Paragraph(vec![ + Inline::plain("1. "), Inline::plain("content"), + ])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 1, &None), "content"); + } + + #[test] + fn strip_prefix_no_match() { + // Text that doesn't start with the expected prefix. + let mut blocks = vec![Block::Paragraph(vec![Inline::plain("text")])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 1, &None), "text"); + + // Starts with number but no separator after it. + let mut blocks = vec![Block::Paragraph(vec![Inline::plain("1.text")])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 1, &None), "1.text"); + + // Resolved number differs from the hardcoded prefix. + let mut blocks = vec![Block::Paragraph(vec![Inline::plain("1. text")])]; + assert_eq!(strip(&mut blocks, MarkerKind::Decimal, 3, &None), "1. text"); + + // Bullets short-circuit via `!marker.ordered()`. + let mut blocks = vec![Block::Paragraph(vec![Inline::plain("- text")])]; + assert_eq!(strip(&mut blocks, MarkerKind::Bullet, 0, &None), "- text"); + } + + #[test] + fn strip_prefix_empty_blocks() { + let mut blocks: Vec = Vec::new(); + strip_list_num_prefix(&mut blocks, MarkerKind::Decimal, 1, &None); + assert!(blocks.is_empty()); + } } From faba625b7897e334bd16afc691dd77077fedd769 Mon Sep 17 00:00:00 2001 From: huanghuang <13652790703@163.com> Date: Tue, 11 Aug 2026 13:52:40 +0800 Subject: [PATCH 2/3] chore(docx): remove unreachable strip == 0 guard num_text.len() >= 1 for ordered markers and sep is always 0 or 1, so strip >= 1 always holds. Remove the dead early-return check. --- src/formats/docx/content.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index 4cba9b4..6a72f4d 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -213,9 +213,6 @@ fn strip_list_num_prefix( _ => return, }; let mut strip = num_text.len() + sep; - if strip == 0 { - return; - } // Drain the leading inlines and re-insert the survivors. let tail: Vec = inlines.split_off(lead); From 00b86f12a3c270d1b690036bf667a3e72124917e Mon Sep 17 00:00:00 2001 From: huanghuang <13652790703@163.com> Date: Tue, 11 Aug 2026 15:01:35 +0800 Subject: [PATCH 3/3] fix(docx): address 3 valid bot review findings in strip_list_num_prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Use paragraph position instead of blocks.first_mut() to handle non-Paragraph items (e.g., Rule) appearing before Paragraph in pieces_into_blocks output. 2. Remove Some("") exact match arm — when paragraph text equals the prefix exactly (e.g. "1." with no following content), it is real content, not a hardened prefix to strip. 3. Preserve Anchor and LineBreak inlines during prefix drain instead of consuming them, since they carry structural meaning (intra-doc link targets, line boundaries). --- src/formats/docx/content.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index 6a72f4d..4eeb65b 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -185,9 +185,12 @@ fn strip_list_num_prefix( // The text the renderer will place before the paragraph. let num_text = label.clone().unwrap_or_else(|| marker.label(number)); - let Some(Block::Paragraph(inlines)) = blocks.first_mut() else { - return; - }; + // A list item's pieces may start with block attachments (text boxes, + // rules, ...) before the paragraph carrying the inline text, so locate + // the first paragraph rather than assuming it is blocks[0]. + let para_idx = blocks.iter().position(|b| matches!(b, Block::Paragraph(_))); + let Some(para_idx) = para_idx else { return }; + let Block::Paragraph(inlines) = &mut blocks[para_idx] else { unreachable!() }; // Flatten enough leading text inlines to decide whether the prefix matches. let mut flat = String::new(); @@ -206,9 +209,10 @@ fn strip_list_num_prefix( } } - // Must start with `num_text` followed by a separator or end-of-string. + // Must start with `num_text` followed by a separator: an exact match with + // nothing after it is content that happens to equal the prefix, not a + // hardened prefix over content we should strip. let sep = match flat.strip_prefix(&num_text) { - Some("") => 0, Some(r) if r.starts_with(' ') || r.starts_with('\t') => 1, _ => return, }; @@ -234,7 +238,10 @@ fn strip_list_num_prefix( } } } - Inline::LineBreak | Inline::Anchor(_) => {} // consume + // Anchors and line breaks carry structural meaning (intra-document link + // targets, source line boundaries), so preserve them instead of + // consuming them as part of the prefix region. + Inline::LineBreak | Inline::Anchor(_) => head.push(inv), other => { strip = 0; head.push(other); @@ -246,7 +253,7 @@ fn strip_list_num_prefix( // Don't leave a ghost paragraph with no visible content. if inlines.is_empty() { - blocks.remove(0); + blocks.remove(para_idx); } }