Skip to content

Fix TextNotFoundError on smart quotes (split TEXT_NODE in w:t) (#9) - #16

Merged
pablospe merged 6 commits into
mainfrom
issue-9
May 15, 2026
Merged

pablospe merged 6 commits into
mainfrom
issue-9

Conversation

@pablospe

@pablospe pablospe commented May 14, 2026 •

Copy link
Copy Markdown
Owner

Fixes #9.

Summary

  • replace_text / suggest_deletion / _insert_text / _parse_revision in track_changes.py no longer rely on firstChild.data for w:t reads or writes — a new _set_node_text helper consolidates split TEXT_NODE children and _get_node_text already concatenates them.
  • The same multi-text-node bug class is fixed in three other modules surfaced by the multi-reviewer pass: comments._parse_comment (truncated comment text), xml_editor.add_xml_space_to_t (missed leading/trailing whitespace in non-first fragments), and ooxml.pack.condense_xml (stripped lone-space TEXT_NODEs from w:delText/w:instrText).
  • XMLEditor._get_element_text now only skips whitespace TEXT_NODEs when they're pretty-print indentation, so the contains= search returns hits for documents where a w:t is split such that a space sits alone in its own TEXT_NODE.

Root cause

xml.dom.minidom can split the text of a single <w:t> element across multiple TEXT_NODE children — reportedly when smart quotes (U+2018, U+2019) are present. Code reading only firstChild.data returned just the first fragment (the TextNotFoundError), and code writing to firstChild.data left the remaining sibling text nodes in place (silently corrupting the document).

Test plan

  • New TestMultiTextNodeWtElements covers the previously-broken paragraph=None paths in replace/delete/insert plus the in-w:ins cross-boundary write path.
  • New TestSmartQuoteEndToEnd builds a real .docx containing U+2018/U+2019, exercises the full open → edit → save → reopen cycle, and asserts the smart quotes survive.
  • New TestSplitTextNodeComment verifies list_comments returns full text when a comment w:t is split.
  • New test_inject_xml_space_for_split_text_nodes verifies xml:space="preserve" is added when whitespace sits in a later TEXT_NODE.
  • New TestCondenseXmlPreservesTextContent verifies tracked-deletion and field-instruction whitespace survives pack.
  • New test_get_element_text_preserves_split_whitespace verifies contains= search works against a split w:t.
  • Full suite: uv run pytest → 507 passed, 6 skipped.
  • uv run ruff check and ruff format clean.

Review process

Local multi-reviewer pass (claude opus + gemini + codex) ran twice:

  • Iteration 1 caught comments.py and xml_editor.py:add_xml_space_to_t as HIGH issues outside the originally-reported file.
  • Iteration 2 caught ooxml/pack.py:condense_xml (CRITICAL: w:delText/w:instrText unprotected) and _get_element_text whitespace stripping (HIGH: broke contains= search).
  • Final iteration: CLEAN from codex; remaining LOW items (DRY extraction, @staticmethod, mock-helper polish) are intentional follow-ups, not blockers.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed comment text truncation when content is split across multiple XML text nodes
    • Improved tracked-change edits (insert/replace/delete) to handle fragmented text correctly
    • Enhanced XML handling to preserve significant whitespace in split text nodes
    • Ensured packed OOXML preserves whitespace-bearing text elements
  • Tests

    • Added unit and end-to-end tests covering split text nodes, smart-quote scenarios, and packing behavior

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 14, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@pablospe has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 27 minutes and 17 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6289097e-5a3f-477b-98eb-bcf150454edd

📥 Commits

Reviewing files that changed from the base of the PR and between cbd6e57 and b0111a0.

📒 Files selected for processing (8)
  • docx_editor/comments.py
  • docx_editor/ooxml/pack.py
  • docx_editor/track_changes.py
  • docx_editor/xml_editor.py
  • tests/test_comments.py
  • tests/test_ooxml.py
  • tests/test_track_changes.py
  • tests/test_xml_editor.py
📝 Walkthrough

Walkthrough

Consolidates text handling for minidom-split <w:t> content by adding a shared reader (get_text_node_data / _get_node_text) and writer (_set_node_text), applying them in track-change edits, comment parsing, XML attribute injection, and OOXML packing; tests and helpers exercise split-text scenarios and smart-quote roundtrips.

Changes

Split Text Node Handling Fix

Layer / File(s) Summary
Track changes text node helpers and refactor
docx_editor/track_changes.py
New _get_node_text wrapper and _set_node_text writer replace direct firstChild.data reads/writes across _rewrite_insert_at, replace_text, suggest_deletion, _remove_from_insertion, _insert_text, and _parse_revision.
XML editor text extraction and whitespace preservation
docx_editor/xml_editor.py
Added get_text_node_data() to concatenate TEXT_NODE children; _get_element_text() preserves whitespace fragments when appropriate; DocxXMLEditor._inject_attributes_to_nodes uses combined text to set xml:space="preserve".
Comment and OOXML text handling
docx_editor/comments.py, docx_editor/ooxml/pack.py
CommentManager._parse_comment extracts comment text via the shared helper for each w:t; condense_xml treats w:delText and w:instrText as significant text-bearing elements to preserve during packing.
Test helpers and coverage
tests/test_track_changes.py, tests/test_comments.py, tests/test_ooxml.py, tests/test_xml_editor.py
Added _attach_text_node, _split_wt_text_nodes, _build_smart_quote_docx and multiple unit and end-to-end tests covering split w:t behavior, OOXML condense whitespace preservation, comment text round-trips, and XML editor whitespace/attribute injection.

Sequence Diagram

sequenceDiagram
  participant Client
  participant replace_text
  participant _get_node_text
  participant w_t_element
  participant _set_node_text

  Client->>replace_text: replace_text(search, replacement)
  replace_text->>_get_node_text: request full text
  _get_node_text->>w_t_element: read all TEXT_NODE children
  w_t_element-->>_get_node_text: concatenated text
  _get_node_text-->>replace_text: merged text
  replace_text->>_set_node_text: provide updated text
  _set_node_text->>w_t_element: remove TEXT_NODE children, append single TEXT_NODE
  _set_node_text-->>replace_text: done
  replace_text-->>Client: operation complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • pablospe/docx-editor#11: Introduces editing logic touching rewrite_paragraph/_rewrite_insert_at that intersects with this PR's tracked-change insertion/splice updates.

Poem

🐰 A rabbit hums through split node stacks,

Merging fragments, fixing smart-quote cracks.
Helpers gather every stray text part,
Rewrites tidy, whole again from start.
Hooray — the docx sings in one big heart!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing TextNotFoundError on smart quotes caused by split TEXT_NODE in w:t elements, directly addressing the primary issue (#9).
Linked Issues check ✅ Passed All coding requirements from issue #9 are met: concatenate TEXT_NODE children on reads via get_text_node_data helper, consolidate to single TEXT_NODE on writes via _set_node_text, apply fixes across track_changes, comments, xml_editor, and ooxml.pack, and preserve whitespace fragments with comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes directly address split TEXT_NODE handling across comments.py, track_changes.py, xml_editor.py, ooxml/pack.py, and related test files; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-9

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented May 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.0%. Comparing base (cbd6e57) to head (b0111a0).

Additional details and impacted files
@@           Coverage Diff           @@
##            main     #16     +/-   ##
=======================================
+ Coverage   93.8%   94.0%   +0.1%     
=======================================
  Files         10      10             
  Lines       2175    2173      -2     
  Branches     492     485      -7     
=======================================
+ Hits        2042    2044      +2     
  Misses        34      34             
+ Partials      99      95      -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docx_editor/track_changes.py`:
- Around line 1070-1076: The branches that update text on a single node (the
elif not before_text, elif not after_text, and the "Middle split" branch that
call _set_node_text on first_node) do not call _set_xml_space_preserve, so
leading/trailing whitespace may be collapsed; after each _set_node_text call in
those branches (the calls that target first_node in track_changes.py) add a call
to _set_xml_space_preserve(first_node, <the_text_you_set>) to match the
multi-node case behavior and preserve whitespace.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7114b830-1989-42d3-8beb-ec484b73b87a

📥 Commits

Reviewing files that changed from the base of the PR and between 6da77a8 and ade25ff.

📒 Files selected for processing (8)
  • docx_editor/comments.py
  • docx_editor/ooxml/pack.py
  • docx_editor/track_changes.py
  • docx_editor/xml_editor.py
  • tests/test_comments.py
  • tests/test_ooxml.py
  • tests/test_track_changes.py
  • tests/test_xml_editor.py

Comment thread docx_editor/track_changes.py
pablospe added 6 commits May 15, 2026 21:33
minidom can split a single <w:t> element's text across multiple
TEXT_NODE children — Word documents with smart quotes (U+2018/U+2019)
have been reported to land in this state. The read paths in
replace_text, suggest_deletion, _insert_text, and _parse_revision only
consulted firstChild.data, so any text adjacent to a smart quote raised
TextNotFoundError. The corresponding write paths assigned to
firstChild.data, which silently corrupted the remaining sibling text
nodes.

Add a _set_node_text helper that consolidates children to a single
TEXT_NODE, document _get_node_text, and route all firstChild.data
text reads/writes through these helpers. Element-level firstChild
traversal (unwrap, restore deletion, ins-insertion point lookup)
is unchanged.

Tests cover the buggy code paths directly (paragraph=None branches
and the cross-boundary in-ins path) and round-trip a real .docx
built with smart-quote content through open → edit → save → reopen.
Code review (gemini reviewer) flagged that the same firstChild.data
bug class exists outside track_changes.py:

- xml_editor.add_xml_space_to_t: inspected only firstChild.data when
  deciding whether to add xml:space="preserve". For a <w:t> split into
  multiple TEXT_NODE children, trailing/leading whitespace sitting in
  a later fragment was missed, so Word would collapse those spaces.

- comments._parse_comment: appended only firstChild.data to text_parts
  when reading comment text. Comment bodies containing characters that
  trigger node splitting (e.g. smart quotes) were returned truncated.

Both sites now concatenate all TEXT_NODE children of each <w:t>,
matching the approach already used by RevisionManager._get_node_text.
Tests construct the split-text-node state directly and verify the
read paths return the full content.
Adversarial review iteration 2 surfaced two more second-order
consequences of issue #9 (minidom can split a single <w:t> across
multiple TEXT_NODE children).

- ooxml.pack.condense_xml only skipped elements with tag suffix ":t".
  Tracked deletions (w:delText) and field instructions (w:instrText)
  were unprotected, so a whitespace-only TEXT_NODE created by a split
  would be stripped on save, corrupting tracked changes that contain
  spaces near smart quotes. Extend the suffix check to cover both.

- XMLEditor._get_element_text unconditionally dropped any whitespace-
  only TEXT_NODE. That's fine for inter-element pretty-print whitespace,
  but it also dropped the lone-space fragment of a split <w:t> like
  ["Library", " ", "Bookshelves"], silently breaking the `contains=`
  search used by get_node/find_all_nodes. Only skip a whitespace
  TEXT_NODE when it has no meaningful-text TEXT_NODE sibling.
The disjunct in test_condense_preserves_whitespace_in_delText was a
near-tautology — the right-hand side evaluated True whenever the space
had been stripped, so the test would have silently passed against a
regression. The stricter form fails fast if the space disappears from
the repacked archive.

Skipped on this iteration:
- xml_editor._get_element_text edge case where a lone whitespace-only
  TEXT_NODE child is dropped (pre-existing behavior, not introduced by
  this PR; xml:space="preserve" guard would be the proper fix)
- pytest.skip pattern in TestSplitTextNodeComment (matches the rest of
  test_comments.py — consistent with project style)
…#9)

CodeRabbit flagged an asymmetry: the multi-node branch of
_remove_from_insertion pairs every _set_node_text() write with
_set_xml_space_preserve(), but the single-node branch did not. If the
surviving text after a deletion has leading or trailing whitespace
(common when only part of an insertion is removed), Word would silently
collapse it on render. Aligning the single-node branch with the
multi-node pattern.
Addresses the three LOW items the previous reviewer pass flagged but
that had been deferred:

* Extract the "concat all TEXT_NODE children" pattern into a single
  module-level helper `xml_editor.get_text_node_data`. Replace the
  four inline copies (build_text_map, add_xml_space_to_t,
  _parse_comment, RevisionManager._get_node_text) with calls into
  it. The method on RevisionManager stays as a thin delegate so
  call sites and any external subclasses remain unchanged.

* `_get_element_text` now treats a whitespace-only TEXT_NODE child
  as content when its parent carries `xml:space="preserve"`, not
  only when it sits alongside a meaningful-text sibling. Closes the
  edge case where a lone-space `<w:t xml:space="preserve"> </w:t>`
  was incorrectly returned as the empty string by `contains=` search.

* New focused unit test for `_set_node_text` asserts the contract
  directly: starting from a multi-TEXT_NODE state, ends with exactly
  one TEXT_NODE carrying the new content. Catches future
  "simplifications" that re-introduce the `firstChild.data =` pattern
  without an integration regression.

* New test covering the `xml:space="preserve"` branch of
  `_get_element_text`.
@pablospe

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@pablospe
pablospe merged commit 0a5eb00 into main May 15, 2026
10 checks passed
@pablospe
pablospe deleted the issue-9 branch May 15, 2026 21:21
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.

replace_text/suggest_deletion/insert_text fail on w:t elements with split text nodes (smart quotes)

1 participant