scripts/docx_edit.py
scripts/docx_edit.pyBrowse 12 files
2,178 tokens
8,619 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"""Edit an existing .docx in place (or to a new file).4 5Subcommands:6 replace find-and-replace text, preserving run formatting7 set-cell set the text of a table cell8 insert insert a paragraph before a given body paragraph index9 delete delete a body paragraph by index10 style apply a paragraph style to a body paragraph by index11 normalize merge adjacent runs with identical formatting12 toc insert a Table of Contents field at a body paragraph index13 page-numbers add "Page X of Y" (PAGE/NUMPAGES fields) to the footer14 15Examples:16 docx_edit.py replace in.docx --find old --replace new -o out.docx17 docx_edit.py set-cell in.docx --table 0 --row 1 --col 2 --text "42"18 docx_edit.py insert in.docx --index 3 --text "New para" --style Normal19 docx_edit.py delete in.docx --index 320 docx_edit.py style in.docx --index 0 --style "Heading 1"21 docx_edit.py normalize in.docx -o out.docx22 docx_edit.py toc in.docx --index 1 -o out.docx23 docx_edit.py page-numbers in.docx -o out.docx24 25Field results (TOC entries, page numbers) are computed by Word or26LibreOffice when the document is opened, not by python-docx; until then27the fields show placeholder text.28"""29from __future__ import annotations30 31import argparse32import json33import sys34 35from docx import Document36 37from docx_common import iter_all_paragraphs, replace_in_paragraph38 39W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"40 41 42def _q(tag: str) -> str:43 return f"{{{W}}}{tag}"44 45 46def cmd_replace(doc, args) -> dict:47 n = 048 for para in iter_all_paragraphs(doc):49 n += replace_in_paragraph(para, args.find, args.replace)50 return {"replacements": n}51 52 53def cmd_set_cell(doc, args) -> dict:54 cell = doc.tables[args.table].cell(args.row, args.col)55 cell.text = args.text56 return {"table": args.table, "row": args.row, "col": args.col}57 58 59def cmd_insert(doc, args) -> dict:60 paras = doc.paragraphs61 if args.index < len(paras):62 anchor = paras[args.index]63 new_para = anchor.insert_paragraph_before(args.text, style=args.style)64 else:65 new_para = doc.add_paragraph(args.text, style=args.style)66 return {"inserted_at": args.index, "text": new_para.text}67 68 69def cmd_delete(doc, args) -> dict:70 para = doc.paragraphs[args.index]71 el = para._element72 el.getparent().remove(el)73 return {"deleted_index": args.index}74 75 76def cmd_style(doc, args) -> dict:77 doc.paragraphs[args.index].style = doc.styles[args.style]78 return {"index": args.index, "style": args.style}79 80 81def _run_format_key(r_el) -> str:82 """Canonical string for a run's w:rPr (None when absent)."""83 from lxml import etree84 rpr = r_el.find(_q("rPr"))85 return "" if rpr is None else etree.tostring(rpr).decode("utf-8")86 87 88def cmd_normalize(doc) -> dict:89 """Merge adjacent sibling runs with identical formatting."""90 merged = 091 for para in iter_all_paragraphs(doc):92 prev = None93 for r_el in list(para._p):94 if r_el.tag != _q("r"):95 prev = None96 continue97 # only merge plain-text runs (no breaks, tabs, drawings...)98 kids = {c.tag for c in r_el} - {_q("rPr"), _q("t")}99 if kids:100 prev = None101 continue102 if (prev is not None103 and _run_format_key(prev) == _run_format_key(r_el)):104 pt = prev.find(_q("t"))105 ct = r_el.find(_q("t"))106 if pt is None:107 pt = prev.makeelement(_q("t"), {})108 prev.append(pt)109 pt.text = (pt.text or "") + ((ct.text or "")110 if ct is not None else "")111 pt.set("{http://www.w3.org/XML/1998/namespace}space",112 "preserve")113 r_el.getparent().remove(r_el)114 merged += 1115 else:116 prev = r_el117 return {"runs_merged": merged}118 119 120def _add_field(para, instr: str, placeholder: str) -> None:121 """Append a complex field (begin/instrText/separate/result/end)."""122 p = para._p123 for ftype, extra in (("begin", None), (None, instr),124 ("separate", None), (None, placeholder),125 ("end", None)):126 r = p.makeelement(_q("r"), {})127 p.append(r)128 if ftype is not None:129 fld = r.makeelement(_q("fldChar"), {_q("fldCharType"): ftype})130 r.append(fld)131 elif extra is instr:132 it = r.makeelement(_q("instrText"), {})133 it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")134 it.text = instr135 r.append(it)136 else:137 t = r.makeelement(_q("t"), {})138 t.text = extra139 r.append(t)140 141 142def cmd_toc(doc, args) -> dict:143 paras = doc.paragraphs144 if args.index < len(paras):145 para = paras[args.index].insert_paragraph_before("")146 else:147 para = doc.add_paragraph("")148 _add_field(para, r' TOC \o "1-3" \h \z \u ',149 "Table of contents - open in Word/LibreOffice and update "150 "fields to populate.")151 return {"toc_inserted_at": args.index}152 153 154def cmd_page_numbers(doc, args) -> dict:155 footer = doc.sections[0].footer156 para = footer.paragraphs[0] if footer.paragraphs \157 else footer.add_paragraph()158 para.add_run("Page ")159 _add_field(para, " PAGE ", "1")160 para.add_run(" of ")161 _add_field(para, " NUMPAGES ", "1")162 return {"footer_fields": ["PAGE", "NUMPAGES"]}163 164 165def main() -> int:166 ap = argparse.ArgumentParser(description="Edit a .docx file.")167 sub = ap.add_subparsers(dest="cmd", required=True)168 169 def common(p):170 p.add_argument("path", help="input .docx")171 p.add_argument("-o", "--output",172 help="output path (default: overwrite input)")173 174 p = sub.add_parser("replace", help="find-and-replace text")175 common(p)176 p.add_argument("--find", required=True)177 p.add_argument("--replace", required=True)178 p.add_argument("--body-only", action="store_true",179 help="skip headers/footers")180 181 p = sub.add_parser("set-cell", help="set table cell text")182 common(p)183 p.add_argument("--table", type=int, required=True, help="table index")184 p.add_argument("--row", type=int, required=True)185 p.add_argument("--col", type=int, required=True)186 p.add_argument("--text", required=True)187 188 p = sub.add_parser("insert", help="insert paragraph at body index")189 common(p)190 p.add_argument("--index", type=int, required=True)191 p.add_argument("--text", required=True)192 p.add_argument("--style", default=None)193 194 p = sub.add_parser("delete", help="delete body paragraph by index")195 common(p)196 p.add_argument("--index", type=int, required=True)197 198 p = sub.add_parser("style", help="apply style to body paragraph")199 common(p)200 p.add_argument("--index", type=int, required=True)201 p.add_argument("--style", required=True)202 203 p = sub.add_parser("normalize",204 help="merge adjacent runs with identical formatting")205 common(p)206 207 p = sub.add_parser("toc", help="insert a TOC field (Word computes it)")208 common(p)209 p.add_argument("--index", type=int, default=0,210 help="body paragraph index to insert before (default 0)")211 212 p = sub.add_parser("page-numbers",213 help="add PAGE/NUMPAGES fields to the footer")214 common(p)215 216 args = ap.parse_args()217 doc = Document(args.path)218 219 if args.cmd == "replace":220 if args.body_only:221 n = 0222 for para in iter_all_paragraphs(doc, include_headers_footers=False):223 n += replace_in_paragraph(para, args.find, args.replace)224 result = {"replacements": n}225 else:226 result = cmd_replace(doc, args)227 elif args.cmd == "set-cell":228 result = cmd_set_cell(doc, args)229 elif args.cmd == "insert":230 result = cmd_insert(doc, args)231 elif args.cmd == "delete":232 result = cmd_delete(doc, args)233 elif args.cmd == "normalize":234 result = cmd_normalize(doc)235 elif args.cmd == "toc":236 result = cmd_toc(doc, args)237 elif args.cmd == "page-numbers":238 result = cmd_page_numbers(doc, args)239 else:240 result = cmd_style(doc, args)241 242 out = args.output or args.path243 doc.save(out)244 result.update({"ok": True, "output": out})245 print(json.dumps(result, ensure_ascii=False))246 return 0247 248 249if __name__ == "__main__":250 sys.exit(main())251 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 52.SKILL.mdView in source ↗52python scripts/docx_read.py out.docx --text53python scripts/docx_edit.py replace out.docx --find old --replace new54python scripts/docx_template.py tpl.docx values.json filled.docx
Source excerpt starting at line 104.104 under `word/media/` out of the package.1053. **Edit.** Use `scripts/docx_edit.py`. `replace` walks body, tables106 (nested included), headers and footers, and preserves run formatting;