scripts/docx_comments.py
scripts/docx_comments.pyBrowse 12 files
2,534 tokens
10,655 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32# MIT License. Part of the Hermes docx skill.3"""List, add, and delete comments in a .docx.4 5Subcommands:6 list JSON per comment: id, author, initials, date, text, anchored_text7 add add a comment anchored to the first occurrence of --target8 delete remove a comment (and its range markers) by --id9 10Examples:11 docx_comments.py list report.docx12 docx_comments.py add report.docx --target "Q3 revenue" \13 --text "Needs a source" --author "Reviewer" -o out.docx14 docx_comments.py delete report.docx --id 0 -o out.docx15 16Uses the native python-docx comments API (>= 1.2) when available; falls17back to building word/comments.xml and the range markers directly for18older versions (or when --xml is passed). Listing and deletion always19work at the XML level so they handle documents from any producer.20"""21from __future__ import annotations22 23import argparse24import datetime as _dt25import json26import sys27from copy import deepcopy28 29from docx import Document30from docx.opc.constants import RELATIONSHIP_TYPE as RT31from lxml import etree32 33from docx_common import iter_part_roots34 35W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"36COMMENTS_CT = ("application/vnd.openxmlformats-officedocument"37 ".wordprocessingml.comments+xml")38 39 40def q(tag: str) -> str:41 return f"{{{W}}}{tag}"42 43 44# ---------------------------------------------------------------- reading45 46def _comments_root(doc):47 """Return the XML root of the comments part, or None."""48 for rel in doc.part.rels.values():49 if rel.reltype == RT.COMMENTS:50 part = rel.target_part51 el = getattr(part, "_element", None)52 if el is not None:53 return el54 return etree.fromstring(part.blob)55 return None56 57 58def _anchored_texts(doc) -> dict:59 """Map comment id -> document text between its range markers."""60 anchored: dict[str, list[str]] = {}61 for root in iter_part_roots(doc):62 active: set[str] = set()63 for el in root.iter():64 if el.tag == q("commentRangeStart"):65 cid = el.get(q("id"))66 active.add(cid)67 anchored.setdefault(cid, [])68 elif el.tag == q("commentRangeEnd"):69 active.discard(el.get(q("id")))70 elif el.tag == q("t") and active:71 for cid in active:72 anchored[cid].append(el.text or "")73 return {cid: "".join(parts) for cid, parts in anchored.items()}74 75 76def list_comments(doc) -> list:77 root = _comments_root(doc)78 if root is None:79 return []80 anchored = _anchored_texts(doc)81 out = []82 for c in root.iter(q("comment")):83 cid = c.get(q("id"))84 text = "\n".join(85 "".join(t.text or "" for t in p.iter(q("t")))86 for p in c.iter(q("p")))87 out.append({"id": cid, "author": c.get(q("author")),88 "initials": c.get(q("initials")),89 "date": c.get(q("date")), "text": text,90 "anchored_text": anchored.get(cid, "")})91 return out92 93 94# ---------------------------------------------------------------- anchoring95 96def _split_run(para, run_el, offset: int):97 """Split a run element at text offset; return the new right-hand run."""98 text = "".join(t.text or "" for t in run_el.iter(q("t")))99 right = deepcopy(run_el)100 run_el.addnext(right)101 for el, s in ((run_el, text[:offset]), (right, text[offset:])):102 for t in list(el.iter(q("t"))):103 el.remove(t)104 t = etree.SubElement(el, q("t"))105 t.text = s106 t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")107 return right108 109 110def find_anchor_runs(doc, target: str):111 """Isolate `target`'s first occurrence into whole runs; return them."""112 from docx_common import iter_all_paragraphs113 for para in iter_all_paragraphs(doc):114 full = para.text115 start = full.find(target)116 if start < 0:117 continue118 end = start + len(target)119 pos = 0120 covered = []121 for run_el in para._p.iter(q("r")):122 rtext = "".join(t.text or "" for t in run_el.iter(q("t")))123 r_start, r_end = pos, pos + len(rtext)124 pos = r_end125 if r_end <= start or r_start >= end:126 continue127 if r_start < start: # split off the left part128 run_el = _split_run(para, run_el, start - r_start)129 r_start = start130 if r_end > end: # split off the right part131 _split_run(para, run_el, end - r_start)132 covered.append(run_el)133 return para, covered134 return None, []135 136 137# ---------------------------------------------------------------- adding138 139def _next_id(doc) -> int:140 root = _comments_root(doc)141 if root is None:142 return 0143 ids = [int(c.get(q("id"), "0")) for c in root.iter(q("comment"))144 if c.get(q("id"), "").isdigit()]145 return max(ids) + 1 if ids else 0146 147 148def add_comment_native(doc, runs, text, author, initials):149 from docx.text.run import Run150 run_objs = [Run(r, None) for r in runs]151 comment = doc.add_comment(run_objs, text=text, author=author,152 initials=initials or "")153 return str(comment.comment_id)154 155 156def add_comment_xml(doc, runs, text, author, initials) -> str:157 cid = str(_next_id(doc))158 root = _comments_root(doc)159 if root is None:160 root = etree.fromstring(161 f'<w:comments xmlns:w="{W}"/>'.encode("utf-8"))162 from docx.opc.packuri import PackURI163 from docx.opc.part import Part164 blob = etree.tostring(root, xml_declaration=True,165 encoding="UTF-8", standalone=True)166 part = Part(PackURI("/word/comments.xml"), COMMENTS_CT, blob,167 doc.part.package)168 doc.part.relate_to(part, RT.COMMENTS)169 # keep a live element on the part so edits reach save()170 part._element = root171 part.blob_ = None172 173 def _blob(self=part):174 return etree.tostring(self._element, xml_declaration=True,175 encoding="UTF-8", standalone=True)176 part.__class__ = type("CommentsXmlPart", (Part,),177 {"blob": property(lambda self: _blob(self))})178 now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")179 comment = etree.SubElement(root, q("comment"))180 comment.set(q("id"), cid)181 comment.set(q("author"), author)182 if initials:183 comment.set(q("initials"), initials)184 comment.set(q("date"), now)185 p = etree.SubElement(comment, q("p"))186 r = etree.SubElement(p, q("r"))187 t = etree.SubElement(r, q("t"))188 t.text = text189 # range markers around the anchor runs + reference run after them190 first, last = runs[0], runs[-1]191 start = first.makeelement(q("commentRangeStart"), {q("id"): cid})192 first.addprevious(start)193 end = last.makeelement(q("commentRangeEnd"), {q("id"): cid})194 last.addnext(end)195 ref_run = last.makeelement(q("r"), {})196 ref = etree.SubElement(ref_run, q("commentReference"))197 ref.set(q("id"), cid)198 end.addnext(ref_run)199 return cid200 201 202# ---------------------------------------------------------------- deleting203 204def delete_comment(doc, cid: str) -> bool:205 root = _comments_root(doc)206 found = False207 if root is not None:208 for c in list(root.iter(q("comment"))):209 if c.get(q("id")) == cid:210 c.getparent().remove(c)211 found = True212 for part_root in iter_part_roots(doc):213 for tag in ("commentRangeStart", "commentRangeEnd",214 "commentReference"):215 for el in list(part_root.iter(q(tag))):216 if el.get(q("id")) == cid:217 parent = el.getparent()218 # remove the wrapping run for reference marks219 if tag == "commentReference" and parent.tag == q("r"):220 parent.getparent().remove(parent)221 else:222 parent.remove(el)223 found = True224 return found225 226 227def main() -> int:228 ap = argparse.ArgumentParser(229 description="List, add, or delete comments in a .docx.")230 sub = ap.add_subparsers(dest="cmd", required=True)231 232 p = sub.add_parser("list", help="list comments as JSON")233 p.add_argument("path", help="input .docx")234 235 p = sub.add_parser("add", help="add a comment anchored to text")236 p.add_argument("path", help="input .docx")237 p.add_argument("-o", "--output", help="output path (default: in place)")238 p.add_argument("--target", required=True,239 help="anchor: first occurrence of this text")240 p.add_argument("--text", required=True, help="comment body")241 p.add_argument("--author", default="Hermes")242 p.add_argument("--initials", default="")243 p.add_argument("--xml", action="store_true",244 help="force the XML fallback (skip native API)")245 246 p = sub.add_parser("delete", help="delete a comment by id")247 p.add_argument("path", help="input .docx")248 p.add_argument("-o", "--output", help="output path (default: in place)")249 p.add_argument("--id", required=True, help="comment id")250 251 args = ap.parse_args()252 doc = Document(args.path)253 254 if args.cmd == "list":255 print(json.dumps({"ok": True, "comments": list_comments(doc)},256 ensure_ascii=False))257 return 0258 259 if args.cmd == "add":260 para, runs = find_anchor_runs(doc, args.target)261 if not runs:262 print(json.dumps({"ok": False,263 "error": f"target not found: {args.target}"}))264 return 1265 native = hasattr(doc, "add_comment") and not args.xml266 if native:267 cid = add_comment_native(doc, runs, args.text, args.author,268 args.initials)269 else:270 cid = add_comment_xml(doc, runs, args.text, args.author,271 args.initials)272 result = {"ok": True, "comment_id": cid,273 "native_api": native, "anchored_to": args.target}274 else: # delete275 if not delete_comment(doc, args.id):276 print(json.dumps({"ok": False,277 "error": f"no comment with id {args.id}"}))278 return 1279 result = {"ok": True, "deleted_id": args.id}280 281 out = args.output or args.path282 doc.save(out)283 result["output"] = out284 print(json.dumps(result, ensure_ascii=False))285 return 0286 287 288if __name__ == "__main__":289 sys.exit(main())290