290 lines
10 KiB
Python
290 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# MIT License. Part of the Hermes docx skill.
|
|
"""List, add, and delete comments in a .docx.
|
|
|
|
Subcommands:
|
|
list JSON per comment: id, author, initials, date, text, anchored_text
|
|
add add a comment anchored to the first occurrence of --target
|
|
delete remove a comment (and its range markers) by --id
|
|
|
|
Examples:
|
|
docx_comments.py list report.docx
|
|
docx_comments.py add report.docx --target "Q3 revenue" \
|
|
--text "Needs a source" --author "Reviewer" -o out.docx
|
|
docx_comments.py delete report.docx --id 0 -o out.docx
|
|
|
|
Uses the native python-docx comments API (>= 1.2) when available; falls
|
|
back to building word/comments.xml and the range markers directly for
|
|
older versions (or when --xml is passed). Listing and deletion always
|
|
work at the XML level so they handle documents from any producer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as _dt
|
|
import json
|
|
import sys
|
|
from copy import deepcopy
|
|
|
|
from docx import Document
|
|
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
from lxml import etree
|
|
|
|
from docx_common import iter_part_roots
|
|
|
|
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
COMMENTS_CT = ("application/vnd.openxmlformats-officedocument"
|
|
".wordprocessingml.comments+xml")
|
|
|
|
|
|
def q(tag: str) -> str:
|
|
return f"{{{W}}}{tag}"
|
|
|
|
|
|
# ---------------------------------------------------------------- reading
|
|
|
|
def _comments_root(doc):
|
|
"""Return the XML root of the comments part, or None."""
|
|
for rel in doc.part.rels.values():
|
|
if rel.reltype == RT.COMMENTS:
|
|
part = rel.target_part
|
|
el = getattr(part, "_element", None)
|
|
if el is not None:
|
|
return el
|
|
return etree.fromstring(part.blob)
|
|
return None
|
|
|
|
|
|
def _anchored_texts(doc) -> dict:
|
|
"""Map comment id -> document text between its range markers."""
|
|
anchored: dict[str, list[str]] = {}
|
|
for root in iter_part_roots(doc):
|
|
active: set[str] = set()
|
|
for el in root.iter():
|
|
if el.tag == q("commentRangeStart"):
|
|
cid = el.get(q("id"))
|
|
active.add(cid)
|
|
anchored.setdefault(cid, [])
|
|
elif el.tag == q("commentRangeEnd"):
|
|
active.discard(el.get(q("id")))
|
|
elif el.tag == q("t") and active:
|
|
for cid in active:
|
|
anchored[cid].append(el.text or "")
|
|
return {cid: "".join(parts) for cid, parts in anchored.items()}
|
|
|
|
|
|
def list_comments(doc) -> list:
|
|
root = _comments_root(doc)
|
|
if root is None:
|
|
return []
|
|
anchored = _anchored_texts(doc)
|
|
out = []
|
|
for c in root.iter(q("comment")):
|
|
cid = c.get(q("id"))
|
|
text = "\n".join(
|
|
"".join(t.text or "" for t in p.iter(q("t")))
|
|
for p in c.iter(q("p")))
|
|
out.append({"id": cid, "author": c.get(q("author")),
|
|
"initials": c.get(q("initials")),
|
|
"date": c.get(q("date")), "text": text,
|
|
"anchored_text": anchored.get(cid, "")})
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- anchoring
|
|
|
|
def _split_run(para, run_el, offset: int):
|
|
"""Split a run element at text offset; return the new right-hand run."""
|
|
text = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
right = deepcopy(run_el)
|
|
run_el.addnext(right)
|
|
for el, s in ((run_el, text[:offset]), (right, text[offset:])):
|
|
for t in list(el.iter(q("t"))):
|
|
el.remove(t)
|
|
t = etree.SubElement(el, q("t"))
|
|
t.text = s
|
|
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
return right
|
|
|
|
|
|
def find_anchor_runs(doc, target: str):
|
|
"""Isolate `target`'s first occurrence into whole runs; return them."""
|
|
from docx_common import iter_all_paragraphs
|
|
for para in iter_all_paragraphs(doc):
|
|
full = para.text
|
|
start = full.find(target)
|
|
if start < 0:
|
|
continue
|
|
end = start + len(target)
|
|
pos = 0
|
|
covered = []
|
|
for run_el in para._p.iter(q("r")):
|
|
rtext = "".join(t.text or "" for t in run_el.iter(q("t")))
|
|
r_start, r_end = pos, pos + len(rtext)
|
|
pos = r_end
|
|
if r_end <= start or r_start >= end:
|
|
continue
|
|
if r_start < start: # split off the left part
|
|
run_el = _split_run(para, run_el, start - r_start)
|
|
r_start = start
|
|
if r_end > end: # split off the right part
|
|
_split_run(para, run_el, end - r_start)
|
|
covered.append(run_el)
|
|
return para, covered
|
|
return None, []
|
|
|
|
|
|
# ---------------------------------------------------------------- adding
|
|
|
|
def _next_id(doc) -> int:
|
|
root = _comments_root(doc)
|
|
if root is None:
|
|
return 0
|
|
ids = [int(c.get(q("id"), "0")) for c in root.iter(q("comment"))
|
|
if c.get(q("id"), "").isdigit()]
|
|
return max(ids) + 1 if ids else 0
|
|
|
|
|
|
def add_comment_native(doc, runs, text, author, initials):
|
|
from docx.text.run import Run
|
|
run_objs = [Run(r, None) for r in runs]
|
|
comment = doc.add_comment(run_objs, text=text, author=author,
|
|
initials=initials or "")
|
|
return str(comment.comment_id)
|
|
|
|
|
|
def add_comment_xml(doc, runs, text, author, initials) -> str:
|
|
cid = str(_next_id(doc))
|
|
root = _comments_root(doc)
|
|
if root is None:
|
|
root = etree.fromstring(
|
|
f'<w:comments xmlns:w="{W}"/>'.encode("utf-8"))
|
|
from docx.opc.packuri import PackURI
|
|
from docx.opc.part import Part
|
|
blob = etree.tostring(root, xml_declaration=True,
|
|
encoding="UTF-8", standalone=True)
|
|
part = Part(PackURI("/word/comments.xml"), COMMENTS_CT, blob,
|
|
doc.part.package)
|
|
doc.part.relate_to(part, RT.COMMENTS)
|
|
# keep a live element on the part so edits reach save()
|
|
part._element = root
|
|
part.blob_ = None
|
|
|
|
def _blob(self=part):
|
|
return etree.tostring(self._element, xml_declaration=True,
|
|
encoding="UTF-8", standalone=True)
|
|
part.__class__ = type("CommentsXmlPart", (Part,),
|
|
{"blob": property(lambda self: _blob(self))})
|
|
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
comment = etree.SubElement(root, q("comment"))
|
|
comment.set(q("id"), cid)
|
|
comment.set(q("author"), author)
|
|
if initials:
|
|
comment.set(q("initials"), initials)
|
|
comment.set(q("date"), now)
|
|
p = etree.SubElement(comment, q("p"))
|
|
r = etree.SubElement(p, q("r"))
|
|
t = etree.SubElement(r, q("t"))
|
|
t.text = text
|
|
# range markers around the anchor runs + reference run after them
|
|
first, last = runs[0], runs[-1]
|
|
start = first.makeelement(q("commentRangeStart"), {q("id"): cid})
|
|
first.addprevious(start)
|
|
end = last.makeelement(q("commentRangeEnd"), {q("id"): cid})
|
|
last.addnext(end)
|
|
ref_run = last.makeelement(q("r"), {})
|
|
ref = etree.SubElement(ref_run, q("commentReference"))
|
|
ref.set(q("id"), cid)
|
|
end.addnext(ref_run)
|
|
return cid
|
|
|
|
|
|
# ---------------------------------------------------------------- deleting
|
|
|
|
def delete_comment(doc, cid: str) -> bool:
|
|
root = _comments_root(doc)
|
|
found = False
|
|
if root is not None:
|
|
for c in list(root.iter(q("comment"))):
|
|
if c.get(q("id")) == cid:
|
|
c.getparent().remove(c)
|
|
found = True
|
|
for part_root in iter_part_roots(doc):
|
|
for tag in ("commentRangeStart", "commentRangeEnd",
|
|
"commentReference"):
|
|
for el in list(part_root.iter(q(tag))):
|
|
if el.get(q("id")) == cid:
|
|
parent = el.getparent()
|
|
# remove the wrapping run for reference marks
|
|
if tag == "commentReference" and parent.tag == q("r"):
|
|
parent.getparent().remove(parent)
|
|
else:
|
|
parent.remove(el)
|
|
found = True
|
|
return found
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="List, add, or delete comments in a .docx.")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
p = sub.add_parser("list", help="list comments as JSON")
|
|
p.add_argument("path", help="input .docx")
|
|
|
|
p = sub.add_parser("add", help="add a comment anchored to text")
|
|
p.add_argument("path", help="input .docx")
|
|
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
p.add_argument("--target", required=True,
|
|
help="anchor: first occurrence of this text")
|
|
p.add_argument("--text", required=True, help="comment body")
|
|
p.add_argument("--author", default="Hermes")
|
|
p.add_argument("--initials", default="")
|
|
p.add_argument("--xml", action="store_true",
|
|
help="force the XML fallback (skip native API)")
|
|
|
|
p = sub.add_parser("delete", help="delete a comment by id")
|
|
p.add_argument("path", help="input .docx")
|
|
p.add_argument("-o", "--output", help="output path (default: in place)")
|
|
p.add_argument("--id", required=True, help="comment id")
|
|
|
|
args = ap.parse_args()
|
|
doc = Document(args.path)
|
|
|
|
if args.cmd == "list":
|
|
print(json.dumps({"ok": True, "comments": list_comments(doc)},
|
|
ensure_ascii=False))
|
|
return 0
|
|
|
|
if args.cmd == "add":
|
|
para, runs = find_anchor_runs(doc, args.target)
|
|
if not runs:
|
|
print(json.dumps({"ok": False,
|
|
"error": f"target not found: {args.target}"}))
|
|
return 1
|
|
native = hasattr(doc, "add_comment") and not args.xml
|
|
if native:
|
|
cid = add_comment_native(doc, runs, args.text, args.author,
|
|
args.initials)
|
|
else:
|
|
cid = add_comment_xml(doc, runs, args.text, args.author,
|
|
args.initials)
|
|
result = {"ok": True, "comment_id": cid,
|
|
"native_api": native, "anchored_to": args.target}
|
|
else: # delete
|
|
if not delete_comment(doc, args.id):
|
|
print(json.dumps({"ok": False,
|
|
"error": f"no comment with id {args.id}"}))
|
|
return 1
|
|
result = {"ok": True, "deleted_id": args.id}
|
|
|
|
out = args.output or args.path
|
|
doc.save(out)
|
|
result["output"] = out
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|