From 69b5a09d981a40081a232f20790ed963508d2bdc Mon Sep 17 00:00:00 2001 From: Gyanu Mayank Date: Tue, 8 Sep 2026 11:39:47 +0530 Subject: [PATCH 1/2] Reattach comment anchors when assigning paragraph.text. clear() dropped commentRangeStart/End and the comment reference run, so comments.xml kept a comment with nothing pointing at it. --- src/docx/text/paragraph.py | 13 ++++++++++++- tests/text/test_paragraph.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..f2cf03ac2 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Iterator, List, cast from docx.enum.style import WD_STYLE_TYPE +from docx.oxml.ns import qn from docx.oxml.text.run import CT_R from docx.shared import StoryChild from docx.styles.style import ParagraphStyle @@ -159,13 +160,23 @@ def text(self) -> str: the text is mapped to a ```` element and each ``\\n`` or ``\\r`` character is mapped to a line break. Paragraph-level formatting, such as style, is preserved. All run-level formatting, such as bold or italic, is removed. + Comment range markers (`w:commentRangeStart` / `w:commentRangeEnd` and the + comment reference run) are reattached around the replacement run so comments + stay anchored. """ return self._p.text @text.setter def text(self, text: str | None): + comment_ids = [ + int(el.get(qn("w:id"))) + for el in self._p.xpath("./w:commentRangeStart") + if el.get(qn("w:id")) is not None + ] self.clear() - self.add_run(text) + run = self.add_run(text) + for comment_id in comment_ids: + run.mark_comment_range(run, comment_id) def _insert_paragraph_before(self): """Return a newly created paragraph, inserted directly before this paragraph.""" diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 0329b1dd3..6ca9d1c1f 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -154,6 +154,28 @@ def it_can_replace_the_text_it_contains(self, text_set_fixture): paragraph.text = text assert paragraph.text == expected_text + def it_reattaches_comment_markers_when_text_is_assigned( + self, fake_parent: t.ProvidesStoryPart + ): + p = cast( + CT_P, + element( + 'w:p/(w:commentRangeStart{w:id=42},w:r/w:t"old"' + ",w:commentRangeEnd{w:id=42}" + ",w:r/(w:rPr/w:rStyle{w:val=CommentReference},w:commentReference{w:id=42}))" + ), + ) + paragraph = Paragraph(p, fake_parent) + + paragraph.text = "new" + + assert paragraph.text == "new" + assert p.xml == xml( + 'w:p/(w:commentRangeStart{w:id=42},w:r/w:t"new"' + ",w:commentRangeEnd{w:id=42}" + ",w:r/(w:rPr/w:rStyle{w:val=CommentReference},w:commentReference{w:id=42}))" + ) + def it_knows_its_alignment_value(self, alignment_get_fixture): paragraph, expected_value = alignment_get_fixture assert paragraph.alignment == expected_value From 91637969969ca0e0f7f902ee05a7183bdc17aec2 Mon Sep 17 00:00:00 2001 From: Gyanu Mayank Date: Wed, 9 Sep 2026 10:42:22 +0530 Subject: [PATCH 2/2] Note that reattached comment ranges cover the whole replacement run A full-text assignment cannot keep the original word span, and footnotes plus tracked changes still drop. Say that on the property so it is not read as preserving the original anchor. --- src/docx/text/paragraph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index f2cf03ac2..3d4d31e6c 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -162,7 +162,8 @@ def text(self) -> str: is preserved. All run-level formatting, such as bold or italic, is removed. Comment range markers (`w:commentRangeStart` / `w:commentRangeEnd` and the comment reference run) are reattached around the replacement run so comments - stay anchored. + stay anchored. The range then covers that whole run, not the original + word span. Footnote references and tracked-change markup are still dropped. """ return self._p.text