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.
260 lines
9.6 KiB
Python
260 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Create an .xlsx workbook from a JSON spec.
|
|
|
|
Spec (JSON object):
|
|
{
|
|
"full_calc_on_load": true, # force recalculation on open (optional)
|
|
"defined_names": {"Rates": "'Data'!$B$2:$B$4"}, # workbook scope
|
|
"sheets": [
|
|
{
|
|
"name": "Data",
|
|
"rows": [["Header", 1, true], ...], # scalars or cell objects (see below)
|
|
"cells": {"A1": {"value": 5, "format": "0.00%"}}, # sparse overrides
|
|
"column_widths": {"A": 22, "B": 12},
|
|
"row_heights": {"1": 24},
|
|
"merges": ["A1:C1"],
|
|
"freeze_panes": "A2",
|
|
"autofilter": "A1:C10",
|
|
"conditional_formats": [
|
|
{"range": "B2:B9", "type": "cell_is", "operator": "greaterThan",
|
|
"formula": ["100"], "fill": "FFC7CE"},
|
|
{"range": "C2:C9", "type": "color_scale"}
|
|
],
|
|
"charts": [
|
|
{"type": "bar", "title": "Sales", "anchor": "F2",
|
|
"data": "B1:B5", "categories": "A2:A5"}
|
|
],
|
|
"validations": [
|
|
{"range": "D2:D9", "type": "list", "formula1": "\"Yes,No,Maybe\""}
|
|
],
|
|
"tables": [
|
|
{"name": "Sales", "range": "A1:C4",
|
|
"style": "TableStyleMedium9"} # native Excel table
|
|
],
|
|
"protection": {"password": "your-password", # NOT security --
|
|
"unlock": ["B2:B9"]} # see SKILL.md Pitfalls
|
|
}
|
|
]
|
|
}
|
|
|
|
Cell object keys (all optional except value/formula):
|
|
value scalar; JSON true/false -> bool, numbers stay numeric
|
|
type "date" or "datetime" -> value parsed from ISO string
|
|
formula e.g. "=SUM(A2:A9)" (leading '=' optional)
|
|
hyperlink URL; value becomes the display text
|
|
note cell note text (or {"text": ..., "author": ...})
|
|
format Excel number format, e.g. "$#,##0.00", "0.0%", "yyyy-mm-dd"
|
|
bold, italic booleans
|
|
font_size points
|
|
font_color hex RGB like "FF0000"
|
|
fill solid fill hex RGB like "DDEBF7"
|
|
border "thin" | "medium" | "thick" (all four sides)
|
|
align "left" | "center" | "right"
|
|
valign "top" | "center" | "bottom"
|
|
wrap boolean (wrap text)
|
|
|
|
Usage:
|
|
xlsx_create.py spec.json out.xlsx
|
|
xlsx_create.py - out.xlsx (spec on stdin)
|
|
|
|
Prints a JSON summary to stdout; exits non-zero on failure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import date, datetime
|
|
|
|
from openpyxl import Workbook
|
|
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
|
|
from openpyxl.comments import Comment
|
|
from openpyxl.formatting.rule import CellIsRule, ColorScaleRule
|
|
from openpyxl.styles import (Alignment, Border, Font, PatternFill,
|
|
Protection, Side)
|
|
from openpyxl.utils import column_index_from_string, range_boundaries
|
|
from openpyxl.workbook.defined_name import DefinedName
|
|
from openpyxl.worksheet.datavalidation import DataValidation
|
|
from openpyxl.worksheet.table import Table, TableStyleInfo
|
|
|
|
|
|
def parse_typed(value, type_hint=None):
|
|
if type_hint == "date" and isinstance(value, str):
|
|
return date.fromisoformat(value)
|
|
if type_hint == "datetime" and isinstance(value, str):
|
|
return datetime.fromisoformat(value)
|
|
return value
|
|
|
|
|
|
def apply_cell(ws, coord, spec):
|
|
cell = ws[coord]
|
|
if isinstance(spec, dict):
|
|
if "formula" in spec:
|
|
f = spec["formula"]
|
|
cell.value = f if f.startswith("=") else "=" + f
|
|
elif "value" in spec:
|
|
cell.value = parse_typed(spec["value"], spec.get("type"))
|
|
if "hyperlink" in spec:
|
|
cell.hyperlink = spec["hyperlink"]
|
|
if cell.value is None:
|
|
cell.value = spec["hyperlink"]
|
|
cell.style = "Hyperlink"
|
|
if "note" in spec:
|
|
note = spec["note"]
|
|
if isinstance(note, dict):
|
|
cell.comment = Comment(note.get("text", ""),
|
|
note.get("author", "xlsx-skill"))
|
|
else:
|
|
cell.comment = Comment(str(note), "xlsx-skill")
|
|
if "format" in spec:
|
|
cell.number_format = spec["format"]
|
|
font_kw = {}
|
|
if spec.get("bold"):
|
|
font_kw["bold"] = True
|
|
if spec.get("italic"):
|
|
font_kw["italic"] = True
|
|
if "font_size" in spec:
|
|
font_kw["size"] = spec["font_size"]
|
|
if "font_color" in spec:
|
|
font_kw["color"] = spec["font_color"]
|
|
if font_kw:
|
|
cell.font = Font(**font_kw)
|
|
if "fill" in spec:
|
|
cell.fill = PatternFill("solid", fgColor=spec["fill"])
|
|
if "border" in spec:
|
|
side = Side(style=spec["border"])
|
|
cell.border = Border(left=side, right=side, top=side, bottom=side)
|
|
align_kw = {}
|
|
if "align" in spec:
|
|
align_kw["horizontal"] = spec["align"]
|
|
if "valign" in spec:
|
|
align_kw["vertical"] = spec["valign"]
|
|
if spec.get("wrap"):
|
|
align_kw["wrap_text"] = True
|
|
if align_kw:
|
|
cell.alignment = Alignment(**align_kw)
|
|
else:
|
|
cell.value = spec
|
|
|
|
|
|
def ref_from_range(ws, rng):
|
|
min_col, min_row, max_col, max_row = range_boundaries(rng)
|
|
return Reference(ws, min_col=min_col, min_row=min_row,
|
|
max_col=max_col, max_row=max_row)
|
|
|
|
|
|
def add_chart(ws, spec):
|
|
kind = spec.get("type", "bar")
|
|
chart = {"bar": BarChart, "line": LineChart, "pie": PieChart}[kind]()
|
|
if "title" in spec:
|
|
chart.title = spec["title"]
|
|
data = ref_from_range(ws, spec["data"])
|
|
chart.add_data(data, titles_from_data=spec.get("titles_from_data", True))
|
|
if "categories" in spec:
|
|
chart.set_categories(ref_from_range(ws, spec["categories"]))
|
|
ws.add_chart(chart, spec.get("anchor", "H2"))
|
|
|
|
|
|
def add_conditional(ws, spec):
|
|
rng = spec["range"]
|
|
kind = spec.get("type", "cell_is")
|
|
if kind == "color_scale":
|
|
rule = ColorScaleRule(
|
|
start_type="min", start_color=spec.get("start_color", "FFF8696B"),
|
|
end_type="max", end_color=spec.get("end_color", "FF63BE7B"))
|
|
else:
|
|
fill = PatternFill("solid", fgColor=spec.get("fill", "FFC7CE"))
|
|
rule = CellIsRule(operator=spec.get("operator", "greaterThan"),
|
|
formula=spec.get("formula", ["0"]), fill=fill)
|
|
ws.conditional_formatting.add(rng, rule)
|
|
|
|
|
|
def build_sheet(ws, spec):
|
|
for row in spec.get("rows", []):
|
|
values, styled = [], []
|
|
for item in row:
|
|
if isinstance(item, dict):
|
|
values.append(None)
|
|
styled.append(item)
|
|
else:
|
|
values.append(item)
|
|
styled.append(None)
|
|
ws.append(values)
|
|
r = ws.max_row
|
|
for idx, item in enumerate(styled, start=1):
|
|
if item is not None:
|
|
apply_cell(ws, ws.cell(row=r, column=idx).coordinate, item)
|
|
for coord, cell_spec in spec.get("cells", {}).items():
|
|
apply_cell(ws, coord, cell_spec)
|
|
for col, width in spec.get("column_widths", {}).items():
|
|
ws.column_dimensions[col].width = width
|
|
for row, height in spec.get("row_heights", {}).items():
|
|
ws.row_dimensions[int(row)].height = height
|
|
for rng in spec.get("merges", []):
|
|
ws.merge_cells(rng)
|
|
if spec.get("freeze_panes"):
|
|
ws.freeze_panes = spec["freeze_panes"]
|
|
if spec.get("autofilter"):
|
|
ws.auto_filter.ref = spec["autofilter"]
|
|
for cf in spec.get("conditional_formats", []):
|
|
add_conditional(ws, cf)
|
|
for ch in spec.get("charts", []):
|
|
add_chart(ws, ch)
|
|
for dv_spec in spec.get("validations", []):
|
|
dv = DataValidation(type=dv_spec.get("type", "list"),
|
|
formula1=dv_spec["formula1"],
|
|
allow_blank=dv_spec.get("allow_blank", True))
|
|
dv.add(dv_spec["range"])
|
|
ws.add_data_validation(dv)
|
|
for t_spec in spec.get("tables", []):
|
|
table = Table(displayName=t_spec["name"], ref=t_spec["range"])
|
|
table.tableStyleInfo = TableStyleInfo(
|
|
name=t_spec.get("style", "TableStyleMedium9"),
|
|
showRowStripes=t_spec.get("row_stripes", True),
|
|
showColumnStripes=t_spec.get("column_stripes", False))
|
|
ws.add_table(table)
|
|
prot = spec.get("protection")
|
|
if prot:
|
|
for rng in prot.get("unlock", []):
|
|
for row in ws[rng]:
|
|
for cell in row:
|
|
cell.protection = Protection(locked=False)
|
|
if prot.get("password"):
|
|
ws.protection.password = prot["password"]
|
|
ws.protection.sheet = True
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description="Create .xlsx from a JSON spec.")
|
|
ap.add_argument("spec", help="path to JSON spec, or '-' for stdin")
|
|
ap.add_argument("output", help="output .xlsx path")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.spec == "-":
|
|
spec = json.load(sys.stdin)
|
|
else:
|
|
with open(args.spec, encoding="utf-8") as fh:
|
|
spec = json.load(fh)
|
|
|
|
wb = Workbook()
|
|
wb.remove(wb.active)
|
|
for sheet_spec in spec.get("sheets", []):
|
|
ws = wb.create_sheet(sheet_spec.get("name", "Sheet1"))
|
|
build_sheet(ws, sheet_spec)
|
|
for name, ref in spec.get("defined_names", {}).items():
|
|
wb.defined_names[name] = DefinedName(name, attr_text=ref)
|
|
if spec.get("full_calc_on_load"):
|
|
wb.calculation.fullCalcOnLoad = True
|
|
wb.save(args.output)
|
|
print(json.dumps({"ok": True, "output": args.output,
|
|
"sheets": wb.sheetnames}, 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)
|