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.
264 lines
11 KiB
Python
264 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Edit an existing .xlsx workbook in place (or to --out).
|
|
|
|
Operations (repeatable where noted, applied in the order listed below):
|
|
--rename-sheet OLD:NEW rename a sheet
|
|
--copy-sheet SRC:NEW duplicate a sheet under a new name
|
|
--insert-rows IDX[:N] insert N rows before row IDX (default N=1)
|
|
--delete-rows IDX[:N] delete N rows starting at row IDX
|
|
--insert-cols IDX[:N] insert N columns before column IDX (number)
|
|
--delete-cols IDX[:N] delete N columns starting at column IDX
|
|
--set CELL=VALUE repeatable; type-inferred (int, float, bool,
|
|
ISO date, else string). '=...' sets a formula.
|
|
--append ROWJSON repeatable; JSON array appended as a row
|
|
--add-table NAME:RANGE[:STYLE] create a native Excel table (ListObject)
|
|
--table-append NAME=ROWJSON append a row inside a table, auto-extending
|
|
the table's range (repeatable)
|
|
--list-tables print tables on the target sheet and exit
|
|
--define-name NAME=REF workbook-scope defined name, e.g.
|
|
"Rates='Data'!$B$2:$B$9" (repeatable)
|
|
--delete-name NAME remove a defined name (repeatable)
|
|
--hyperlink CELL=URL[|TEXT] set a hyperlink (optional display text)
|
|
--note CELL=TEXT[|AUTHOR] set a cell note/comment (repeatable)
|
|
--clear-note CELL remove a cell note (repeatable)
|
|
--protect [PASSWORD] enable sheet protection; combine with
|
|
--unlock RANGE to leave ranges editable.
|
|
NOT security: trivially strippable (see
|
|
SKILL.md Pitfalls).
|
|
--recalc set fullCalcOnLoad so Excel/LibreOffice
|
|
recomputes all formulas on next open
|
|
|
|
WARNING: openpyxl does NOT shift merged-cell ranges, chart anchors, or
|
|
formula references when rows/columns are inserted or deleted. Verify any
|
|
sheet containing merges or formulas after structural edits — or use
|
|
xlsx_restructure.py, which rewrites references for you.
|
|
|
|
Usage:
|
|
xlsx_edit.py book.xlsx --sheet Data --set B2=42 --set C2=2026-01-01 \
|
|
--set "D2==SUM(B2:C2)" --recalc
|
|
xlsx_edit.py book.xlsx --sheet Data --append '["Widget", 9.99, true]'
|
|
xlsx_edit.py book.xlsx --copy-sheet Data:Backup --rename-sheet Data:Main
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import date, datetime
|
|
|
|
from openpyxl import load_workbook
|
|
from openpyxl.comments import Comment
|
|
from openpyxl.styles import Protection
|
|
from openpyxl.utils import get_column_letter, range_boundaries
|
|
from openpyxl.workbook.defined_name import DefinedName
|
|
from openpyxl.worksheet.table import Table, TableStyleInfo
|
|
|
|
|
|
def infer(text):
|
|
if text.startswith("="):
|
|
return text # formula
|
|
low = text.lower()
|
|
if low in ("true", "false"):
|
|
return low == "true"
|
|
for caster in (int, float):
|
|
try:
|
|
return caster(text)
|
|
except ValueError:
|
|
pass
|
|
for parser in (date.fromisoformat, datetime.fromisoformat):
|
|
try:
|
|
return parser(text)
|
|
except ValueError:
|
|
pass
|
|
return text
|
|
|
|
|
|
def parse_idx(arg):
|
|
if ":" in arg:
|
|
idx, n = arg.split(":", 1)
|
|
return int(idx), int(n)
|
|
return int(arg), 1
|
|
|
|
|
|
def add_table(ws, spec):
|
|
parts = spec.split(":")
|
|
if len(parts) < 3:
|
|
raise ValueError("--add-table needs NAME:RANGE like Sales:A1:C9")
|
|
name = parts[0]
|
|
rng = ":".join(parts[1:3])
|
|
style = parts[3] if len(parts) > 3 else "TableStyleMedium9"
|
|
table = Table(displayName=name, ref=rng)
|
|
table.tableStyleInfo = TableStyleInfo(name=style, showRowStripes=True)
|
|
ws.add_table(table)
|
|
|
|
|
|
def table_append(ws, name, row_values):
|
|
table = ws.tables[name]
|
|
min_col, min_row, max_col, max_row = range_boundaries(table.ref)
|
|
new_row = max_row + 1
|
|
for offset, value in enumerate(row_values):
|
|
ws.cell(row=new_row, column=min_col + offset, value=value)
|
|
table.ref = (f"{get_column_letter(min_col)}{min_row}:"
|
|
f"{get_column_letter(max_col)}{new_row}")
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description="Edit an existing .xlsx workbook.",
|
|
epilog="Plain insert/delete does not shift merges/formula refs — "
|
|
"use xlsx_restructure.py for reference-aware moves.")
|
|
ap.add_argument("file", help="path to .xlsx file")
|
|
ap.add_argument("--sheet", help="target sheet (default: active)")
|
|
ap.add_argument("--out", help="output path (default: edit in place)")
|
|
ap.add_argument("--rename-sheet", action="append", default=[],
|
|
metavar="OLD:NEW")
|
|
ap.add_argument("--copy-sheet", action="append", default=[],
|
|
metavar="SRC:NEW")
|
|
ap.add_argument("--insert-rows", action="append", default=[],
|
|
metavar="IDX[:N]")
|
|
ap.add_argument("--delete-rows", action="append", default=[],
|
|
metavar="IDX[:N]")
|
|
ap.add_argument("--insert-cols", action="append", default=[],
|
|
metavar="IDX[:N]")
|
|
ap.add_argument("--delete-cols", action="append", default=[],
|
|
metavar="IDX[:N]")
|
|
ap.add_argument("--set", action="append", default=[], metavar="CELL=VALUE")
|
|
ap.add_argument("--append", action="append", default=[], metavar="ROWJSON")
|
|
ap.add_argument("--add-table", action="append", default=[],
|
|
metavar="NAME:RANGE[:STYLE]")
|
|
ap.add_argument("--table-append", action="append", default=[],
|
|
metavar="NAME=ROWJSON")
|
|
ap.add_argument("--list-tables", action="store_true",
|
|
help="print tables on the target sheet and exit")
|
|
ap.add_argument("--define-name", action="append", default=[],
|
|
metavar="NAME=REF")
|
|
ap.add_argument("--delete-name", action="append", default=[],
|
|
metavar="NAME")
|
|
ap.add_argument("--hyperlink", action="append", default=[],
|
|
metavar="CELL=URL[|TEXT]")
|
|
ap.add_argument("--note", action="append", default=[],
|
|
metavar="CELL=TEXT[|AUTHOR]")
|
|
ap.add_argument("--clear-note", action="append", default=[],
|
|
metavar="CELL")
|
|
ap.add_argument("--protect", nargs="?", const="", metavar="PASSWORD",
|
|
help="protect the target sheet (integrity signal only, "
|
|
"NOT security)")
|
|
ap.add_argument("--unlock", action="append", default=[], metavar="RANGE",
|
|
help="cell range left editable under --protect")
|
|
ap.add_argument("--recalc", action="store_true",
|
|
help="force full recalculation when the file is opened")
|
|
args = ap.parse_args(argv)
|
|
|
|
wb = load_workbook(args.file)
|
|
changes = []
|
|
|
|
for pair in args.rename_sheet:
|
|
old, new = pair.split(":", 1)
|
|
wb[old].title = new
|
|
changes.append(f"rename {old}->{new}")
|
|
for pair in args.copy_sheet:
|
|
src, new = pair.split(":", 1)
|
|
copy = wb.copy_worksheet(wb[src])
|
|
copy.title = new
|
|
changes.append(f"copy {src}->{new}")
|
|
|
|
ws = wb[args.sheet] if args.sheet else wb.active
|
|
|
|
if args.list_tables:
|
|
print(json.dumps({"ok": True, "sheet": ws.title,
|
|
"tables": {t.displayName: {
|
|
"ref": t.ref,
|
|
"style": t.tableStyleInfo.name
|
|
if t.tableStyleInfo else None}
|
|
for t in ws.tables.values()}},
|
|
ensure_ascii=False))
|
|
return 0
|
|
|
|
for arg in args.insert_rows:
|
|
idx, n = parse_idx(arg)
|
|
ws.insert_rows(idx, n)
|
|
changes.append(f"insert_rows {idx}x{n}")
|
|
for arg in args.delete_rows:
|
|
idx, n = parse_idx(arg)
|
|
ws.delete_rows(idx, n)
|
|
changes.append(f"delete_rows {idx}x{n}")
|
|
for arg in args.insert_cols:
|
|
idx, n = parse_idx(arg)
|
|
ws.insert_cols(idx, n)
|
|
changes.append(f"insert_cols {idx}x{n}")
|
|
for arg in args.delete_cols:
|
|
idx, n = parse_idx(arg)
|
|
ws.delete_cols(idx, n)
|
|
changes.append(f"delete_cols {idx}x{n}")
|
|
|
|
for assignment in args.set:
|
|
coord, raw = assignment.split("=", 1)
|
|
ws[coord] = infer(raw)
|
|
changes.append(f"set {coord}")
|
|
for row_json in args.append:
|
|
ws.append(json.loads(row_json))
|
|
changes.append(f"append row {ws.max_row}")
|
|
|
|
for spec in args.add_table:
|
|
add_table(ws, spec)
|
|
changes.append(f"add_table {spec.split(':')[0]}")
|
|
for spec in args.table_append:
|
|
name, row_json = spec.split("=", 1)
|
|
table_append(ws, name, json.loads(row_json))
|
|
changes.append(f"table_append {name} -> {ws.tables[name].ref}")
|
|
|
|
for spec in args.define_name:
|
|
name, ref = spec.split("=", 1)
|
|
wb.defined_names[name] = DefinedName(name, attr_text=ref)
|
|
changes.append(f"define_name {name}")
|
|
for name in args.delete_name:
|
|
del wb.defined_names[name]
|
|
changes.append(f"delete_name {name}")
|
|
|
|
for spec in args.hyperlink:
|
|
coord, rest = spec.split("=", 1)
|
|
url, _, text = rest.partition("|")
|
|
cell = ws[coord]
|
|
cell.hyperlink = url
|
|
cell.value = text or (cell.value if cell.value is not None else url)
|
|
cell.style = "Hyperlink"
|
|
changes.append(f"hyperlink {coord}")
|
|
for spec in args.note:
|
|
coord, rest = spec.split("=", 1)
|
|
text, _, author = rest.partition("|")
|
|
ws[coord].comment = Comment(text, author or "xlsx-skill")
|
|
changes.append(f"note {coord}")
|
|
for coord in args.clear_note:
|
|
ws[coord].comment = None
|
|
changes.append(f"clear_note {coord}")
|
|
|
|
if args.protect is not None:
|
|
for rng in args.unlock:
|
|
for row in ws[rng]:
|
|
for cell in row:
|
|
cell.protection = Protection(locked=False)
|
|
if args.protect:
|
|
ws.protection.password = args.protect
|
|
ws.protection.sheet = True
|
|
changes.append(f"protect {ws.title}"
|
|
+ (f" (unlocked {len(args.unlock)} ranges)"
|
|
if args.unlock else ""))
|
|
|
|
if args.recalc:
|
|
wb.calculation.fullCalcOnLoad = True
|
|
changes.append("fullCalcOnLoad")
|
|
|
|
out = args.out or args.file
|
|
wb.save(out)
|
|
print(json.dumps({"ok": True, "output": out, "sheet": ws.title,
|
|
"changes": changes}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as exc: # noqa: BLE001
|
|
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
|
sys.exit(1)
|