Skip to content

Feat: Add low level comments and comment replies support #1467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/docx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -58,5 +61,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None:
PartFactory,
SettingsPart,
StylesPart,
CommentsPart,
CommentsExtendedPart,
part_class_selector,
)
6 changes: 6 additions & 0 deletions src/docx/opc/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
145 changes: 145 additions & 0 deletions src/docx/oxml/comments.py
Original file line number Diff line number Diff line change
@@ -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):
"""``<w:comment>`` 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):
"""``<w:comments>`` 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 ``<w:commentRangeStart>`` 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 ``<w:commentRangeEnd>`` 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 ``<w:commentReference>`` element having id `id`."""
comment_reference = OxmlElement("w:commentReference")
comment_reference._id = _id
return comment_reference


class CT_CommentExtended(BaseOxmlElement):
"""``<w15:commentEx>`` 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):
"""``<w15:commentsEx>`` 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 <w15:commentEx> element with paraId {para_id}")
1 change: 1 addition & 0 deletions src/docx/oxml/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 75 additions & 3 deletions src/docx/oxml/text/paragraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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")
Expand Down Expand Up @@ -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
44 changes: 44 additions & 0 deletions src/docx/parts/comments.py
Original file line number Diff line number Diff line change
@@ -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
``<w:comments>`` 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
``<w15:commentsEx>`` 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)
Loading