docx/comment-thread

A parent comment authored by 'Reviewer A' is attached to a run in the main story and receives one reply from 'Reviewer B'; the reply is linked to its parent via the w16cid:paraIdParent attribute on the second <w:comment> in word/comments.xml.

Library verdicts: python-docx: — docxjs: —

Metadata

Feature iddocx/comment-thread
Formatdocx
Categoryreview
Spececma-376-5-part-1 § 17.13.4 w:comment

XPath assertions

ID / partPredicateXPathpython-docxdocxjs
two-comments-in-comments-part
word/comments.xml
exist
word/comments.xml must carry at least two <w:comment> elements — the parent and one reply.
//w:comment[2]
parent-comment-by-author-a
word/comments.xml
exist
The parent comment (author='Reviewer A') must carry a stable w16cid:paraId.
//w:comment[@w:author='Reviewer A' and @w16cid:paraId]
reply-comment-links-to-parent
word/comments.xml
exist
The reply comment (author='Reviewer B') must carry w16cid:paraIdParent pointing at the parent's paraId ('00000001' in this fixture).
//w:comment[@w:author='Reviewer B' and @w16cid:paraIdParent='00000001']
reply-body-text
word/comments.xml
exist
The reply's body text must be the fixture literal 'Thanks, I agree.'
//w:comment[@w:author='Reviewer B']/w:p/w:r/w:t[normalize-space()='Thanks, I agree.']

Render assertions

IDPredicateSelectorpython-docxdocxjs
comment-anchor-renderedcss_selector/ exist
Some marker for the commented range should exist in the rendered DOM. Threaded-reply rendering is an even deeper gap than single-comment rendering — most renderers today emit nothing at all for review comments; this assertion is expected to fail on present-day renderers. Tracked as a known gap.
.docx-wrapper .comment, .docx-wrapper [data-comment], .docx-wrapper .comment-reference

Generator source

scripts/gen_comment_thread.py

#!/usr/bin/env python3
"""Generate ``fixtures/docx/comment-thread.docx``.

A parent review comment with one reply. Exercises the Word 2016+
threaded-comments mechanism where the reply is linked to its parent
via ``w16cid:paraIdParent`` on the reply's ``<w:comment>`` element.

Uses the fork's ``Document.add_comment(...)`` + ``Comment.add_reply(...)``
APIs (python-docx >= 2026.05.0). Both comments' ``w16cid:paraId``
values are pinned manually so the fixture is byte-reproducible; by
default the fork mints random 8-char hex paraIds, which would churn
every re-save.
"""

from __future__ import annotations

import datetime as dt
from pathlib import Path

from docx import Document

_REPO_ROOT = Path(__file__).resolve().parent.parent
_OUT = _REPO_ROOT / "fixtures" / "docx" / "comment-thread.docx"

# Fixed date so the w:date attribute doesn't churn on re-save.
_FIXED_DATE = dt.datetime(2026, 5, 4, 0, 0, 0, tzinfo=dt.timezone.utc)

# Stable paraId values — the fork mints random 8-char hex otherwise.
# Parent must be pinned *before* add_reply so the reply's
# paraIdParent is copied from the pinned value.
_PARENT_PARA_ID = "00000001"
_REPLY_PARA_ID = "00000002"


def main() -> int:
    doc = Document()
    paragraph = doc.add_paragraph("Commented paragraph")

    parent = doc.add_comment(
        runs=paragraph.runs,
        text="Please review this sentence.",
        author="Reviewer A",
        initials="A",
        date=_FIXED_DATE,
    )
    parent._element.paraId = _PARENT_PARA_ID  # pin for reproducibility

    reply = parent.add_reply(
        text="Thanks, I agree.",
        author="Reviewer B",
        initials="B",
        date=_FIXED_DATE,
    )
    reply._element.paraId = _REPLY_PARA_ID  # pin for reproducibility

    _OUT.parent.mkdir(parents=True, exist_ok=True)
    doc.save(_OUT, reproducible=True)
    print(_OUT)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Manifest (expanded)

{
  "$schema": "../manifest.schema.json",
  "id": "docx/comment-thread",
  "title": "Threaded review comment (reply)",
  "format": "docx",
  "category": "review",
  "summary": "A parent comment authored by 'Reviewer A' is attached to a run in the main story and receives one reply from 'Reviewer B'; the reply is linked to its parent via the w16cid:paraIdParent attribute on the second <w:comment> in word/comments.xml.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "17.13.4",
    "element": "w:comment",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/WordprocessingML.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/wml.xsd",
    "notes": "Threaded comments are a Word 2016+ Microsoft extension (namespace http://schemas.microsoft.com/office/word/2016/wordml/cid, prefix w16cid) — NOT in ECMA-376. Each <w:comment> carries a unique w16cid:paraId; a reply sets w16cid:paraIdParent to the parent's paraId. The Microsoft-sanctioned way to expose the thread is a separate commentsExtended.xml part with <w15:commentEx> records, but in practice Word also reads the w16cid:paraIdParent form emitted by python-docx 2026.05.0+ and reconstructs the thread from comments.xml alone."
  },
  "fixtures": {
    "machine": "docx/comment-thread"
  },
  "generator": {
    "python": "scripts/gen_comment_thread.py"
  },
  "assertions": [
    {
      "id": "two-comments-in-comments-part",
      "part": "word/comments.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "//w:comment[2]",
      "must": "exist",
      "description": "word/comments.xml must carry at least two <w:comment> elements — the parent and one reply."
    },
    {
      "id": "parent-comment-by-author-a",
      "part": "word/comments.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
        "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid"
      },
      "xpath": "//w:comment[@w:author='Reviewer A' and @w16cid:paraId]",
      "must": "exist",
      "description": "The parent comment (author='Reviewer A') must carry a stable w16cid:paraId."
    },
    {
      "id": "reply-comment-links-to-parent",
      "part": "word/comments.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
        "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid"
      },
      "xpath": "//w:comment[@w:author='Reviewer B' and @w16cid:paraIdParent='00000001']",
      "must": "exist",
      "description": "The reply comment (author='Reviewer B') must carry w16cid:paraIdParent pointing at the parent's paraId ('00000001' in this fixture)."
    },
    {
      "id": "reply-body-text",
      "part": "word/comments.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "//w:comment[@w:author='Reviewer B']/w:p/w:r/w:t[normalize-space()='Thanks, I agree.']",
      "must": "exist",
      "description": "The reply's body text must be the fixture literal 'Thanks, I agree.'"
    }
  ],
  "render_assertions": [
    {
      "id": "comment-anchor-rendered",
      "kind": "css_selector",
      "selector": ".docx-wrapper .comment, .docx-wrapper [data-comment], .docx-wrapper .comment-reference",
      "must": "exist",
      "description": "Some marker for the commented range should exist in the rendered DOM. Threaded-reply rendering is an even deeper gap than single-comment rendering — most renderers today emit nothing at all for review comments; this assertion is expected to fail on present-day renderers. Tracked as a known gap."
    }
  ]
}

Fixture

Download comment-thread.docx (19.2 KB)

Reference preview

Reference (machine, page 1 PNG)

docx/comment-thread page 1 reference

Reference PDF


Download PDF Open in PDF.js

Spec notes

Threaded comments are a Word 2016+ Microsoft extension (namespace http://schemas.microsoft.com/office/word/2016/wordml/cid, prefix w16cid) — NOT in ECMA-376. Each <w:comment> carries a unique w16cid:paraId; a reply sets w16cid:paraIdParent to the parent's paraId. The Microsoft-sanctioned way to expose the thread is a separate commentsExtended.xml part with <w15:commentEx> records, but in practice Word also reads the w16cid:paraIdParent form emitted by python-docx 2026.05.0+ and reconstructs the thread from comments.xml alone.