Files
aiturk-hermes-ide/skills/productivity/xlsx/scripts/xlsx_create.py
T

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)