From 5cb32d7787a79ad255ad6f11ad735d2513dd08c1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:26:57 -0700 Subject: [PATCH 01/26] xfail: acceptance test for Document.comments --- features/doc-comments.feature | 40 ++++++++++ features/steps/comments.py | 69 ++++++++++++++++++ .../steps/test_files/comments-rich-para.docx | Bin 0 -> 19974 bytes src/docx/comments.py | 24 ++++++ 4 files changed, 133 insertions(+) create mode 100644 features/doc-comments.feature create mode 100644 features/steps/comments.py create mode 100644 features/steps/test_files/comments-rich-para.docx create mode 100644 src/docx/comments.py diff --git a/features/doc-comments.feature b/features/doc-comments.feature new file mode 100644 index 000000000..c49edaa77 --- /dev/null +++ b/features/doc-comments.feature @@ -0,0 +1,40 @@ +Feature: Document.comments + In order to operate on comments added to a document + As a developer using python-docx + I need access to the comments collection for the document + And I need methods allowing access to the comments in the collection + + + @wip + Scenario Outline: Access document comments + Given a document having comments part + Then document.comments is a Comments object + + Examples: having a comments part or not + | a-or-no | + | a | + | no | + + + @wip + Scenario Outline: Comments.__len__() + Given a Comments object with comments + Then len(comments) == + + Examples: len(comments) values + | count | + | 0 | + | 4 | + + + @wip + Scenario: Comments.__iter__() + Given a Comments object with 4 comments + Then iterating comments yields 4 Comment objects + + + @wip + Scenario: Comments.get() + Given a Comments object with 4 comments + When I call comments.get(2) + Then the result is a Comment object with id 2 diff --git a/features/steps/comments.py b/features/steps/comments.py new file mode 100644 index 000000000..81993aeda --- /dev/null +++ b/features/steps/comments.py @@ -0,0 +1,69 @@ +"""Step implementations for document comments-related features.""" + +from behave import given, then, when +from behave.runner import Context + +from docx import Document +from docx.comments import Comment, Comments + +from helpers import test_docx + +# given ==================================================== + + +@given("a Comments object with {count} comments") +def given_a_comments_object_with_count_comments(context: Context, count: str): + testfile_name = {"0": "doc-default", "4": "comments-rich-para"}[count] + context.comments = Document(test_docx(testfile_name)).comments + + +@given("a document having a comments part") +def given_a_document_having_a_comments_part(context: Context): + context.document = Document(test_docx("comments-rich-para")) + + +@given("a document having no comments part") +def given_a_document_having_no_comments_part(context: Context): + context.document = Document(test_docx("doc-default")) + + +# when ===================================================== + + +@when("I call comments.get(2)") +def when_I_call_comments_get_2(context: Context): + context.comment = context.comments.get(2) + + +# then ===================================================== + + +@then("document.comments is a Comments object") +def then_document_comments_is_a_Comments_object(context: Context): + document = context.document + assert type(document.comments) is Comments + + +@then("iterating comments yields {count} Comment objects") +def then_iterating_comments_yields_count_comments(context: Context, count: str): + comment_iter = iter(context.comments) + + comment = next(comment_iter) + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + + remaining = list(comment_iter) + assert len(remaining) == int(count) - 1, "iterating comments did not yield the expected count" + + +@then("len(comments) == {count}") +def then_len_comments_eq_count(context: Context, count: str): + actual = len(context.comments) + expected = int(count) + assert actual == expected, f"expected len(comments) of {expected}, got {actual}" + + +@then("the result is a Comment object with id 2") +def then_the_result_is_a_comment_object_with_id_2(context: Context): + comment = context.comment + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + assert comment.comment_id == 2, f"expected comment_id `2`, got '{comment.comment_id}'" diff --git a/features/steps/test_files/comments-rich-para.docx b/features/steps/test_files/comments-rich-para.docx new file mode 100644 index 0000000000000000000000000000000000000000..e63db413e871466da97e906aef754bc06b2f9601 GIT binary patch literal 19974 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

X~P4qY=uK;@5HN2zz`!^XNM-mzTsy6@tZ{CDG zy&Km$rKrtNlX{cT}J=f=k4W%uDMhQk@J0Wqv3hb)ikYQuNYV2otSjZ<=M zQE{Q-RA)%`8Q-?&L-Qm`a$qFM&X7KJf5c8qTKA`C8ngjN5wGRB;mOLWyqzSfR{w9~ ziUToo*IScI%m(09r1667hh_RBj{{_vtLAY`UO{Y_4LHp=EDU6(p0rDyFr-l(bkG^i zU3%1A#ThN`qug|{;VyrR$8T)>`zP4z!oMBcwbv#IlfZzKfwuqv1~Bih)uF>DBTFNO zUy$ji4e?lAGWd-sax2s&VL-!>+sM9NG25irrTHBDYK?Dri-LJxNt)@z%3=*;z{hku z81R&c5AY2;$b#@p-P=)w2Ib-@gpr2?;w*GG-Qn84Fik$n{H=vAjfIVkto9q5&k65D z6W}_d!ZdkRZShk=<`YQu`dU00X=}7Z#bQEGj9^ad;b@nGxzoLzKvLsT!YaTnzj21Y zvm)I&_Gq9LVGQZ!V()?(mc|Z`^rJ@_G6)M3u$CICk;s(~LlM`a5MU~3VM>fZaYBrd za5l;Hc)y`b?2n5r-&&?*@Ila^Z>sPC6%Ix26pv`pRxg!_o3H&Y_ehSERLWsF=*51B z8*9*m z)R^u(ZXS=)Qp4dc`PprzeeZg%?p#~f6TTTTSK1nm9}hAS!9Jt+r2|o7g?M?`6C59&*5+~jIMf%e%lF{ea9g`<6yxXGz`^f%H8#bN+UE6m zF%oTk;u6`{_Oeqn=k;{2dzdS5)MQeY&hLKnJm>ZN)D3!O=a3Lc7=aD9_m0oWssfw+ zlRAL#@-CY5EAoJ(L$q85I28_@+glPaPfu^k%5}H->MeZ4gHFh1);1I5)$ku$sBq^R z=x^G2dq0KS9;KCjuenr7pvd&JBLx zexc5V_tSZ(id`Y`<62qsgV;T+;mg9zm66N3=aG6gL>*H^dBZp%70B#~Z#?wITOdt) zPj7h}ze1BA@~wDm*z z#%W#p$x=kXF-Vs2?y?gpBg@#9j|_4x)!F5|ScSGs@x(-Opvb2)eW?j1T!Z7~R{gH3 zM7^t!>lx_EK_iQ*5oN>P!b1wR_a!+&Z-NcJF$y{n1{QxATh%AIpp`=<(aE`gn1)XT z(Q>R{(>eHLmZQ))lUWm8Po!_)<*#84bal|oVBNZC+jM!I+4a8PU4`mjzl-zkei^Oh zdOxl97A^q{TseSl#pmnBi<`Ri^{S96GJ`NtoGWw+k2TQf+q zbn{c+osZk2mEH*t7*@4Xby}*hfovIE9$>L~vdUWN*Nf7QS0(Kh=iRT(u?p|q7e{dw z8IDrpH1&%X7*=iy^Mh*e?oViJcINY;2_Cd+$u-kBERd`phX*niaBF;ejxfa(uy085 zhU1#F3}m^7h@ANBXIw6&T!tK!dnm`rLY`qW*`=~#<}

$8{yk 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 Date: Mon, 9 Jun 2025 22:31:23 -0700 Subject: [PATCH 02/26] comments: add Document.comments Provides access to the comments collection from the document object. --- docs/conf.py | 2 ++ src/docx/document.py | 6 ++++++ src/docx/parts/document.py | 6 ++++++ tests/test_document.py | 11 +++++++++++ 4 files changed, 25 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index e37e9be7e..60e28fa4c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -83,6 +83,8 @@ .. |CharacterStyle| replace:: :class:`.CharacterStyle` +.. |Comments| replace:: :class:`.Comments` + .. |Cm| replace:: :class:`.Cm` .. |ColorFormat| replace:: :class:`.ColorFormat` diff --git a/src/docx/document.py b/src/docx/document.py index 2cf0a1c38..5de03bf9d 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.comments import Comments from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings @@ -106,6 +107,11 @@ def add_table(self, rows: int, cols: int, style: str | _TableStyle | None = None table.style = style return table + @property + def comments(self) -> Comments: + """A |Comments| object providing access to comments added to the document.""" + return self._part.comments + @property def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index dea0845f7..78841f47a 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -15,6 +15,7 @@ from docx.shared import lazyproperty if TYPE_CHECKING: + from docx.comments import Comments from docx.enum.style import WD_STYLE_TYPE from docx.opc.coreprops import CoreProperties from docx.settings import Settings @@ -42,6 +43,11 @@ def add_header_part(self): rId = self.relate_to(header_part, RT.HEADER) return header_part, rId + @property + def comments(self) -> Comments: + """|Comments| object providing access to the comments added to this document.""" + raise NotImplementedError + @property def core_properties(self) -> CoreProperties: """A |CoreProperties| object providing read/write access to the core properties diff --git a/tests/test_document.py b/tests/test_document.py index 739813321..0b36017a5 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -9,6 +9,7 @@ import pytest +from docx.comments import Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -164,6 +165,12 @@ def it_can_save_the_document_to_a_file(self, document_part_: Mock): document_part_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments(self, document_part_: Mock, comments_: Mock): + document_part_.comments = comments_ + document = Document(cast(CT_Document, element("w:document")), document_part_) + + assert document.comments is comments_ + def it_provides_access_to_its_core_properties( self, document_part_: Mock, core_properties_: Mock ): @@ -281,6 +288,10 @@ def _block_width_prop_(self, request: FixtureRequest): def body_prop_(self, request: FixtureRequest): return property_mock(request, Document, "_body") + @pytest.fixture + def comments_(self, request: FixtureRequest): + return instance_mock(request, Comments) + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) From 8f184cc41811995916fa0d0c02d9dab761092a67 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:32:47 -0700 Subject: [PATCH 03/26] comments: add DocumentPart.comments Provide a way to get a `Comments` object from the `DocumentPart`. --- src/docx/parts/comments.py | 15 +++++++++++++++ src/docx/parts/document.py | 11 ++++++++++- tests/parts/test_document.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/docx/parts/comments.py diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py new file mode 100644 index 000000000..6258ceed2 --- /dev/null +++ b/src/docx/parts/comments.py @@ -0,0 +1,15 @@ +"""Contains comments added to the document.""" + +from __future__ import annotations + +from docx.comments import Comments +from docx.parts.story import StoryPart + + +class CommentsPart(StoryPart): + """Container part for comments added to the document.""" + + @property + def comments(self) -> Comments: + """A |Comments| proxy object for the `w:comments` root element of this part.""" + raise NotImplementedError diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 78841f47a..e804647f6 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -6,6 +6,7 @@ from docx.document import Document from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.parts.comments import CommentsPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -46,7 +47,7 @@ def add_header_part(self): @property def comments(self) -> Comments: """|Comments| object providing access to the comments added to this document.""" - raise NotImplementedError + return self._comments_part.comments @property def core_properties(self) -> CoreProperties: @@ -124,6 +125,14 @@ def styles(self): document.""" return self._styles_part.styles + @property + def _comments_part(self) -> CommentsPart: + """A |CommentsPart| object providing access to the comments added to this document. + + Creates a default comments part if one is not present. + """ + raise NotImplementedError + @property def _settings_part(self) -> SettingsPart: """A |SettingsPart| object providing access to the document-level settings for diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index cfe9e870c..c8b7793f9 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -4,12 +4,14 @@ import pytest +from docx.comments import Comments from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties from docx.opc.packuri import PackURI from docx.package import Package +from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart @@ -109,6 +111,17 @@ def it_can_save_the_package_to_a_file(self, package_: Mock): package_.save.assert_called_once_with("foobar.docx") + def it_provides_access_to_the_comments_added_to_the_document( + self, _comments_part_prop_: Mock, comments_part_: Mock, comments_: Mock, package_: Mock + ): + comments_part_.comments = comments_ + _comments_part_prop_.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + assert document_part.comments is comments_ + def it_provides_access_to_the_document_settings( self, _settings_part_prop_: Mock, settings_part_: Mock, settings_: Mock, package_: Mock ): @@ -282,6 +295,22 @@ def and_it_creates_a_default_styles_part_if_not_present( # -- fixtures -------------------------------------------------------------------------------- + @pytest.fixture + def comments_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Comments) + + @pytest.fixture + def CommentsPart_(self, request: FixtureRequest) -> Mock: + return class_mock(request, "docx.parts.document.CommentsPart") + + @pytest.fixture + def comments_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, CommentsPart) + + @pytest.fixture + def _comments_part_prop_(self, request: FixtureRequest) -> Mock: + return property_mock(request, DocumentPart, "_comments_part") + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) From ae0e82d979a972d758918072c7088fa49fa5eec3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:34:02 -0700 Subject: [PATCH 04/26] comments: add DocumentPart._comments_part Also involves adding `CommentsPart.default`. Because the comments part is optional, we need a mechanism to add a default (empty) comments part when one is not present. This is what `CommentsPart.default()` is for. --- src/docx/oxml/comments.py | 15 +++++++++++ src/docx/parts/comments.py | 26 +++++++++++++++++++ src/docx/parts/document.py | 8 +++++- src/docx/templates/default-comments.xml | 5 ++++ tests/parts/test_comments.py | 25 +++++++++++++++++++ tests/parts/test_document.py | 33 +++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/docx/oxml/comments.py create mode 100644 src/docx/templates/default-comments.xml create mode 100644 tests/parts/test_comments.py diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..65624b738 --- /dev/null +++ b/src/docx/oxml/comments.py @@ -0,0 +1,15 @@ +"""Custom element classes related to document comments.""" + +from __future__ import annotations + +from docx.oxml.xmlchemy import BaseOxmlElement + + +class CT_Comments(BaseOxmlElement): + """`w:comments` element, the root element for the comments part. + + Simply contains a collection of `w:comment` elements, each representing a single comment. Each + contained comment is identified by a unique `w:id` attribute, used to reference the comment + from the document text. The offset of the comment in this collection is arbitrary; it is + essentially a _set_ implemented as a list. + """ diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index 6258ceed2..e43f24a8e 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -2,7 +2,17 @@ from __future__ import annotations +import os +from typing import cast + +from typing_extensions import Self + from docx.comments import Comments +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments +from docx.oxml.parser import parse_xml +from docx.package import Package from docx.parts.story import StoryPart @@ -13,3 +23,19 @@ class CommentsPart(StoryPart): def comments(self) -> Comments: """A |Comments| proxy object for the `w:comments` root element of this part.""" raise NotImplementedError + + @classmethod + def default(cls, package: Package) -> Self: + """A newly created comments part, containing a default empty `w:comments` element.""" + partname = PackURI("/word/comments.xml") + content_type = CT.WML_COMMENTS + element = cast("CT_Comments", parse_xml(cls._default_comments_xml())) + return cls(partname, content_type, element, package) + + @classmethod + def _default_comments_xml(cls) -> bytes: + """A byte-string containing XML for a default comments part.""" + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-comments.xml") + with open(path, "rb") as f: + xml_bytes = f.read() + return xml_bytes diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index e804647f6..4960264b1 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -131,7 +131,13 @@ def _comments_part(self) -> CommentsPart: Creates a default comments part if one is not present. """ - raise NotImplementedError + try: + return cast(CommentsPart, self.part_related_by(RT.COMMENTS)) + except KeyError: + assert self.package is not None + comments_part = CommentsPart.default(self.package) + self.relate_to(comments_part, RT.COMMENTS) + return comments_part @property def _settings_part(self) -> SettingsPart: diff --git a/src/docx/templates/default-comments.xml b/src/docx/templates/default-comments.xml new file mode 100644 index 000000000..2afdda20b --- /dev/null +++ b/src/docx/templates/default-comments.xml @@ -0,0 +1,5 @@ + + diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py new file mode 100644 index 000000000..5e6ef988c --- /dev/null +++ b/tests/parts/test_comments.py @@ -0,0 +1,25 @@ +"""Unit test suite for the docx.parts.hdrftr module.""" + +from __future__ import annotations + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.package import Package +from docx.parts.comments import CommentsPart + + +class DescribeCommentsPart: + """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + + def it_constructs_a_default_comments_part_to_help(self): + package = Package() + + comments_part = CommentsPart.default(package) + + assert isinstance(comments_part, CommentsPart) + assert comments_part.partname == "/word/comments.xml" + assert comments_part.content_type == CT.WML_COMMENTS + assert comments_part.package is package + assert comments_part.element.tag == ( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}comments" + ) + assert len(comments_part.element) == 0 diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c8b7793f9..c27990baf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -227,6 +227,39 @@ def it_can_get_the_id_of_a_style( styles_.get_style_id.assert_called_once_with(style_, WD_STYLE_TYPE.CHARACTER) assert style_id == "BodyCharacter" + def it_provides_access_to_its_comments_part_to_help( + self, package_: Mock, part_related_by_: Mock, comments_part_: Mock + ): + part_related_by_.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + comments_part = document_part._comments_part + + part_related_by_.assert_called_once_with(document_part, RT.COMMENTS) + assert comments_part is comments_part_ + + def and_it_creates_a_default_comments_part_if_not_present( + self, + package_: Mock, + part_related_by_: Mock, + CommentsPart_: Mock, + comments_part_: Mock, + relate_to_: Mock, + ): + part_related_by_.side_effect = KeyError + CommentsPart_.default.return_value = comments_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + comments_part = document_part._comments_part + + CommentsPart_.default.assert_called_once_with(package_) + relate_to_.assert_called_once_with(document_part, comments_part_, RT.COMMENTS) + assert comments_part is comments_part_ + def it_provides_access_to_its_settings_part_to_help( self, part_related_by_: Mock, settings_part_: Mock, package_: Mock ): From 9c8a2e91fa743bf8f19226eb3353c9fa1a6973b3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:02 -0700 Subject: [PATCH 05/26] comments: add CommentsPart.comments --- src/docx/comments.py | 10 ++++++++++ src/docx/parts/comments.py | 8 +++++++- tests/parts/test_comments.py | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/docx/comments.py b/src/docx/comments.py index 9165e884d..587837baa 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,12 +2,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from docx.blkcntnr import BlockItemContainer +if TYPE_CHECKING: + from docx.oxml.comments import CT_Comments + from docx.parts.comments import CommentsPart + class Comments: """Collection containing the comments added to this document.""" + def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): + self._comments_elm = comments_elm + self._comments_part = comments_part + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index e43f24a8e..111bfb878 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -19,10 +19,16 @@ class CommentsPart(StoryPart): """Container part for comments added to the document.""" + def __init__( + self, partname: PackURI, content_type: str, element: CT_Comments, package: Package + ): + super().__init__(partname, content_type, element, package) + self._comments = element + @property def comments(self) -> Comments: """A |Comments| proxy object for the `w:comments` root element of this part.""" - raise NotImplementedError + return Comments(self._comments, self) @classmethod def default(cls, package: Package) -> Self: diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py index 5e6ef988c..4cab7783b 100644 --- a/tests/parts/test_comments.py +++ b/tests/parts/test_comments.py @@ -2,14 +2,38 @@ from __future__ import annotations +from typing import cast + +import pytest + +from docx.comments import Comments from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart +from ..unitutil.cxml import element +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock + class DescribeCommentsPart: """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + def it_provides_access_to_its_comments_collection( + self, Comments_: Mock, comments_: Mock, package_: Mock + ): + Comments_.return_value = comments_ + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), CT.WML_COMMENTS, comments_elm, package_ + ) + + comments = comments_part.comments + + Comments_.assert_called_once_with(comments_part.element, comments_part) + assert comments is comments_ + def it_constructs_a_default_comments_part_to_help(self): package = Package() @@ -23,3 +47,17 @@ def it_constructs_a_default_comments_part_to_help(self): "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}comments" ) assert len(comments_part.element) == 0 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def Comments_(self, request: FixtureRequest) -> Mock: + return class_mock(request, "docx.parts.comments.Comments") + + @pytest.fixture + def comments_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Comments) + + @pytest.fixture + def package_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Package) From 595deccd0d4700cc993332f0cde61a98ca9a443b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:36:50 -0700 Subject: [PATCH 06/26] comments: package-loader loads CommentsPart CommentsPart is loaded as XML-part on document deserialization. --- features/doc-comments.feature | 1 - src/docx/__init__.py | 3 +++ src/docx/parts/comments.py | 6 +++++- tests/parts/test_comments.py | 26 +++++++++++++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index c49edaa77..d23a763a5 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -5,7 +5,6 @@ Feature: Document.comments And I need methods allowing access to the comments in the collection - @wip Scenario Outline: Access document comments Given a document having comments part Then document.comments is a Comments object diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..987e8a267 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -25,6 +25,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart @@ -41,6 +42,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_class_selector = part_class_selector PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart +PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart @@ -51,6 +53,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: del ( CT, CorePropertiesPart, + CommentsPart, DocumentPart, FooterPart, HeaderPart, diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py index 111bfb878..0e4cc7438 100644 --- a/src/docx/parts/comments.py +++ b/src/docx/parts/comments.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import cast +from typing import TYPE_CHECKING, cast from typing_extensions import Self @@ -15,6 +15,10 @@ from docx.package import Package from docx.parts.story import StoryPart +if TYPE_CHECKING: + from docx.oxml.comments import CT_Comments + from docx.package import Package + class CommentsPart(StoryPart): """Container part for comments added to the document.""" diff --git a/tests/parts/test_comments.py b/tests/parts/test_comments.py index 4cab7783b..049c9e737 100644 --- a/tests/parts/test_comments.py +++ b/tests/parts/test_comments.py @@ -8,18 +8,34 @@ from docx.comments import Comments from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory from docx.oxml.comments import CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart from ..unitutil.cxml import element -from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock, method_mock class DescribeCommentsPart: """Unit test suite for `docx.parts.comments.CommentsPart` objects.""" + def it_is_used_by_the_part_loader_to_construct_a_comments_part( + self, package_: Mock, CommentsPart_load_: Mock, comments_part_: Mock + ): + partname = PackURI("/word/comments.xml") + content_type = CT.WML_COMMENTS + reltype = RT.COMMENTS + blob = b"" + CommentsPart_load_.return_value = comments_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + CommentsPart_load_.assert_called_once_with(partname, content_type, blob, package_) + assert part is comments_part_ + def it_provides_access_to_its_comments_collection( self, Comments_: Mock, comments_: Mock, package_: Mock ): @@ -58,6 +74,14 @@ def Comments_(self, request: FixtureRequest) -> Mock: def comments_(self, request: FixtureRequest) -> Mock: return instance_mock(request, Comments) + @pytest.fixture + def comments_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, CommentsPart) + + @pytest.fixture + def CommentsPart_load_(self, request: FixtureRequest) -> Mock: + return method_mock(request, CommentsPart, "load", autospec=False) + @pytest.fixture def package_(self, request: FixtureRequest) -> Mock: return instance_mock(request, Package) From 6c0024c52e477707685ba5c373abb2336b9fef36 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:41:05 -0700 Subject: [PATCH 07/26] comments: add Comments.__len__() --- features/doc-comments.feature | 1 - src/docx/comments.py | 4 +++ src/docx/oxml/__init__.py | 27 ++++++++++++------- src/docx/oxml/comments.py | 16 +++++++++++- tests/test_comments.py | 49 +++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/test_comments.py diff --git a/features/doc-comments.feature b/features/doc-comments.feature index d23a763a5..6aaffee68 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -15,7 +15,6 @@ Feature: Document.comments | no | - @wip Scenario Outline: Comments.__len__() Given a Comments object with comments Then len(comments) == diff --git a/src/docx/comments.py b/src/docx/comments.py index 587837baa..736cbb7ab 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -18,6 +18,10 @@ def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): self._comments_elm = comments_elm self._comments_part = comments_part + def __len__(self) -> int: + """The number of comments in this collection.""" + return len(self._comments_elm.comment_lst) + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 3fbc114ae..37f608cef 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -1,3 +1,5 @@ +# ruff: noqa: E402, I001 + """Initializes oxml sub-package. This including registering custom element classes corresponding to Open XML elements. @@ -84,16 +86,21 @@ # --------------------------------------------------------------------------- # other custom element class mappings -from .coreprops import CT_CoreProperties # noqa +from .comments import CT_Comments, CT_Comment + +register_element_cls("w:comments", CT_Comments) +register_element_cls("w:comment", CT_Comment) + +from .coreprops import CT_CoreProperties register_element_cls("cp:coreProperties", CT_CoreProperties) -from .document import CT_Body, CT_Document # noqa +from .document import CT_Body, CT_Document register_element_cls("w:body", CT_Body) register_element_cls("w:document", CT_Document) -from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa +from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr register_element_cls("w:abstractNumId", CT_DecimalNumber) register_element_cls("w:ilvl", CT_DecimalNumber) @@ -104,7 +111,7 @@ register_element_cls("w:numbering", CT_Numbering) register_element_cls("w:startOverride", CT_DecimalNumber) -from .section import ( # noqa +from .section import ( CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, @@ -122,11 +129,11 @@ register_element_cls("w:sectPr", CT_SectPr) register_element_cls("w:type", CT_SectType) -from .settings import CT_Settings # noqa +from .settings import CT_Settings register_element_cls("w:settings", CT_Settings) -from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles # noqa +from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles register_element_cls("w:basedOn", CT_String) register_element_cls("w:latentStyles", CT_LatentStyles) @@ -141,7 +148,7 @@ register_element_cls("w:uiPriority", CT_DecimalNumber) register_element_cls("w:unhideWhenUsed", CT_OnOff) -from .table import ( # noqa +from .table import ( CT_Height, CT_Row, CT_Tbl, @@ -178,7 +185,7 @@ register_element_cls("w:vAlign", CT_VerticalJc) register_element_cls("w:vMerge", CT_VMerge) -from .text.font import ( # noqa +from .text.font import ( CT_Color, CT_Fonts, CT_Highlight, @@ -217,11 +224,11 @@ register_element_cls("w:vertAlign", CT_VerticalAlignRun) register_element_cls("w:webHidden", CT_OnOff) -from .text.paragraph import CT_P # noqa +from .text.paragraph import CT_P register_element_cls("w:p", CT_P) -from .text.parfmt import ( # noqa +from .text.parfmt import ( CT_Ind, CT_Jc, CT_PPr, diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 65624b738..1e818ebfb 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,7 @@ from __future__ import annotations -from docx.oxml.xmlchemy import BaseOxmlElement +from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -13,3 +13,17 @@ class CT_Comments(BaseOxmlElement): from the document text. The offset of the comment in this collection is arbitrary; it is essentially a _set_ implemented as a list. """ + + # -- type-declarations to fill in the gaps for metaclass-added methods -- + comment_lst: list[CT_Comment] + + comment = ZeroOrMore("w:comment") + + +class CT_Comment(BaseOxmlElement): + """`w:comment` element, representing a single comment. + + A comment is a so-called "story" and can contain paragraphs and tables much like a table-cell. + While probably most often used for a single sentence or phrase, a comment can contain rich + content, including multiple rich-text paragraphs, hyperlinks, images, and tables. + """ diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 000000000..2bde587c6 --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,49 @@ +"""Unit test suite for the docx.comments module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.comments import Comments +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.comments import CT_Comments +from docx.package import Package +from docx.parts.comments import CommentsPart + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeComments: + """Unit-test suite for `docx.comments.Comments`.""" + + @pytest.mark.parametrize( + ("cxml", "count"), + [ + ("w:comments", 0), + ("w:comments/w:comment", 1), + ("w:comments/(w:comment,w:comment,w:comment)", 3), + ], + ) + def it_knows_how_many_comments_it_contains(self, cxml: str, count: int, package_: Mock): + comments_elm = cast(CT_Comments, element(cxml)) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + assert len(comments) == count + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def package_(self, request: FixtureRequest): + return instance_mock(request, Package) From 88ff3cab593bd68440e0d552631f2f80c476447d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:43:23 -0700 Subject: [PATCH 08/26] comments: add Comments.__iter__() --- features/doc-comments.feature | 1 - src/docx/blkcntnr.py | 3 ++- src/docx/comments.py | 15 +++++++++++++-- tests/test_comments.py | 23 ++++++++++++++++++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index 6aaffee68..fbe2fd278 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -25,7 +25,6 @@ Feature: Document.comments | 4 | - @wip Scenario: Comments.__iter__() Given a Comments object with 4 comments Then iterating comments yields 4 Comment objects diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index 951e03427..82c7ef727 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.oxml.comments import CT_Comment from docx.oxml.document import CT_Body from docx.oxml.section import CT_HdrFtr from docx.oxml.table import CT_Tc @@ -26,7 +27,7 @@ from docx.styles.style import ParagraphStyle from docx.table import Table -BlockItemElement: TypeAlias = "CT_Body | CT_HdrFtr | CT_Tc" +BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_HdrFtr | CT_Tc" class BlockItemContainer(StoryChild): diff --git a/src/docx/comments.py b/src/docx/comments.py index 736cbb7ab..6ccdec83b 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator from docx.blkcntnr import BlockItemContainer if TYPE_CHECKING: - from docx.oxml.comments import CT_Comments + from docx.oxml.comments import CT_Comment, CT_Comments from docx.parts.comments import CommentsPart @@ -18,6 +18,13 @@ def __init__(self, comments_elm: CT_Comments, comments_part: CommentsPart): self._comments_elm = comments_elm self._comments_part = comments_part + def __iter__(self) -> Iterator[Comment]: + """Iterator over the comments in this collection.""" + return ( + Comment(comment_elm, self._comments_part) + for comment_elm in self._comments_elm.comment_lst + ) + def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) @@ -36,3 +43,7 @@ class Comment(BlockItemContainer): Note that certain content like tables may not be displayed in the Word comment sidebar due to space limitations. Such "over-sized" content can still be viewed in the review pane. """ + + def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): + super().__init__(comment_elm, comments_part) + self._comment_elm = comment_elm diff --git a/tests/test_comments.py b/tests/test_comments.py index 2bde587c6..b38e429f9 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -6,7 +6,7 @@ import pytest -from docx.comments import Comments +from docx.comments import Comment, Comments from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI from docx.oxml.comments import CT_Comments @@ -42,6 +42,27 @@ def it_knows_how_many_comments_it_contains(self, cxml: str, count: int, package_ assert len(comments) == count + def it_is_iterable_over_the_comments_it_contains(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments/(w:comment,w:comment)")) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment_iter = iter(comments) + + comment1 = next(comment_iter) + assert type(comment1) is Comment, "expected a `Comment` object" + comment2 = next(comment_iter) + assert type(comment2) is Comment, "expected a `Comment` object" + with pytest.raises(StopIteration): + next(comment_iter) + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From e2aec420ba2d43991e4fa8acb04f200c65229ad0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:47:47 -0700 Subject: [PATCH 09/26] comments: add Comments.get() To get a comment by id, None when not found. --- src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 5 +++++ tests/test_comments.py | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/docx/comments.py b/src/docx/comments.py index 6ccdec83b..4a3da9dae 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -29,6 +29,11 @@ def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) + def get(self, comment_id: int) -> Comment | None: + """Return the comment identified by `comment_id`, or |None| if not found.""" + comment_elm = self._comments_elm.get_comment_by_id(comment_id) + return Comment(comment_elm, self._comments_part) if comment_elm is not None else None + class Comment(BlockItemContainer): """Proxy for a single comment in the document. diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 1e818ebfb..c5d84bc31 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -19,6 +19,11 @@ class CT_Comments(BaseOxmlElement): comment = ZeroOrMore("w:comment") + def get_comment_by_id(self, comment_id: int) -> CT_Comment | None: + """Return the `w:comment` element identified by `comment_id`, or |None| if not found.""" + comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]") + return comment_elms[0] if comment_elms else None + class CT_Comment(BaseOxmlElement): """`w:comment` element, representing a single comment. diff --git a/tests/test_comments.py b/tests/test_comments.py index b38e429f9..a32f7acbf 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,3 +1,5 @@ +# pyright: reportPrivateUsage=false + """Unit test suite for the docx.comments module.""" from __future__ import annotations @@ -63,6 +65,26 @@ def it_is_iterable_over_the_comments_it_contains(self, package_: Mock): with pytest.raises(StopIteration): next(comment_iter) + def it_can_get_a_comment_by_id(self, package_: Mock): + comments_elm = cast( + CT_Comments, + element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"), + ) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment = comments.get(2) + + assert type(comment) is Comment, "expected a `Comment` object" + assert comment._comment_elm is comments_elm.comment_lst[1] + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 0eeaa2f0760b61374fef5f2912f63f7ded4bcaeb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:49:31 -0700 Subject: [PATCH 10/26] comments: add Comment.comment_id --- features/doc-comments.feature | 1 - src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 5 ++++- tests/test_comments.py | 18 +++++++++++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/features/doc-comments.feature b/features/doc-comments.feature index fbe2fd278..944146e5e 100644 --- a/features/doc-comments.feature +++ b/features/doc-comments.feature @@ -30,7 +30,6 @@ Feature: Document.comments Then iterating comments yields 4 Comment objects - @wip Scenario: Comments.get() Given a Comments object with 4 comments When I call comments.get(2) diff --git a/src/docx/comments.py b/src/docx/comments.py index 4a3da9dae..d3f58343f 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -52,3 +52,8 @@ class Comment(BlockItemContainer): def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + + @property + def comment_id(self) -> int: + """The unique identifier of this comment.""" + return self._comment_elm.id diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index c5d84bc31..a24e1dba2 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,8 @@ from __future__ import annotations -from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore +from docx.oxml.simpletypes import ST_DecimalNumber +from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -32,3 +33,5 @@ class CT_Comment(BaseOxmlElement): While probably most often used for a single sentence or phrase, a comment can contain rich content, including multiple rich-text paragraphs, hyperlinks, images, and tables. """ + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] diff --git a/tests/test_comments.py b/tests/test_comments.py index a32f7acbf..8f9fd473f 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -11,7 +11,7 @@ from docx.comments import Comment, Comments from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI -from docx.oxml.comments import CT_Comments +from docx.oxml.comments import CT_Comment, CT_Comments from docx.package import Package from docx.parts.comments import CommentsPart @@ -90,3 +90,19 @@ def it_can_get_a_comment_by_id(self, package_: Mock): @pytest.fixture def package_(self, request: FixtureRequest): return instance_mock(request, Package) + + +class DescribeComment: + """Unit-test suite for `docx.comments.Comment`.""" + + def it_knows_its_comment_id(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.comment_id == 42 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def comments_part_(self, request: FixtureRequest): + return instance_mock(request, CommentsPart) From 7cf36d648fb18b13979ea7df631724d0dda1bc2c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 22:57:11 -0700 Subject: [PATCH 11/26] xfail: acceptance test for Comment properties --- features/cmt-props.feature | 40 +++++++++++++++++++++++++ features/steps/comments.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 features/cmt-props.feature diff --git a/features/cmt-props.feature b/features/cmt-props.feature new file mode 100644 index 000000000..6eead5aa7 --- /dev/null +++ b/features/cmt-props.feature @@ -0,0 +1,40 @@ +Feature: Get comment properties + In order to characterize comments by their metadata + As a developer using python-docx + I need methods to access comment metadata properties + + + Scenario: Comment.id + Given a Comment object + Then comment.comment_id is the comment identifier + + + @wip + Scenario: Comment.author + Given a Comment object + Then comment.author is the author of the comment + + + @wip + Scenario: Comment.initials + Given a Comment object + Then comment.initials is the initials of the comment author + + + @wip + Scenario: Comment.timestamp + Given a Comment object + Then comment.timestamp is the date and time the comment was authored + + + @wip + Scenario: Comment.paragraphs[0].text + Given a Comment object + When I assign para_text = comment.paragraphs[0].text + Then para_text is the text of the first paragraph in the comment + + + @wip + Scenario: Retrieve embedded image from a comment + Given a Comment object containing an embedded image + Then I can extract the image from the comment diff --git a/features/steps/comments.py b/features/steps/comments.py index 81993aeda..14c7d3359 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -1,16 +1,29 @@ """Step implementations for document comments-related features.""" +import datetime as dt + from behave import given, then, when from behave.runner import Context from docx import Document from docx.comments import Comment, Comments +from docx.drawing import Drawing from helpers import test_docx # given ==================================================== +@given("a Comment object") +def given_a_comment_object(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.get(0) + + +@given("a Comment object containing an embedded image") +def given_a_comment_object_containing_an_embedded_image(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.get(1) + + @given("a Comments object with {count} comments") def given_a_comments_object_with_count_comments(context: Context, count: str): testfile_name = {"0": "doc-default", "4": "comments-rich-para"}[count] @@ -30,6 +43,11 @@ def given_a_document_having_no_comments_part(context: Context): # when ===================================================== +@when("I assign para_text = comment.paragraphs[0].text") +def when_I_assign_para_text(context: Context): + context.para_text = context.comment.paragraphs[0].text + + @when("I call comments.get(2)") def when_I_call_comments_get_2(context: Context): context.comment = context.comments.get(2) @@ -38,12 +56,48 @@ def when_I_call_comments_get_2(context: Context): # then ===================================================== +@then("comment.author is the author of the comment") +def then_comment_author_is_the_author_of_the_comment(context: Context): + actual = context.comment.author + assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" + + +@then("comment.comment_id is the comment identifier") +def then_comment_comment_id_is_the_comment_identifier(context: Context): + assert context.comment.comment_id == 0 + + +@then("comment.initials is the initials of the comment author") +def then_comment_initials_is_the_initials_of_the_comment_author(context: Context): + initials = context.comment.initials + assert initials == "SJC", f"expected initials 'SJC', got '{initials}'" + + +@then("comment.timestamp is the date and time the comment was authored") +def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): + assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) + + @then("document.comments is a Comments object") def then_document_comments_is_a_Comments_object(context: Context): document = context.document assert type(document.comments) is Comments +@then("I can extract the image from the comment") +def then_I_can_extract_the_image_from_the_comment(context: Context): + paragraph = context.comment.paragraphs[0] + run = paragraph.runs[2] + drawing = next(d for d in run.iter_inner_content() if isinstance(d, Drawing)) + assert drawing.has_picture + + image = drawing.image + + assert image.content_type == "image/jpeg", f"got {image.content_type}" + assert image.filename == "image.jpg", f"got {image.filename}" + assert image.sha1 == "1be010ea47803b00e140b852765cdf84f491da47", f"got {image.sha1}" + + @then("iterating comments yields {count} Comment objects") def then_iterating_comments_yields_count_comments(context: Context, count: str): comment_iter = iter(context.comments) @@ -62,6 +116,13 @@ def then_len_comments_eq_count(context: Context, count: str): assert actual == expected, f"expected len(comments) of {expected}, got {actual}" +@then("para_text is the text of the first paragraph in the comment") +def then_para_text_is_the_text_of_the_first_paragraph_in_the_comment(context: Context): + actual = context.para_text + expected = "Text with hyperlink https://google.com embedded." + assert actual == expected, f"expected para_text '{expected}', got '{actual}'" + + @then("the result is a Comment object with id 2") def then_the_result_is_a_comment_object_with_id_2(context: Context): comment = context.comment From 8af46fe57a84299da4ffc3e1528eecc35accca92 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:05:07 -0700 Subject: [PATCH 12/26] comments: add Comment.author --- features/cmt-props.feature | 1 - src/docx/comments.py | 5 +++++ src/docx/oxml/comments.py | 3 ++- tests/test_comments.py | 6 ++++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index 6eead5aa7..95fe17746 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -9,7 +9,6 @@ Feature: Get comment properties Then comment.comment_id is the comment identifier - @wip Scenario: Comment.author Given a Comment object Then comment.author is the author of the comment diff --git a/src/docx/comments.py b/src/docx/comments.py index d3f58343f..a107f7b0b 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -53,6 +53,11 @@ def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + @property + def author(self) -> str: + """The recorded author of this comment.""" + return self._comment_elm.author + @property def comment_id(self) -> int: """The unique identifier of this comment.""" diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index a24e1dba2..1aa71add5 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,7 @@ from __future__ import annotations -from docx.oxml.simpletypes import ST_DecimalNumber +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore @@ -35,3 +35,4 @@ class CT_Comment(BaseOxmlElement): """ id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] diff --git a/tests/test_comments.py b/tests/test_comments.py index 8f9fd473f..7b0e3588c 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -101,6 +101,12 @@ def it_knows_its_comment_id(self, comments_part_: Mock): assert comment.comment_id == 42 + def it_knows_its_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Steve Canny}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.author == "Steve Canny" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From cab50c5e65da92e31f64401f535efa0d9d0d8e84 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:01 -0700 Subject: [PATCH 13/26] comments: add Comment.initials --- features/cmt-props.feature | 1 - src/docx/comments.py | 9 +++++++++ src/docx/oxml/comments.py | 5 ++++- tests/test_comments.py | 6 ++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index 95fe17746..f1a7fbc4c 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -14,7 +14,6 @@ Feature: Get comment properties Then comment.author is the author of the comment - @wip Scenario: Comment.initials Given a Comment object Then comment.initials is the initials of the comment author diff --git a/src/docx/comments.py b/src/docx/comments.py index a107f7b0b..cc1a86161 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -62,3 +62,12 @@ def author(self) -> str: def comment_id(self) -> int: """The unique identifier of this comment.""" return self._comment_elm.id + + @property + def initials(self) -> str | None: + """Read/write. The recorded initials of the comment author. + + This attribute is optional in the XML, returns |None| if not set. Assigning |None| removes + any existing initials from the XML. + """ + return self._comment_elm.initials diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 1aa71add5..b841cdfe9 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,7 +3,7 @@ from __future__ import annotations from docx.oxml.simpletypes import ST_DecimalNumber, ST_String -from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore class CT_Comments(BaseOxmlElement): @@ -36,3 +36,6 @@ class CT_Comment(BaseOxmlElement): id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] + initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:initials", ST_String + ) diff --git a/tests/test_comments.py b/tests/test_comments.py index 7b0e3588c..9e4f64d68 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -107,6 +107,12 @@ def it_knows_its_author(self, comments_part_: Mock): assert comment.author == "Steve Canny" + def it_knows_the_initials_of_its_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=SJC}")) + comment = Comment(comment_elm, comments_part_) + + assert comment.initials == "SJC" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From cfb87e7708f561f09e6d4f3e7289c659c226eafb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:06:54 -0700 Subject: [PATCH 14/26] comments: add Comment.timestamp --- features/cmt-props.feature | 1 - src/docx/comments.py | 9 ++++++ src/docx/oxml/comments.py | 7 ++++- src/docx/oxml/simpletypes.py | 53 ++++++++++++++++++++++++++++++++++++ tests/test_comments.py | 10 +++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index f1a7fbc4c..ab5450dfa 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -19,7 +19,6 @@ Feature: Get comment properties Then comment.initials is the initials of the comment author - @wip Scenario: Comment.timestamp Given a Comment object Then comment.timestamp is the date and time the comment was authored diff --git a/src/docx/comments.py b/src/docx/comments.py index cc1a86161..e5d25fd79 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING, Iterator from docx.blkcntnr import BlockItemContainer @@ -71,3 +72,11 @@ def initials(self) -> str | None: any existing initials from the XML. """ return self._comment_elm.initials + + @property + def timestamp(self) -> dt.datetime | None: + """The date and time this comment was authored. + + This attribute is optional in the XML, returns |None| if not set. + """ + return self._comment_elm.date diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index b841cdfe9..612a51f8a 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,9 @@ from __future__ import annotations -from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +import datetime as dt + +from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore @@ -39,3 +41,6 @@ class CT_Comment(BaseOxmlElement): initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:initials", ST_String ) + date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:date", ST_DateTime + ) diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index 69d4b65d4..a0fc87d3f 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -9,6 +9,7 @@ from __future__ import annotations +import datetime as dt from typing import TYPE_CHECKING, Any, Tuple from docx.exceptions import InvalidXmlError @@ -213,6 +214,58 @@ def validate(cls, value: Any) -> None: cls.validate_int_in_range(value, -27273042329600, 27273042316900) +class ST_DateTime(BaseSimpleType): + @classmethod + def convert_from_xml(cls, str_value: str) -> dt.datetime: + """Convert an xsd:dateTime string to a datetime object.""" + + def parse_xsd_datetime(dt_str: str) -> dt.datetime: + # -- handle trailing 'Z' (Zulu/UTC), common in Word files -- + if dt_str.endswith("Z"): + try: + # -- optional fractional seconds case -- + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace( + tzinfo=dt.timezone.utc + ) + except ValueError: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + + # -- handles explicit offsets like +00:00, -05:00, or naive datetimes -- + try: + return dt.datetime.fromisoformat(dt_str) + except ValueError: + # -- fall-back to parsing as naive datetime (with or without fractional seconds) -- + try: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S.%f") + except ValueError: + return dt.datetime.strptime(dt_str, "%Y-%m-%dT%H:%M:%S") + + try: + # -- parse anything reasonable, but never raise, just use default epoch time -- + return parse_xsd_datetime(str_value) + except Exception: + return dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) + + @classmethod + def convert_to_xml(cls, value: dt.datetime) -> str: + # -- convert naive datetime to timezon-aware assuming local timezone -- + if value.tzinfo is None: + value = value.astimezone() + + # -- convert to UTC if not already -- + value = value.astimezone(dt.timezone.utc) + + # -- format with 'Z' suffix for UTC -- + return value.strftime("%Y-%m-%dT%H:%M:%SZ") + + @classmethod + def validate(cls, value: Any) -> None: + if not isinstance(value, dt.datetime): + raise TypeError("only a datetime.datetime object may be assigned, got '%s'" % value) + + class ST_DecimalNumber(XsdInt): pass diff --git a/tests/test_comments.py b/tests/test_comments.py index 9e4f64d68..ea9e97c96 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -4,6 +4,7 @@ from __future__ import annotations +import datetime as dt from typing import cast import pytest @@ -113,6 +114,15 @@ def it_knows_the_initials_of_its_author(self, comments_part_: Mock): assert comment.initials == "SJC" + def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): + comment_elm = cast( + CT_Comment, + element("w:comment{w:id=42,w:date=2023-10-01T12:34:56Z}"), + ) + comment = Comment(comment_elm, comments_part_) + + assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 19175adf57d91f314e95fa72972c6065f72b4dff Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:09:32 -0700 Subject: [PATCH 15/26] comments: add Comment.paragraphs Actual implementation is primarily inherited from `BlockItemContainer`, but support for those operations must be present in `CT_Comment` and it's worth testing explicitly. --- features/cmt-props.feature | 1 - src/docx/oxml/comments.py | 23 +++++++++++++++++++++++ tests/test_comments.py | 12 ++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/features/cmt-props.feature b/features/cmt-props.feature index ab5450dfa..f5c636196 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -24,7 +24,6 @@ Feature: Get comment properties Then comment.timestamp is the date and time the comment was authored - @wip Scenario: Comment.paragraphs[0].text Given a Comment object When I assign para_text = comment.paragraphs[0].text diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 612a51f8a..0ebd7e200 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,10 +3,15 @@ from __future__ import annotations import datetime as dt +from typing import TYPE_CHECKING, Callable from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore +if TYPE_CHECKING: + from docx.oxml.table import CT_Tbl + from docx.oxml.text.paragraph import CT_P + class CT_Comments(BaseOxmlElement): """`w:comments` element, the root element for the comments part. @@ -36,6 +41,7 @@ class CT_Comment(BaseOxmlElement): content, including multiple rich-text paragraphs, hyperlinks, images, and tables. """ + # -- attributes on `w:comment` -- id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] initials: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] @@ -44,3 +50,20 @@ class CT_Comment(BaseOxmlElement): date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:date", ST_DateTime ) + + # -- children -- + + p = ZeroOrMore("w:p", successors=()) + tbl = ZeroOrMore("w:tbl", successors=()) + + # -- type-declarations for methods added by metaclass -- + + add_p: Callable[[], CT_P] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] + _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + + @property + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: + """Generate all `w:p` and `w:tbl` elements in this comment.""" + return self.xpath("./w:p | ./w:tbl") diff --git a/tests/test_comments.py b/tests/test_comments.py index ea9e97c96..2a0615a79 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -123,6 +123,18 @@ def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock): + comment_elm = cast( + CT_Comment, + element('w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")'), + ) + comment = Comment(comment_elm, comments_part_) + + paragraphs = comment.paragraphs + + assert len(paragraphs) == 2 + assert [para.text for para in paragraphs] == ["First para", "Second para"] + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 432dd15eb343476024167a3ffec39e2b86d5585c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:11:42 -0700 Subject: [PATCH 16/26] drawing: add image extraction from Drawing --- features/cmt-props.feature | 1 - src/docx/drawing/__init__.py | 39 +++++++++++++++++++ tests/test_comments.py | 4 +- tests/test_drawing.py | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 tests/test_drawing.py diff --git a/features/cmt-props.feature b/features/cmt-props.feature index f5c636196..e4e620828 100644 --- a/features/cmt-props.feature +++ b/features/cmt-props.feature @@ -30,7 +30,6 @@ Feature: Get comment properties Then para_text is the text of the first paragraph in the comment - @wip Scenario: Retrieve embedded image from a comment Given a Comment object containing an embedded image Then I can extract the image from the comment diff --git a/src/docx/drawing/__init__.py b/src/docx/drawing/__init__.py index f40205747..00d1f51bb 100644 --- a/src/docx/drawing/__init__.py +++ b/src/docx/drawing/__init__.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: import docx.types as t + from docx.image.image import Image class Drawing(Parented): @@ -18,3 +19,41 @@ def __init__(self, drawing: CT_Drawing, parent: t.ProvidesStoryPart): super().__init__(parent) self._parent = parent self._drawing = self._element = drawing + + @property + def has_picture(self) -> bool: + """True when `drawing` contains an embedded picture. + + A drawing can contain a picture, but it can also contain a chart, SmartArt, or a + drawing canvas. Methods related to a picture, like `.image`, will raise when the drawing + does not contain a picture. Use this value to determine whether image methods will succeed. + + This value is `False` when a linked picture is present. This should be relatively rare and + the image would only be retrievable from the filesystem. + + Note this does not distinguish between inline and floating images. The presence of either + one will cause this value to be `True`. + """ + xpath_expr = ( + # -- an inline picture -- + "./wp:inline/a:graphic/a:graphicData/pic:pic" + # -- a floating picture -- + " | ./wp:anchor/a:graphic/a:graphicData/pic:pic" + ) + # -- xpath() will return a list, empty if there are no matches -- + return bool(self._drawing.xpath(xpath_expr)) + + @property + def image(self) -> Image: + """An `Image` proxy object for the image in this (picture) drawing. + + Raises `ValueError` when this drawing does contains something other than a picture. Use + `.has_picture` to qualify drawing objects before using this property. + """ + picture_rIds = self._drawing.xpath(".//pic:blipFill/a:blip/@r:embed") + if not picture_rIds: + raise ValueError("drawing does not contain a picture") + rId = picture_rIds[0] + doc_part = self.part + image_part = doc_part.related_parts[rId] + return image_part.image diff --git a/tests/test_comments.py b/tests/test_comments.py index 2a0615a79..a4be3dbb4 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -1,6 +1,6 @@ # pyright: reportPrivateUsage=false -"""Unit test suite for the docx.comments module.""" +"""Unit test suite for the `docx.comments` module.""" from __future__ import annotations @@ -21,7 +21,7 @@ class DescribeComments: - """Unit-test suite for `docx.comments.Comments`.""" + """Unit-test suite for `docx.comments.Comments` objects.""" @pytest.mark.parametrize( ("cxml", "count"), diff --git a/tests/test_drawing.py b/tests/test_drawing.py new file mode 100644 index 000000000..c8fedb1a4 --- /dev/null +++ b/tests/test_drawing.py @@ -0,0 +1,74 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.drawing` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.drawing import Drawing +from docx.image.image import Image +from docx.oxml.drawing import CT_Drawing +from docx.parts.document import DocumentPart +from docx.parts.image import ImagePart + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeDrawing: + """Unit-test suite for `docx.drawing.Drawing` objects.""" + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic", True), + ("w:drawing/wp:anchor/a:graphic/a:graphicData/pic:pic", True), + ("w:drawing/wp:inline/a:graphic/a:graphicData/a:grpSp", False), + ("w:drawing/wp:anchor/a:graphic/a:graphicData/a:chart", False), + ], + ) + def it_knows_when_it_contains_a_Picture( + self, cxml: str, expected_value: bool, document_part_: Mock + ): + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + assert drawing.has_picture == expected_value + + def it_provides_access_to_the_image_in_a_Picture_drawing( + self, document_part_: Mock, image_part_: Mock, image_: Mock + ): + image_part_.image = image_ + document_part_.part.related_parts = {"rId1": image_part_} + cxml = ( + "w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/a:blip{r:embed=rId1}" + ) + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + + image = drawing.image + + assert image is image_ + + def but_it_raises_when_the_drawing_does_not_contain_a_Picture(self, document_part_: Mock): + drawing = Drawing( + cast(CT_Drawing, element("w:drawing/wp:inline/a:graphic/a:graphicData/a:grpSp")), + document_part_, + ) + + with pytest.raises(ValueError, match="drawing does not contain a picture"): + drawing.image + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def document_part_(self, request: FixtureRequest): + return instance_mock(request, DocumentPart) + + @pytest.fixture + def image_(self, request: FixtureRequest): + return instance_mock(request, Image) + + @pytest.fixture + def image_part_(self, request: FixtureRequest): + return instance_mock(request, ImagePart) From d360409273a9fdfd2d6a26a7f35b8f3bfc781f04 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:20:17 -0700 Subject: [PATCH 17/26] xfail: acceptance test for Comment mutations --- features/cmt-mutations.feature | 66 +++++++++ features/steps/comments.py | 131 ++++++++++++++++++ .../steps/test_files/comments-rich-para.docx | Bin 19974 -> 20023 bytes 3 files changed, 197 insertions(+) create mode 100644 features/cmt-mutations.feature diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature new file mode 100644 index 000000000..634e7c1bc --- /dev/null +++ b/features/cmt-mutations.feature @@ -0,0 +1,66 @@ +Feature: Comment mutations + In order to add and modify the content of a comment + As a developer using python-docx + I need mutation methods on Comment objects + + + @wip + Scenario: Comments.add_comment() + Given a Comments object with 0 comments + When I assign comment = comments.add_comment() + Then comment.comment_id == 0 + And len(comment.paragraphs) == 1 + And comment.paragraphs[0].style.name == "CommentText" + And len(comments) == 1 + And comments.get(0) == comment + + + @wip + Scenario: Comments.add_comment() specifying author and initials + Given a Comments object with 0 comments + When I assign comment = comments.add_comment(author="John Doe", initials="JD") + Then comment.author == "John Doe" + And comment.initials == "JD" + + + @wip + Scenario: Comment.add_paragraph() specifying text and style + Given a default Comment object + When I assign paragraph = comment.add_paragraph(text, style) + Then len(comment.paragraphs) == 2 + And paragraph.text == text + And paragraph.style == style + And comment.paragraphs[-1] == paragraph + + + @wip + Scenario: Comment.add_paragraph() not specifying text or style + Given a default Comment object + When I assign paragraph = comment.add_paragraph() + Then len(comment.paragraphs) == 2 + And paragraph.text == "" + And paragraph.style == "CommentText" + And comment.paragraphs[-1] == paragraph + + + @wip + Scenario: Add image to comment + Given a default Comment object + When I assign paragraph = comment.add_paragraph() + And I assign run = paragraph.add_run() + And I call run.add_picture() + Then run.iter_inner_content() yields a single Picture drawing + + + @wip + Scenario: update Comment.author + Given a Comment object + When I assign "Jane Smith" to comment.author + Then comment.author == "Jane Smith" + + + @wip + Scenario: update Comment.initials + Given a Comment object + When I assign "JS" to comment.initials + Then comment.initials == "JS" diff --git a/features/steps/comments.py b/features/steps/comments.py index 14c7d3359..2bca6d5a6 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -30,6 +30,11 @@ def given_a_comments_object_with_count_comments(context: Context, count: str): context.comments = Document(test_docx(testfile_name)).comments +@given("a default Comment object") +def given_a_default_comment_object(context: Context): + context.comment = Document(test_docx("comments-rich-para")).comments.add_comment() + + @given("a document having a comments part") def given_a_document_having_a_comments_part(context: Context): context.document = Document(test_docx("comments-rich-para")) @@ -43,11 +48,48 @@ def given_a_document_having_no_comments_part(context: Context): # when ===================================================== +@when('I assign "{author}" to comment.author') +def when_I_assign_author_to_comment_author(context: Context, author: str): + context.comment.author = author + + +@when("I assign comment = comments.add_comment()") +def when_I_assign_comment_eq_add_comment(context: Context): + context.comment = context.comments.add_comment() + + +@when('I assign comment = comments.add_comment(author="John Doe", initials="JD")') +def when_I_assign_comment_eq_comments_add_comment_with_author_and_initials(context: Context): + context.comment = context.comments.add_comment(author="John Doe", initials="JD") + + +@when('I assign "{initials}" to comment.initials') +def when_I_assign_initials(context: Context, initials: str): + context.comment.initials = initials + + @when("I assign para_text = comment.paragraphs[0].text") def when_I_assign_para_text(context: Context): context.para_text = context.comment.paragraphs[0].text +@when("I assign paragraph = comment.add_paragraph()") +def when_I_assign_default_add_paragraph(context: Context): + context.paragraph = context.comment.add_paragraph() + + +@when("I assign paragraph = comment.add_paragraph(text, style)") +def when_I_assign_add_paragraph_with_text_and_style(context: Context): + context.para_text = text = "Comment text" + context.para_style = style = "Normal" + context.paragraph = context.comment.add_paragraph(text, style) + + +@when("I assign run = paragraph.add_run()") +def when_I_assign_paragraph_add_run(context: Context): + context.run = context.paragraph.add_run() + + @when("I call comments.get(2)") def when_I_call_comments_get_2(context: Context): context.comment = context.comments.get(2) @@ -62,6 +104,17 @@ def then_comment_author_is_the_author_of_the_comment(context: Context): assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" +@then('comment.author == "{author}"') +def then_comment_author_eq_author(context: Context, author: str): + actual = context.comment.author + assert actual == author, f"expected author '{author}', got '{actual}'" + + +@then("comment.comment_id == 0") +def then_comment_id_is_0(context: Context): + assert context.comment.comment_id == 0 + + @then("comment.comment_id is the comment identifier") def then_comment_comment_id_is_the_comment_identifier(context: Context): assert context.comment.comment_id == 0 @@ -73,11 +126,42 @@ def then_comment_initials_is_the_initials_of_the_comment_author(context: Context assert initials == "SJC", f"expected initials 'SJC', got '{initials}'" +@then('comment.initials == "{initials}"') +def then_comment_initials_eq_initials(context: Context, initials: str): + actual = context.comment.initials + assert actual == initials, f"expected initials '{initials}', got '{actual}'" + + +@then("comment.paragraphs[{idx}] == paragraph") +def then_comment_paragraphs_idx_eq_paragraph(context: Context, idx: str): + actual = context.comment.paragraphs[int(idx)]._p + expected = context.paragraph._p + assert actual == expected, "paragraphs do not compare equal" + + +@then('comment.paragraphs[{idx}].style.name == "{style}"') +def then_comment_paragraphs_idx_style_name_eq_style(context: Context, idx: str, style: str): + actual = context.comment.paragraphs[int(idx)]._p.style + expected = style + assert actual == expected, f"expected style name '{expected}', got '{actual}'" + + @then("comment.timestamp is the date and time the comment was authored") def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) +@then("comments.get({id}) == comment") +def then_comments_get_comment_id_eq_comment(context: Context, id: str): + comment_id = int(id) + comment = context.comments.get(comment_id) + + assert type(comment) is Comment, f"expected a Comment object, got {type(comment)}" + assert comment.comment_id == comment_id, ( + f"expected comment_id '{comment_id}', got '{comment.comment_id}'" + ) + + @then("document.comments is a Comments object") def then_document_comments_is_a_Comments_object(context: Context): document = context.document @@ -109,6 +193,13 @@ def then_iterating_comments_yields_count_comments(context: Context, count: str): assert len(remaining) == int(count) - 1, "iterating comments did not yield the expected count" +@then("len(comment.paragraphs) == {count}") +def then_len_comment_paragraphs_eq_count(context: Context, count: str): + actual = len(context.comment.paragraphs) + expected = int(count) + assert actual == expected, f"expected len(comment.paragraphs) of {expected}, got {actual}" + + @then("len(comments) == {count}") def then_len_comments_eq_count(context: Context, count: str): actual = len(context.comments) @@ -123,6 +214,46 @@ def then_para_text_is_the_text_of_the_first_paragraph_in_the_comment(context: Co assert actual == expected, f"expected para_text '{expected}', got '{actual}'" +@then("paragraph.style == style") +def then_paragraph_style_eq_known_style(context: Context): + actual = context.paragraph.style.name + expected = context.para_style + assert actual == expected, f"expected paragraph.style '{expected}', got '{actual}'" + + +@then('paragraph.style == "{style}"') +def then_paragraph_style_eq_style(context: Context, style: str): + actual = context.paragraph._p.style + expected = style + assert actual == expected, f"expected paragraph.style '{expected}', got '{actual}'" + + +@then("paragraph.text == text") +def then_paragraph_text_eq_known_text(context: Context): + actual = context.paragraph.text + expected = context.para_text + assert actual == expected, f"expected paragraph.text '{expected}', got '{actual}'" + + +@then('paragraph.text == ""') +def then_paragraph_text_eq_text(context: Context): + actual = context.paragraph.text + expected = "" + assert actual == expected, f"expected paragraph.text '{expected}', got '{actual}'" + + +@then("run.iter_inner_content() yields a single Picture drawing") +def then_run_iter_inner_content_yields_a_single_picture_drawing(context: Context): + inner_content = list(context.run.iter_inner_content()) + + assert len(inner_content) == 1, ( + f"expected a single inner content element, got {len(inner_content)}" + ) + inner_content_item = inner_content[0] + assert isinstance(inner_content_item, Drawing) + assert inner_content_item.has_picture + + @then("the result is a Comment object with id 2") def then_the_result_is_a_comment_object_with_id_2(context: Context): comment = context.comment diff --git a/features/steps/test_files/comments-rich-para.docx b/features/steps/test_files/comments-rich-para.docx index e63db413e871466da97e906aef754bc06b2f9601..245c17224c22d0a6d72958be917d9ef1a899bad6 100644 GIT binary patch delta 5354 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 From 8ac9fc4f6b50b9b7f208974e853f1995d63a834a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:24:17 -0700 Subject: [PATCH 18/26] comments: add Comments.add_comment() Only with `text` parameter so far. Author and initials parameters to follow. --- features/cmt-mutations.feature | 5 --- src/docx/comments.py | 60 ++++++++++++++++++++++++++ src/docx/oxml/comments.py | 57 ++++++++++++++++++++++++- tests/oxml/test_comments.py | 31 ++++++++++++++ tests/test_comments.py | 78 ++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 tests/oxml/test_comments.py diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature index 634e7c1bc..6fda8810b 100644 --- a/features/cmt-mutations.feature +++ b/features/cmt-mutations.feature @@ -4,7 +4,6 @@ Feature: Comment mutations I need mutation methods on Comment objects - @wip Scenario: Comments.add_comment() Given a Comments object with 0 comments When I assign comment = comments.add_comment() @@ -15,7 +14,6 @@ Feature: Comment mutations And comments.get(0) == comment - @wip Scenario: Comments.add_comment() specifying author and initials Given a Comments object with 0 comments When I assign comment = comments.add_comment(author="John Doe", initials="JD") @@ -23,7 +21,6 @@ Feature: Comment mutations And comment.initials == "JD" - @wip Scenario: Comment.add_paragraph() specifying text and style Given a default Comment object When I assign paragraph = comment.add_paragraph(text, style) @@ -33,7 +30,6 @@ Feature: Comment mutations And comment.paragraphs[-1] == paragraph - @wip Scenario: Comment.add_paragraph() not specifying text or style Given a default Comment object When I assign paragraph = comment.add_paragraph() @@ -43,7 +39,6 @@ Feature: Comment mutations And comment.paragraphs[-1] == paragraph - @wip Scenario: Add image to comment Given a default Comment object When I assign paragraph = comment.add_paragraph() diff --git a/src/docx/comments.py b/src/docx/comments.py index e5d25fd79..7fd39d54a 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -10,6 +10,8 @@ if TYPE_CHECKING: from docx.oxml.comments import CT_Comment, CT_Comments from docx.parts.comments import CommentsPart + from docx.styles.style import ParagraphStyle + from docx.text.paragraph import Paragraph class Comments: @@ -30,6 +32,48 @@ def __len__(self) -> int: """The number of comments in this collection.""" return len(self._comments_elm.comment_lst) + def add_comment(self, text: str = "", author: str = "", initials: str | None = "") -> Comment: + """Add a new comment to the document and return it. + + The comment is added to the end of the comments collection and is assigned a unique + comment-id. + + If `text` is provided, it is added to the comment. This option provides for the common + case where a comment contains a modest passage of plain text. Multiple paragraphs can be + added using the `text` argument by separating their text with newlines (`"\\\\n"`). + Between newlines, text is interpreted as it is in `Document.add_paragraph(text=...)`. + + The default is to place a single empty paragraph in the comment, which is the same + behavior as the Word UI when you add a comment. New runs can be added to the first + paragraph in the empty comment with `comments.paragraphs[0].add_run()` to adding more + complex text with emphasis or images. Additional paragraphs can be added using + `.add_paragraph()`. + + `author` is a required attribute, set to the empty string by default. + + `initials` is an optional attribute, set to the empty string by default. Passing |None| + for the `initials` parameter causes that attribute to be omitted from the XML. + """ + comment_elm = self._comments_elm.add_comment() + comment_elm.author = author + comment_elm.initials = initials + comment_elm.date = dt.datetime.now(dt.timezone.utc) + comment = Comment(comment_elm, self._comments_part) + + if text == "": + return comment + + para_text_iter = iter(text.split("\n")) + + first_para_text = next(para_text_iter) + first_para = comment.paragraphs[0] + first_para.add_run(first_para_text) + + for s in para_text_iter: + comment.add_paragraph(text=s) + + return comment + def get(self, comment_id: int) -> Comment | None: """Return the comment identified by `comment_id`, or |None| if not found.""" 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): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: + """Return paragraph newly added to the end of the content in this container. + + The paragraph has `text` in a single run if present, and is given paragraph style `style`. + When `style` is |None| or ommitted, the "CommentText" paragraph style is applied, which is + the default style for comments. + """ + paragraph = super().add_paragraph(text, style) + + # -- have to assign style directly to element because `paragraph.style` raises when + # -- a style is not present in the styles part + if style is None: + paragraph._p.style = "CommentText" # pyright: ignore[reportPrivateUsage] + + return paragraph + @property def author(self) -> str: """The recorded author of this comment.""" diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 0ebd7e200..ad9821759 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,8 +3,10 @@ from __future__ import annotations import datetime as dt -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, cast +from docx.oxml.ns import nsdecls +from docx.oxml.parser import parse_xml from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore @@ -27,11 +29,64 @@ class CT_Comments(BaseOxmlElement): comment = ZeroOrMore("w:comment") + def add_comment(self) -> CT_Comment: + """Return newly added `w:comment` child of this `w:comments`. + + The returned `w:comment` element is the minimum valid value, having a `w:id` value unique + within the existing comments and the required `w:author` attribute present but set to the + empty string. It's content is limited to a single run containing the necessary annotation + reference but no text. Content is added by adding runs to this first paragraph and by + adding additional paragraphs as needed. + """ + next_id = self._next_available_comment_id() + comment = cast( + CT_Comment, + parse_xml( + f'' + f" " + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f" " + f"" + ), + ) + self.append(comment) + return comment + def get_comment_by_id(self, comment_id: int) -> CT_Comment | None: """Return the `w:comment` element identified by `comment_id`, or |None| if not found.""" comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]") return comment_elms[0] if comment_elms else None + def _next_available_comment_id(self) -> int: + """The next available comment id. + + According to the schema, this can be any positive integer, as big as you like, and the + default mechanism is to use `max() + 1`. However, if that yields a value larger than will + fit in a 32-bit signed integer, we take a more deliberate approach to use the first + ununsed integer starting from 0. + """ + used_ids = [int(x) for x in self.xpath("./w:comment/@w:id")] + + next_id = max(used_ids, default=-1) + 1 + + if next_id <= 2**31 - 1: + return next_id + + # -- fall-back to enumerating all used ids to find the first unused one -- + for expected, actual in enumerate(sorted(used_ids)): + if expected != actual: + return expected + + return len(used_ids) + class CT_Comment(BaseOxmlElement): """`w:comment` element, representing a single comment. diff --git a/tests/oxml/test_comments.py b/tests/oxml/test_comments.py new file mode 100644 index 000000000..8fc116144 --- /dev/null +++ b/tests/oxml/test_comments.py @@ -0,0 +1,31 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.oxml.comments` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.oxml.comments import CT_Comments + +from ..unitutil.cxml import element + + +class DescribeCT_Comments: + """Unit-test suite for `docx.oxml.comments.CT_Comments`.""" + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:comments", 0), + ("w:comments/(w:comment{w:id=1})", 2), + ("w:comments/(w:comment{w:id=4},w:comment{w:id=2147483646})", 2147483647), + ("w:comments/(w:comment{w:id=1},w:comment{w:id=2147483647})", 0), + ("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})", 4), + ], + ) + def it_finds_the_next_available_comment_id_to_help(self, cxml: str, expected_value: int): + comments_elm = cast(CT_Comments, element(cxml)) + assert comments_elm._next_available_comment_id() == expected_value diff --git a/tests/test_comments.py b/tests/test_comments.py index a4be3dbb4..8f5be2d1e 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -13,6 +13,7 @@ from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI from docx.oxml.comments import CT_Comment, CT_Comments +from docx.oxml.ns import qn from docx.package import Package from docx.parts.comments import CommentsPart @@ -86,8 +87,85 @@ def it_can_get_a_comment_by_id(self, package_: Mock): assert type(comment) is Comment, "expected a `Comment` object" assert comment._comment_elm is comments_elm.comment_lst[1] + def but_it_returns_None_when_no_comment_with_that_id_exists(self, package_: Mock): + comments_elm = cast( + CT_Comments, + element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"), + ) + comments = Comments( + comments_elm, + CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ), + ) + + comment = comments.get(4) + + assert comment is None, "expected None when no comment with that id exists" + + def it_can_add_a_new_comment(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + now_before = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + comments = Comments(comments_elm, comments_part) + + comment = comments.add_comment() + + now_after = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + # -- a comment is unconditionally added, and returned for any further adjustment -- + assert isinstance(comment, Comment) + # -- it is "linked" to the comments part so it can add images and hyperlinks, etc. -- + assert comment.part is comments_part + # -- comment numbering starts at 0, and is incremented for each new comment -- + assert comment.comment_id == 0 + # -- author is a required attribut, but is the empty string by default -- + assert comment.author == "" + # -- initials is an optional attribute, but defaults to the empty string, same as Word -- + assert comment.initials == "" + # -- timestamp is also optional, but defaults to now-UTC -- + assert comment.timestamp is not None + assert now_before <= comment.timestamp <= now_after + # -- by default, a new comment contains a single empty paragraph -- + assert [p.text for p in comment.paragraphs] == [""] + # -- that paragraph has the "CommentText" style, same as Word applies -- + comment_elm = comment._comment_elm + assert len(comment_elm.p_lst) == 1 + p = comment_elm.p_lst[0] + assert p.style == "CommentText" + # -- and that paragraph contains a single run with the necessary annotation reference -- + assert len(p.r_lst) == 1 + r = comment_elm.p_lst[0].r_lst[0] + assert r.style == "CommentReference" + assert r[-1].tag == qn("w:annotationRef") + + def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, package_: Mock): + comment = comments.add_comment(text="para 1\n\npara 2") + + assert len(comment.paragraphs) == 3 + assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"] + assert all(p._p.style == "CommentText" for p in comment.paragraphs) + # -- fixtures -------------------------------------------------------------------------------- + @pytest.fixture + def comments(self, package_: Mock) -> Comments: + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + return Comments(comments_elm, comments_part) + @pytest.fixture def package_(self, request: FixtureRequest): return instance_mock(request, Package) From 761f4ccd7751afeeaa5fff5c6f47325c3e0970fa Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 23:26:14 -0700 Subject: [PATCH 19/26] comments: add Comment.author, .initials setters - allow setting on construction - allow update with property setters --- features/cmt-mutations.feature | 2 -- src/docx/comments.py | 13 ++++++++++++- src/docx/shared.py | 2 +- tests/test_comments.py | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/features/cmt-mutations.feature b/features/cmt-mutations.feature index 6fda8810b..1ef9ad2db 100644 --- a/features/cmt-mutations.feature +++ b/features/cmt-mutations.feature @@ -47,14 +47,12 @@ Feature: Comment mutations Then run.iter_inner_content() yields a single Picture drawing - @wip Scenario: update Comment.author Given a Comment object When I assign "Jane Smith" to comment.author Then comment.author == "Jane Smith" - @wip Scenario: update Comment.initials Given a Comment object When I assign "JS" to comment.initials diff --git a/src/docx/comments.py b/src/docx/comments.py index 7fd39d54a..f0b359ee7 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -116,9 +116,16 @@ def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = Non @property def author(self) -> str: - """The recorded author of this comment.""" + """Read/write. The recorded author of this comment. + + This field is required but can be set to the empty string. + """ return self._comment_elm.author + @author.setter + def author(self, value: str): + self._comment_elm.author = value + @property def comment_id(self) -> int: """The unique identifier of this comment.""" @@ -133,6 +140,10 @@ def initials(self) -> str | None: """ return self._comment_elm.initials + @initials.setter + def initials(self, value: str | None): + self._comment_elm.initials = value + @property def timestamp(self) -> dt.datetime | None: """The date and time this comment was authored. diff --git a/src/docx/shared.py b/src/docx/shared.py index 1d561227b..6c12dc91e 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -328,7 +328,7 @@ def __init__(self, parent: t.ProvidesXmlPart): self._parent = parent @property - def part(self): + def part(self) -> XmlPart: """The package part containing this object.""" return self._parent.part diff --git a/tests/test_comments.py b/tests/test_comments.py index 8f5be2d1e..bdc38af9a 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -153,6 +153,14 @@ def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"] assert all(p._p.style == "CommentText" for p in comment.paragraphs) + def and_it_sets_the_author_and_their_initials_when_adding_a_comment_when_provided( + self, comments: Comments, package_: Mock + ): + comment = comments.add_comment(author="Steve Canny", initials="SJC") + + assert comment.author == "Steve Canny" + assert comment.initials == "SJC" + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture @@ -213,6 +221,33 @@ def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock) assert len(paragraphs) == 2 assert [para.text for para in paragraphs] == ["First para", "Second para"] + def it_can_update_the_comment_author(self, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Old Author}")) + comment = Comment(comment_elm, comments_part_) + + comment.author = "New Author" + + assert comment.author == "New Author" + + @pytest.mark.parametrize( + "initials", + [ + # -- valid initials -- + "XYZ", + # -- empty string is valid + "", + # -- None is valid, removes existing initials + None, + ], + ) + def it_can_update_the_comment_initials(self, initials: str | None, comments_part_: Mock): + comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=ABC}")) + comment = Comment(comment_elm, comments_part_) + + comment.initials = initials + + assert comment.initials == initials + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture From 66da52204db395466cc7ea033af0f5bffd228953 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 20:46:35 -0700 Subject: [PATCH 20/26] xfail: acceptance test for Document.add_comment() --- features/doc-add-comment.feature | 14 ++++++++++++++ features/steps/comments.py | 31 +++++++++++++++++++++++++++---- features/steps/settings.py | 17 +++++++++++------ 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 features/doc-add-comment.feature diff --git a/features/doc-add-comment.feature b/features/doc-add-comment.feature new file mode 100644 index 000000000..73560044a --- /dev/null +++ b/features/doc-add-comment.feature @@ -0,0 +1,14 @@ +Feature: Add a comment to a document + In order add a comment to a document + As a developer using python-docx + I need a way to add a comment specifying both its content and its reference + + + @wip + Scenario: Document.add_comment(runs, text, author, initials) + Given a document having a comments part + When I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD") + Then comment is a Comment object + And comment.text == "A comment" + And comment.author == "John Doe" + And comment.initials == "JD" diff --git a/features/steps/comments.py b/features/steps/comments.py index 2bca6d5a6..39680f257 100644 --- a/features/steps/comments.py +++ b/features/steps/comments.py @@ -63,6 +63,17 @@ def when_I_assign_comment_eq_comments_add_comment_with_author_and_initials(conte context.comment = context.comments.add_comment(author="John Doe", initials="JD") +@when('I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD")') +def when_I_assign_comment_eq_document_add_comment(context: Context): + runs = list(context.document.paragraphs[0].runs) + context.comment = context.document.add_comment( + runs=runs, + text="A comment", + author="John Doe", + initials="JD", + ) + + @when('I assign "{initials}" to comment.initials') def when_I_assign_initials(context: Context, initials: str): context.comment.initials = initials @@ -98,10 +109,9 @@ def when_I_call_comments_get_2(context: Context): # then ===================================================== -@then("comment.author is the author of the comment") -def then_comment_author_is_the_author_of_the_comment(context: Context): - actual = context.comment.author - assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" +@then("comment is a Comment object") +def then_comment_is_a_Comment_object(context: Context): + assert type(context.comment) is Comment @then('comment.author == "{author}"') @@ -110,6 +120,12 @@ def then_comment_author_eq_author(context: Context, author: str): assert actual == author, f"expected author '{author}', got '{actual}'" +@then("comment.author is the author of the comment") +def then_comment_author_is_the_author_of_the_comment(context: Context): + actual = context.comment.author + assert actual == "Steve Canny", f"expected author 'Steve Canny', got '{actual}'" + + @then("comment.comment_id == 0") def then_comment_id_is_0(context: Context): assert context.comment.comment_id == 0 @@ -146,6 +162,13 @@ def then_comment_paragraphs_idx_style_name_eq_style(context: Context, idx: str, assert actual == expected, f"expected style name '{expected}', got '{actual}'" +@then('comment.text == "{text}"') +def then_comment_text_eq_text(context: Context, text: str): + actual = context.comment.text + expected = text + assert actual == expected, f"expected text '{expected}', got '{actual}'" + + @then("comment.timestamp is the date and time the comment was authored") def then_comment_timestamp_is_the_date_and_time_the_comment_was_authored(context: Context): assert context.comment.timestamp == dt.datetime(2025, 6, 7, 11, 20, 0, tzinfo=dt.timezone.utc) diff --git a/features/steps/settings.py b/features/steps/settings.py index 1b03661eb..882f5ded3 100644 --- a/features/steps/settings.py +++ b/features/steps/settings.py @@ -1,6 +1,7 @@ """Step implementations for document settings-related features.""" from behave import given, then, when +from behave.runner import Context from docx import Document from docx.settings import Settings @@ -11,17 +12,19 @@ @given("a document having a settings part") -def given_a_document_having_a_settings_part(context): +def given_a_document_having_a_settings_part(context: Context): context.document = Document(test_docx("doc-word-default-blank")) @given("a document having no settings part") -def given_a_document_having_no_settings_part(context): +def given_a_document_having_no_settings_part(context: Context): context.document = Document(test_docx("set-no-settings-part")) @given("a Settings object {with_or_without} odd and even page headers as settings") -def given_a_Settings_object_with_or_without_odd_and_even_hdrs(context, with_or_without): +def given_a_Settings_object_with_or_without_odd_and_even_hdrs( + context: Context, with_or_without: str +): testfile_name = {"with": "doc-odd-even-hdrs", "without": "sct-section-props"}[with_or_without] context.settings = Document(test_docx(testfile_name)).settings @@ -30,7 +33,9 @@ def given_a_Settings_object_with_or_without_odd_and_even_hdrs(context, with_or_w @when("I assign {bool_val} to settings.odd_and_even_pages_header_footer") -def when_I_assign_value_to_settings_odd_and_even_pages_header_footer(context, bool_val): +def when_I_assign_value_to_settings_odd_and_even_pages_header_footer( + context: Context, bool_val: str +): context.settings.odd_and_even_pages_header_footer = eval(bool_val) @@ -38,13 +43,13 @@ def when_I_assign_value_to_settings_odd_and_even_pages_header_footer(context, bo @then("document.settings is a Settings object") -def then_document_settings_is_a_Settings_object(context): +def then_document_settings_is_a_Settings_object(context: Context): document = context.document assert type(document.settings) is Settings @then("settings.odd_and_even_pages_header_footer is {bool_val}") -def then_settings_odd_and_even_pages_header_footer_is(context, bool_val): +def then_settings_odd_and_even_pages_header_footer_is(context: Context, bool_val: str): actual = context.settings.odd_and_even_pages_header_footer expected = eval(bool_val) assert actual == expected, "settings.odd_and_even_pages_header_footer is %s" % actual From af3b973dd2c938f6851537978fe76f4f5e91dcc9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 16:31:54 -0700 Subject: [PATCH 21/26] comments: add Document.add_comment() --- src/docx/document.py | 50 +++++++++++++++++++++++++++++++++++++-- src/docx/oxml/shared.py | 3 +-- src/docx/oxml/xmlchemy.py | 3 +-- src/docx/text/run.py | 7 ++++++ tests/test_document.py | 34 +++++++++++++++++++++++++- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/docx/document.py b/src/docx/document.py index 5de03bf9d..1168c4ae8 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -5,17 +5,18 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator, List +from typing import IO, TYPE_CHECKING, Iterator, List, Sequence from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.section import Section, Sections from docx.shared import ElementProxy, Emu, Inches, Length +from docx.text.run import Run if TYPE_CHECKING: import docx.types as t - from docx.comments import Comments + from docx.comments import Comment, Comments from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart from docx.settings import Settings @@ -37,6 +38,51 @@ def __init__(self, element: CT_Document, part: DocumentPart): self._part = part self.__body = None + def add_comment( + self, runs: Run | Sequence[Run], text: str, author: str = "", initials: str | None = "" + ) -> Comment: + """Add a comment to the document, anchored to the specified runs. + + `runs` can be a single `Run` object or a non-empty sequence of `Run` objects. Only the + first and last run of a sequence are used, it's just more convenient to pass a whole + sequence when that's what you have handy, like `paragraph.runs` for example. When `runs` + contains a single `Run` object, that run serves as both the first and last run. + + A comment can be anchored only on an even run boundary, meaning the text the comment + "references" must be a non-zero integer number of consecutive runs. The runs need not be + _contiguous_ per se, like the first can be in one paragraph and the last in the next + paragraph, but all runs between the first and the last will be included in the reference. + + The comment reference range is delimited by placing a `w:commentRangeStart` element before + the first run and a `w:commentRangeEnd` element after the last run. This is why only the + first and last run are required and why a single run can serve as both first and last. + Word works out which text to highlight in the UI based on these range markers. + + `text` allows the contents of a simple comment to be provided in the call, providing for + the common case where a comment is a single phrase or sentence without special formatting + such as bold or italics. More complex comments can be added using the returned `Comment` + object in much the same way as a `Document` or (table) `Cell` object, using methods like + `.add_paragraph()`, .add_run()`, etc. + + The `author` and `initials` parameters allow that metadata to be set for the comment. + `author` is a required attribute on a comment and is the empty string by default. + `initials` is optional on a comment and may be omitted by passing |None|, but Word adds an + `initials` attribute by default and we follow that convention by using the empty string + when no `initials` argument is provided. + """ + # -- normalize `runs` to a sequence of runs -- + runs = [runs] if isinstance(runs, Run) else runs + first_run = runs[0] + last_run = runs[-1] + + # -- Note that comments can only appear in the document part -- + comment = self.comments.add_comment(text=text, author=author, initials=initials) + + # -- let the first run orchestrate placement of the comment range start and end -- + first_run.mark_comment_range(last_run, comment.comment_id) + + return comment + def add_heading(self, text: str = "", level: int = 1): """Return a heading paragraph newly added to the end of the document. diff --git a/src/docx/oxml/shared.py b/src/docx/oxml/shared.py index 8c2ebc9a9..8cfcd2be1 100644 --- a/src/docx/oxml/shared.py +++ b/src/docx/oxml/shared.py @@ -46,8 +46,7 @@ class CT_String(BaseOxmlElement): @classmethod def new(cls, nsptagname: str, val: str): - """Return a new ``CT_String`` element with tagname `nsptagname` and ``val`` - attribute set to `val`.""" + """A new `CT_String`` element with tagname `nsptagname` and `val` attribute set to `val`.""" elm = cast(CT_String, OxmlElement(nsptagname)) elm.val = val return elm diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index df75ee18c..e2c54b392 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -423,8 +423,7 @@ def _new_method_name(self): class Choice(_BaseChildElement): - """Defines a child element belonging to a group, only one of which may appear as a - child.""" + """Defines a child element belonging to a group, only one of which may appear as a child.""" @property def nsptagname(self): diff --git a/src/docx/text/run.py b/src/docx/text/run.py index d35988370..d49876eaf 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -173,6 +173,13 @@ def iter_inner_content(self) -> Iterator[str | Drawing | RenderedPageBreak]: elif isinstance(item, CT_Drawing): # pyright: ignore[reportUnnecessaryIsInstance] yield Drawing(item, self) + def mark_comment_range(self, last_run: Run, comment_id: int) -> None: + """Mark the range of runs from this run to `last_run` (inclusive) as belonging to a comment. + + `comment_id` identfies the comment that references this range. + """ + raise NotImplementedError + @property def style(self) -> CharacterStyle: """Read/write. diff --git a/tests/test_document.py b/tests/test_document.py index 0b36017a5..53efacf8d 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -9,7 +9,7 @@ import pytest -from docx.comments import Comments +from docx.comments import Comment, Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -39,6 +39,26 @@ class DescribeDocument: """Unit-test suite for `docx.document.Document`.""" + def it_can_add_a_comment( + self, + document_part_: Mock, + comments_prop_: Mock, + comments_: Mock, + comment_: Mock, + run_mark_comment_range_: Mock, + ): + comment_.comment_id = 42 + comments_.add_comment.return_value = comment_ + comments_prop_.return_value = comments_ + document = Document(cast(CT_Document, element("w:document/w:body/w:p/w:r")), document_part_) + run = document.paragraphs[0].runs[0] + + comment = document.add_comment(run, "Comment text.") + + comments_.add_comment.assert_called_once_with("Comment text.", "", "") + run_mark_comment_range_.assert_called_once_with(run, run, 42) + assert comment is comment_ + @pytest.mark.parametrize( ("level", "style"), [(0, "Title"), (1, "Heading 1"), (2, "Heading 2"), (9, "Heading 9")] ) @@ -288,10 +308,18 @@ def _block_width_prop_(self, request: FixtureRequest): def body_prop_(self, request: FixtureRequest): return property_mock(request, Document, "_body") + @pytest.fixture + def comment_(self, request: FixtureRequest): + return instance_mock(request, Comment) + @pytest.fixture def comments_(self, request: FixtureRequest): return instance_mock(request, Comments) + @pytest.fixture + def comments_prop_(self, request: FixtureRequest): + return property_mock(request, Document, "comments") + @pytest.fixture def core_properties_(self, request: FixtureRequest): return instance_mock(request, CoreProperties) @@ -325,6 +353,10 @@ def picture_(self, request: FixtureRequest): def run_(self, request: FixtureRequest): return instance_mock(request, Run) + @pytest.fixture + def run_mark_comment_range_(self, request: FixtureRequest): + return method_mock(request, Run, "mark_comment_range") + @pytest.fixture def Section_(self, request: FixtureRequest): return class_mock(request, "docx.document.Section") From e3a321d26195fdd6e368f59b63be06b1277dac14 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 17:51:50 -0700 Subject: [PATCH 22/26] comments: add Run.mark_comment_range() --- src/docx/oxml/text/run.py | 33 ++++++++++++++++++++++++++++++++- src/docx/text/run.py | 7 ++++++- tests/text/test_run.py | 13 +++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 88efae83c..7496aa616 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -2,10 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Iterator, List +from typing import TYPE_CHECKING, Callable, Iterator, List, cast from docx.oxml.drawing import CT_Drawing from docx.oxml.ns import qn +from docx.oxml.parser import OxmlElement from docx.oxml.simpletypes import ST_BrClear, ST_BrType from docx.oxml.text.font import CT_RPr from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne @@ -87,6 +88,19 @@ def iter_items() -> Iterator[str | CT_Drawing | CT_LastRenderedPageBreak]: return list(iter_items()) + def insert_comment_range_end_and_reference_below(self, comment_id: int) -> None: + """Insert a `w:commentRangeEnd` and `w:commentReference` element after this run. + + The `w:commentRangeEnd` element is the immediate sibling of this `w:r` and is followed by + a `w:r` containing the `w:commentReference` element. + """ + self.addnext(self._new_comment_reference_run(comment_id)) + self.addnext(OxmlElement("w:commentRangeEnd", attrs={qn("w:id"): str(comment_id)})) + + def insert_comment_range_start_above(self, comment_id: int) -> None: + """Insert a `w:commentRangeStart` element with `comment_id` before this run.""" + self.addprevious(OxmlElement("w:commentRangeStart", attrs={qn("w:id"): str(comment_id)})) + @property def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreaks` descendants of this run.""" @@ -132,6 +146,23 @@ def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: self.insert(0, rPr) return rPr + def _new_comment_reference_run(self, comment_id: int) -> CT_R: + """Return a new `w:r` element with `w:commentReference` referencing `comment_id`. + + Should look like this: + + + + + + + """ + r = cast(CT_R, OxmlElement("w:r")) + rPr = r.get_or_add_rPr() + rPr.style = "CommentReference" + r.append(OxmlElement("w:commentReference", attrs={qn("w:id"): str(comment_id)})) + return r + # ------------------------------------------------------------------------------------ # Run inner-content elements diff --git a/src/docx/text/run.py b/src/docx/text/run.py index d49876eaf..57ea31fa4 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -178,7 +178,12 @@ def mark_comment_range(self, last_run: Run, comment_id: int) -> None: `comment_id` identfies the comment that references this range. """ - raise NotImplementedError + # -- insert `w:commentRangeStart` with `comment_id` before this (first) run -- + self._r.insert_comment_range_start_above(comment_id) + + # -- insert `w:commentRangeEnd` and `w:commentReference` run with `comment_id` after + # -- `last_run` + last_run._r.insert_comment_range_end_and_reference_below(comment_id) @property def style(self) -> CharacterStyle: diff --git a/tests/text/test_run.py b/tests/text/test_run.py index a54120fdd..910f445d1 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -11,6 +11,7 @@ from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart from docx.shape import InlineShape @@ -122,6 +123,18 @@ def it_can_iterate_its_inner_content_items( actual = [type(item).__name__ for item in inner_content] assert actual == expected, f"expected: {expected}, got: {actual}" + def it_can_mark_a_comment_reference_range(self, paragraph_: Mock): + p = cast(CT_P, element('w:p/w:r/w:t"referenced text"')) + run = last_run = Run(p.r_lst[0], paragraph_) + + run.mark_comment_range(last_run, comment_id=42) + + assert p.xml == xml( + 'w:p/(w:commentRangeStart{w:id=42},w:r/w:t"referenced text"' + ",w:commentRangeEnd{w:id=42}" + ",w:r/(w:rPr/w:rStyle{w:val=CommentReference},w:commentReference{w:id=42}))" + ) + def it_knows_its_character_style( self, part_prop_: Mock, document_part_: Mock, paragraph_: Mock ): From a809d6cc8aec18648850d8b94d554f05621e433a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 9 Jun 2025 18:05:15 -0700 Subject: [PATCH 23/26] comments: add Comment.text --- features/doc-add-comment.feature | 1 - src/docx/comments.py | 10 ++++++++++ tests/test_comments.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/features/doc-add-comment.feature b/features/doc-add-comment.feature index 73560044a..36f46244a 100644 --- a/features/doc-add-comment.feature +++ b/features/doc-add-comment.feature @@ -4,7 +4,6 @@ Feature: Add a comment to a document I need a way to add a comment specifying both its content and its reference - @wip Scenario: Document.add_comment(runs, text, author, initials) Given a document having a comments part When I assign comment = document.add_comment(runs, "A comment", "John Doe", "JD") diff --git a/src/docx/comments.py b/src/docx/comments.py index f0b359ee7..9b69cbcec 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -144,6 +144,16 @@ def initials(self) -> str | None: def initials(self, value: str | None): self._comment_elm.initials = value + @property + def text(self) -> str: + """The text content of this comment as a string. + + Only content in paragraphs is included and of course all emphasis and styling is stripped. + + Paragraph boundaries are indicated with a newline ("\n") + """ + return "\n".join(p.text for p in self.paragraphs) + @property def timestamp(self) -> dt.datetime | None: """The date and time this comment was authored. diff --git a/tests/test_comments.py b/tests/test_comments.py index bdc38af9a..0f292ec8a 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -209,6 +209,26 @@ def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock): assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc) + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:comment{w:id=42}", ""), + ('w:comment{w:id=42}/w:p/w:r/w:t"Comment text."', "Comment text."), + ( + 'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")', + "First para\nSecond para", + ), + ( + 'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p,w:p/w:r/w:t"Second para")', + "First para\n\nSecond para", + ), + ], + ) + def it_can_summarize_its_content_as_text( + self, cxml: str, expected_value: str, comments_part_: Mock + ): + assert Comment(cast(CT_Comment, element(cxml)), comments_part_).text == expected_value + def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock): comment_elm = cast( CT_Comment, From 4fbe1f684e08aa7eebb0ce6bfedfce512b5c95a2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 11 Jun 2025 21:07:55 -0700 Subject: [PATCH 24/26] docs: add Comments docs - developer/analysis docs - user docs - API docs --- docs/_static/img/comment-parts.png | Bin 0 -> 30058 bytes docs/api/comments.rst | 27 ++ docs/conf.py | 6 +- docs/dev/analysis/features/comments.rst | 419 ++++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docs/index.rst | 2 + docs/user/comments.rst | 168 ++++++++++ pyproject.toml | 26 +- src/docx/comments.py | 2 +- src/docx/document.py | 6 +- src/docx/templates/default-comments.xml | 11 +- uv.lock | 303 +++++++++++------ 12 files changed, 846 insertions(+), 125 deletions(-) create mode 100644 docs/_static/img/comment-parts.png create mode 100644 docs/api/comments.rst create mode 100644 docs/dev/analysis/features/comments.rst create mode 100644 docs/user/comments.rst diff --git a/docs/_static/img/comment-parts.png b/docs/_static/img/comment-parts.png new file mode 100644 index 0000000000000000000000000000000000000000..c7db1be54bbbb04abf5286053f9aaea94c26eab9 GIT binary patch literal 30058 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