🌐 AI搜索 & 代理 主页
Skip to content

Commit 8ac9fc4

Browse files
committed
comments: add Comments.add_comment()
Only with `text` parameter so far. Author and initials parameters to follow.
1 parent d360409 commit 8ac9fc4

File tree

5 files changed

+225
-6
lines changed

5 files changed

+225
-6
lines changed

features/cmt-mutations.feature

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ Feature: Comment mutations
44
I need mutation methods on Comment objects
55

66

7-
@wip
87
Scenario: Comments.add_comment()
98
Given a Comments object with 0 comments
109
When I assign comment = comments.add_comment()
@@ -15,15 +14,13 @@ Feature: Comment mutations
1514
And comments.get(0) == comment
1615

1716

18-
@wip
1917
Scenario: Comments.add_comment() specifying author and initials
2018
Given a Comments object with 0 comments
2119
When I assign comment = comments.add_comment(author="John Doe", initials="JD")
2220
Then comment.author == "John Doe"
2321
And comment.initials == "JD"
2422

2523

26-
@wip
2724
Scenario: Comment.add_paragraph() specifying text and style
2825
Given a default Comment object
2926
When I assign paragraph = comment.add_paragraph(text, style)
@@ -33,7 +30,6 @@ Feature: Comment mutations
3330
And comment.paragraphs[-1] == paragraph
3431

3532

36-
@wip
3733
Scenario: Comment.add_paragraph() not specifying text or style
3834
Given a default Comment object
3935
When I assign paragraph = comment.add_paragraph()
@@ -43,7 +39,6 @@ Feature: Comment mutations
4339
And comment.paragraphs[-1] == paragraph
4440

4541

46-
@wip
4742
Scenario: Add image to comment
4843
Given a default Comment object
4944
When I assign paragraph = comment.add_paragraph()

src/docx/comments.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
if TYPE_CHECKING:
1111
from docx.oxml.comments import CT_Comment, CT_Comments
1212
from docx.parts.comments import CommentsPart
13+
from docx.styles.style import ParagraphStyle
14+
from docx.text.paragraph import Paragraph
1315

1416

1517
class Comments:
@@ -30,6 +32,48 @@ def __len__(self) -> int:
3032
"""The number of comments in this collection."""
3133
return len(self._comments_elm.comment_lst)
3234

35+
def add_comment(self, text: str = "", author: str = "", initials: str | None = "") -> Comment:
36+
"""Add a new comment to the document and return it.
37+
38+
The comment is added to the end of the comments collection and is assigned a unique
39+
comment-id.
40+
41+
If `text` is provided, it is added to the comment. This option provides for the common
42+
case where a comment contains a modest passage of plain text. Multiple paragraphs can be
43+
added using the `text` argument by separating their text with newlines (`"\\\\n"`).
44+
Between newlines, text is interpreted as it is in `Document.add_paragraph(text=...)`.
45+
46+
The default is to place a single empty paragraph in the comment, which is the same
47+
behavior as the Word UI when you add a comment. New runs can be added to the first
48+
paragraph in the empty comment with `comments.paragraphs[0].add_run()` to adding more
49+
complex text with emphasis or images. Additional paragraphs can be added using
50+
`.add_paragraph()`.
51+
52+
`author` is a required attribute, set to the empty string by default.
53+
54+
`initials` is an optional attribute, set to the empty string by default. Passing |None|
55+
for the `initials` parameter causes that attribute to be omitted from the XML.
56+
"""
57+
comment_elm = self._comments_elm.add_comment()
58+
comment_elm.author = author
59+
comment_elm.initials = initials
60+
comment_elm.date = dt.datetime.now(dt.timezone.utc)
61+
comment = Comment(comment_elm, self._comments_part)
62+
63+
if text == "":
64+
return comment
65+
66+
para_text_iter = iter(text.split("\n"))
67+
68+
first_para_text = next(para_text_iter)
69+
first_para = comment.paragraphs[0]
70+
first_para.add_run(first_para_text)
71+
72+
for s in para_text_iter:
73+
comment.add_paragraph(text=s)
74+
75+
return comment
76+
3377
def get(self, comment_id: int) -> Comment | None:
3478
"""Return the comment identified by `comment_id`, or |None| if not found."""
3579
comment_elm = self._comments_elm.get_comment_by_id(comment_id)
@@ -54,6 +98,22 @@ def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart):
5498
super().__init__(comment_elm, comments_part)
5599
self._comment_elm = comment_elm
56100

101+
def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph:
102+
"""Return paragraph newly added to the end of the content in this container.
103+
104+
The paragraph has `text` in a single run if present, and is given paragraph style `style`.
105+
When `style` is |None| or ommitted, the "CommentText" paragraph style is applied, which is
106+
the default style for comments.
107+
"""
108+
paragraph = super().add_paragraph(text, style)
109+
110+
# -- have to assign style directly to element because `paragraph.style` raises when
111+
# -- a style is not present in the styles part
112+
if style is None:
113+
paragraph._p.style = "CommentText" # pyright: ignore[reportPrivateUsage]
114+
115+
return paragraph
116+
57117
@property
58118
def author(self) -> str:
59119
"""The recorded author of this comment."""

src/docx/oxml/comments.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import datetime as dt
6-
from typing import TYPE_CHECKING, Callable
6+
from typing import TYPE_CHECKING, Callable, cast
77

8+
from docx.oxml.ns import nsdecls
9+
from docx.oxml.parser import parse_xml
810
from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String
911
from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore
1012

@@ -27,11 +29,64 @@ class CT_Comments(BaseOxmlElement):
2729

2830
comment = ZeroOrMore("w:comment")
2931

32+
def add_comment(self) -> CT_Comment:
33+
"""Return newly added `w:comment` child of this `w:comments`.
34+
35+
The returned `w:comment` element is the minimum valid value, having a `w:id` value unique
36+
within the existing comments and the required `w:author` attribute present but set to the
37+
empty string. It's content is limited to a single run containing the necessary annotation
38+
reference but no text. Content is added by adding runs to this first paragraph and by
39+
adding additional paragraphs as needed.
40+
"""
41+
next_id = self._next_available_comment_id()
42+
comment = cast(
43+
CT_Comment,
44+
parse_xml(
45+
f'<w:comment {nsdecls("w")} w:id="{next_id}" w:author="">'
46+
f" <w:p>"
47+
f" <w:pPr>"
48+
f' <w:pStyle w:val="CommentText"/>'
49+
f" </w:pPr>"
50+
f" <w:r>"
51+
f" <w:rPr>"
52+
f' <w:rStyle w:val="CommentReference"/>'
53+
f" </w:rPr>"
54+
f" <w:annotationRef/>"
55+
f" </w:r>"
56+
f" </w:p>"
57+
f"</w:comment>"
58+
),
59+
)
60+
self.append(comment)
61+
return comment
62+
3063
def get_comment_by_id(self, comment_id: int) -> CT_Comment | None:
3164
"""Return the `w:comment` element identified by `comment_id`, or |None| if not found."""
3265
comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]")
3366
return comment_elms[0] if comment_elms else None
3467

68+
def _next_available_comment_id(self) -> int:
69+
"""The next available comment id.
70+
71+
According to the schema, this can be any positive integer, as big as you like, and the
72+
default mechanism is to use `max() + 1`. However, if that yields a value larger than will
73+
fit in a 32-bit signed integer, we take a more deliberate approach to use the first
74+
ununsed integer starting from 0.
75+
"""
76+
used_ids = [int(x) for x in self.xpath("./w:comment/@w:id")]
77+
78+
next_id = max(used_ids, default=-1) + 1
79+
80+
if next_id <= 2**31 - 1:
81+
return next_id
82+
83+
# -- fall-back to enumerating all used ids to find the first unused one --
84+
for expected, actual in enumerate(sorted(used_ids)):
85+
if expected != actual:
86+
return expected
87+
88+
return len(used_ids)
89+
3590

3691
class CT_Comment(BaseOxmlElement):
3792
"""`w:comment` element, representing a single comment.

tests/oxml/test_comments.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# pyright: reportPrivateUsage=false
2+
3+
"""Unit-test suite for `docx.oxml.comments` module."""
4+
5+
from __future__ import annotations
6+
7+
from typing import cast
8+
9+
import pytest
10+
11+
from docx.oxml.comments import CT_Comments
12+
13+
from ..unitutil.cxml import element
14+
15+
16+
class DescribeCT_Comments:
17+
"""Unit-test suite for `docx.oxml.comments.CT_Comments`."""
18+
19+
@pytest.mark.parametrize(
20+
("cxml", "expected_value"),
21+
[
22+
("w:comments", 0),
23+
("w:comments/(w:comment{w:id=1})", 2),
24+
("w:comments/(w:comment{w:id=4},w:comment{w:id=2147483646})", 2147483647),
25+
("w:comments/(w:comment{w:id=1},w:comment{w:id=2147483647})", 0),
26+
("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})", 4),
27+
],
28+
)
29+
def it_finds_the_next_available_comment_id_to_help(self, cxml: str, expected_value: int):
30+
comments_elm = cast(CT_Comments, element(cxml))
31+
assert comments_elm._next_available_comment_id() == expected_value

tests/test_comments.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from docx.opc.constants import CONTENT_TYPE as CT
1414
from docx.opc.packuri import PackURI
1515
from docx.oxml.comments import CT_Comment, CT_Comments
16+
from docx.oxml.ns import qn
1617
from docx.package import Package
1718
from docx.parts.comments import CommentsPart
1819

@@ -86,8 +87,85 @@ def it_can_get_a_comment_by_id(self, package_: Mock):
8687
assert type(comment) is Comment, "expected a `Comment` object"
8788
assert comment._comment_elm is comments_elm.comment_lst[1]
8889

90+
def but_it_returns_None_when_no_comment_with_that_id_exists(self, package_: Mock):
91+
comments_elm = cast(
92+
CT_Comments,
93+
element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"),
94+
)
95+
comments = Comments(
96+
comments_elm,
97+
CommentsPart(
98+
PackURI("/word/comments.xml"),
99+
CT.WML_COMMENTS,
100+
comments_elm,
101+
package_,
102+
),
103+
)
104+
105+
comment = comments.get(4)
106+
107+
assert comment is None, "expected None when no comment with that id exists"
108+
109+
def it_can_add_a_new_comment(self, package_: Mock):
110+
comments_elm = cast(CT_Comments, element("w:comments"))
111+
comments_part = CommentsPart(
112+
PackURI("/word/comments.xml"),
113+
CT.WML_COMMENTS,
114+
comments_elm,
115+
package_,
116+
)
117+
now_before = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
118+
comments = Comments(comments_elm, comments_part)
119+
120+
comment = comments.add_comment()
121+
122+
now_after = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
123+
# -- a comment is unconditionally added, and returned for any further adjustment --
124+
assert isinstance(comment, Comment)
125+
# -- it is "linked" to the comments part so it can add images and hyperlinks, etc. --
126+
assert comment.part is comments_part
127+
# -- comment numbering starts at 0, and is incremented for each new comment --
128+
assert comment.comment_id == 0
129+
# -- author is a required attribut, but is the empty string by default --
130+
assert comment.author == ""
131+
# -- initials is an optional attribute, but defaults to the empty string, same as Word --
132+
assert comment.initials == ""
133+
# -- timestamp is also optional, but defaults to now-UTC --
134+
assert comment.timestamp is not None
135+
assert now_before <= comment.timestamp <= now_after
136+
# -- by default, a new comment contains a single empty paragraph --
137+
assert [p.text for p in comment.paragraphs] == [""]
138+
# -- that paragraph has the "CommentText" style, same as Word applies --
139+
comment_elm = comment._comment_elm
140+
assert len(comment_elm.p_lst) == 1
141+
p = comment_elm.p_lst[0]
142+
assert p.style == "CommentText"
143+
# -- and that paragraph contains a single run with the necessary annotation reference --
144+
assert len(p.r_lst) == 1
145+
r = comment_elm.p_lst[0].r_lst[0]
146+
assert r.style == "CommentReference"
147+
assert r[-1].tag == qn("w:annotationRef")
148+
149+
def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, package_: Mock):
150+
comment = comments.add_comment(text="para 1\n\npara 2")
151+
152+
assert len(comment.paragraphs) == 3
153+
assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"]
154+
assert all(p._p.style == "CommentText" for p in comment.paragraphs)
155+
89156
# -- fixtures --------------------------------------------------------------------------------
90157

158+
@pytest.fixture
159+
def comments(self, package_: Mock) -> Comments:
160+
comments_elm = cast(CT_Comments, element("w:comments"))
161+
comments_part = CommentsPart(
162+
PackURI("/word/comments.xml"),
163+
CT.WML_COMMENTS,
164+
comments_elm,
165+
package_,
166+
)
167+
return Comments(comments_elm, comments_part)
168+
91169
@pytest.fixture
92170
def package_(self, request: FixtureRequest):
93171
return instance_mock(request, Package)

0 commit comments

Comments
 (0)