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, ) 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/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/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..8d48b17fc --- /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_OnOff, ST_String +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: ST_DecimalNumber) -> "CT_CommentRangeStart": + """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: ST_DecimalNumber) -> "CT_CommentRangeEnd": + """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: ST_DecimalNumber) -> "CT_CommentReference": + """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", ST_OnOff) + parent_para_id = OptionalAttribute("w15:paraIdParent", ST_String) + + +class CT_CommentsExtended(BaseOxmlElement): + """```` element, the root element of the commentsExtended part.""" + + add_comments_extended_element: Callable[[], CT_CommentExtended] + comments_extended_element = 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_element() + 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/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", diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..57045c784 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -4,10 +4,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, List, cast - +from typing import TYPE_CHECKING, Callable, Dict, List, cast + +from docx.oxml.ns import qn +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.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 @@ -16,6 +26,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): @@ -26,6 +37,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") @@ -104,3 +116,63 @@ def text(self): # pyright: ignore[reportIncompatibleMethodOverride] def _insert_pPr(self, pPr: CT_PPr) -> CT_PPr: self.insert(0, pPr) return pPr + + 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", + text: str, + metadata: Dict[str, str | bool | "CT_Comment"], + ) -> CT_Comment: + """ + Add a comment to this paragraph. + """ + 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 = 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) + 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)) + + return comment 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) 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.""" diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..32703bedb 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,65 @@ 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 mark_comment_start( + self, + text: str, + author: str, + initials: str, + date: str, + resolved: Optional[bool] = False, + parent: Optional["CT_Comment"] = None, + ) -> "CT_Comment": + """ + 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 = { + "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): + """ + 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( + self, + text: str, + author: str, + initials: str, + date: str, + resolved: Optional[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, + "parent": parent, + } + return self._p.add_comment(comments_part, comments_extended_part, text, metadata)