fad88cf130
Same clean-room discipline as the initial rewrite (isolated subagents, functional specs only, predecessor content banned including via git history; transcripts retained). All additions test-proven. docx (13->29 tests): - docx_revisions.py: tracked changes list/accept/reject (all or by id), incl. tables and headers/footers, via direct oxml manipulation - docx_comments.py: list/add/delete comments (native python-docx >=1.2 API with XML fallback), anchored-text extraction - docx_validate.py: package health check (rels, images, styles, CRC) with JSON severity report — explicitly not XSD validation - docx_edit.py: run normalization; TOC + PAGE/NUMPAGES field insertion xlsx (5->12 tests): - xlsx_restructure.py: reference-aware insert/delete rows/cols — rewrites formulas on all sheets (absolute refs, ranges, cross-sheet, quoted names), shifts merges/autofilter/freeze/validation/CF ranges, tables, defined names; JSON report incl. honest not_shifted list - native Excel tables, named ranges, hyperlinks, cell notes, sheet protection (documented as strippable, not security) - xlsx_recalc.py: headless LibreOffice recalc with graceful degrade powerpoint (11->21 tests): - pptx_render.py: all slides -> PNGs (soffice + pdftoppm/pdftocairo), wired to vision_analyze review loop in SKILL.md - run-merge normalize before replace (identical-format splits lossless) - surgical chart ops (series/category/title) wrapping replace_data - slide duplication with rel remap (clean refusal on chart slides) - backgrounds, hyperlinks, slide numbers, footers, notes editing pdf (8->21 tests): - pdf_make_form.py: JSON spec -> AcroForm (text/checkbox/radio/dropdown) - pdf_form_layout.py: pre-build layout lint (bounds/overlap/pairing) + rendered box overlay for vision_analyze review - pdf_page_image.py + shared _raster.py: pypdfium2 -> pdftoppm chain, graceful degrade; connected to scanned-PDF triage flow - pdf_stamp.py: text/image stamps at coordinates (rotation/opacity) - pdf_meta.py: DocInfo metadata + attachments round-trip Gates re-verified independently: 83 skill tests green under LC_ALL=C, repo invariant suite 29/29, SkillEvaluator pii+unicode+lint 3/3 x4.
148 lines
4.5 KiB
Python
148 lines
4.5 KiB
Python
#!/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())
|