Skip to content

fix: add_comment finds anchors that span w:t run boundaries - #21

Merged
pablospe merged 5 commits into
mainfrom
task/cb994ed6-add_comment---must-find-anchor
May 17, 2026
Merged

pablospe merged 5 commits into
mainfrom
task/cb994ed6-add_comment---must-find-anchor

Conversation

@pablospe

@pablospe pablospe commented May 16, 2026 •

Copy link
Copy Markdown
Owner

Closes #14.

Summary

Changes

  • CommentManager.add_comment now uses build_text_map + find_in_text_map for character-precise anchor lookup that spans run boundaries.
  • New keyword-only paragraph= and occurrence= parameters bring the API in line with the hash-anchored edit methods.
  • Range markers are placed at exact character positions; runs are split when start/end land in different w:t nodes.

Test plan

  • uv run pytest tests/test_comments.py
  • Confirm a comment can be anchored to text split across a smart-quote / formatting boundary
  • Confirm paragraph= + occurrence= behave the same as the edit-method counterparts

Summary by CodeRabbit

  • New Features

    • Anchor comments to a specific paragraph and select which occurrence to target
    • Much finer, character-precise comment placement across formatting and run boundaries
    • Preserves non-text inline elements when placing comment markers
  • Tests

    • Extensive regression and edge-case tests for cross-boundary anchoring, multi-occurrence selection, and error conditions (e.g., invalid paragraph indexes, empty anchors, stale-hash detection)

Review Change Stack

add_comment() used get_node(tag='w:t', contains=anchor_text), which
only matched text inside a single <w:t> run. count_matches() and the
hash-ref edit API used build_text_map() and matched across runs, so
add_comment() raised TextNotFoundError on anchors that count_matches
reported as present (real-world repro on a 9,641-paragraph docx).

add_comment now uses build_text_map + find_in_text_map to locate the
anchor at exact character positions across run boundaries. New
paragraph= and occurrence= keyword-only parameters bring the API in
line with the hash-anchored edit methods. Range markers are placed
character-precisely; runs are split when start and end positions sit
inside different <w:t> nodes.
@coderabbitai

coderabbitai Bot commented May 16, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 98a17818-a2c1-43e9-b31b-a2a68d8dc748

📥 Commits

Reviewing files that changed from the base of the PR and between e84815b and 3bebed2.

📒 Files selected for processing (1)
  • tests/test_comments.py

📝 Walkthrough

Walkthrough

Adds paragraph-scoped and occurrence-based anchoring to Document.add_comment/CommentManager.add_comment, resolves anchors using the document text map to compute exact character offsets, and inserts character-precise comment range markers by splitting w:t nodes and rebuilding runs. Tests cover split text nodes and cross-run anchors.

Changes

Extended comment anchoring with paragraph scoping and text-map precision

Layer / File(s) Summary
Document API extended for paragraph-scoped comment anchoring
docx_editor/document.py, docx_editor/comments.py (imports)
Document.add_comment now accepts optional keyword-only paragraph (paragraph reference string) and occurrence (match index) parameters, forwarding them to CommentManager.add_comment. Module imports broadened to include exception types and XML-text-map utilities.
CommentManager anchor resolution with text-map matching
docx_editor/comments.py (add_comment, _find_anchor, _place_markers, _rebuild_run_with_markers)
add_comment validates inputs, uses _find_anchor to resolve the exact character-range match via text-map search (scoped by optional paragraph with hash validation, and occurrence selection), generates comment/paragraph/durable IDs and UTC timestamp, and delegates precise marker insertion to _place_markers. _place_markers computes start/end positions from text-map match offsets and places comment range markers at character boundaries. _rebuild_run_with_markers reconstructs affected runs by splitting w:t nodes at marker positions and preserving direct non-w:t children to maintain document order and formatting.
XML manipulation and DOM traversal utilities
docx_editor/comments.py (_find_ancestor_run, _get_rPr_xml, _escape_xml)
Module-level helpers locate the nearest w:r ancestor for a text node, serialize a run's direct w:rPr formatting child, and escape XML text for safe inclusion into generated comment range marker fragments.
Comprehensive test suite: split nodes and cross-boundary anchoring
tests/test_comments.py (imports, _split_first_run_text, _find_paragraph_with_text, _paragraph_ref, TestSplitTextNodeComment, TestCommentCrossBoundaryAnchor)
Expanded imports for new exception types. Helpers manipulate Word DOM (_split_first_run_text rewrites a run's w:t into two runs; _find_paragraph_with_text and _paragraph_ref locate paragraphs and derive list references). Split-text-node test verifies list_comments() returns full combined text when underlying w:t is split. Cross-boundary anchor tests synthesize anchors spanning multiple runs/text nodes, formatting changes, and tracked changes; validate marker placement, paragraph-scoped lookup, stale hash raising, occurrence selection, and non-text sibling preservation (tabs, line breaks). Error case tests verify empty anchor rejection, CJK multi-run anchors, out-of-range paragraph index handling, truncated paragraph preview on stale-hash, and orphan text-node (w:t without w:r ancestor) exceptions.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Document
    participant CommentManager
    participant TextMap
    participant WordXML

    Caller->>Document: add_comment(anchor_text, comment, paragraph?, occurrence?)
    Document->>CommentManager: add_comment(anchor_text, comment, paragraph?, occurrence?)
    CommentManager->>CommentManager: Validate anchor_text (not empty)
    alt paragraph scoped search
        CommentManager->>CommentManager: Load paragraph by reference, validate hash
        CommentManager->>TextMap: Search anchor within paragraph with occurrence
    else document-wide search
        CommentManager->>TextMap: Search anchor across document with occurrence
    end
    TextMap-->>CommentManager: TextMapMatch(node, start_offset, end_offset)
    CommentManager->>CommentManager: Generate comment ID, paragraph ID, durable ID, timestamp
    CommentManager->>WordXML: _place_markers(match) — compute positions
    CommentManager->>WordXML: _rebuild_run_with_markers — split w:t nodes, insert markers
    WordXML->>WordXML: Split affected runs, preserve non-w:t children, insert w:commentRangeStart/End
    WordXML-->>CommentManager: Updated run structure
    CommentManager-->>Document: comment_id
    Document-->>Caller: comment_id
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Through runs and w:t I hop and peep,
I split the text where boundaries sleep;
Markers placed with careful art,
Anchors now land at every part;
A tiny hop, a precise start.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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 fix: enabling add_comment to find anchors that span across w:t run boundaries, which was the core issue raised in #14.
Linked Issues check ✅ Passed The PR successfully addresses issue #14's core requirement: add_comment now uses character-precise anchor lookup (build_text_map + find_in_text_map) to accept anchors spanning multiple w:t runs, matching find_text behavior. The implementation adds paragraph and occurrence parameters for deterministic anchoring.
Out of Scope Changes check ✅ Passed All changes are scoped to the comment anchoring functionality: docx_editor/comments.py (extended anchor resolution), docx_editor/document.py (API parameter forwarding), and tests/test_comments.py (regression/boundary tests). No extraneous modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/cb994ed6-add_comment---must-find-anchor

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.

ty could not narrow start_offset/end_offset (int | None) or
start_marker/end_marker (str | None) through the is_start_here /
is_end_here boolean flags, producing 8 unsupported-operator /
invalid-argument-type diagnostics on the int+1 and list.append calls.

Make the offsets plain int (callers pass 0 as placeholder) and the
markers plain str (empty string = no marker). bool(start_marker)
replaces start_marker is not None. Semantics and tests unchanged.
@codecov

codecov Bot commented May 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.1%. Comparing base (8b851f3) to head (3bebed2).

Files with missing lines Patch % Lines
docx_editor/comments.py 96.0% 0 Missing and 5 partials ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##            main     #21    +/-   ##
======================================
  Coverage   94.0%   94.1%            
======================================
  Files         10      10            
  Lines       2180    2291   +111     
  Branches     484     515    +31     
======================================
+ Hits        2051    2156   +105     
- Misses        34      36     +2     
- Partials      95      99     +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.

pablospe added 2 commits May 16, 2026 20:50
Pins the run-boundary fix to vincentfyt's reported failure mode: a
29-character Chinese anchor spanning nine <w:t> elements. count_matches
and find_text accepted it before this branch, add_comment rejected it.
Three small regression tests fill the codecov/patch gaps in the new
add_comment cross-run anchor code:

- test_paragraph_index_out_of_range_raises — exercises the
  ParagraphIndexError raise in _find_anchor when paragraph= refers to
  an index past the end of the document.
- test_stale_hash_long_paragraph_truncates_preview — exercises the
  "preview += '...'" truncation branch of HashMismatchError when the
  paragraph text exceeds 80 chars.
- test_orphan_wt_raises_comment_error — exercises the
  _find_ancestor_run -> None path by synthesizing a bare <w:t> with
  no <w:r> ancestor, asserting the CommentError guard fires.

Coverage on docx_editor/comments.py goes from ~92% to 95%.

@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/comments.py`:
- Around line 735-744: Replace the manual chain of .replace calls in _escape_xml
with the standard library helper: call html.escape(text, quote=True) and then
(if you still need apostrophe escaped) replace "'" with "&apos;" on the result;
update the _escape_xml function to use html.escape for &, <, > and " and only
handle the apostrophe specially to keep behavior identical while reducing
maintenance.
🪄 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: 3ecdd6e9-3ec4-4a1d-95a4-a1e901b96817

📥 Commits

Reviewing files that changed from the base of the PR and between 8b851f3 and e84815b.

📒 Files selected for processing (3)
  • docx_editor/comments.py
  • docx_editor/document.py
  • tests/test_comments.py

Comment thread docx_editor/comments.py
Comment on lines +735 to +744
def _escape_xml(text: str) -> str:
"""Escape text for safe XML inclusion."""
return (
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider using html.escape instead of manual XML escaping.

html.escape (already imported at line 6) handles &, <, >, and " when quote=True. For the apostrophe, you could extend it or keep the current implementation. The current code is correct but introduces minor maintenance overhead.

🤖 Prompt for 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.

In `@docx_editor/comments.py` around lines 735 - 744, Replace the manual chain of
.replace calls in _escape_xml with the standard library helper: call
html.escape(text, quote=True) and then (if you still need apostrophe escaped)
replace "'" with "&apos;" on the result; update the _escape_xml function to use
html.escape for &, <, > and " and only handle the apostrophe specially to keep
behavior identical while reducing maintenance.

Three additional tests for the unscoped (paragraph=None) anchor lookup
in CommentManager._find_anchor, where codecov/patch flagged 3 missing
statements (lines 216-217, 219-223):

- test_document_wide_occurrence_skips_earlier_matches — exercises the
  current/local counter increments inside the while loop when
  occurrence > 0.
- test_document_wide_occurrence_out_of_range — exercises the
  TextNotFoundError raise with total_occurrences set on the
  occurrence > 0 / current > 0 fall-through.
- test_scoped_occurrence_when_anchor_absent — exercises the symmetric
  scoped path where occurrence > 0 but total == 0, raising plain
  TextNotFoundError without total_occurrences.

Statement coverage on docx_editor/comments.py reaches 100%
(Miss=0). Remaining partials are branch-only on defensive guards.
@pablospe
pablospe merged commit cc2efab into main May 17, 2026
10 checks passed
@snowmays

Copy link
Copy Markdown
Contributor

Thanks for shipping this! We hit the same issue (~30% anchor failure on Chinese commercial contracts with whitespace placeholders) and were working around it with a wrapper that finds a unique substring within the target paragraph. The new keyword-only paragraph= parameter is exactly the API we needed — once it ships, our wrapper drops to a single line.

Any rough ETA for a PyPI release that includes this fix? We're currently pinned to 0.2.3 in production. Happy to provide additional test cases (Chinese commercial contracts with table/form-field placeholders) if useful for regression coverage.

@pablospe

Copy link
Copy Markdown
Owner Author

I will do soon a new release. Let you know. I was working on something else, and waiting to put it together, but it doesn't hurt to release a new version.

Any rough ETA for a PyPI release that includes this fix? We're currently pinned to 0.2.3 in production. Happy to provide additional test cases (Chinese commercial contracts with table/form-field placeholders) if useful for regression coverage

That would be awesome! Could you please propose a Pull Request? I will merge it.

@pablospe
pablospe deleted the task/cb994ed6-add_comment---must-find-anchor branch May 18, 2026 17:42
@pablospe

Copy link
Copy Markdown
Owner Author

@snowmays you can install the new version now 0.2.4
pip install --upgrade docx-editor

Or, if you prefer a specific version:
pip install docx-editor==0.2.4

pablospe added a commit that referenced this pull request Jul 14, 2026
* fix: unify occurrence search through text maps (ISSUES.md #21)

Document-wide replace/delete/insert used an element-level w:t search
(_get_nth_match) with a text-map fallback, and the two counted
occurrences over different sequences: a match split across fragmented
runs was invisible to the element-level path, so occurrence=N could
target different matches depending on which path won.

Route all three methods through a single _find_document_wide text-map
lookup (the same sequence count_matches uses), delete the duplicate
single-node edit bodies and the drift shim (~250 lines), and rewrite
_insert_near_match to split the edge w:t at the match boundary,
fixing mid-node anchor placement for the paragraph-scoped and
batch_edit insert paths as well ("HelloXX world", not "Hello worldXX").

TextNotFoundError.total_occurrences now matches count_matches, and
runs with non-text children (w:tab, w:br) keep them during inserts.

* review: add occurrence default to _find_document_wide

Multi-reviewer pass on PR #39. Fixed: missing `occurrence: int = 0`
default, matching the sibling finders (_find_in_paragraph,
_find_across_boundaries).

Verified as not applicable here and deferred with notes in ISSUES.md:
straddle-match insertion into a foreign w:ins (needs #19's
ins-splitting machinery; in-place rebuild would nest w:ins) and
front-hoisted non-text run children in the insert rebuild (#20, same
pattern as the existing replace/delete paths). xml:space on generated
w:t was a false positive — the editor's attribute injection adds it.
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.

add_comment fails on anchors that find_text accepts

2 participants