docx

Create, read, edit, template, and review Word .docx files.

  • word
  • docx
  • documents
  • office
  • templates
  • revisions
  • comments

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/skills/productivity/docx'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md
#!/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; fallsback to building word/comments.xml and the range markers directly forolder versions (or when --xml is passed). Listing and deletion alwayswork at the XML level so they handle documents from any producer."""from __future__ import annotations import argparseimport datetime as _dtimport jsonimport sysfrom copy import deepcopy from docx import Documentfrom docx.opc.constants import RELATIONSHIP_TYPE as RTfrom 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()) 
Referenced from SKILL.md