Skip to content

Commit af3b973

Browse files
committed
comments: add Document.add_comment()
1 parent 66da522 commit af3b973

File tree

5 files changed

+90
-7
lines changed

5 files changed

+90
-7
lines changed

src/docx/document.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55

66
from __future__ import annotations
77

8-
from typing import IO, TYPE_CHECKING, Iterator, List
8+
from typing import IO, TYPE_CHECKING, Iterator, List, Sequence
99

1010
from docx.blkcntnr import BlockItemContainer
1111
from docx.enum.section import WD_SECTION
1212
from docx.enum.text import WD_BREAK
1313
from docx.section import Section, Sections
1414
from docx.shared import ElementProxy, Emu, Inches, Length
15+
from docx.text.run import Run
1516

1617
if TYPE_CHECKING:
1718
import docx.types as t
18-
from docx.comments import Comments
19+
from docx.comments import Comment, Comments
1920
from docx.oxml.document import CT_Body, CT_Document
2021
from docx.parts.document import DocumentPart
2122
from docx.settings import Settings
@@ -37,6 +38,51 @@ def __init__(self, element: CT_Document, part: DocumentPart):
3738
self._part = part
3839
self.__body = None
3940

41+
def add_comment(
42+
self, runs: Run | Sequence[Run], text: str, author: str = "", initials: str | None = ""
43+
) -> Comment:
44+
"""Add a comment to the document, anchored to the specified runs.
45+
46+
`runs` can be a single `Run` object or a non-empty sequence of `Run` objects. Only the
47+
first and last run of a sequence are used, it's just more convenient to pass a whole
48+
sequence when that's what you have handy, like `paragraph.runs` for example. When `runs`
49+
contains a single `Run` object, that run serves as both the first and last run.
50+
51+
A comment can be anchored only on an even run boundary, meaning the text the comment
52+
"references" must be a non-zero integer number of consecutive runs. The runs need not be
53+
_contiguous_ per se, like the first can be in one paragraph and the last in the next
54+
paragraph, but all runs between the first and the last will be included in the reference.
55+
56+
The comment reference range is delimited by placing a `w:commentRangeStart` element before
57+
the first run and a `w:commentRangeEnd` element after the last run. This is why only the
58+
first and last run are required and why a single run can serve as both first and last.
59+
Word works out which text to highlight in the UI based on these range markers.
60+
61+
`text` allows the contents of a simple comment to be provided in the call, providing for
62+
the common case where a comment is a single phrase or sentence without special formatting
63+
such as bold or italics. More complex comments can be added using the returned `Comment`
64+
object in much the same way as a `Document` or (table) `Cell` object, using methods like
65+
`.add_paragraph()`, .add_run()`, etc.
66+
67+
The `author` and `initials` parameters allow that metadata to be set for the comment.
68+
`author` is a required attribute on a comment and is the empty string by default.
69+
`initials` is optional on a comment and may be omitted by passing |None|, but Word adds an
70+
`initials` attribute by default and we follow that convention by using the empty string
71+
when no `initials` argument is provided.
72+
"""
73+
# -- normalize `runs` to a sequence of runs --
74+
runs = [runs] if isinstance(runs, Run) else runs
75+
first_run = runs[0]
76+
last_run = runs[-1]
77+
78+
# -- Note that comments can only appear in the document part --
79+
comment = self.comments.add_comment(text=text, author=author, initials=initials)
80+
81+
# -- let the first run orchestrate placement of the comment range start and end --
82+
first_run.mark_comment_range(last_run, comment.comment_id)
83+
84+
return comment
85+
4086
def add_heading(self, text: str = "", level: int = 1):
4187
"""Return a heading paragraph newly added to the end of the document.
4288

src/docx/oxml/shared.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@ class CT_String(BaseOxmlElement):
4646

4747
@classmethod
4848
def new(cls, nsptagname: str, val: str):
49-
"""Return a new ``CT_String`` element with tagname `nsptagname` and ``val``
50-
attribute set to `val`."""
49+
"""A new `CT_String`` element with tagname `nsptagname` and `val` attribute set to `val`."""
5150
elm = cast(CT_String, OxmlElement(nsptagname))
5251
elm.val = val
5352
return elm

src/docx/oxml/xmlchemy.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,7 @@ def _new_method_name(self):
423423

424424

425425
class Choice(_BaseChildElement):
426-
"""Defines a child element belonging to a group, only one of which may appear as a
427-
child."""
426+
"""Defines a child element belonging to a group, only one of which may appear as a child."""
428427

429428
@property
430429
def nsptagname(self):

src/docx/text/run.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ def iter_inner_content(self) -> Iterator[str | Drawing | RenderedPageBreak]:
173173
elif isinstance(item, CT_Drawing): # pyright: ignore[reportUnnecessaryIsInstance]
174174
yield Drawing(item, self)
175175

176+
def mark_comment_range(self, last_run: Run, comment_id: int) -> None:
177+
"""Mark the range of runs from this run to `last_run` (inclusive) as belonging to a comment.
178+
179+
`comment_id` identfies the comment that references this range.
180+
"""
181+
raise NotImplementedError
182+
176183
@property
177184
def style(self) -> CharacterStyle:
178185
"""Read/write.

tests/test_document.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from docx.comments import Comments
12+
from docx.comments import Comment, Comments
1313
from docx.document import Document, _Body
1414
from docx.enum.section import WD_SECTION
1515
from docx.enum.text import WD_BREAK
@@ -39,6 +39,26 @@
3939
class DescribeDocument:
4040
"""Unit-test suite for `docx.document.Document`."""
4141

42+
def it_can_add_a_comment(
43+
self,
44+
document_part_: Mock,
45+
comments_prop_: Mock,
46+
comments_: Mock,
47+
comment_: Mock,
48+
run_mark_comment_range_: Mock,
49+
):
50+
comment_.comment_id = 42
51+
comments_.add_comment.return_value = comment_
52+
comments_prop_.return_value = comments_
53+
document = Document(cast(CT_Document, element("w:document/w:body/w:p/w:r")), document_part_)
54+
run = document.paragraphs[0].runs[0]
55+
56+
comment = document.add_comment(run, "Comment text.")
57+
58+
comments_.add_comment.assert_called_once_with("Comment text.", "", "")
59+
run_mark_comment_range_.assert_called_once_with(run, run, 42)
60+
assert comment is comment_
61+
4262
@pytest.mark.parametrize(
4363
("level", "style"), [(0, "Title"), (1, "Heading 1"), (2, "Heading 2"), (9, "Heading 9")]
4464
)
@@ -288,10 +308,18 @@ def _block_width_prop_(self, request: FixtureRequest):
288308
def body_prop_(self, request: FixtureRequest):
289309
return property_mock(request, Document, "_body")
290310

311+
@pytest.fixture
312+
def comment_(self, request: FixtureRequest):
313+
return instance_mock(request, Comment)
314+
291315
@pytest.fixture
292316
def comments_(self, request: FixtureRequest):
293317
return instance_mock(request, Comments)
294318

319+
@pytest.fixture
320+
def comments_prop_(self, request: FixtureRequest):
321+
return property_mock(request, Document, "comments")
322+
295323
@pytest.fixture
296324
def core_properties_(self, request: FixtureRequest):
297325
return instance_mock(request, CoreProperties)
@@ -325,6 +353,10 @@ def picture_(self, request: FixtureRequest):
325353
def run_(self, request: FixtureRequest):
326354
return instance_mock(request, Run)
327355

356+
@pytest.fixture
357+
def run_mark_comment_range_(self, request: FixtureRequest):
358+
return method_mock(request, Run, "mark_comment_range")
359+
328360
@pytest.fixture
329361
def Section_(self, request: FixtureRequest):
330362
return class_mock(request, "docx.document.Section")

0 commit comments

Comments
 (0)