Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user