Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Create, read, edit, and manipulate Word (.docx) documents with formatting, tables, and tracked changes
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/comment.py
1"""Add comments to DOCX documents.23Usage:4python comment.py unpacked/ 0 "Comment text"5python comment.py unpacked/ 1 "Reply text" --parent 067Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes).89After running, add markers to document.xml:10<w:commentRangeStart w:id="0"/>11... commented content ...12<w:commentRangeEnd w:id="0"/>13<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>14"""1516import argparse17import random18import shutil19import sys20from datetime import datetime, timezone21from pathlib import Path2223import defusedxml.minidom2425TEMPLATE_DIR = Path(__file__).parent / "templates"26NS = {27"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",28"w14": "http://schemas.microsoft.com/office/word/2010/wordml",29"w15": "http://schemas.microsoft.com/office/word/2012/wordml",30"w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",31"w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",32}3334COMMENT_XML = """\35<w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">36<w:p w14:paraId="{para_id}" w14:textId="77777777">37<w:r>38<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>39<w:annotationRef/>40</w:r>41<w:r>42<w:rPr>43<w:color w:val="000000"/>44<w:sz w:val="20"/>45<w:szCs w:val="20"/>46</w:rPr>47<w:t>{text}</w:t>48</w:r>49</w:p>50</w:comment>"""5152COMMENT_MARKER_TEMPLATE = """53Add to document.xml (markers must be direct children of w:p, never inside w:r):54<w:commentRangeStart w:id="{cid}"/>55<w:r>...</w:r>56<w:commentRangeEnd w:id="{cid}"/>57<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""5859REPLY_MARKER_TEMPLATE = """60Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):61<w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>62<w:r>...</w:r>63<w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>64<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>65<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""666768def _generate_hex_id() -> str:69return f"{random.randint(0, 0x7FFFFFFE):08X}"707172SMART_QUOTE_ENTITIES = {73"\u201c": "“",74"\u201d": "”",75"\u2018": "‘",76"\u2019": "’",77}787980def _encode_smart_quotes(text: str) -> str:81for char, entity in SMART_QUOTE_ENTITIES.items():82text = text.replace(char, entity)83return text848586def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:87dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))88root = dom.getElementsByTagName(root_tag)[0]89ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())90wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")91for child in wrapper_dom.documentElement.childNodes:92if child.nodeType == child.ELEMENT_NODE:93root.appendChild(dom.importNode(child, True))94output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))95xml_path.write_text(output, encoding="utf-8")969798def _find_para_id(comments_path: Path, comment_id: int) -> str | None:99dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))100for c in dom.getElementsByTagName("w:comment"):101if c.getAttribute("w:id") == str(comment_id):102for p in c.getElementsByTagName("w:p"):103if pid := p.getAttribute("w14:paraId"):104return pid105return None106107108def _get_next_rid(rels_path: Path) -> int:109dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))110max_rid = 0111for rel in dom.getElementsByTagName("Relationship"):112rid = rel.getAttribute("Id")113if rid and rid.startswith("rId"):114try:115max_rid = max(max_rid, int(rid[3:]))116except ValueError:117pass118return max_rid + 1119120121def _has_relationship(rels_path: Path, target: str) -> bool:122dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))123for rel in dom.getElementsByTagName("Relationship"):124if rel.getAttribute("Target") == target:125return True126return False127128129def _has_content_type(ct_path: Path, part_name: str) -> bool:130dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))131for override in dom.getElementsByTagName("Override"):132if override.getAttribute("PartName") == part_name:133return True134return False135136137def _ensure_comment_relationships(unpacked_dir: Path) -> None:138rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"139if not rels_path.exists():140return141142if _has_relationship(rels_path, "comments.xml"):143return144145dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))146root = dom.documentElement147next_rid = _get_next_rid(rels_path)148149rels = [150(151"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",152"comments.xml",153),154(155"http://schemas.microsoft.com/office/2011/relationships/commentsExtended",156"commentsExtended.xml",157),158(159"http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",160"commentsIds.xml",161),162(163"http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",164"commentsExtensible.xml",165),166]167168for rel_type, target in rels:169rel = dom.createElement("Relationship")170rel.setAttribute("Id", f"rId{next_rid}")171rel.setAttribute("Type", rel_type)172rel.setAttribute("Target", target)173root.appendChild(rel)174next_rid += 1175176rels_path.write_bytes(dom.toxml(encoding="UTF-8"))177178179def _ensure_comment_content_types(unpacked_dir: Path) -> None:180ct_path = unpacked_dir / "[Content_Types].xml"181if not ct_path.exists():182return183184if _has_content_type(ct_path, "/word/comments.xml"):185return186187dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))188root = dom.documentElement189190overrides = [191(192"/word/comments.xml",193"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",194),195(196"/word/commentsExtended.xml",197"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",198),199(200"/word/commentsIds.xml",201"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",202),203(204"/word/commentsExtensible.xml",205"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",206),207]208209for part_name, content_type in overrides:210override = dom.createElement("Override")211override.setAttribute("PartName", part_name)212override.setAttribute("ContentType", content_type)213root.appendChild(override)214215ct_path.write_bytes(dom.toxml(encoding="UTF-8"))216217218def add_comment(219unpacked_dir: str,220comment_id: int,221text: str,222author: str = "Claude",223initials: str = "C",224parent_id: int | None = None,225) -> tuple[str, str]:226word = Path(unpacked_dir) / "word"227if not word.exists():228return "", f"Error: {word} not found"229230para_id, durable_id = _generate_hex_id(), _generate_hex_id()231ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")232233comments = word / "comments.xml"234first_comment = not comments.exists()235if first_comment:236shutil.copy(TEMPLATE_DIR / "comments.xml", comments)237_ensure_comment_relationships(Path(unpacked_dir))238_ensure_comment_content_types(Path(unpacked_dir))239_append_xml(240comments,241"w:comments",242COMMENT_XML.format(243id=comment_id,244author=author,245date=ts,246initials=initials,247para_id=para_id,248text=text,249),250)251252ext = word / "commentsExtended.xml"253if not ext.exists():254shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)255if parent_id is not None:256parent_para = _find_para_id(comments, parent_id)257if not parent_para:258return "", f"Error: Parent comment {parent_id} not found"259_append_xml(260ext,261"w15:commentsEx",262f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',263)264else:265_append_xml(266ext,267"w15:commentsEx",268f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',269)270271ids = word / "commentsIds.xml"272if not ids.exists():273shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)274_append_xml(275ids,276"w16cid:commentsIds",277f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',278)279280extensible = word / "commentsExtensible.xml"281if not extensible.exists():282shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)283_append_xml(284extensible,285"w16cex:commentsExtensible",286f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',287)288289action = "reply" if parent_id is not None else "comment"290return para_id, f"Added {action} {comment_id} (para_id={para_id})"291292293if __name__ == "__main__":294p = argparse.ArgumentParser(description="Add comments to DOCX documents")295p.add_argument("unpacked_dir", help="Unpacked DOCX directory")296p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")297p.add_argument("text", help="Comment text")298p.add_argument("--author", default="Claude", help="Author name")299p.add_argument("--initials", default="C", help="Author initials")300p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")301args = p.parse_args()302303para_id, msg = add_comment(304args.unpacked_dir,305args.comment_id,306args.text,307args.author,308args.initials,309args.parent,310)311print(msg)312if "Error" in msg:313sys.exit(1)314cid = args.comment_id315if args.parent is not None:316print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))317else:318print(COMMENT_MARKER_TEMPLATE.format(cid=cid))319