Extract Comments and Tracked Changes from DOCX
Legal returns a contract with thirty comments and a hundred tracked edits, and the request is a spreadsheet: every comment with its author, date and the sentence it is attached to, plus every change showing what was inserted or deleted and by whom. python-docx opens the file without complaint, but paragraph.text shows a strange mixture — inserted wording present, deleted wording absent — and there is no obvious way to reach the comments at all. Copying the review pane by hand takes an afternoon and misses the replies.
Root Cause
Review data is split across the package. Comment text lives in a separate part, word/comments.xml, with one w:comment element per comment. The location of each comment lives in the main document as a pair of markers, w:commentRangeStart and w:commentRangeEnd, sharing the comment's id, with the commented text between them — possibly spanning several runs or paragraphs. Replies and resolved status are stored in yet another part, word/commentsExtended.xml, linked by paragraph ids. Tracked changes are inline: inserted runs are wrapped in w:ins, and deleted runs are wrapped in w:del with their text in w:delText instead of w:t. python-docx's text property reads w:t elements, so it includes inserted text and silently omits deleted text — which is neither the original nor a clearly defined "accepted" view once formatting changes and moved text are involved. Extracting review data means reading these structures directly.
Minimal Diagnostic
Count the review structures so you know what the document actually contains before writing the export.
# pip install lxml
import zipfile
from pathlib import Path
from lxml import etree
SOURCE = Path("in/contract-review-v3.docx")
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
NS = {"w": W}
def review_inventory(path: Path) -> None:
try:
zf = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as exc:
raise SystemExit(f"not a readable .docx: {exc}")
with zf:
names = set(zf.namelist())
body = etree.fromstring(zf.read("word/document.xml"))
print("parts:", sorted(n for n in names if "comment" in n.lower()))
if "word/comments.xml" in names:
comments = etree.fromstring(zf.read("word/comments.xml"))
print("comments:", len(comments.findall("w:comment", NS)))
print("comment ranges in body:", len(body.findall(".//w:commentRangeStart", NS)))
print("insertions:", len(body.findall(".//w:ins", NS)),
"deletions:", len(body.findall(".//w:del", NS)),
"moves:", len(body.findall(".//w:moveFrom", NS)) + len(body.findall(".//w:moveTo", NS)),
"format changes:", len(body.findall(".//w:rPrChange", NS)))
authors = {el.get(f"{{{W}}}author") for el in body.iter()
if el.get(f"{{{W}}}author")}
print("authors:", sorted(authors))
if __name__ == "__main__":
review_inventory(SOURCE)
parts: ['word/comments.xml', 'word/commentsExtended.xml', 'word/commentsIds.xml']
comments: 34
comment ranges in body: 31
insertions: 58 deletions: 47 moves: 4 format changes: 12
authors: ['A. Patel', 'Legal Review', 'M. Chen']
Thirty-four comments but thirty-one ranges: three are replies, which have no range of their own. Moves and formatting changes will need a decision too.
Fix: Join Comment Text to Its Anchored Range
Read comment metadata from comments.xml, then walk the body in document order collecting text between each range start and end. Changed lines carry comments.
# pip install lxml "pandas>=2.2"
import zipfile
from pathlib import Path
import pandas as pd
from lxml import etree
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"
NS = {"w": W, "w15": W15}
q = lambda name: f"{{{W}}}{name}"
def comment_table(path: Path) -> pd.DataFrame:
with zipfile.ZipFile(path) as zf:
names = set(zf.namelist())
body = etree.fromstring(zf.read("word/document.xml"))
comments = etree.fromstring(zf.read("word/comments.xml")) if "word/comments.xml" in names else None
extended = etree.fromstring(zf.read("word/commentsExtended.xml")) \
if "word/commentsExtended.xml" in names else None
if comments is None:
return pd.DataFrame(columns=["id", "author", "date", "comment", "anchored_text", "parent_id", "resolved"])
meta = {}
for c in comments.findall("w:comment", NS):
paras = c.findall("w:p", NS)
meta[c.get(q("id"))] = {
"author": c.get(q("author")), "date": c.get(q("date")),
"comment": "\n".join("".join(p.itertext()) for p in paras).strip(),
"para_id": paras[-1].get(f"{{{W14}}}paraId") if paras else None, # changed: link to replies
}
anchored, open_ids = {}, set()
for el in body.iter(): # changed: document order
if el.tag == q("commentRangeStart"):
open_ids.add(el.get(q("id")))
anchored.setdefault(el.get(q("id")), [])
elif el.tag == q("commentRangeEnd"):
open_ids.discard(el.get(q("id")))
elif el.tag in (q("t"), q("delText")) and open_ids: # changed: include deleted text
for cid in open_ids:
anchored[cid].append(el.text or "")
parent_of, resolved = {}, {}
if extended is not None:
by_para = {m["para_id"]: cid for cid, m in meta.items() if m["para_id"]}
for ex in extended.findall("w15:commentEx", NS):
cid = by_para.get(ex.get(f"{{{W15}}}paraId"))
if cid is None:
continue
resolved[cid] = ex.get(f"{{{W15}}}done") == "1"
parent = ex.get(f"{{{W15}}}paraIdParent")
if parent:
parent_of[cid] = by_para.get(parent)
rows = [{"id": cid, **{k: v for k, v in m.items() if k != "para_id"},
"anchored_text": "".join(anchored.get(cid, [])).strip(),
"parent_id": parent_of.get(cid), "resolved": resolved.get(cid, False)}
for cid, m in meta.items()]
return pd.DataFrame(rows)
if __name__ == "__main__":
try:
table = comment_table(Path("in/contract-review-v3.docx"))
except (OSError, KeyError, etree.XMLSyntaxError) as exc:
raise SystemExit(f"cannot read review data: {exc}")
print(table[["author", "comment", "anchored_text", "parent_id"]].head().to_string(index=False))
Walking every element with body.iter() preserves document order, so a comment range that starts in one paragraph and ends in the next collects both parts. Replies have no range, so their anchored_text is empty and parent_id points to the comment they answer — fill it from the parent when exporting, so each row in the spreadsheet is self-explanatory.
Variant Fix 1: List Tracked Changes with Context
Export each insertion and deletion with author, date and the surrounding sentence, so reviewers can read changes without opening Word:
# pip install lxml "pandas>=2.2"
import zipfile
from pathlib import Path
import pandas as pd
from lxml import etree
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
q = lambda name: f"{{{W}}}{name}"
def tracked_changes(path: Path, context: int = 60) -> pd.DataFrame:
with zipfile.ZipFile(path) as zf:
body = etree.fromstring(zf.read("word/document.xml"))
rows = []
for p_index, para in enumerate(body.iter(q("p"))):
accepted = "".join(t.text or "" for t in para.iter(q("t")))
for change in para:
if change.tag not in (q("ins"), q("del")):
continue
kind = "insert" if change.tag == q("ins") else "delete"
text_tag = q("t") if kind == "insert" else q("delText")
text = "".join(t.text or "" for t in change.iter(text_tag))
rows.append({
"paragraph": p_index, "kind": kind, "text": text,
"author": change.get(q("author")), "date": change.get(q("date")),
"context": accepted[:context] + ("…" if len(accepted) > context else ""),
})
return pd.DataFrame(rows)
The loop inspects only direct children of each paragraph, which is where run-level changes live. Changes inside tables are found because table cells contain paragraphs visited by body.iter. Paragraph-mark changes (a whole paragraph inserted) are recorded in w:pPr/w:rPr/w:ins and need a separate check if they matter.
Variant Fix 2: Produce Original or Accepted Text
For comparisons and text mining you often need a clean version of the document with all changes accepted, or the original before review. Build either view by choosing which wrappers to keep:
# pip install lxml
import zipfile
from pathlib import Path
from lxml import etree
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
q = lambda name: f"{{{W}}}{name}"
def document_text(path: Path, view: str = "accepted") -> str:
if view not in ("accepted", "original"):
raise ValueError("view must be 'accepted' or 'original'")
with zipfile.ZipFile(path) as zf:
body = etree.fromstring(zf.read("word/document.xml"))
skip = q("del") if view == "accepted" else q("ins")
skip_move = q("moveFrom") if view == "accepted" else q("moveTo")
paragraphs = []
for para in body.iter(q("p")):
parts = []
for el in para.iter(q("t"), q("delText")):
ancestors = {a.tag for a in el.iterancestors()}
if skip in ancestors or skip_move in ancestors:
continue
if el.tag == q("delText") and view == "accepted":
continue
parts.append(el.text or "")
paragraphs.append("".join(parts))
return "\n".join(p for p in paragraphs if p.strip())
Moves count as a deletion at the old position and an insertion at the new one, so they are handled by the same choice. For a full semantic diff between versions of the text, feed both views into difflib or into the comparison approach from Comparing and Reconciling Spreadsheets after splitting into sentences.
Writing the Review Workbook
Combine both tables into one workbook reviewers can filter by author and status. Fill replies' anchored text from their parent comment so each row stands alone:
# pip install "pandas>=2.2" xlsxwriter
from pathlib import Path
import pandas as pd
def write_review(path: Path, dest: Path) -> None:
comments = comment_table(path)
parents = comments.set_index("id")["anchored_text"]
comments["anchored_text"] = comments.apply(
lambda r: r["anchored_text"] or parents.get(r["parent_id"], ""), axis=1)
comments["date"] = pd.to_datetime(comments["date"], errors="coerce").dt.tz_localize(None)
changes = tracked_changes(path)
changes["date"] = pd.to_datetime(changes["date"], errors="coerce").dt.tz_localize(None)
dest.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(dest, engine="xlsxwriter", datetime_format="yyyy-mm-dd hh:mm") as writer:
comments.to_excel(writer, sheet_name="Comments", index=False)
changes.to_excel(writer, sheet_name="Changes", index=False)
for ws in writer.sheets.values():
ws.freeze_panes(1, 0)
ws.set_column(0, 8, 22)
Comment dates are ISO-8601 strings with a Z suffix; converting them to naive datetimes before writing avoids the Excel timezone error covered in fix Excel does not support timezones error.
Verification
Cross-check counts against the inventory, confirm every non-reply comment has anchored text, and confirm the accepted and original views differ exactly where changes exist:
# pip install lxml "pandas>=2.2"
from pathlib import Path
def verify_review(path: Path) -> None:
comments = comment_table(path)
changes = tracked_changes(path)
top_level = comments[comments["parent_id"].isna()]
empty = top_level[top_level["anchored_text"] == ""]
assert empty.empty, f"{len(empty)} top-level comment(s) have no anchored text: ids {list(empty['id'])}"
accepted, original = document_text(path, "accepted"), document_text(path, "original")
if changes.empty:
assert accepted == original, "views differ although no tracked changes were found"
else:
assert accepted != original, "tracked changes found but views are identical"
print(f"{len(comments)} comments ({len(comments) - len(top_level)} replies), {len(changes)} changes verified")
A top-level comment with no anchored text usually means its range markers sit inside a text box or header, outside document.xml's body paragraphs; extend the walk to those parts when the check fires.
FAQ
Does python-docx support comments now? Recent python-docx releases have added comment APIs for creating and reading comments, but reply threads, resolved status and tracked changes still need the XML approach shown here.
Can I accept all tracked changes programmatically?
Yes: remove w:del and w:moveFrom elements, unwrap w:ins and w:moveTo (keeping their runs), and drop w:rPrChange/w:pPrChange. Save to a new file and compare views to confirm.
Why are some authors shown as "Author"? Word anonymises names when "Remove personal information from file properties on save" is enabled. The original names are not recoverable from the file.
Are comments in headers and footnotes included?
Their range markers live in those parts. Run the range walk over word/header*.xml and word/footnotes.xml as well if reviewers comment there.
Related
- Extracting Data from Word Documents with Python — package parts and content containers
- Fix python-docx Missing Text in Text Boxes — reaching content outside body paragraphs
- Batch Replace Placeholders in Many DOCX Files — applying agreed wording back into documents
- Find Differences Between Two Excel Files — a structured diff approach for tabular review data