From b276696b24e64c00eefffcf1a806559e6aec4435 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Thu, 30 Jan 2025 05:40:53 +0000 Subject: [PATCH 01/14] comments: add `CT_Comments` and related element classes --- src/docx/oxml/comments.py | 145 ++++++++++++++++++++++++++++++++ src/docx/oxml/text/paragraph.py | 4 +- 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/docx/oxml/comments.py diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..2a1223dc6 --- /dev/null +++ b/src/docx/oxml/comments.py @@ -0,0 +1,145 @@ +import random +from typing import TYPE_CHECKING, Callable, List, Optional, cast + +from docx.oxml.parser import OxmlElement +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String, XsdBoolean +from docx.oxml.xmlchemy import ( + BaseOxmlElement, + OneOrMore, + OptionalAttribute, + RequiredAttribute, + ZeroOrOne, +) + +if TYPE_CHECKING: + from docx.oxml.text.paragraph import CT_P + + +class CT_Comment(BaseOxmlElement): + """```` element.""" + + add_paragraph: Callable[[], "CT_P"] + + id = RequiredAttribute("w:id", ST_DecimalNumber) + author = RequiredAttribute("w:author", ST_String) + initials = RequiredAttribute("w:initials", ST_String) + date = RequiredAttribute("w:date", ST_String) + paragraph = ZeroOrOne("w:p", successors=("w:comment",)) + + def add_para(self, para): + """Add a paragraph to the comment.""" + para_id = self.get_random_id() + para.para_id = para_id + self._insert_paragraph(para) + + @property + def para_id(self) -> ST_String: + """Return the paragraph id of the comment""" + return self.paragraph.para_id + + @para_id.setter + def para_id(self, value: ST_String): + self.paragraph.para_id = value + + @staticmethod + def get_random_id() -> ST_String: + """Generates a random id""" + return cast(ST_String, hex(random.getrandbits(24))[2:].upper()) + + +class CT_Comments(BaseOxmlElement): + """```` element, the root element of the comments part.""" + + add_comments: Callable[[], CT_Comment] + comments = OneOrMore("w:comment") + + @property + def _next_comment_id(self) -> int: + """Return the next comment ID to use.""" + comment_ids: List[int] = [ + int(id_str) for id_str in self.xpath("./w:comment/@w:id") if id_str.isdigit() + ] + return max(comment_ids) + 1 if len(comment_ids) > 0 else 1 + + def add_comment(self, para: "CT_P", author: str, initials: str, date: str) -> "CT_Comment": + """Return comment added to this part.""" + comment_id = self._next_comment_id + comment = self.add_comments() + comment.id = comment_id + comment.author = author + comment.initials = initials + comment.date = date + comment.add_para(para) + return comment + + +class CT_CommentRangeStart(BaseOxmlElement): + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id): + """Return a new ```` element having id `id`.""" + comment_range_start = OxmlElement("w:commentRangeStart") + comment_range_start._id = _id + return comment_range_start + + +class CT_CommentRangeEnd(BaseOxmlElement): + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id): + """Return a new ```` element having id `id`.""" + comment_range_end = OxmlElement("w:commentRangeEnd") + comment_range_end._id = _id + return comment_range_end + + +class CT_CommentReference(BaseOxmlElement): + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id): + """Return a new ```` element having id `id`.""" + comment_reference = OxmlElement("w:commentReference") + comment_reference._id = _id + return comment_reference + + +class CT_CommentExtended(BaseOxmlElement): + """```` element, the root element of the commentsExtended part.""" + + para_id = RequiredAttribute("w15:paraId", ST_String) + resolved = RequiredAttribute("w15:done", XsdBoolean) + parent_para_id = OptionalAttribute("w15:paraIdParent", ST_String) + + +class CT_CommentsExtended(BaseOxmlElement): + """```` element, the root element of the commentsExtended part.""" + + add_comments_extended_sequence: Callable[[], CT_CommentExtended] + comments_extended_sequence = OneOrMore("w15:commentEx") + + def add_comment_reference( + self, + comment: str, + parent: Optional[str] = None, + resolved: Optional[bool] = False, + ) -> CT_CommentExtended: + """Add a reply to the comment identified by `parent_comment_id`.""" + comment_ext = self.add_comments_extended_sequence() + comment_ext.para_id = comment.para_id + if parent is not None: + comment_ext.parent_para_id = parent.para_id + comment_ext.resolved = resolved + return comment_ext + + def get_element(self, para_id: str) -> Optional[CT_CommentExtended]: + """Return the comment extended element for the given paragraph id""" + try: + return self.xpath(f"./w15:commentEx[@w15:paraId='{para_id}']")[0] + except: + raise KeyError(f"no element with paraId {para_id}") diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..3f09ec4a3 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -7,7 +7,8 @@ from typing import TYPE_CHECKING, Callable, List, cast from docx.oxml.parser import OxmlElement -from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne +from docx.oxml.simpletypes import ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne if TYPE_CHECKING: from docx.enum.text import WD_PARAGRAPH_ALIGNMENT @@ -26,6 +27,7 @@ class CT_P(BaseOxmlElement): hyperlink_lst: List[CT_Hyperlink] r_lst: List[CT_R] + para_id = OptionalAttribute("w14:paraId", ST_String) pPr: CT_PPr | None = ZeroOrOne("w:pPr") # pyright: ignore[reportAssignmentType] hyperlink = ZeroOrMore("w:hyperlink") r = ZeroOrMore("w:r") From 97910226920345dc1d20650071fb246537ecebde Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Thu, 30 Jan 2025 05:55:30 +0000 Subject: [PATCH 02/14] comments: add CommentsPart and CommentsExtendedPart --- src/docx/opc/constants.py | 6 ++++++ src/docx/parts/comments.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/docx/parts/comments.py diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 89d3c16cc..9e63259dc 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -198,6 +198,9 @@ class CONTENT_TYPE: WML_COMMENTS = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" ) + WML_COMMENTS_EXTENDED = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml" + ) WML_DOCUMENT = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) @@ -298,6 +301,9 @@ class RELATIONSHIP_TYPE: "http://schemas.openxmlformats.org/officeDocument/2006/relationships" "/comments" ) + COMMENTS_EXTENDED = ( + "http://schemas.microsoft.com/office/2011/relationships/commentsExtended" + ) COMMENT_AUTHORS = ( "http://schemas.openxmlformats.org/officeDocument/2006/relationships" "/commentAuthors" diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py new file mode 100644 index 000000000..4285462f5 --- /dev/null +++ b/src/docx/parts/comments.py @@ -0,0 +1,44 @@ +"""|CommentsPart| and closely related objects.""" + +from typing import TYPE_CHECKING, cast + +from docx.opc.constants import CONTENT_TYPE +from docx.opc.packuri import PackURI +from docx.oxml.parser import OxmlElement +from docx.opc.part import XmlPart +from docx.oxml.ns import nsmap + +if TYPE_CHECKING: + from docx.oxml.comments import ( + CT_Comments, + CT_CommentsExtended, + ) + from docx.package import Package + + +class CommentsPart(XmlPart): + """Proxy for the comments.xml part containing comments definitions for a document + or glossary.""" + + @classmethod + def new(cls, package: "Package"): + """Return newly created empty comments part, containing only the root + ```` element.""" + partname = PackURI("/word/comments.xml") + content_type = CONTENT_TYPE.WML_COMMENTS + element = cast("CT_Comments", OxmlElement("w:comments", nsdecls=nsmap)) + return cls(partname, content_type, element, package) + + +class CommentsExtendedPart(XmlPart): + """Proxy for the commentsExtended.xml part containing comments definitions for a document + or glossary.""" + + @classmethod + def new(cls, package: "Package"): + """Return newly created empty comments part, containing only the root + ```` element.""" + partname = PackURI("/word/commentsExtended.xml") + content_type = CONTENT_TYPE.WML_COMMENTS_EXTENDED + element = cast("CT_CommentsExtended", OxmlElement("w15:commentsEx")) + return cls(partname, content_type, element, package) From 60bc6f03696d700e4e1ac1bc234a047609ac8605 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Thu, 30 Jan 2025 06:13:55 +0000 Subject: [PATCH 03/14] comments: add comments_part and comments_extended_part to DocumentPart --- src/docx/parts/document.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 416bb1a27..87fcb0e34 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -7,6 +7,7 @@ from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.parts.comments import CommentsExtendedPart, CommentsPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -102,6 +103,34 @@ def numbering_part(self): self.relate_to(numbering_part, RT.NUMBERING) return numbering_part + @lazyproperty + def comments_part(self) -> CommentsPart: + """The |CommentsPart| object providing access to the comments part of this + document. + + Creates an empty comments part if one is not present. + """ + try: + return cast(CommentsPart, self.part_related_by(RT.COMMENTS)) + except KeyError: + comments_part = CommentsPart.new(self.package) + self.relate_to(comments_part, RT.COMMENTS) + return comments_part + + @lazyproperty + def comments_extended_part(self) -> CommentsExtendedPart: + """The |CommentsExtendedPart| object providing access to the comments extended part of this + document. + + Creates an empty comments extended part if one is not present. + """ + try: + return cast(CommentsExtendedPart, self.part_related_by(RT.COMMENTS_EXTENDED)) + except KeyError: + comments_extended_part = CommentsExtendedPart.new(self.package) + self.relate_to(comments_extended_part, RT.COMMENTS_EXTENDED) + return comments_extended_part + def save(self, path_or_stream: str | IO[bytes]): """Save this document to `path_or_stream`, which can be either a path to a filesystem location (a string) or a file-like object.""" From ae545d154302a732d97e3ecfca85e7e1f514a859 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Thu, 30 Jan 2025 10:33:53 +0000 Subject: [PATCH 04/14] comments: register the element classes with xml tags --- src/docx/oxml/__init__.py | 18 ++++++++++++++++++ src/docx/oxml/ns.py | 1 + 2 files changed, 19 insertions(+) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..99fda552a 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -241,3 +241,21 @@ register_element_cls("w:tab", CT_TabStop) register_element_cls("w:tabs", CT_TabStops) register_element_cls("w:widowControl", CT_OnOff) + +from .comments import ( + CT_CommentExtended, + CT_Comments, + CT_Comment, + CT_CommentRangeStart, + CT_CommentRangeEnd, + CT_CommentReference, + CT_CommentsExtended, +) + +register_element_cls("w:comments", CT_Comments) +register_element_cls("w:comment", CT_Comment) +register_element_cls("w:commentRangeStart", CT_CommentRangeStart) +register_element_cls("w:commentRangeEnd", CT_CommentRangeEnd) +register_element_cls("w:commentReference", CT_CommentReference) +register_element_cls("w15:commentsEx", CT_CommentsExtended) +register_element_cls("w15:commentEx", CT_CommentExtended) \ No newline at end of file diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 5bed1e6a0..4e67e0850 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -18,6 +18,7 @@ "sl": "http://schemas.openxmlformats.org/schemaLibrary/2006/main", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", "xml": "http://www.w3.org/XML/1998/namespace", "xsi": "http://www.w3.org/2001/XMLSchema-instance", From 1b7635e82a75657efe1b08f1fe20691c006a5a52 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 05:10:59 +0000 Subject: [PATCH 05/14] comments: add `add_comment` method to CT_P --- src/docx/oxml/text/paragraph.py | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 3f09ec4a3..1e9522d6e 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -4,8 +4,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, List, cast - +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, cast + +from docx.oxml.comments import ( + CT_Comment, + CT_CommentRangeEnd, + CT_CommentRangeStart, + CT_CommentReference, + CT_Comments, + CT_CommentsExtended, +) from docx.oxml.parser import OxmlElement from docx.oxml.simpletypes import ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne @@ -17,6 +25,7 @@ from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.oxml.text.parfmt import CT_PPr from docx.oxml.text.run import CT_R + from docx.parts.comments import CommentsExtendedPart, CommentsPart class CT_P(BaseOxmlElement): @@ -106,3 +115,32 @@ def text(self): # pyright: ignore[reportIncompatibleMethodOverride] def _insert_pPr(self, pPr: CT_PPr) -> CT_PPr: self.insert(0, pPr) return pPr + + def add_comment( + self, + comments_part: "CommentsPart", + comments_extended_part: "CommentsExtendedPart", + text: str, + metadata: Dict[str, str | bool], + ) -> CT_Comment: + """ + Add a comment to this paragraph. + """ + comment_ele = cast(CT_Comments, comments_part.element) + comments_extended_ele = cast(CT_CommentsExtended, comments_extended_part.element) + + new_p = cast(CT_P, OxmlElement("w:p")) + new_p.add_r().text = text + comment = comment_ele.add_comment( + new_p, metadata["author"], metadata["initials"], metadata["date"] + ) + # TODO: modify this insert call to insert below the any existing comment reference. + self.insert(0, CT_CommentRangeStart.new(comment.id)) + self.append(CT_CommentRangeEnd.new(comment.id)) + self.add_r().append(CT_CommentReference.new(comment.id)) + + resolved = metadata.get("resolved", False) + parent = metadata.get("parent") + comments_extended_ele.add_comment_reference(comment, parent, resolved) + + return comment From b7d50407050ef3c1832f60335abf84b92bef8872 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 05:20:38 +0000 Subject: [PATCH 06/14] comments: add `add_comment` method to Paragraph --- src/docx/text/paragraph.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..9b90d9d8a 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, List, cast +from typing import TYPE_CHECKING, Iterator, List, Optional, cast from docx.enum.style import WD_STYLE_TYPE from docx.oxml.text.run import CT_R @@ -18,6 +18,7 @@ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle + from docx.oxml.comments import CT_Comment class Paragraph(StoryChild): @@ -171,3 +172,28 @@ def _insert_paragraph_before(self): """Return a newly created paragraph, inserted directly before this paragraph.""" p = self._p.add_p_before() return Paragraph(p, self._parent) + + def add_comment( + self, + text: str, + author: str, + initials: str, + date: str, + resolved: bool = False, + parent: Optional["CT_Comment"] = None, + ) -> "CT_Comment": + """Add a comment to this paragraph. + + The comment is added to the end of the paragraph. The `text` argument is the + text of the comment, and the `metadata` argument is a dictionary of metadata + about the comment. The keys and values in the dictionary are arbitrary strings. + """ + comments_part = self.part._document_part.comments_part + comments_extended_part = self.part._document_part.comments_extended_part + metadata = { + "author": author, + "initials": initials, + "date": date, + "resolved": resolved, + } + return self._p.add_comment(comments_part, comments_extended_part, text, metadata, parent) From 29ddc016ee1f8a27269468558f561d559f920705 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 05:22:32 +0000 Subject: [PATCH 07/14] comments: register `CommentsPart` and `CommentsExtendedPart` in `PartFactory` --- src/docx/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..3c98211bd 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Type from docx.api import Document +from docx.parts.comments import CommentsExtendedPart, CommentsPart if TYPE_CHECKING: from docx.opc.part import Part @@ -47,6 +48,8 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart PartFactory.part_type_for[CT.WML_STYLES] = StylesPart +PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart +PartFactory.part_type_for[CT.WML_COMMENTS_EXTENDED] = CommentsExtendedPart del ( CT, @@ -58,5 +61,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory, SettingsPart, StylesPart, + CommentsPart, + CommentsExtendedPart, part_class_selector, ) From ed394082f4e9156ac2dab43c477641e1a4c34a2a Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 05:29:14 +0000 Subject: [PATCH 08/14] comments: fix args and arg types --- src/docx/oxml/text/paragraph.py | 2 +- src/docx/text/paragraph.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 1e9522d6e..44d06388e 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -121,7 +121,7 @@ def add_comment( comments_part: "CommentsPart", comments_extended_part: "CommentsExtendedPart", text: str, - metadata: Dict[str, str | bool], + metadata: Dict[str, str | bool | "CT_Comment"], ) -> CT_Comment: """ Add a comment to this paragraph. diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 9b90d9d8a..2df824825 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -179,7 +179,7 @@ def add_comment( author: str, initials: str, date: str, - resolved: bool = False, + resolved: Optional[bool] = False, parent: Optional["CT_Comment"] = None, ) -> "CT_Comment": """Add a comment to this paragraph. @@ -195,5 +195,6 @@ def add_comment( "initials": initials, "date": date, "resolved": resolved, + "parent": parent, } - return self._p.add_comment(comments_part, comments_extended_part, text, metadata, parent) + return self._p.add_comment(comments_part, comments_extended_part, text, metadata) From 7aec7f2644f100caa999025bfef23f752d69beb3 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 06:41:14 +0000 Subject: [PATCH 09/14] comments: add type references to CT_CommentRangeStart, CT_CommentRangeEnd, CT_CommentReference --- src/docx/oxml/comments.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 2a1223dc6..3476df823 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -78,7 +78,7 @@ class CT_CommentRangeStart(BaseOxmlElement): _id = RequiredAttribute("w:id", ST_DecimalNumber) @classmethod - def new(cls, _id): + def new(cls, _id: ST_DecimalNumber) -> "CT_CommentRangeStart": """Return a new ```` element having id `id`.""" comment_range_start = OxmlElement("w:commentRangeStart") comment_range_start._id = _id @@ -90,7 +90,7 @@ class CT_CommentRangeEnd(BaseOxmlElement): _id = RequiredAttribute("w:id", ST_DecimalNumber) @classmethod - def new(cls, _id): + def new(cls, _id: ST_DecimalNumber) -> "CT_CommentRangeEnd": """Return a new ```` element having id `id`.""" comment_range_end = OxmlElement("w:commentRangeEnd") comment_range_end._id = _id @@ -102,7 +102,7 @@ class CT_CommentReference(BaseOxmlElement): _id = RequiredAttribute("w:id", ST_DecimalNumber) @classmethod - def new(cls, _id): + def new(cls, _id: ST_DecimalNumber) -> "CT_CommentReference": """Return a new ```` element having id `id`.""" comment_reference = OxmlElement("w:commentReference") comment_reference._id = _id From 07cf25243d2b6940eff138d20e0613adbaa03296 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 06:41:51 +0000 Subject: [PATCH 10/14] comments: update logic to insert `CT_CommentRangeStart` element --- src/docx/oxml/text/paragraph.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 44d06388e..7c817f7a9 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -4,8 +4,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, cast +from typing import TYPE_CHECKING, Callable, Dict, List, cast +from docx.opc.oxml import qn from docx.oxml.comments import ( CT_Comment, CT_CommentRangeEnd, @@ -134,8 +135,11 @@ def add_comment( comment = comment_ele.add_comment( new_p, metadata["author"], metadata["initials"], metadata["date"] ) - # TODO: modify this insert call to insert below the any existing comment reference. - self.insert(0, CT_CommentRangeStart.new(comment.id)) + cmt_range_start = CT_CommentRangeStart.new(comment.id) + if self.find(qn("w:commentRangeStart")) is not None: + self.insert(0, cmt_range_start) + else: + self.insert_element_before(cmt_range_start, "w:commentRangeStart") self.append(CT_CommentRangeEnd.new(comment.id)) self.add_r().append(CT_CommentReference.new(comment.id)) From ab337f08f2ee5edd81348e5ef4a091855ba8b056 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 09:01:58 +0000 Subject: [PATCH 11/14] comments: update attrib name in `CT_CommentsExtended` --- src/docx/oxml/comments.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 3476df823..4233de86b 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -120,8 +120,8 @@ class CT_CommentExtended(BaseOxmlElement): class CT_CommentsExtended(BaseOxmlElement): """```` element, the root element of the commentsExtended part.""" - add_comments_extended_sequence: Callable[[], CT_CommentExtended] - comments_extended_sequence = OneOrMore("w15:commentEx") + add_comments_extended_element: Callable[[], CT_CommentExtended] + comments_extended_element = OneOrMore("w15:commentEx") def add_comment_reference( self, @@ -130,7 +130,7 @@ def add_comment_reference( resolved: Optional[bool] = False, ) -> CT_CommentExtended: """Add a reply to the comment identified by `parent_comment_id`.""" - comment_ext = self.add_comments_extended_sequence() + comment_ext = self.add_comments_extended_element() comment_ext.para_id = comment.para_id if parent is not None: comment_ext.parent_para_id = parent.para_id From 2b6e011216d2d2f5bf54508c78387e4db231452f Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Fri, 31 Jan 2025 09:08:13 +0000 Subject: [PATCH 12/14] comments: update `CT_CommentExtended` xml attrib type --- src/docx/oxml/comments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index 4233de86b..8d48b17fc 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Callable, List, Optional, cast from docx.oxml.parser import OxmlElement -from docx.oxml.simpletypes import ST_DecimalNumber, ST_String, XsdBoolean +from docx.oxml.simpletypes import ST_DecimalNumber, ST_OnOff, ST_String from docx.oxml.xmlchemy import ( BaseOxmlElement, OneOrMore, @@ -113,7 +113,7 @@ class CT_CommentExtended(BaseOxmlElement): """```` element, the root element of the commentsExtended part.""" para_id = RequiredAttribute("w15:paraId", ST_String) - resolved = RequiredAttribute("w15:done", XsdBoolean) + resolved = RequiredAttribute("w15:done", ST_OnOff) parent_para_id = OptionalAttribute("w15:paraIdParent", ST_String) From 8df991bf915503396538d6c2645576eb4f979c1f Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Wed, 5 Feb 2025 14:31:59 +0000 Subject: [PATCH 13/14] comments: update logic to support comments across the multiple paragraphs. Add `mark_comment_start` and `mark_comment_end` methods to support comments spanning across multiple paragraphs --- src/docx/oxml/text/paragraph.py | 46 ++++++++++++++++++++++++++------- src/docx/text/paragraph.py | 25 ++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 7c817f7a9..57045c784 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, cast -from docx.opc.oxml import qn +from docx.oxml.ns import qn from docx.oxml.comments import ( CT_Comment, CT_CommentRangeEnd, @@ -117,7 +117,24 @@ def _insert_pPr(self, pPr: CT_PPr) -> CT_PPr: self.insert(0, pPr) return pPr - def add_comment( + def mark_comment_start( + self, + comments_part: "CommentsPart", + comments_extended_part: "CommentsExtendedPart", + text: str, + metadata: Dict[str, str | bool | "CT_Comment"], + ): + """Start a comment marker.""" + comment = self._create_comment(comments_part, comments_extended_part, text, metadata) + self.append(CT_CommentRangeStart.new(comment.id)) + return comment + + def mark_comment_end(self, id: str): + """End a comment marker.""" + self.append(CT_CommentRangeEnd.new(id)) + self.add_r().append(CT_CommentReference.new(id)) + + def _create_comment( self, comments_part: "CommentsPart", comments_extended_part: "CommentsExtendedPart", @@ -127,14 +144,29 @@ def add_comment( """ Add a comment to this paragraph. """ - comment_ele = cast(CT_Comments, comments_part.element) + comments_ele = cast(CT_Comments, comments_part.element) comments_extended_ele = cast(CT_CommentsExtended, comments_extended_part.element) - new_p = cast(CT_P, OxmlElement("w:p")) new_p.add_r().text = text - comment = comment_ele.add_comment( + comment = comments_ele.add_comment( new_p, metadata["author"], metadata["initials"], metadata["date"] ) + resolved = metadata.get("resolved", False) + parent = metadata.get("parent") + comments_extended_ele.add_comment_reference(comment, parent, resolved) + return comment + + def add_comment( + self, + comments_part: "CommentsPart", + comments_extended_part: "CommentsExtendedPart", + text: str, + metadata: Dict[str, str | bool | "CT_Comment"], + ) -> CT_Comment: + """ + Add a comment to this paragraph. + """ + comment = self._create_comment(comments_part, comments_extended_part, text, metadata) cmt_range_start = CT_CommentRangeStart.new(comment.id) if self.find(qn("w:commentRangeStart")) is not None: self.insert(0, cmt_range_start) @@ -143,8 +175,4 @@ def add_comment( self.append(CT_CommentRangeEnd.new(comment.id)) self.add_r().append(CT_CommentReference.new(comment.id)) - resolved = metadata.get("resolved", False) - parent = metadata.get("parent") - comments_extended_ele.add_comment_reference(comment, parent, resolved) - return comment diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 2df824825..8a5e43f41 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -173,6 +173,31 @@ def _insert_paragraph_before(self): p = self._p.add_p_before() return Paragraph(p, self._parent) + def mark_comment_start( + self, + text: str, + author: str, + initials: str, + date: str, + resolved: Optional[bool] = False, + parent: Optional["CT_Comment"] = None, + ) -> "CT_Comment": + """Start a comment marker.""" + comments_part = self.part._document_part.comments_part + comments_extended_part = self.part._document_part.comments_extended_part + metadata = { + "author": author, + "initials": initials, + "date": date, + "resolved": resolved, + "parent": parent, + } + return self._p.mark_comment_start(comments_part, comments_extended_part, text, metadata) + + def mark_comment_end(self, id: str): + """End a comment marker.""" + self._p.mark_comment_end(id) + def add_comment( self, text: str, From 316cfbab2f796d00d36b9975aeb0a85f6d45cbf6 Mon Sep 17 00:00:00 2001 From: bhavasagar-dv Date: Wed, 5 Feb 2025 14:57:33 +0000 Subject: [PATCH 14/14] comments: update doc string and vaildations for `mark_comment_end` --- src/docx/text/paragraph.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 8a5e43f41..32703bedb 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -182,7 +182,9 @@ def mark_comment_start( resolved: Optional[bool] = False, parent: Optional["CT_Comment"] = None, ) -> "CT_Comment": - """Start a comment marker.""" + """ + Adds a `commentRangeStart` to this paragraph. + """ comments_part = self.part._document_part.comments_part comments_extended_part = self.part._document_part.comments_extended_part metadata = { @@ -195,7 +197,16 @@ def mark_comment_start( return self._p.mark_comment_start(comments_part, comments_extended_part, text, metadata) def mark_comment_end(self, id: str): - """End a comment marker.""" + """ + Adds a `commentRangeEnd` and `commentReference` to this paragraph. + + Raises |ValueError| if the `commentRangeStart` for this `id` is not found or + if `commentRangeEnd` was already added. + """ + if len(self._parent._element.xpath(f"//w:commentRangeStart[@w:id='{id}']")) == 0: + raise ValueError("Comment start marker not found") + if len(self._parent._element.xpath(f"//w:commentRangeEnd[@w:id='{id}']")) > 0: + raise ValueError("Comment end marker was already added") self._p.mark_comment_end(id) def add_comment(