Skip to content

docx_plus.comments.anchor

Anchor a comment to a run, paragraph, or run range — and undo the same. Writes the three body-side OOXML elements python-docx skips (w:commentRangeStart, w:commentRangeEnd, the CommentReference marker run) plus the comment body in comments.xml (created on first use). delete_comment is the inverse and idempotent.

Architecture walkthrough: ARCHITECTURE.md §7.6.

docx_plus.comments.anchor

Anchor and remove comments — the body-side OOXML python-docx skips.

python-docx 1.x writes <w:comment> into comments.xml but omits the three body-side elements that anchor a comment to a text range — w:commentRangeStart, w:commentRangeEnd, and the CommentReference marker run. As a result, comments added via python-docx show up in the review pane but have nothing in the document text to point at. This module fills that gap.

:func:add_comment wraps a run, paragraph, or run-range with the three body markers and appends a matching w:comment body to comments.xml (the comments part is created on first use).

:func:delete_comment removes everything :func:add_comment wrote.

This module imports only from docx_plus.core and the sibling docx_plus.comments.registry (SPEC §9.1).

CommentTarget module-attribute

CommentTarget = Run | Paragraph | tuple[Run, Run]

CommentRef dataclass

CommentRef(comment_id: int, body_element: _Element)

Handle for an inserted comment.

Attributes:

Name Type Description
comment_id int

The w:id value assigned to the comment.

body_element _Element

The <w:comment> lxml element appended to comments.xml. Mutate it directly for advanced edits (extra paragraphs, formatted runs) the v0.2 surface does not yet expose.

CommentNotFoundError

Bases: DocxPlusError, KeyError

Raised when no <w:comment> with the requested id exists.

Subclasses :class:KeyError so existing except KeyError: clauses still catch it; also :class:DocxPlusError per SPEC §9.7.

add_comment

add_comment(
    target: CommentTarget,
    text: str,
    *,
    author: str = "",
    initials: str | None = None,
    id_registry: CommentIdRegistry | None = None,
) -> CommentRef

Anchor a comment to a run, paragraph, or run range.

Writes the three body-side OOXML elements python-docx skips (w:commentRangeStart, w:commentRangeEnd, the CommentReference marker run) plus the comment body entry in comments.xml. The comments part is created on first use.

Parameters:

Name Type Description Default
target CommentTarget

Where the comment anchors.

  • A python-docx :class:~docx.text.run.Run wraps that one run.
  • A :class:~docx.text.paragraph.Paragraph wraps every run in the paragraph; the paragraph must contain at least one run.
  • A (start_run, end_run) tuple spans from the start run's leading edge to the end run's trailing edge. Both runs must already be parented and live in the main document body. The caller is responsible for ordering: start_run must appear before end_run in document order. A reversed pair is accepted without error but produces a backwards range that Word renders as empty.
required
text str

Comment body text. Whitespace is preserved (xml:space="preserve").

required
author str

Author shown in the review pane. The empty string is legal and is what python-docx's own add_comment uses.

''
initials str | None

Author initials shown alongside the author. None defaults to the first character of author; pass an empty string to suppress the attribute entirely.

None
id_registry CommentIdRegistry | None

Pre-existing registry to share across an editing session (useful when inserting many comments). A fresh :class:CommentIdRegistry is constructed from the target's document if not supplied.

None

Returns:

Name Type Description
A CommentRef

class:CommentRef with the assigned comment id and a handle

CommentRef

to the comment body element in comments.xml.

Raises:

Type Description
ValueError

If target is a paragraph with no runs.

TypeError

If target is not a Run, Paragraph, or (Run, Run) tuple.

Example

from docx import Document from docx_plus.comments import add_comment doc = Document() p = doc.add_paragraph("Hello world") ref = add_comment(p, "Greeting", author="Reviewer")

Source code in docx_plus/comments/anchor.py
def add_comment(
    target: CommentTarget,
    text: str,
    *,
    author: str = "",
    initials: str | None = None,
    id_registry: CommentIdRegistry | None = None,
) -> CommentRef:
    """Anchor a comment to a run, paragraph, or run range.

    Writes the three body-side OOXML elements python-docx skips
    (``w:commentRangeStart``, ``w:commentRangeEnd``, the
    ``CommentReference`` marker run) plus the comment body entry in
    ``comments.xml``. The comments part is created on first use.

    Args:
        target: Where the comment anchors.

            - A python-docx :class:`~docx.text.run.Run` wraps that one run.
            - A :class:`~docx.text.paragraph.Paragraph` wraps every run
              in the paragraph; the paragraph must contain at least one
              run.
            - A ``(start_run, end_run)`` tuple spans from the start run's
              leading edge to the end run's trailing edge. Both runs
              must already be parented and live in the main document
              body. The caller is responsible for ordering: ``start_run``
              must appear before ``end_run`` in document order. A reversed
              pair is accepted without error but produces a backwards
              range that Word renders as empty.
        text: Comment body text. Whitespace is preserved
            (``xml:space="preserve"``).
        author: Author shown in the review pane. The empty string is
            legal and is what python-docx's own ``add_comment`` uses.
        initials: Author initials shown alongside the author. ``None``
            defaults to the first character of ``author``; pass an
            empty string to suppress the attribute entirely.
        id_registry: Pre-existing registry to share across an editing
            session (useful when inserting many comments). A fresh
            :class:`CommentIdRegistry` is constructed from the target's
            document if not supplied.

    Returns:
        A :class:`CommentRef` with the assigned comment id and a handle
        to the comment body element in ``comments.xml``.

    Raises:
        ValueError: If ``target`` is a paragraph with no runs.
        TypeError: If ``target`` is not a Run, Paragraph, or
            ``(Run, Run)`` tuple.

    Example:
        >>> from docx import Document
        >>> from docx_plus.comments import add_comment
        >>> doc = Document()
        >>> p = doc.add_paragraph("Hello world")
        >>> ref = add_comment(p, "Greeting", author="Reviewer")
    """
    start_anchor, end_anchor, doc = _normalize_target(target)

    if id_registry is None:
        id_registry = CommentIdRegistry(doc)
    comment_id = id_registry.next()
    cid = str(comment_id)

    range_start = el("w:commentRangeStart", **{"w:id": cid})
    range_end = el("w:commentRangeEnd", **{"w:id": cid})
    ref_run = _build_reference_run(comment_id)

    start_anchor.addprevious(range_start)
    end_anchor.addnext(range_end)
    range_end.addnext(ref_run)

    _, comments_root = get_or_create_part(doc, COMMENTS_SPEC)
    body = _build_comment_body(comment_id, text, author, initials)
    comments_root.append(body)

    return CommentRef(comment_id=comment_id, body_element=body)

edit_comment

edit_comment(doc: Document, comment_id: int, text: str) -> None

Replace the body text of an existing comment in place.

Removes all child block-level content of the matching <w:comment> element and appends a fresh paragraph with text as its run. The <w:comment> element's attributes (w:author, w:date, w:initials) are preserved — only the body content changes. The body-side range markers and reference run are also untouched, so the comment stays anchored to the same text range.

Parameters:

Name Type Description Default
doc Document

The python-docx :class:~docx.document.Document to mutate.

required
comment_id int

The w:id of the comment to edit.

required
text str

New comment body text. Whitespace is preserved (xml:space="preserve").

required

Raises:

Type Description
CommentNotFoundError

If no comment with comment_id exists, including the case where the comments part itself is absent. Subclasses :class:KeyError, so except KeyError also catches it (SPEC §16).

Source code in docx_plus/comments/anchor.py
def edit_comment(doc: Document, comment_id: int, text: str) -> None:
    """Replace the body text of an existing comment in place.

    Removes all child block-level content of the matching ``<w:comment>``
    element and appends a fresh paragraph with ``text`` as its run. The
    ``<w:comment>`` element's attributes (``w:author``, ``w:date``,
    ``w:initials``) are preserved — only the body content changes. The
    body-side range markers and reference run are also untouched, so the
    comment stays anchored to the same text range.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to mutate.
        comment_id: The ``w:id`` of the comment to edit.
        text: New comment body text. Whitespace is preserved
            (``xml:space="preserve"``).

    Raises:
        CommentNotFoundError: If no comment with ``comment_id`` exists,
            including the case where the comments part itself is absent.
            Subclasses :class:`KeyError`, so ``except KeyError`` also
            catches it (SPEC §16).
    """
    cid = str(comment_id)
    try:
        comments_part = cast("XmlPart", doc.part.part_related_by(RT.COMMENTS))
    except KeyError as exc:
        raise CommentNotFoundError(comment_id) from exc

    matches = xpath(comments_part.element, "./w:comment[@w:id=$cid]", cid=cid)
    if not matches:
        raise CommentNotFoundError(comment_id)

    comment_el = matches[0]
    # Strip ALL block-level children — ECMA-376 17.13.4.2 (`CT_Comment`)
    # extends `EG_BlockLevelElts`, so a comment authored in Word may
    # legally contain `<w:tbl>`, `<w:sdt>`, `<w:customXml>`, etc. in
    # addition to paragraphs. Filtering to `<w:p>` only would leave
    # those siblings next to the freshly built paragraph. The comment
    # element's own attributes (author / date / initials) live on the
    # element itself, not on its children, so removal is safe.
    for child in list(comment_el):
        remove(child)
    comment_el.append(_build_comment_paragraph(text))

delete_comment

delete_comment(doc: Document, comment_id: int) -> None

Remove all traces of a comment from the document.

Removes:

  • The <w:comment> body in comments.xml
  • Every <w:commentRangeStart> and <w:commentRangeEnd> marker in the document body
  • The reference run hosting <w:commentReference>

Idempotent: deleting a comment id that doesn't exist is a no-op.

Parameters:

Name Type Description Default
doc Document

The python-docx :class:~docx.document.Document to mutate.

required
comment_id int

The w:id value of the comment to remove.

required
Source code in docx_plus/comments/anchor.py
def delete_comment(doc: Document, comment_id: int) -> None:
    """Remove all traces of a comment from the document.

    Removes:

    - The ``<w:comment>`` body in ``comments.xml``
    - Every ``<w:commentRangeStart>`` and ``<w:commentRangeEnd>`` marker
      in the document body
    - The reference run hosting ``<w:commentReference>``

    Idempotent: deleting a comment id that doesn't exist is a no-op.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to mutate.
        comment_id: The ``w:id`` value of the comment to remove.
    """
    cid = str(comment_id)
    body = doc.element.body

    for tag_expr in (
        ".//w:commentRangeStart[@w:id=$cid]",
        ".//w:commentRangeEnd[@w:id=$cid]",
    ):
        for elem in xpath(body, tag_expr, cid=cid):
            remove(elem)

    for ref in xpath(body, ".//w:commentReference[@w:id=$cid]", cid=cid):
        _remove_reference_marker(ref)

    try:
        comments_part = cast("XmlPart", doc.part.part_related_by(RT.COMMENTS))
    except KeyError:
        return
    comments_root = comments_part.element
    for comment_el in xpath(comments_root, "./w:comment[@w:id=$cid]", cid=cid):
        remove(comment_el)

clear_all_comments

clear_all_comments(doc: Document, *, remove_part: bool = False) -> None

Remove every comment in the document.

Single-pass: walks the document body once removing every <w:commentRangeStart>, <w:commentRangeEnd>, and <w:commentReference> marker regardless of id, then walks comments.xml once removing every <w:comment> entry. Idempotent: a document with no comments is a no-op.

Parameters:

Name Type Description Default
doc Document

The python-docx :class:~docx.document.Document to scrub.

required
remove_part bool

When False (default) the now-empty comments part is left in place so a subsequent :func:add_comment reuses it without re-creating the relationship. When True the part and its relationship are torn down entirely, so the saved document carries no comments part at all — useful when a consumer dislikes an empty-but-related comments part, and the cleaner state for a document that is done with comments.

False
Source code in docx_plus/comments/anchor.py
def clear_all_comments(doc: Document, *, remove_part: bool = False) -> None:
    """Remove every comment in the document.

    Single-pass: walks the document body once removing every
    ``<w:commentRangeStart>``, ``<w:commentRangeEnd>``, and
    ``<w:commentReference>`` marker regardless of id, then walks
    ``comments.xml`` once removing every ``<w:comment>`` entry.
    Idempotent: a document with no comments is a no-op.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to scrub.
        remove_part: When ``False`` (default) the now-empty comments part
            is left in place so a subsequent :func:`add_comment` reuses it
            without re-creating the relationship. When ``True`` the part
            and its relationship are torn down entirely, so the saved
            document carries no comments part at all — useful when a
            consumer dislikes an empty-but-related comments part, and the
            cleaner state for a document that is done with comments.
    """
    body = doc.element.body

    for tag_expr in (
        ".//w:commentRangeStart",
        ".//w:commentRangeEnd",
    ):
        for elem in xpath(body, tag_expr):
            remove(elem)

    for ref in xpath(body, ".//w:commentReference"):
        _remove_reference_marker(ref)

    try:
        comments_part = cast("XmlPart", doc.part.part_related_by(RT.COMMENTS))
    except KeyError:
        return

    if remove_part:
        _drop_comments_part(doc)
        return

    comments_root = comments_part.element
    for comment_el in list(comments_root.findall(qn("w:comment"))):
        remove(comment_el)