| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- """Add comments to DOCX documents.
- Usage:
- python comment.py unpacked/ 0 "Comment text"
- python comment.py unpacked/ 1 "Reply text" --parent 0
- Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes).
- After running, add markers to document.xml:
- <w:commentRangeStart w:id="0"/>
- ... commented content ...
- <w:commentRangeEnd w:id="0"/>
- <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
- """
- import argparse
- import random
- import shutil
- import sys
- from datetime import datetime, timezone
- from pathlib import Path
- import defusedxml.minidom
- TEMPLATE_DIR = Path(__file__).parent / "templates"
- NS = {
- "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",
- "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
- "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
- }
- COMMENT_XML = """\
- <w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
- <w:p w14:paraId="{para_id}" w14:textId="77777777">
- <w:r>
- <w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
- <w:annotationRef/>
- </w:r>
- <w:r>
- <w:rPr>
- <w:color w:val="000000"/>
- <w:sz w:val="20"/>
- <w:szCs w:val="20"/>
- </w:rPr>
- <w:t>{text}</w:t>
- </w:r>
- </w:p>
- </w:comment>"""
- COMMENT_MARKER_TEMPLATE = """
- Add to document.xml (markers must be direct children of w:p, never inside w:r):
- <w:commentRangeStart w:id="{cid}"/>
- <w:r>...</w:r>
- <w:commentRangeEnd w:id="{cid}"/>
- <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
- REPLY_MARKER_TEMPLATE = """
- Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
- <w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
- <w:r>...</w:r>
- <w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
- <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
- <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
- def _generate_hex_id() -> str:
- return f"{random.randint(0, 0x7FFFFFFE):08X}"
- SMART_QUOTE_ENTITIES = {
- "\u201c": "“",
- "\u201d": "”",
- "\u2018": "‘",
- "\u2019": "’",
- }
- def _encode_smart_quotes(text: str) -> str:
- for char, entity in SMART_QUOTE_ENTITIES.items():
- text = text.replace(char, entity)
- return text
- def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
- dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
- root = dom.getElementsByTagName(root_tag)[0]
- ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
- wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")
- for child in wrapper_dom.documentElement.childNodes:
- if child.nodeType == child.ELEMENT_NODE:
- root.appendChild(dom.importNode(child, True))
- output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
- xml_path.write_text(output, encoding="utf-8")
- def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
- dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
- for c in dom.getElementsByTagName("w:comment"):
- if c.getAttribute("w:id") == str(comment_id):
- for p in c.getElementsByTagName("w:p"):
- if pid := p.getAttribute("w14:paraId"):
- return pid
- return None
- def _get_next_rid(rels_path: Path) -> int:
- dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
- max_rid = 0
- for rel in dom.getElementsByTagName("Relationship"):
- rid = rel.getAttribute("Id")
- if rid and rid.startswith("rId"):
- try:
- max_rid = max(max_rid, int(rid[3:]))
- except ValueError:
- pass
- return max_rid + 1
- def _has_relationship(rels_path: Path, target: str) -> bool:
- dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
- for rel in dom.getElementsByTagName("Relationship"):
- if rel.getAttribute("Target") == target:
- return True
- return False
- def _has_content_type(ct_path: Path, part_name: str) -> bool:
- dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
- for override in dom.getElementsByTagName("Override"):
- if override.getAttribute("PartName") == part_name:
- return True
- return False
- def _ensure_comment_relationships(unpacked_dir: Path) -> None:
- rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
- if not rels_path.exists():
- return
- if _has_relationship(rels_path, "comments.xml"):
- return
- dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
- root = dom.documentElement
- next_rid = _get_next_rid(rels_path)
- rels = [
- (
- "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
- "comments.xml",
- ),
- (
- "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
- "commentsExtended.xml",
- ),
- (
- "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
- "commentsIds.xml",
- ),
- (
- "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
- "commentsExtensible.xml",
- ),
- ]
- for rel_type, target in rels:
- rel = dom.createElement("Relationship")
- rel.setAttribute("Id", f"rId{next_rid}")
- rel.setAttribute("Type", rel_type)
- rel.setAttribute("Target", target)
- root.appendChild(rel)
- next_rid += 1
- rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
- def _ensure_comment_content_types(unpacked_dir: Path) -> None:
- ct_path = unpacked_dir / "[Content_Types].xml"
- if not ct_path.exists():
- return
- if _has_content_type(ct_path, "/word/comments.xml"):
- return
- dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
- root = dom.documentElement
- overrides = [
- (
- "/word/comments.xml",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
- ),
- (
- "/word/commentsExtended.xml",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
- ),
- (
- "/word/commentsIds.xml",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
- ),
- (
- "/word/commentsExtensible.xml",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
- ),
- ]
- for part_name, content_type in overrides:
- override = dom.createElement("Override")
- override.setAttribute("PartName", part_name)
- override.setAttribute("ContentType", content_type)
- root.appendChild(override)
- ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
- def add_comment(
- unpacked_dir: str,
- comment_id: int,
- text: str,
- author: str = "Claude",
- initials: str = "C",
- parent_id: int | None = None,
- ) -> tuple[str, str]:
- word = Path(unpacked_dir) / "word"
- if not word.exists():
- return "", f"Error: {word} not found"
- para_id, durable_id = _generate_hex_id(), _generate_hex_id()
- ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
- comments = word / "comments.xml"
- first_comment = not comments.exists()
- if first_comment:
- shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
- _ensure_comment_relationships(Path(unpacked_dir))
- _ensure_comment_content_types(Path(unpacked_dir))
- _append_xml(
- comments,
- "w:comments",
- COMMENT_XML.format(
- id=comment_id,
- author=author,
- date=ts,
- initials=initials,
- para_id=para_id,
- text=text,
- ),
- )
- ext = word / "commentsExtended.xml"
- if not ext.exists():
- shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
- if parent_id is not None:
- parent_para = _find_para_id(comments, parent_id)
- if not parent_para:
- return "", f"Error: Parent comment {parent_id} not found"
- _append_xml(
- ext,
- "w15:commentsEx",
- f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',
- )
- else:
- _append_xml(
- ext,
- "w15:commentsEx",
- f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
- )
- ids = word / "commentsIds.xml"
- if not ids.exists():
- shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
- _append_xml(
- ids,
- "w16cid:commentsIds",
- f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
- )
- extensible = word / "commentsExtensible.xml"
- if not extensible.exists():
- shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
- _append_xml(
- extensible,
- "w16cex:commentsExtensible",
- f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',
- )
- action = "reply" if parent_id is not None else "comment"
- return para_id, f"Added {action} {comment_id} (para_id={para_id})"
- if __name__ == "__main__":
- p = argparse.ArgumentParser(description="Add comments to DOCX documents")
- p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
- p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
- p.add_argument("text", help="Comment text")
- p.add_argument("--author", default="Claude", help="Author name")
- p.add_argument("--initials", default="C", help="Author initials")
- p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
- args = p.parse_args()
- para_id, msg = add_comment(
- args.unpacked_dir,
- args.comment_id,
- args.text,
- args.author,
- args.initials,
- args.parent,
- )
- print(msg)
- if "Error" in msg:
- sys.exit(1)
- cid = args.comment_id
- if args.parent is not None:
- print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))
- else:
- print(COMMENT_MARKER_TEMPLATE.format(cid=cid))
|