Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,289 @@
#!/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())
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
# MIT License. Shared helpers for the docx skill scripts.
"""Shared helpers: paragraph iteration and run-preserving text replacement."""
from __future__ import annotations
def iter_all_paragraphs(doc, include_headers_footers: bool = True):
"""Yield every paragraph in body, tables (recursively), headers, footers."""
yield from _iter_container(doc)
if include_headers_footers:
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
if part is not None:
yield from _iter_container(part)
def _iter_container(container):
for para in container.paragraphs:
yield para
for table in container.tables:
yield from _iter_table(table)
def _iter_table(table):
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
yield para
for nested in cell.tables:
yield from _iter_table(nested)
def iter_part_roots(doc):
"""Yield the XML root of the body plus every header/footer part."""
yield doc.element.body
seen = set()
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
if part is not None and id(part._element) not in seen:
seen.add(id(part._element))
yield part._element
def replace_in_paragraph(para, old: str, new: str) -> int:
"""Replace `old` with `new` in a paragraph, preserving run formatting.
Strategy: first replace occurrences fully contained in a single run
(formatting fully preserved). If the needle spans multiple runs, the
matched runs are collapsed: the replacement inherits the formatting of
the run where the match starts. Returns number of replacements made.
"""
if not old or old not in para.text:
return 0
count = 0
# Pass 1: within-run replacements.
for run in para.runs:
if old in run.text:
count += run.text.count(old)
run.text = run.text.replace(old, new)
# Pass 2: cross-run occurrences.
while old in para.text:
runs = para.runs
# Map paragraph text offsets to (run_index, offset_in_run).
full = "".join(r.text for r in runs)
start = full.find(old)
if start < 0:
break
end = start + len(old)
pos = 0
spans = [] # (run_idx, cut_start, cut_end) portions inside the match
for i, r in enumerate(runs):
r_start, r_end = pos, pos + len(r.text)
if r_end > start and r_start < end:
spans.append((i, max(start, r_start) - r_start,
min(end, r_end) - r_start))
pos = r_end
first = True
for i, cs, ce in spans:
t = runs[i].text
if first:
runs[i].text = t[:cs] + new + t[ce:]
first = False
else:
runs[i].text = t[:cs] + t[ce:]
count += 1
return count
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Create a .docx document from a JSON spec.
Usage: docx_create.py spec.json output.docx
Run with --help for the spec format summary.
Spec (JSON object):
{
"page": {"width_mm": 210, "height_mm": 297,
"margins_mm": {"top": 25, "bottom": 25, "left": 20, "right": 20}},
"header": "text shown in page header",
"footer": "text shown in page footer",
"styles": [{"name": "MyStyle", "base": "Normal", "font": "Arial",
"size_pt": 12, "bold": true, "color": "1F4E79"}],
"blocks": [
{"type": "heading", "text": "Title", "level": 1},
{"type": "paragraph", "style": "MyStyle", "runs": [
{"text": "plain "}, {"text": "bold", "bold": true},
{"text": " italic", "italic": true},
{"text": " under", "underline": true}]},
{"type": "paragraph", "text": "shortcut: single plain run"},
{"type": "bullet_list", "items": ["a", "b"]},
{"type": "numbered_list", "items": ["one", "two"]},
{"type": "table", "header": ["Col1", "Col2"],
"rows": [["1", "2"]], "style": "Light Grid Accent 1",
"header_bold": true},
{"type": "image", "path": "pic.png", "width_mm": 60},
{"type": "page_break"},
{"type": "toc"}
]
}
Extras: `"footer_page_numbers": true` at the top level adds a
"Page X of Y" footer built from PAGE/NUMPAGES fields, and a `toc` block
inserts a Table of Contents field. Field results are computed by
Word/LibreOffice when the file is opened, not by python-docx.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_BREAK
from docx.shared import Mm, Pt, RGBColor
def apply_page(doc, page: dict) -> None:
section = doc.sections[0]
if "width_mm" in page:
section.page_width = Mm(page["width_mm"])
if "height_mm" in page:
section.page_height = Mm(page["height_mm"])
m = page.get("margins_mm", {})
for side in ("top", "bottom", "left", "right"):
if side in m:
setattr(section, f"{side}_margin", Mm(m[side]))
def add_styles(doc, styles: list) -> None:
for s in styles:
style = doc.styles.add_style(s["name"], WD_STYLE_TYPE.PARAGRAPH)
if s.get("base"):
style.base_style = doc.styles[s["base"]]
font = style.font
if s.get("font"):
font.name = s["font"]
if s.get("size_pt"):
font.size = Pt(s["size_pt"])
if s.get("bold") is not None:
font.bold = s["bold"]
if s.get("italic") is not None:
font.italic = s["italic"]
if s.get("color"):
font.color.rgb = RGBColor.from_string(s["color"])
def add_runs(para, block: dict) -> None:
runs = block.get("runs")
if runs is None:
runs = [{"text": block.get("text", "")}]
for r in runs:
run = para.add_run(r.get("text", ""))
if r.get("bold"):
run.bold = True
if r.get("italic"):
run.italic = True
if r.get("underline"):
run.underline = True
def add_block(doc, block: dict) -> None:
btype = block["type"]
if btype == "heading":
doc.add_heading(block.get("text", ""), level=block.get("level", 1))
elif btype == "paragraph":
para = doc.add_paragraph(style=block.get("style"))
add_runs(para, block)
elif btype == "bullet_list":
for item in block.get("items", []):
doc.add_paragraph(item, style="List Bullet")
elif btype == "numbered_list":
for item in block.get("items", []):
doc.add_paragraph(item, style="List Number")
elif btype == "table":
header = block.get("header", [])
rows = block.get("rows", [])
ncols = len(header) if header else (len(rows[0]) if rows else 1)
table = doc.add_table(rows=0, cols=ncols)
table.style = block.get("style", "Table Grid")
if header:
cells = table.add_row().cells
for i, text in enumerate(header):
cells[i].text = str(text)
if block.get("header_bold", True):
for para in cells[i].paragraphs:
for run in para.runs:
run.bold = True
for row in rows:
cells = table.add_row().cells
for i, text in enumerate(row):
cells[i].text = str(text)
elif btype == "image":
width = Mm(block["width_mm"]) if block.get("width_mm") else None
doc.add_picture(block["path"], width=width)
elif btype == "page_break":
doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE)
elif btype == "toc":
from docx_edit import _add_field
para = doc.add_paragraph()
_add_field(para, r' TOC \o "1-3" \h \z \u ',
"Table of contents - open in Word/LibreOffice and "
"update fields to populate.")
else:
raise ValueError(f"unknown block type: {btype}")
def main() -> int:
ap = argparse.ArgumentParser(
description="Create a .docx from a JSON spec.",
epilog="See the module docstring (top of this file) for the spec format.")
ap.add_argument("spec", help="path to JSON spec file")
ap.add_argument("output", help="path of .docx to write")
args = ap.parse_args()
with open(args.spec, encoding="utf-8") as f:
spec = json.load(f)
doc = Document()
if spec.get("page"):
apply_page(doc, spec["page"])
if spec.get("styles"):
add_styles(doc, spec["styles"])
if spec.get("header"):
doc.sections[0].header.paragraphs[0].text = spec["header"]
if spec.get("footer"):
doc.sections[0].footer.paragraphs[0].text = spec["footer"]
for block in spec.get("blocks", []):
add_block(doc, block)
if spec.get("footer_page_numbers"):
from docx_edit import _add_field
para = doc.sections[0].footer.paragraphs[0]
para.add_run("Page ")
_add_field(para, " PAGE ", "1")
para.add_run(" of ")
_add_field(para, " NUMPAGES ", "1")
doc.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"blocks": len(spec.get("blocks", []))}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Edit an existing .docx in place (or to a new file).
Subcommands:
replace find-and-replace text, preserving run formatting
set-cell set the text of a table cell
insert insert a paragraph before a given body paragraph index
delete delete a body paragraph by index
style apply a paragraph style to a body paragraph by index
normalize merge adjacent runs with identical formatting
toc insert a Table of Contents field at a body paragraph index
page-numbers add "Page X of Y" (PAGE/NUMPAGES fields) to the footer
Examples:
docx_edit.py replace in.docx --find old --replace new -o out.docx
docx_edit.py set-cell in.docx --table 0 --row 1 --col 2 --text "42"
docx_edit.py insert in.docx --index 3 --text "New para" --style Normal
docx_edit.py delete in.docx --index 3
docx_edit.py style in.docx --index 0 --style "Heading 1"
docx_edit.py normalize in.docx -o out.docx
docx_edit.py toc in.docx --index 1 -o out.docx
docx_edit.py page-numbers in.docx -o out.docx
Field results (TOC entries, page numbers) are computed by Word or
LibreOffice when the document is opened, not by python-docx; until then
the fields show placeholder text.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx_common import iter_all_paragraphs, replace_in_paragraph
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def _q(tag: str) -> str:
return f"{{{W}}}{tag}"
def cmd_replace(doc, args) -> dict:
n = 0
for para in iter_all_paragraphs(doc):
n += replace_in_paragraph(para, args.find, args.replace)
return {"replacements": n}
def cmd_set_cell(doc, args) -> dict:
cell = doc.tables[args.table].cell(args.row, args.col)
cell.text = args.text
return {"table": args.table, "row": args.row, "col": args.col}
def cmd_insert(doc, args) -> dict:
paras = doc.paragraphs
if args.index < len(paras):
anchor = paras[args.index]
new_para = anchor.insert_paragraph_before(args.text, style=args.style)
else:
new_para = doc.add_paragraph(args.text, style=args.style)
return {"inserted_at": args.index, "text": new_para.text}
def cmd_delete(doc, args) -> dict:
para = doc.paragraphs[args.index]
el = para._element
el.getparent().remove(el)
return {"deleted_index": args.index}
def cmd_style(doc, args) -> dict:
doc.paragraphs[args.index].style = doc.styles[args.style]
return {"index": args.index, "style": args.style}
def _run_format_key(r_el) -> str:
"""Canonical string for a run's w:rPr (None when absent)."""
from lxml import etree
rpr = r_el.find(_q("rPr"))
return "" if rpr is None else etree.tostring(rpr).decode("utf-8")
def cmd_normalize(doc) -> dict:
"""Merge adjacent sibling runs with identical formatting."""
merged = 0
for para in iter_all_paragraphs(doc):
prev = None
for r_el in list(para._p):
if r_el.tag != _q("r"):
prev = None
continue
# only merge plain-text runs (no breaks, tabs, drawings...)
kids = {c.tag for c in r_el} - {_q("rPr"), _q("t")}
if kids:
prev = None
continue
if (prev is not None
and _run_format_key(prev) == _run_format_key(r_el)):
pt = prev.find(_q("t"))
ct = r_el.find(_q("t"))
if pt is None:
pt = prev.makeelement(_q("t"), {})
prev.append(pt)
pt.text = (pt.text or "") + ((ct.text or "")
if ct is not None else "")
pt.set("{http://www.w3.org/XML/1998/namespace}space",
"preserve")
r_el.getparent().remove(r_el)
merged += 1
else:
prev = r_el
return {"runs_merged": merged}
def _add_field(para, instr: str, placeholder: str) -> None:
"""Append a complex field (begin/instrText/separate/result/end)."""
p = para._p
for ftype, extra in (("begin", None), (None, instr),
("separate", None), (None, placeholder),
("end", None)):
r = p.makeelement(_q("r"), {})
p.append(r)
if ftype is not None:
fld = r.makeelement(_q("fldChar"), {_q("fldCharType"): ftype})
r.append(fld)
elif extra is instr:
it = r.makeelement(_q("instrText"), {})
it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
it.text = instr
r.append(it)
else:
t = r.makeelement(_q("t"), {})
t.text = extra
r.append(t)
def cmd_toc(doc, args) -> dict:
paras = doc.paragraphs
if args.index < len(paras):
para = paras[args.index].insert_paragraph_before("")
else:
para = doc.add_paragraph("")
_add_field(para, r' TOC \o "1-3" \h \z \u ',
"Table of contents - open in Word/LibreOffice and update "
"fields to populate.")
return {"toc_inserted_at": args.index}
def cmd_page_numbers(doc, args) -> dict:
footer = doc.sections[0].footer
para = footer.paragraphs[0] if footer.paragraphs \
else footer.add_paragraph()
para.add_run("Page ")
_add_field(para, " PAGE ", "1")
para.add_run(" of ")
_add_field(para, " NUMPAGES ", "1")
return {"footer_fields": ["PAGE", "NUMPAGES"]}
def main() -> int:
ap = argparse.ArgumentParser(description="Edit a .docx file.")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p):
p.add_argument("path", help="input .docx")
p.add_argument("-o", "--output",
help="output path (default: overwrite input)")
p = sub.add_parser("replace", help="find-and-replace text")
common(p)
p.add_argument("--find", required=True)
p.add_argument("--replace", required=True)
p.add_argument("--body-only", action="store_true",
help="skip headers/footers")
p = sub.add_parser("set-cell", help="set table cell text")
common(p)
p.add_argument("--table", type=int, required=True, help="table index")
p.add_argument("--row", type=int, required=True)
p.add_argument("--col", type=int, required=True)
p.add_argument("--text", required=True)
p = sub.add_parser("insert", help="insert paragraph at body index")
common(p)
p.add_argument("--index", type=int, required=True)
p.add_argument("--text", required=True)
p.add_argument("--style", default=None)
p = sub.add_parser("delete", help="delete body paragraph by index")
common(p)
p.add_argument("--index", type=int, required=True)
p = sub.add_parser("style", help="apply style to body paragraph")
common(p)
p.add_argument("--index", type=int, required=True)
p.add_argument("--style", required=True)
p = sub.add_parser("normalize",
help="merge adjacent runs with identical formatting")
common(p)
p = sub.add_parser("toc", help="insert a TOC field (Word computes it)")
common(p)
p.add_argument("--index", type=int, default=0,
help="body paragraph index to insert before (default 0)")
p = sub.add_parser("page-numbers",
help="add PAGE/NUMPAGES fields to the footer")
common(p)
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "replace":
if args.body_only:
n = 0
for para in iter_all_paragraphs(doc, include_headers_footers=False):
n += replace_in_paragraph(para, args.find, args.replace)
result = {"replacements": n}
else:
result = cmd_replace(doc, args)
elif args.cmd == "set-cell":
result = cmd_set_cell(doc, args)
elif args.cmd == "insert":
result = cmd_insert(doc, args)
elif args.cmd == "delete":
result = cmd_delete(doc, args)
elif args.cmd == "normalize":
result = cmd_normalize(doc)
elif args.cmd == "toc":
result = cmd_toc(doc, args)
elif args.cmd == "page-numbers":
result = cmd_page_numbers(doc, args)
else:
result = cmd_style(doc, args)
out = args.output or args.path
doc.save(out)
result.update({"ok": True, "output": out})
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Read a .docx: text, structure outline, styles, images, revision detection.
Usage:
docx_read.py file.docx --text # full text incl. tables + headers/footers
docx_read.py file.docx --structure # JSON outline (headings, tables, counts)
docx_read.py file.docx --styles # JSON list of styles actually used
docx_read.py file.docx --images DIR # extract embedded images into DIR
docx_read.py file.docx --revisions # JSON: tracked changes / comments present?
Text output is JSON: {"body": [...], "tables": [[...rows]], "headers": [...],
"footers": [...]}. Body text is the accepted/as-is text (python-docx ignores
deleted-in-revision text and shows inserted text).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import zipfile
from docx import Document
def table_to_rows(table) -> list:
return [[cell.text for cell in row.cells] for row in table.rows]
def extract_text(doc) -> dict:
out = {"body": [p.text for p in doc.paragraphs],
"tables": [table_to_rows(t) for t in doc.tables],
"headers": [], "footers": []}
for section in doc.sections:
out["headers"].extend(p.text for p in section.header.paragraphs)
out["footers"].extend(p.text for p in section.footer.paragraphs)
for t in section.header.tables:
out["headers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
for t in section.footer.tables:
out["footers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
return out
def extract_structure(doc) -> dict:
outline = []
for i, para in enumerate(doc.paragraphs):
style = para.style.name if para.style else ""
if style.startswith("Heading"):
try:
level = int(style.split()[-1])
except ValueError:
level = 1
outline.append({"index": i, "level": level, "text": para.text})
return {
"outline": outline,
"paragraph_count": len(doc.paragraphs),
"table_count": len(doc.tables),
"tables": [{"rows": len(t.rows), "cols": len(t.columns)}
for t in doc.tables],
"section_count": len(doc.sections),
}
def styles_used(doc) -> list:
used = set()
for para in doc.paragraphs:
if para.style:
used.add(para.style.name)
for run in para.runs:
if run.style:
used.add(run.style.name)
for table in doc.tables:
if table.style:
used.add(table.style.name)
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
if para.style:
used.add(para.style.name)
return sorted(used)
def extract_images(path: str, outdir: str) -> list:
os.makedirs(outdir, exist_ok=True)
written = []
with zipfile.ZipFile(path) as zf:
for name in zf.namelist():
if name.startswith("word/media/"):
target = os.path.join(outdir, os.path.basename(name))
with open(target, "wb") as f:
f.write(zf.read(name))
written.append(target)
return written
def detect_revisions(path: str) -> dict:
"""Detect tracked changes and comments by scanning the raw XML parts."""
markers = {"insertions": b"<w:ins ", "deletions": b"<w:del ",
"format_changes": b"<w:rPrChange"}
result = {k: False for k in markers}
result["comments"] = False
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
result["comments"] = any(n.startswith("word/comments") for n in names)
for name in names:
if name.startswith("word/") and name.endswith(".xml"):
data = zf.read(name)
for key, marker in markers.items():
if marker in data:
result[key] = True
result["has_tracked_changes"] = any(
result[k] for k in ("insertions", "deletions", "format_changes"))
return result
def main() -> int:
ap = argparse.ArgumentParser(description="Read/inspect a .docx file.")
ap.add_argument("path", help=".docx file to read")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--text", action="store_true", help="extract all text as JSON")
g.add_argument("--structure", action="store_true", help="outline JSON")
g.add_argument("--styles", action="store_true", help="styles used, JSON")
g.add_argument("--images", metavar="DIR", help="extract images to DIR")
g.add_argument("--revisions", action="store_true",
help="detect tracked changes / comments")
args = ap.parse_args()
if args.images:
print(json.dumps({"images": extract_images(args.path, args.images)},
ensure_ascii=False))
return 0
if args.revisions:
print(json.dumps(detect_revisions(args.path), ensure_ascii=False))
return 0
doc = Document(args.path)
if args.text:
out = extract_text(doc)
elif args.structure:
out = extract_structure(doc)
else:
out = {"styles": styles_used(doc)}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Inspect and resolve tracked changes (w:ins / w:del) in a .docx.
Subcommands:
list JSON list of revisions: id, author, date, type, text
accept-all accept every insertion and deletion
reject-all reject every insertion and deletion
accept accept one revision by --id
reject reject one revision by --id
Examples:
docx_revisions.py list report.docx
docx_revisions.py accept-all report.docx -o accepted.docx
docx_revisions.py reject report.docx --id 3 -o out.docx
Semantics (direct XML manipulation, python-docx oxml layer):
accept w:ins -> unwrap (keep inserted runs) reject w:ins -> remove
accept w:del -> remove reject w:del -> restore
(restore = w:delText tags renamed to w:t, wrapper unwrapped)
Covers run-level insertions/deletions anywhere in body, tables (nested
included), headers and footers. Row/paragraph-mark revisions and format
changes (w:rPrChange etc.) are reported by docx_read.py --revisions but
not resolved here.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx_common import iter_part_roots
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def q(tag: str) -> str:
return f"{{{W}}}{tag}"
INS, DEL = q("ins"), q("del")
def _iter_revision_elements(doc):
"""Yield every w:ins / w:del element across body, headers, footers."""
for root in iter_part_roots(doc):
for el in root.iter(INS, DEL):
yield el
def _rev_text(el) -> str:
tag = q("delText") if el.tag == DEL else q("t")
return "".join(t.text or "" for t in el.iter(tag))
def _rev_record(el) -> dict:
return {
"id": el.get(q("id")),
"author": el.get(q("author")),
"date": el.get(q("date")),
"type": "insertion" if el.tag == INS else "deletion",
"text": _rev_text(el),
}
def _unwrap(el) -> None:
"""Replace `el` with its children, keeping document order."""
parent = el.getparent()
idx = list(parent).index(el)
for child in list(el):
parent.insert(idx, child)
idx += 1
parent.remove(el)
def _apply(el, accept: bool) -> None:
if el.getparent() is None: # already detached via an outer wrapper
return
if el.tag == INS:
if accept:
_unwrap(el)
else:
el.getparent().remove(el)
else: # w:del
if accept:
el.getparent().remove(el)
else:
for dt in list(el.iter(q("delText"))):
dt.tag = q("t")
_unwrap(el)
def resolve(doc, accept: bool, rev_id: str | None = None) -> int:
targets = [el for el in _iter_revision_elements(doc)
if rev_id is None or el.get(q("id")) == rev_id]
for el in targets:
_apply(el, accept)
return len(targets)
def main() -> int:
ap = argparse.ArgumentParser(
description="List, accept, or reject tracked changes in a .docx.")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p, out=True):
p.add_argument("path", help="input .docx")
if out:
p.add_argument("-o", "--output",
help="output path (default: overwrite input)")
common(sub.add_parser("list", help="list revisions as JSON"), out=False)
common(sub.add_parser("accept-all", help="accept every revision"))
common(sub.add_parser("reject-all", help="reject every revision"))
for name in ("accept", "reject"):
p = sub.add_parser(name, help=f"{name} one revision by id")
common(p)
p.add_argument("--id", required=True, help="revision id (w:id)")
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "list":
revs = [_rev_record(el) for el in _iter_revision_elements(doc)]
print(json.dumps({"ok": True, "revisions": revs}, ensure_ascii=False))
return 0
accept = args.cmd in ("accept-all", "accept")
rev_id = getattr(args, "id", None)
n = resolve(doc, accept, rev_id)
if rev_id is not None and n == 0:
print(json.dumps({"ok": False,
"error": f"no revision with id {rev_id}"}))
return 1
out = args.output or args.path
doc.save(out)
print(json.dumps({"ok": True, "output": out, "resolved": n,
"action": "accept" if accept else "reject"},
ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Fill {{placeholder}} tokens in a .docx from a JSON mapping.
Tokens are replaced everywhere: body paragraphs, tables (including nested
tables), headers and footers. Run formatting is preserved; tokens split
across runs are handled.
Usage:
docx_template.py template.docx values.json output.docx
docx_template.py template.docx values.json output.docx --strict
values.json: {"name": "Ada", "date": "2026-01-01"} fills {{name}}, {{date}}.
With --strict, exits 1 if any {{token}} remains unfilled after processing.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from docx import Document
from docx_common import iter_all_paragraphs, replace_in_paragraph
TOKEN_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}")
def main() -> int:
ap = argparse.ArgumentParser(
description="Fill {{token}} placeholders in a .docx from JSON.")
ap.add_argument("template", help="input .docx with {{tokens}}")
ap.add_argument("values", help="JSON file of token -> value")
ap.add_argument("output", help="output .docx path")
ap.add_argument("--strict", action="store_true",
help="fail if any token remains unfilled")
args = ap.parse_args()
with open(args.values, encoding="utf-8") as f:
values = json.load(f)
doc = Document(args.template)
filled = {}
for para in iter_all_paragraphs(doc):
# Normalize whitespace variants like {{ name }} first.
for m in set(TOKEN_RE.findall(para.text)):
if m in values:
# Replace any spacing variant with canonical token, then fill.
for variant in set(
t.group(0) for t in TOKEN_RE.finditer(para.text)
if t.group(1) == m):
n = replace_in_paragraph(para, variant, str(values[m]))
filled[m] = filled.get(m, 0) + n
remaining = sorted({m for para in iter_all_paragraphs(doc)
for m in TOKEN_RE.findall(para.text)})
doc.save(args.output)
result = {"ok": True, "output": args.output, "filled": filled,
"unfilled_tokens": remaining}
if args.strict and remaining:
result["ok"] = False
print(json.dumps(result, ensure_ascii=False))
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Health-check a .docx package and report issues as JSON.
Usage: docx_validate.py file.docx
Checks (health-check tier, NOT full XSD schema validation):
- the file is a readable zip and python-docx can open it
- required package parts exist ([Content_Types].xml, document.xml)
- every relationship in every .rels file resolves to a part in the
package (dangling image/hyperlink/etc. rels are reported; external
targets such as hyperlinks are skipped)
- r:embed / r:id references in document.xml resolve to relationships
- embedded images are non-empty and start with known magic bytes
(PNG/JPEG/GIF/BMP/TIFF/EMF/WMF/SVG); no PIL required
- paragraph and run style ids referenced by the document exist in
styles.xml
Output: {"ok": bool, "issues": [{"severity": "error"|"warning", ...}]}
Exit code 1 when any error-severity issue is found (warnings exit 0).
"""
from __future__ import annotations
import argparse
import json
import posixpath
import sys
import zipfile
from lxml import etree
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PR = "http://schemas.openxmlformats.org/package/2006/relationships"
IMAGE_MAGIC = (
b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a",
b"BM", b"II*\x00", b"MM\x00*",
b"\x01\x00\x00\x00", # EMF
b"\xd7\xcd\xc6\x9a", b"\x01\x00\x09\x00", # WMF variants
b"<?xml", b"<svg",
)
def _issue(issues, severity, code, detail):
issues.append({"severity": severity, "code": code, "detail": detail})
def _rel_target(base_part: str, target: str) -> str:
base_dir = posixpath.dirname(base_part)
return posixpath.normpath(posixpath.join(base_dir, target)).lstrip("/")
def validate(path: str) -> dict:
issues: list[dict] = []
try:
zf = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as exc:
_issue(issues, "error", "not-a-zip", str(exc))
return {"ok": False, "issues": issues}
names = set(zf.namelist())
bad = zf.testzip()
if bad is not None:
_issue(issues, "error", "corrupt-member", f"CRC check failed: {bad}")
for required in ("[Content_Types].xml", "word/document.xml"):
if required not in names:
_issue(issues, "error", "missing-part",
f"required part absent: {required}")
if issues and any(i["severity"] == "error" for i in issues):
return {"ok": False, "issues": issues}
# --- relationships resolve ------------------------------------------
rel_ids_by_source: dict[str, dict] = {}
for rels_name in [n for n in names if n.endswith(".rels")]:
try:
root = etree.fromstring(zf.read(rels_name))
except etree.XMLSyntaxError as exc:
_issue(issues, "error", "bad-rels-xml", f"{rels_name}: {exc}")
continue
source_part = posixpath.normpath(
posixpath.join(posixpath.dirname(rels_name), ".."))
source_part = "" if source_part == "." else source_part
ids = {}
for rel in root.iter(f"{{{PR}}}Relationship"):
rid, target = rel.get("Id"), rel.get("Target", "")
mode = rel.get("TargetMode", "Internal")
ids[rid] = target
if mode == "External":
continue
resolved = _rel_target(source_part + "/x" if source_part
else "x", target)
if resolved not in names:
_issue(issues, "error", "dangling-rel",
f"{rels_name}: {rid} -> {target} (missing part)")
rel_ids_by_source[source_part or "_package"] = ids
# --- r:id / r:embed references in document.xml -----------------------
doc_root = etree.fromstring(zf.read("word/document.xml"))
doc_rels = rel_ids_by_source.get("word", {})
for el in doc_root.iter():
for attr in (f"{{{R}}}id", f"{{{R}}}embed", f"{{{R}}}link"):
rid = el.get(attr)
if rid and rid not in doc_rels:
_issue(issues, "error", "unresolved-reference",
f"document.xml references {rid} with no relationship")
# --- embedded images decode ------------------------------------------
for name in [n for n in names if n.startswith("word/media/")]:
data = zf.read(name)
if not data:
_issue(issues, "error", "empty-image", name)
elif not any(data.startswith(m) for m in IMAGE_MAGIC):
_issue(issues, "warning", "unknown-image-format",
f"{name}: unrecognized magic bytes")
# --- styles referenced exist ------------------------------------------
defined = set()
if "word/styles.xml" in names:
styles_root = etree.fromstring(zf.read("word/styles.xml"))
defined = {s.get(f"{{{W}}}styleId")
for s in styles_root.iter(f"{{{W}}}style")}
for tag, attr in ((f"{{{W}}}pStyle", f"{{{W}}}val"),
(f"{{{W}}}rStyle", f"{{{W}}}val"),
(f"{{{W}}}tblStyle", f"{{{W}}}val")):
for el in doc_root.iter(tag):
sid = el.get(attr)
if sid and sid not in defined:
_issue(issues, "error", "missing-style",
f"style id referenced but not defined: {sid}")
# --- python-docx can open it ------------------------------------------
try:
from docx import Document
Document(path)
except Exception as exc: # noqa: BLE001 - triage tool, report anything
_issue(issues, "error", "python-docx-open-failed", str(exc))
ok = not any(i["severity"] == "error" for i in issues)
return {"ok": ok, "issues": issues}
def main() -> int:
ap = argparse.ArgumentParser(
description="Health-check a .docx (not XSD schema validation).")
ap.add_argument("path", help="the .docx file to check")
args = ap.parse_args()
report = validate(args.path)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
sys.exit(main())