comment.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """Add comments to DOCX documents.
  2. Usage:
  3. python comment.py unpacked/ 0 "Comment text"
  4. python comment.py unpacked/ 1 "Reply text" --parent 0
  5. Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes).
  6. After running, add markers to document.xml:
  7. <w:commentRangeStart w:id="0"/>
  8. ... commented content ...
  9. <w:commentRangeEnd w:id="0"/>
  10. <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
  11. """
  12. import argparse
  13. import random
  14. import shutil
  15. import sys
  16. from datetime import datetime, timezone
  17. from pathlib import Path
  18. import defusedxml.minidom
  19. TEMPLATE_DIR = Path(__file__).parent / "templates"
  20. NS = {
  21. "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
  22. "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
  23. "w15": "http://schemas.microsoft.com/office/word/2012/wordml",
  24. "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
  25. "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
  26. }
  27. COMMENT_XML = """\
  28. <w:comment w:id="{id}" w:author="{author}" w:date="{date}" w:initials="{initials}">
  29. <w:p w14:paraId="{para_id}" w14:textId="77777777">
  30. <w:r>
  31. <w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
  32. <w:annotationRef/>
  33. </w:r>
  34. <w:r>
  35. <w:rPr>
  36. <w:color w:val="000000"/>
  37. <w:sz w:val="20"/>
  38. <w:szCs w:val="20"/>
  39. </w:rPr>
  40. <w:t>{text}</w:t>
  41. </w:r>
  42. </w:p>
  43. </w:comment>"""
  44. COMMENT_MARKER_TEMPLATE = """
  45. Add to document.xml (markers must be direct children of w:p, never inside w:r):
  46. <w:commentRangeStart w:id="{cid}"/>
  47. <w:r>...</w:r>
  48. <w:commentRangeEnd w:id="{cid}"/>
  49. <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
  50. REPLY_MARKER_TEMPLATE = """
  51. Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
  52. <w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
  53. <w:r>...</w:r>
  54. <w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
  55. <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
  56. <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
  57. def _generate_hex_id() -> str:
  58. return f"{random.randint(0, 0x7FFFFFFE):08X}"
  59. SMART_QUOTE_ENTITIES = {
  60. "\u201c": "&#x201C;",
  61. "\u201d": "&#x201D;",
  62. "\u2018": "&#x2018;",
  63. "\u2019": "&#x2019;",
  64. }
  65. def _encode_smart_quotes(text: str) -> str:
  66. for char, entity in SMART_QUOTE_ENTITIES.items():
  67. text = text.replace(char, entity)
  68. return text
  69. def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
  70. dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
  71. root = dom.getElementsByTagName(root_tag)[0]
  72. ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
  73. wrapper_dom = defusedxml.minidom.parseString(f"<root {ns_attrs}>{content}</root>")
  74. for child in wrapper_dom.documentElement.childNodes:
  75. if child.nodeType == child.ELEMENT_NODE:
  76. root.appendChild(dom.importNode(child, True))
  77. output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
  78. xml_path.write_text(output, encoding="utf-8")
  79. def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
  80. dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
  81. for c in dom.getElementsByTagName("w:comment"):
  82. if c.getAttribute("w:id") == str(comment_id):
  83. for p in c.getElementsByTagName("w:p"):
  84. if pid := p.getAttribute("w14:paraId"):
  85. return pid
  86. return None
  87. def _get_next_rid(rels_path: Path) -> int:
  88. dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
  89. max_rid = 0
  90. for rel in dom.getElementsByTagName("Relationship"):
  91. rid = rel.getAttribute("Id")
  92. if rid and rid.startswith("rId"):
  93. try:
  94. max_rid = max(max_rid, int(rid[3:]))
  95. except ValueError:
  96. pass
  97. return max_rid + 1
  98. def _has_relationship(rels_path: Path, target: str) -> bool:
  99. dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
  100. for rel in dom.getElementsByTagName("Relationship"):
  101. if rel.getAttribute("Target") == target:
  102. return True
  103. return False
  104. def _has_content_type(ct_path: Path, part_name: str) -> bool:
  105. dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
  106. for override in dom.getElementsByTagName("Override"):
  107. if override.getAttribute("PartName") == part_name:
  108. return True
  109. return False
  110. def _ensure_comment_relationships(unpacked_dir: Path) -> None:
  111. rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
  112. if not rels_path.exists():
  113. return
  114. if _has_relationship(rels_path, "comments.xml"):
  115. return
  116. dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
  117. root = dom.documentElement
  118. next_rid = _get_next_rid(rels_path)
  119. rels = [
  120. (
  121. "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
  122. "comments.xml",
  123. ),
  124. (
  125. "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
  126. "commentsExtended.xml",
  127. ),
  128. (
  129. "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
  130. "commentsIds.xml",
  131. ),
  132. (
  133. "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
  134. "commentsExtensible.xml",
  135. ),
  136. ]
  137. for rel_type, target in rels:
  138. rel = dom.createElement("Relationship")
  139. rel.setAttribute("Id", f"rId{next_rid}")
  140. rel.setAttribute("Type", rel_type)
  141. rel.setAttribute("Target", target)
  142. root.appendChild(rel)
  143. next_rid += 1
  144. rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
  145. def _ensure_comment_content_types(unpacked_dir: Path) -> None:
  146. ct_path = unpacked_dir / "[Content_Types].xml"
  147. if not ct_path.exists():
  148. return
  149. if _has_content_type(ct_path, "/word/comments.xml"):
  150. return
  151. dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
  152. root = dom.documentElement
  153. overrides = [
  154. (
  155. "/word/comments.xml",
  156. "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
  157. ),
  158. (
  159. "/word/commentsExtended.xml",
  160. "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
  161. ),
  162. (
  163. "/word/commentsIds.xml",
  164. "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
  165. ),
  166. (
  167. "/word/commentsExtensible.xml",
  168. "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
  169. ),
  170. ]
  171. for part_name, content_type in overrides:
  172. override = dom.createElement("Override")
  173. override.setAttribute("PartName", part_name)
  174. override.setAttribute("ContentType", content_type)
  175. root.appendChild(override)
  176. ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
  177. def add_comment(
  178. unpacked_dir: str,
  179. comment_id: int,
  180. text: str,
  181. author: str = "Claude",
  182. initials: str = "C",
  183. parent_id: int | None = None,
  184. ) -> tuple[str, str]:
  185. word = Path(unpacked_dir) / "word"
  186. if not word.exists():
  187. return "", f"Error: {word} not found"
  188. para_id, durable_id = _generate_hex_id(), _generate_hex_id()
  189. ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  190. comments = word / "comments.xml"
  191. first_comment = not comments.exists()
  192. if first_comment:
  193. shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
  194. _ensure_comment_relationships(Path(unpacked_dir))
  195. _ensure_comment_content_types(Path(unpacked_dir))
  196. _append_xml(
  197. comments,
  198. "w:comments",
  199. COMMENT_XML.format(
  200. id=comment_id,
  201. author=author,
  202. date=ts,
  203. initials=initials,
  204. para_id=para_id,
  205. text=text,
  206. ),
  207. )
  208. ext = word / "commentsExtended.xml"
  209. if not ext.exists():
  210. shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
  211. if parent_id is not None:
  212. parent_para = _find_para_id(comments, parent_id)
  213. if not parent_para:
  214. return "", f"Error: Parent comment {parent_id} not found"
  215. _append_xml(
  216. ext,
  217. "w15:commentsEx",
  218. f'<w15:commentEx w15:paraId="{para_id}" w15:paraIdParent="{parent_para}" w15:done="0"/>',
  219. )
  220. else:
  221. _append_xml(
  222. ext,
  223. "w15:commentsEx",
  224. f'<w15:commentEx w15:paraId="{para_id}" w15:done="0"/>',
  225. )
  226. ids = word / "commentsIds.xml"
  227. if not ids.exists():
  228. shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
  229. _append_xml(
  230. ids,
  231. "w16cid:commentsIds",
  232. f'<w16cid:commentId w16cid:paraId="{para_id}" w16cid:durableId="{durable_id}"/>',
  233. )
  234. extensible = word / "commentsExtensible.xml"
  235. if not extensible.exists():
  236. shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
  237. _append_xml(
  238. extensible,
  239. "w16cex:commentsExtensible",
  240. f'<w16cex:commentExtensible w16cex:durableId="{durable_id}" w16cex:dateUtc="{ts}"/>',
  241. )
  242. action = "reply" if parent_id is not None else "comment"
  243. return para_id, f"Added {action} {comment_id} (para_id={para_id})"
  244. if __name__ == "__main__":
  245. p = argparse.ArgumentParser(description="Add comments to DOCX documents")
  246. p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
  247. p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
  248. p.add_argument("text", help="Comment text")
  249. p.add_argument("--author", default="Claude", help="Author name")
  250. p.add_argument("--initials", default="C", help="Author initials")
  251. p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
  252. args = p.parse_args()
  253. para_id, msg = add_comment(
  254. args.unpacked_dir,
  255. args.comment_id,
  256. args.text,
  257. args.author,
  258. args.initials,
  259. args.parent,
  260. )
  261. print(msg)
  262. if "Error" in msg:
  263. sys.exit(1)
  264. cid = args.comment_id
  265. if args.parent is not None:
  266. print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))
  267. else:
  268. print(COMMENT_MARKER_TEMPLATE.format(cid=cid))