Files
hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py
T
Teknium fad88cf130 feat: extend clean-room office skills toward full parity
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.
2026-08-08 10:46:20 -07:00

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)