Files
aiturk-hermes-ide/skills/productivity/docx/scripts/docx_edit.py
T

251 lines
8.4 KiB
Python

#!/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())