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: —
| Feature id | docx/comment-thread |
|---|---|
| Format | docx |
| Category | review |
| Spec | ecma-376-5-part-1 § 17.13.4 w:comment |
| ID / part | Predicate | XPath | python-docx | docxjs |
|---|---|---|---|---|
two-comments-in-comments-partword/comments.xml | existword/comments.xml must carry at least two <w:comment> elements — the parent and one reply. | //w:comment[2] | — | — |
parent-comment-by-author-aword/comments.xml | existThe parent comment (author='Reviewer A') must carry a stable w16cid:paraId. | //w:comment[@w:author='Reviewer A' and @w16cid:paraId] | — | — |
reply-comment-links-to-parentword/comments.xml | existThe 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-textword/comments.xml | existThe 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.'] | — | — |
| ID | Predicate | Selector | python-docx | docxjs |
|---|---|---|---|---|
comment-anchor-rendered | css_selector/ existSome 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 | — | — |
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())
{
"$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."
}
]
}
