Skip to content

fix: run-level w:rPr must not leak from drawing text boxes (ISSUES.md #31) - #47

Merged
pablospe merged 2 commits into
mainfrom
task/fe95aa99-rPr-correctness---render_wt-de
Jul 14, 2026
Merged

pablospe merged 2 commits into
mainfrom
task/fe95aa99-rPr-correctness---render_wt-de

Conversation

@pablospe

@pablospe pablospe commented Jul 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the rPr correctness bug tracked as ISSUES.md #31 (repo-local issue list — not GitHub issue 31) plus the render_wt dedup that rode along with it.

Bug fix

run.getElementsByTagName("w:rPr") searches all descendants, so when an outer run had no w:rPr but contained a w:drawing whose text-box run did, the inner run's formatting leaked into rebuilt fragments. Three sites in track_changes.py (_get_run_info, the _rewrite_insert_at empty-paragraph branch, the foreign-w:ins middle-split) now use the new get_rPr_xml(), which iterates direct children only.

Consolidation

  • _escape_xml was defined verbatim in both track_changes.py and comments.py — moved to xml_editor.py, re-imported (existing test imports keep working).
  • New render_plain_wt() replaces five copy-pasted preserve-plain-w:t branches across the render_wt closures in both modules.
  • comments.py::_get_rPr_xml promoted to xml_editor.py::get_rPr_xml (it was already the correct reference implementation).

Behavior change (intended, spec-conformant)

_build_style_outline_map now treats a w:style with no w:type attribute as a paragraph style (ECMA-376 default) instead of skipping it, so typeless styles carrying w:outlineLvl now contribute outline levels.

Net −41 lines in docx_editor/, +148 lines of tests. Target release: 0.5.1 (no version bump here).

Tests

  • TestNestedRPrNoLeak — regression coverage for both bug paths (replace via _get_run_info, insert into a visually-empty paragraph); all new behavior tests fail against the old code.
  • TestRunFragmentHelpers — unit tests for get_rPr_xml / render_plain_wt (direct child / none / descendant-only, empty node, escaping).
  • TestStyleMissingTypeDefaultsToParagraph — typeless style resolves outline_level.
  • Full suite: 949 passed, 2 skipped. ruff check / ruff format --check clean. ty check exit 0, diagnostics byte-identical to baseline.

Summary by CodeRabbit

  • Bug Fixes

    • Improved comment and tracked-change rendering to preserve text formatting consistently.
    • Prevented formatting from nested text boxes from leaking into surrounding runs.
    • Correctly handles paragraph styles without an explicit type attribute.
    • Ensured special characters and empty text nodes are rendered safely.
  • Tests

    • Added coverage for nested formatting, typeless paragraph styles, XML rendering, and tracked-text updates.

…31)

- Replace three descendant-scanning getElementsByTagName("w:rPr") sites in
  track_changes.py with new get_rPr_xml() (direct children only), so a w:rPr
  nested in a drawing's text box no longer leaks into rebuilt outer runs.
- Consolidate duplicated helpers into xml_editor.py: _escape_xml (was defined
  twice) and new render_plain_wt() replacing five copy-pasted preserve-plain-w:t
  branches across the render_wt closures.
- _build_style_outline_map: a w:style without w:type now defaults to paragraph
  per ECMA-376 instead of being skipped.
- Tests: nested-rPr no-leak regression (replace + empty-paragraph insert paths),
  unit tests for the new helpers, typeless-style outline resolution.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pablospe, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9ce78957-f15d-4a03-a2ce-130803a907b4

📥 Commits

Reviewing files that changed from the base of the PR and between 31762c7 and 79d9014.

📒 Files selected for processing (3)
  • docx_editor/xml_editor.py
  • tests/test_run_child_order.py
  • tests/test_xml_editor.py
📝 Walkthrough

Walkthrough

Shared XML helpers now handle run properties, escaping, and plain text rendering. Comment and tracked-change reconstruction use them, typeless paragraph styles resolve correctly, and tests cover rendering and nested property behavior.

Changes

XML rendering and document reconstruction

Layer / File(s) Summary
Shared XML helpers and style handling
docx_editor/xml_editor.py, tests/test_xml_editor.py, tests/test_paragraph_location.py
Adds direct w:rPr extraction and plain-text rendering helpers, treats typeless styles as paragraph styles, and tests both behaviors.
Comment marker rendering
docx_editor/comments.py
Uses shared escaping, run-property extraction, and plain-text rendering while rebuilding comment-marked runs.
Tracked-change rendering
docx_editor/track_changes.py, tests/test_run_child_order.py
Applies shared helpers across tracked edits and verifies textbox-contained run properties do not leak into rebuilt or inserted top-level runs.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Poem

A bunny found some XML hay,
And shared the tools along the way.
Runs stayed neat, text escaped bright,
No textbox boldness took a flight.
Hop, hop—clean documents today!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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 clearly describes the main fix: preventing run-level w:rPr leakage from drawing text boxes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/fe95aa99-rPr-correctness---render_wt-de

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.

@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: 2

🤖 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/xml_editor.py`:
- Around line 665-674: Update render_plain_wt to preserve the original w:t
element attributes by reusing wt.toxml() instead of manually constructing the
element from escaped text. Keep the existing empty-text behavior and rPr_xml
wrapper unchanged.
- Around line 641-650: Replace the custom replacement chain in _escape_xml with
xml.sax.saxutils.escape, adding the standard-library import at the module level
and preserving the function’s text-to-escaped-XML behavior.
🪄 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: 52eec353-0de5-4976-af94-30e34ba3d906

📥 Commits

Reviewing files that changed from the base of the PR and between d668731 and 31762c7.

📒 Files selected for processing (6)
  • docx_editor/comments.py
  • docx_editor/track_changes.py
  • docx_editor/xml_editor.py
  • tests/test_paragraph_location.py
  • tests/test_run_child_order.py
  • tests/test_xml_editor.py

Comment thread docx_editor/xml_editor.py
Comment on lines +641 to +650
def _escape_xml(text: str) -> str:
"""Escape text for safe XML inclusion."""
return (
text
.replace("&", "&")
.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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use xml.sax.saxutils.escape for XML escaping.

The custom string replacement chain works correctly, but Python's standard library provides this exact functionality. Prefer using standard libraries over rolling custom string replacement logic.

♻️ Proposed refactor

Add the import at the top of the file:

import xml.sax.saxutils

Then replace the function:

 def _escape_xml(text: str) -> str:
     """Escape text for safe XML inclusion."""
-    return (
-        text
-        .replace("&", "&amp;")
-        .replace("<", "&lt;")
-        .replace(">", "&gt;")
-        .replace('"', "&quot;")
-        .replace("'", "&apos;")
-    )
+    return xml.sax.saxutils.escape(text, {'"': "&quot;", "'": "&apos;"})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _escape_xml(text: str) -> str:
"""Escape text for safe XML inclusion."""
return (
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def _escape_xml(text: str) -> str:
"""Escape text for safe XML inclusion."""
return xml.sax.saxutils.escape(text, {'"': "&quot;", "'": "&apos;"})
🤖 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/xml_editor.py` around lines 641 - 650, Replace the custom
replacement chain in _escape_xml with xml.sax.saxutils.escape, adding the
standard-library import at the module level and preserving the function’s
text-to-escaped-XML behavior.

Comment thread docx_editor/xml_editor.py
Comment on lines +665 to +674
def render_plain_wt(wt, rPr_xml: str) -> list[str]:
"""Render an unmatched ``w:t`` node as a plain run fragment carrying ``rPr_xml``.

The shared preserve-as-is step for ``render_wt`` callbacks passed to
:func:`rebuild_run_fragments` — nodes with no text yield no fragment.
"""
wt_text = get_text_node_data(wt)
if not wt_text:
return []
return [f"<w:r>{rPr_xml}<w:t>{_escape_xml(wt_text)}</w:t></w:r>"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Preserve <w:t> attributes by using toxml().

Manually rebuilding the <w:t> element drops any original attributes it carried, such as xml:space="preserve". Although DocxXMLEditor attempts to automatically restore xml:space during insertion, using wt.toxml() is simpler and more robust because it inherently preserves all original attributes while natively handling XML text escaping.

♻️ Proposed refactor
 def render_plain_wt(wt, rPr_xml: str) -> list[str]:
     """Render an unmatched ``w:t`` node as a plain run fragment carrying ``rPr_xml``.
 
     The shared preserve-as-is step for ``render_wt`` callbacks passed to
     :func:`rebuild_run_fragments` — nodes with no text yield no fragment.
     """
-    wt_text = get_text_node_data(wt)
-    if not wt_text:
+    if not get_text_node_data(wt):
         return []
-    return [f"<w:r>{rPr_xml}<w:t>{_escape_xml(wt_text)}</w:t></w:r>"]
+    return [f"<w:r>{rPr_xml}{wt.toxml()}</w:r>"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def render_plain_wt(wt, rPr_xml: str) -> list[str]:
"""Render an unmatched ``w:t`` node as a plain run fragment carrying ``rPr_xml``.
The shared preserve-as-is step for ``render_wt`` callbacks passed to
:func:`rebuild_run_fragments` — nodes with no text yield no fragment.
"""
wt_text = get_text_node_data(wt)
if not wt_text:
return []
return [f"<w:r>{rPr_xml}<w:t>{_escape_xml(wt_text)}</w:t></w:r>"]
def render_plain_wt(wt, rPr_xml: str) -> list[str]:
"""Render an unmatched ``w:t`` node as a plain run fragment carrying ``rPr_xml``.
The shared preserve-as-is step for ``render_wt`` callbacks passed to
:func:`rebuild_run_fragments` — nodes with no text yield no fragment.
"""
if not get_text_node_data(wt):
return []
return [f"<w:r>{rPr_xml}{wt.toxml()}</w:r>"]
🤖 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/xml_editor.py` around lines 665 - 674, Update render_plain_wt to
preserve the original w:t element attributes by reusing wt.toxml() instead of
manually constructing the element from escaped text. Keep the existing
empty-text behavior and rPr_xml wrapper unchanged.

@codecov

codecov Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.3%. Comparing base (d668731) to head (79d9014).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@          Coverage Diff          @@
##            main     #47   +/-   ##
=====================================
  Coverage   95.2%   95.3%           
=====================================
  Files         11      11           
  Lines       3210    3192   -18     
  Branches     657     649    -8     
=====================================
- Hits        3059    3045   -14     
+ Misses        70      69    -1     
+ Partials      81      78    -3     

☔ View full report in Codecov by Harness.
📢 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.

- tests/test_xml_editor.py: _parse_run now reuses conftest.parse_paragraph
  instead of duplicating its minidom body (drops local defusedxml/NS imports).
- tests/test_run_child_order.py: rename _has_direct_rpr -> _has_direct_rPr to
  match the codebase's rPr spelling (get_rPr_xml, run_rPr, TestNestedRPrNoLeak).
- xml_editor.py: _build_style_outline_map docstring now reflects that a missing
  w:type defaults to paragraph per ECMA-376.
@pablospe
pablospe merged commit 0a354fd into main Jul 14, 2026
8 checks passed
pablospe added a commit that referenced this pull request Jul 22, 2026
…S.md #47)

Replace operations now trim words shared by find/replace_with at either
end, so only the changed words become revisions. A replace that only
adds or only removes words degenerates into a pure insertion or
deletion; replacing text with itself is a no-op (EditResult keeps the
input ref with group_id=None, revision_ids=()).

The replacement insertion carries the rPr of the run that contributed
the most characters to the trimmed span (ties break to the earliest
run), replacing the old first-run rule at all four insertion sites:
same-context, own-ins, foreign-ins, and mixed-state.

TextMapMatch.narrowed() trims a match span and recomputes
spans_boundary with the same rule as find_in_text_map, so trimmed
matches dispatch to the same code path a direct search would.
pablospe added a commit that referenced this pull request Jul 22, 2026
…S.md #47) (#66)

* feat: word-level trimming + majority-rPr formatting on replace (ISSUES.md #47)

Replace operations now trim words shared by find/replace_with at either
end, so only the changed words become revisions. A replace that only
adds or only removes words degenerates into a pure insertion or
deletion; replacing text with itself is a no-op (EditResult keeps the
input ref with group_id=None, revision_ids=()).

The replacement insertion carries the rPr of the run that contributed
the most characters to the trimmed span (ties break to the earliest
run), replacing the old first-run rule at all four insertion sites:
same-context, own-ins, foreign-ins, and mixed-state.

TextMapMatch.narrowed() trims a match span and recomputes
spans_boundary with the same rule as find_in_text_map, so trimmed
matches dispatch to the same code path a direct search would.

* docs: majority-rPr wording — formatting covers most characters, not single run

The tally groups by serialized rPr string, so runs sharing identical
formatting count together; two same-rPr runs can jointly out-total a
larger differently-formatted run. Say "the formatting that covers the
most characters" instead of "the run that contributed the most
characters" everywhere the rule is documented.
@pablospe
pablospe deleted the task/fe95aa99-rPr-correctness---render_wt-de branch August 30, 2026 19:29
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