Skip to content

fix(docx): strip hardcoded numbering prefix from list item text - #80

Open
Risk-hao wants to merge 3 commits into
firecrawl:mainfrom
Risk-hao:fix-duplicate-list-numbering
Open

fix(docx): strip hardcoded numbering prefix from list item text#80
Risk-hao wants to merge 3 commits into
firecrawl:mainfrom
Risk-hao:fix-duplicate-list-numbering

Conversation

@Risk-hao

@Risk-hao Risk-hao commented Aug 11, 2026

Copy link
Copy Markdown

Programmatic DOCX generators (python-docx, docx.js, agent-written
reports) are common today, and they typically emit both w:numPr and the
rendered prefix in w:t runs. Result on the page: "1. 1. text" instead
of "1. text". AnyDoc previously honored that double encoding, so
Markdown/HTML downstream looked broken.

This strips the hardcoded prefix from list item text. The numPr model
is preserved, so ordered lists still render correctly.

All tests pass (222).

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.
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/docx/content.rs">

<violation number="1" location="src/formats/docx/content.rs:201">
P2: A hard break can be silently removed from list content because `Inline::LineBreak` is treated as transparent while matching the prefix. Stopping the scan at a line break preserves source line boundaries and prevents matching a prefix across it.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/docx/content.rs Outdated
flat.push_str(text);
lead += 1;
}
Inline::LineBreak | Inline::Anchor(_) => lead += 1,

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A hard break can be silently removed from list content because Inline::LineBreak is treated as transparent while matching the prefix. Stopping the scan at a line break preserves source line boundaries and prevents matching a prefix across it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/docx/content.rs, line 201:

<comment>A hard break can be silently removed from list content because `Inline::LineBreak` is treated as transparent while matching the prefix. Stopping the scan at a line break preserves source line boundaries and prevents matching a prefix across it.</comment>

<file context>
@@ -172,11 +172,90 @@ fn collect_blocks(
+                flat.push_str(text);
+                lead += 1;
+            }
+            Inline::LineBreak | Inline::Anchor(_) => lead += 1,
+            _ => break,
+        }
</file context>
Suggested change
Inline::LineBreak | Inline::Anchor(_) => lead += 1,
Inline::Anchor(_) => lead += 1,
Inline::LineBreak => break,
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a bug. Scanning skips LineBreak in flat but the
prefix still matches correctly ("1. text" starts with "1. "), and Fix 3
explicitly pushes LineBreak back into head, so it's preserved.

Comment thread src/formats/docx/content.rs Outdated
Comment thread src/formats/docx/content.rs Outdated
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).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/docx/content.rs">

<violation number="1" location="src/formats/docx/content.rs:191">
P2: The new first-paragraph lookup can target the wrong paragraph when a preceding text box is present. Because text-box content is converted into `Block::Paragraph` (via `walk_drawing` -> `parse_blocks`, and `pieces_into_blocks` always produces a Paragraph), a list item ordered as `[textbox attachment, item text]` yields `[Paragraph(textbox…), Paragraph("1. text")]`, and `position(Paragraph)` matches the text box's paragraph. That both skips stripping the item's real prefix (so the stated text-box-first goal isn't met) and risks stripping text-box content whose first line coincidentally starts with the resolved label. Consider locating the paragraph whose leading inlines actually match the expected prefix, or tracking which blocks derive from the item's own runs vs. text-box attachments, rather than taking the first Paragraph.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

// 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(_)));

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new first-paragraph lookup can target the wrong paragraph when a preceding text box is present. Because text-box content is converted into Block::Paragraph (via walk_drawing -> parse_blocks, and pieces_into_blocks always produces a Paragraph), a list item ordered as [textbox attachment, item text] yields [Paragraph(textbox…), Paragraph("1. text")], and position(Paragraph) matches the text box's paragraph. That both skips stripping the item's real prefix (so the stated text-box-first goal isn't met) and risks stripping text-box content whose first line coincidentally starts with the resolved label. Consider locating the paragraph whose leading inlines actually match the expected prefix, or tracking which blocks derive from the item's own runs vs. text-box attachments, rather than taking the first Paragraph.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/docx/content.rs, line 191:

<comment>The new first-paragraph lookup can target the wrong paragraph when a preceding text box is present. Because text-box content is converted into `Block::Paragraph` (via `walk_drawing` -> `parse_blocks`, and `pieces_into_blocks` always produces a Paragraph), a list item ordered as `[textbox attachment, item text]` yields `[Paragraph(textbox…), Paragraph("1. text")]`, and `position(Paragraph)` matches the text box's paragraph. That both skips stripping the item's real prefix (so the stated text-box-first goal isn't met) and risks stripping text-box content whose first line coincidentally starts with the resolved label. Consider locating the paragraph whose leading inlines actually match the expected prefix, or tracking which blocks derive from the item's own runs vs. text-box attachments, rather than taking the first Paragraph.</comment>

<file context>
@@ -185,9 +185,12 @@ fn strip_list_num_prefix(
+    // 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!() };
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is pre-existing, not from Fix 1. The
old blocks.first_mut() and the new position(Paragraph) have the same
behavior when a textbox Paragraph comes first. Fix 1 only deals with
non-Paragraph blocks (Rule, Image) blocking the item. Disambiguating
textbox paragraphs would be a separate change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant