qubicl-cli 0.1.0-dev.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +129 -19
  2. package/dist/SBOM.spdx.json +222 -0
  3. package/dist/THIRD_PARTY_NOTICES.txt +244 -0
  4. package/dist/assets/chromium-seccomp.json +181 -0
  5. package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
  6. package/dist/assets/computer/Dockerfile +191 -49
  7. package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
  8. package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
  9. package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
  10. package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
  11. package/dist/assets/computer/browser-skills-requirements.txt +12 -0
  12. package/dist/assets/computer/chromium-wrapper.sh +12 -0
  13. package/dist/assets/computer/control.mjs +112 -52
  14. package/dist/assets/computer/entrypoint.sh +272 -35
  15. package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
  16. package/dist/assets/computer/manifests/browser.json +72 -0
  17. package/dist/assets/computer/manifests/computer.json +76 -0
  18. package/dist/assets/computer/manifests/file-system.json +42 -0
  19. package/dist/assets/computer/manifests/workstation.json +78 -0
  20. package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
  21. package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
  22. package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  23. package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
  24. package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
  25. package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
  26. package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
  27. package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  28. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  29. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  30. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  31. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  32. package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
  33. package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
  34. package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
  35. package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
  36. package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
  37. package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
  38. package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
  39. package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
  40. package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
  41. package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
  42. package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
  43. package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
  44. package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
  45. package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
  46. package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
  47. package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
  48. package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
  49. package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
  50. package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
  51. package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
  52. package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
  53. package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
  54. package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
  55. package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
  56. package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
  57. package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
  58. package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
  59. package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
  60. package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
  61. package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
  62. package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
  63. package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
  64. package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
  65. package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
  66. package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
  67. package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
  68. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
  69. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
  70. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
  71. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
  72. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
  73. package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
  74. package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
  75. package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
  76. package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
  77. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
  78. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
  79. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
  80. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
  81. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
  82. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
  83. package/dist/assets/computer/skills/core-catalog.json +246 -0
  84. package/dist/assets/computer/skills-requirements.txt +18 -0
  85. package/dist/assets/computer/web-provider.py +592 -0
  86. package/dist/assets/computer/web-requirements.txt +37 -0
  87. package/dist/assets/gateway/Dockerfile +3 -1
  88. package/dist/assets/gateway/gateway.mjs +69 -40
  89. package/dist/assets/image-catalog.json +183 -0
  90. package/dist/qubicl.mjs +297 -167
  91. package/package.json +8 -8
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Render every slide of a .pptx to per-slide PNG images.
3
+
4
+ Pipeline: LibreOffice (soffice --headless --convert-to pdf) turns the deck
5
+ into a PDF, then poppler (pdftoppm, or pdftocairo as an alternate) splits
6
+ the PDF into one PNG per slide.
7
+
8
+ Output is JSON. When both tools are present:
9
+ {"rendered": true, "files": ["render/slide-1.png", ...]}
10
+ When either tool is missing the script still exits 0 and reports:
11
+ {"rendered": false, "missing": ["soffice"], "guidance": "..."}
12
+ so callers can degrade gracefully (fall back to pptx_read.py --outline).
13
+ """
14
+ import argparse
15
+ import glob
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+
23
+
24
+ def find_tools():
25
+ """Return (soffice, splitter, missing) using shutil.which."""
26
+ soffice = shutil.which("soffice")
27
+ splitter = shutil.which("pdftoppm") or shutil.which("pdftocairo")
28
+ missing = []
29
+ if not soffice:
30
+ missing.append("soffice")
31
+ if not splitter:
32
+ missing.append("pdftoppm (or pdftocairo)")
33
+ return soffice, splitter, missing
34
+
35
+
36
+ def render(pptx_path, out_dir, prefix, dpi):
37
+ soffice, splitter, missing = find_tools()
38
+ if missing:
39
+ return {
40
+ "rendered": False, "missing": missing,
41
+ "guidance": "Install LibreOffice (soffice) and poppler-utils "
42
+ "(pdftoppm/pdftocairo) to render slides. Without "
43
+ "them, verify decks with pptx_read.py --outline "
44
+ "instead.",
45
+ }
46
+
47
+ os.makedirs(out_dir, exist_ok=True)
48
+ with tempfile.TemporaryDirectory() as tmp:
49
+ proc = subprocess.run(
50
+ [soffice, "--headless", "--convert-to", "pdf",
51
+ "--outdir", tmp, pptx_path],
52
+ capture_output=True, text=True, encoding="utf-8",
53
+ errors="replace", timeout=300)
54
+ pdfs = glob.glob(os.path.join(tmp, "*.pdf"))
55
+ if proc.returncode != 0 or not pdfs:
56
+ raise SystemExit(f"soffice PDF conversion failed: {proc.stderr}")
57
+ pdf = pdfs[0]
58
+ out_prefix = os.path.join(out_dir, prefix)
59
+ proc = subprocess.run(
60
+ [splitter, "-png", "-r", str(dpi), pdf, out_prefix],
61
+ capture_output=True, text=True, encoding="utf-8",
62
+ errors="replace", timeout=300)
63
+ if proc.returncode != 0:
64
+ raise SystemExit(f"{os.path.basename(splitter)} failed: "
65
+ f"{proc.stderr}")
66
+
67
+ files = sorted(glob.glob(out_prefix + "*.png"))
68
+ return {"rendered": True, "files": files}
69
+
70
+
71
+ def main(argv=None):
72
+ if hasattr(sys.stdout, "reconfigure"):
73
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
74
+ parser = argparse.ArgumentParser(
75
+ description="Render each slide of a .pptx to a PNG via "
76
+ "soffice + pdftoppm/pdftocairo.",
77
+ epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
78
+ parser.add_argument("pptx", help="path to the .pptx file")
79
+ parser.add_argument("--outdir", default="render",
80
+ help="directory for PNGs (default: ./render)")
81
+ parser.add_argument("--prefix", default="slide",
82
+ help="PNG filename prefix (default: slide)")
83
+ parser.add_argument("--dpi", type=int, default=100,
84
+ help="render resolution (default: 100)")
85
+ args = parser.parse_args(argv)
86
+
87
+ result = render(args.pptx, args.outdir, args.prefix, args.dpi)
88
+ print(json.dumps(result, indent=2))
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nous Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: xlsx
3
+ description: Create, inspect, edit, restructure, convert, and validate XLSX workbooks and CSV files using Qubicl's pinned local spreadsheet tools. Use for formulas, tables, charts, formatting, workbook structure, and CSV interoperability.
4
+ ---
5
+
6
+ # Spreadsheet workflows
7
+
8
+ Keep workbook artifacts under `/home/qubicl`. Obtain this skill's `resourceRoot` from `skill_view` and run its helpers with `/opt/qubicl/skills-venv/bin/python`. The computer and workstation presets contain the pinned spreadsheet libraries; do not install packages during a task.
9
+
10
+ Useful helpers beneath `<resourceRoot>/scripts/`:
11
+
12
+ - `xlsx_read.py FILE --sheets|--json|--csv|--formulas|--notes|--names`
13
+ - `xlsx_create.py SPEC.json OUTPUT.xlsx`
14
+ - `xlsx_edit.py FILE` for values, rows, sheets, tables, names, links, notes, and protection
15
+ - `xlsx_restructure.py FILE` for reference-aware row or column changes
16
+ - `csv_to_xlsx.py INPUT.csv OUTPUT.xlsx`
17
+ - `xlsx_to_csv.py INPUT.xlsx OUTPUT.csv`
18
+ - `xlsx_recalc.py FILE` when LibreOffice is available
19
+
20
+ Read `references/restructuring.md` before inserting or deleting rows or columns in a formula-heavy workbook.
21
+
22
+ Inspect workbook structure and formulas before editing. Preserve formulas unless replacement is intentional, use a new output for risky transformations, and read the result back afterward. LibreOffice recalculation is available only on workstation computers; explain that limitation rather than manufacturing cached formula values.
@@ -0,0 +1,71 @@
1
+ # Reference-aware restructuring (xlsx_restructure.py)
2
+
3
+ `scripts/xlsx_restructure.py` performs one row/column insert or delete
4
+ and rewrites everything that references the moved cells. This document
5
+ gives the exact rewrite rules and honest limits.
6
+
7
+ ## What gets rewritten
8
+
9
+ | Artifact | Scope | Behavior |
10
+ |---|---|---|
11
+ | Formula references | ALL sheets | A1 refs into the edited sheet are shifted; refs into a fully deleted region become `#REF!` |
12
+ | Merged-cell ranges | edited sheet | shifted; expanded when they span the insertion point; dropped (reported `to: null`) when fully deleted |
13
+ | Autofilter ref | edited sheet | shifted/expanded like a range |
14
+ | Freeze panes | edited sheet | anchor cell shifted (never below row/col of the pane's own minimum) |
15
+ | Data validations | edited sheet | each range in the sqref shifted; deleted ranges removed |
16
+ | Conditional formats | edited sheet | applied range (sqref) shifted |
17
+ | Native tables | edited sheet | table `ref` shifted/expanded |
18
+ | Defined names | workbook scope | `attr_text` refs into the edited sheet rewritten |
19
+ | Row heights / column widths | edited sheet | dimension keys re-indexed |
20
+
21
+ ## Reference grammar handled
22
+
23
+ - Relative and absolute coordinates in any mix: `B2`, `$B2`, `B$2`,
24
+ `$B$2` — the `$` flags are preserved through the shift.
25
+ - Ranges `B2:D9`, including partial-absolute endpoints.
26
+ - Cross-sheet refs: `Data!B2`, `'My Sheet'!$A$1:$C$9` (quoted names may
27
+ contain doubled quotes `''`). Only refs whose sheet qualifier matches
28
+ the edited sheet are touched; unqualified refs are interpreted
29
+ relative to the formula's own sheet.
30
+ - String literals inside formulas (`"See B2"`) are never rewritten.
31
+ - Function names that look like cells (`LOG10(...)`) are not touched
32
+ (a reference is never followed by `(`).
33
+ - Whole-row/column refs (`B:B`, `2:2`) pass through unchanged — Excel
34
+ semantics keep them valid across inserts within the span.
35
+
36
+ ## Shift semantics
37
+
38
+ Insert of N at index i: every coordinate >= i moves +N; range endpoints
39
+ move independently, so a range spanning i grows by N.
40
+
41
+ Delete of N at index i: coordinates before i are unchanged; coordinates
42
+ past the deleted block move -N; a single cell inside the block becomes
43
+ `#REF!`; a RANGE partially covering the block is clamped (Excel does the
44
+ same); a range entirely inside the block becomes `#REF!` (formulas) or
45
+ is removed (merges/validations).
46
+
47
+ ## What it CANNOT shift (honest limits)
48
+
49
+ - **Chart anchors and plotted ranges** — openpyxl chart objects are not
50
+ reliably round-tripped; anchors stay where they were. Re-create
51
+ charts after restructuring if their data moved.
52
+ - **Images / drawings** — same reason.
53
+ - **Conditional-format RULE formulas** — the applied range (sqref) is
54
+ shifted, but formulas inside `cell_is`/`expression` rules (e.g.
55
+ `$B1>100`) are left as-is. Review them if they reference moved cells.
56
+ - **Sheet-local defined names** and names using R1C1 or union/
57
+ intersection operators are rewritten only if they parse as plain A1
58
+ refs; anything else passes through untouched.
59
+ - **Structured table references** in formulas (`Table1[Sales]`) don't
60
+ need shifting (they follow the table), and are left alone.
61
+
62
+ Every run prints a JSON report listing exactly which formulas, merges,
63
+ tables, names, and ranges were changed, plus a fixed `not_shifted` list
64
+ of the above limits — inspect it after any structural edit.
65
+
66
+ ## One op per invocation
67
+
68
+ The CLI takes exactly one of `--insert-rows/--delete-rows/
69
+ --insert-cols/--delete-cols` (as `IDX[:N]`; columns accept letters).
70
+ For multiple operations run it repeatedly — ordering compound shifts in
71
+ one pass is where spreadsheet tools historically corrupt references.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Convert a CSV file to a styled .xlsx workbook with type inference.
3
+
4
+ Type inference per cell (disable with --no-infer):
5
+ int, float, bool ("true"/"false", case-insensitive), ISO date
6
+ (YYYY-MM-DD) and ISO datetime; everything else stays a string.
7
+
8
+ Styling applied by default (disable with --plain):
9
+ bold header row with a light fill, frozen top row, autofilter over the
10
+ data range, and column widths sized to the longest cell (capped at 60).
11
+
12
+ Usage:
13
+ csv_to_xlsx.py data.csv out.xlsx
14
+ csv_to_xlsx.py data.csv out.xlsx --sheet-name Import --encoding cp1252
15
+ csv_to_xlsx.py data.csv out.xlsx --delimiter ';' --no-infer
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import sys
23
+ from datetime import date, datetime
24
+
25
+ from openpyxl import Workbook
26
+ from openpyxl.styles import Font, PatternFill
27
+ from openpyxl.utils import get_column_letter
28
+
29
+ MAX_COL_WIDTH = 60
30
+ COL_PADDING = 2
31
+ DEFAULT_COL_WIDTH = 8
32
+
33
+
34
+ def infer(text):
35
+ if text == "":
36
+ return None
37
+ low = text.lower()
38
+ if low in ("true", "false"):
39
+ return low == "true"
40
+ for caster in (int, float):
41
+ try:
42
+ return caster(text)
43
+ except ValueError:
44
+ pass
45
+ for parser in (date.fromisoformat, datetime.fromisoformat):
46
+ try:
47
+ return parser(text)
48
+ except ValueError:
49
+ pass
50
+ return text
51
+
52
+
53
+ def main(argv=None):
54
+ ap = argparse.ArgumentParser(description="CSV -> styled .xlsx converter.")
55
+ ap.add_argument("csv_file", help="input CSV path")
56
+ ap.add_argument("output", help="output .xlsx path")
57
+ ap.add_argument("--sheet-name", default="Sheet1")
58
+ ap.add_argument("--encoding", default="utf-8",
59
+ help="CSV file encoding (default utf-8)")
60
+ ap.add_argument("--delimiter", default=",")
61
+ ap.add_argument("--no-infer", action="store_true",
62
+ help="keep every cell as a string")
63
+ ap.add_argument("--plain", action="store_true",
64
+ help="skip header styling / freeze / autofilter")
65
+ args = ap.parse_args(argv)
66
+
67
+ with open(args.csv_file, newline="", encoding=args.encoding) as fh:
68
+ rows = list(csv.reader(fh, delimiter=args.delimiter))
69
+
70
+ wb = Workbook()
71
+ ws = wb.active
72
+ ws.title = args.sheet_name
73
+ for i, row in enumerate(rows):
74
+ if args.no_infer or i == 0:
75
+ ws.append(row)
76
+ else:
77
+ ws.append([infer(cell) for cell in row])
78
+
79
+ if rows and not args.plain:
80
+ header_font = Font(bold=True)
81
+ header_fill = PatternFill("solid", fgColor="DDEBF7")
82
+ for cell in ws[1]:
83
+ cell.font = header_font
84
+ cell.fill = header_fill
85
+ ws.freeze_panes = "A2"
86
+ ws.auto_filter.ref = ws.dimensions
87
+ for col_idx in range(1, ws.max_column + 1):
88
+ longest = max((len(str(r[col_idx - 1])) for r in rows
89
+ if len(r) >= col_idx), default=DEFAULT_COL_WIDTH)
90
+ ws.column_dimensions[get_column_letter(col_idx)].width = \
91
+ min(longest + COL_PADDING, MAX_COL_WIDTH)
92
+
93
+ wb.save(args.output)
94
+ print(json.dumps({"ok": True, "output": args.output,
95
+ "rows": len(rows)}, ensure_ascii=False))
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ try:
101
+ sys.exit(main())
102
+ except Exception as exc: # noqa: BLE001
103
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
104
+ sys.exit(1)
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """Create an .xlsx workbook from a JSON spec.
3
+
4
+ Spec (JSON object):
5
+ {
6
+ "full_calc_on_load": true, # force recalculation on open (optional)
7
+ "defined_names": {"Rates": "'Data'!$B$2:$B$4"}, # workbook scope
8
+ "sheets": [
9
+ {
10
+ "name": "Data",
11
+ "rows": [["Header", 1, true], ...], # scalars or cell objects (see below)
12
+ "cells": {"A1": {"value": 5, "format": "0.00%"}}, # sparse overrides
13
+ "column_widths": {"A": 22, "B": 12},
14
+ "row_heights": {"1": 24},
15
+ "merges": ["A1:C1"],
16
+ "freeze_panes": "A2",
17
+ "autofilter": "A1:C10",
18
+ "conditional_formats": [
19
+ {"range": "B2:B9", "type": "cell_is", "operator": "greaterThan",
20
+ "formula": ["100"], "fill": "FFC7CE"},
21
+ {"range": "C2:C9", "type": "color_scale"}
22
+ ],
23
+ "charts": [
24
+ {"type": "bar", "title": "Sales", "anchor": "F2",
25
+ "data": "B1:B5", "categories": "A2:A5"}
26
+ ],
27
+ "validations": [
28
+ {"range": "D2:D9", "type": "list", "formula1": "\"Yes,No,Maybe\""}
29
+ ],
30
+ "tables": [
31
+ {"name": "Sales", "range": "A1:C4",
32
+ "style": "TableStyleMedium9"} # native Excel table
33
+ ],
34
+ "protection": {"password": "your-password", # NOT security --
35
+ "unlock": ["B2:B9"]} # see SKILL.md Pitfalls
36
+ }
37
+ ]
38
+ }
39
+
40
+ Cell object keys (all optional except value/formula):
41
+ value scalar; JSON true/false -> bool, numbers stay numeric
42
+ type "date" or "datetime" -> value parsed from ISO string
43
+ formula e.g. "=SUM(A2:A9)" (leading '=' optional)
44
+ hyperlink URL; value becomes the display text
45
+ note cell note text (or {"text": ..., "author": ...})
46
+ format Excel number format, e.g. "$#,##0.00", "0.0%", "yyyy-mm-dd"
47
+ bold, italic booleans
48
+ font_size points
49
+ font_color hex RGB like "FF0000"
50
+ fill solid fill hex RGB like "DDEBF7"
51
+ border "thin" | "medium" | "thick" (all four sides)
52
+ align "left" | "center" | "right"
53
+ valign "top" | "center" | "bottom"
54
+ wrap boolean (wrap text)
55
+
56
+ Usage:
57
+ xlsx_create.py spec.json out.xlsx
58
+ xlsx_create.py - out.xlsx (spec on stdin)
59
+
60
+ Prints a JSON summary to stdout; exits non-zero on failure.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import argparse
65
+ import json
66
+ import sys
67
+ from datetime import date, datetime
68
+
69
+ from openpyxl import Workbook
70
+ from openpyxl.chart import BarChart, LineChart, PieChart, Reference
71
+ from openpyxl.comments import Comment
72
+ from openpyxl.formatting.rule import CellIsRule, ColorScaleRule
73
+ from openpyxl.styles import (Alignment, Border, Font, PatternFill,
74
+ Protection, Side)
75
+ from openpyxl.utils import column_index_from_string, range_boundaries
76
+ from openpyxl.workbook.defined_name import DefinedName
77
+ from openpyxl.worksheet.datavalidation import DataValidation
78
+ from openpyxl.worksheet.table import Table, TableStyleInfo
79
+
80
+
81
+ def parse_typed(value, type_hint=None):
82
+ if type_hint == "date" and isinstance(value, str):
83
+ return date.fromisoformat(value)
84
+ if type_hint == "datetime" and isinstance(value, str):
85
+ return datetime.fromisoformat(value)
86
+ return value
87
+
88
+
89
+ def apply_cell(ws, coord, spec):
90
+ cell = ws[coord]
91
+ if isinstance(spec, dict):
92
+ if "formula" in spec:
93
+ f = spec["formula"]
94
+ cell.value = f if f.startswith("=") else "=" + f
95
+ elif "value" in spec:
96
+ cell.value = parse_typed(spec["value"], spec.get("type"))
97
+ if "hyperlink" in spec:
98
+ cell.hyperlink = spec["hyperlink"]
99
+ if cell.value is None:
100
+ cell.value = spec["hyperlink"]
101
+ cell.style = "Hyperlink"
102
+ if "note" in spec:
103
+ note = spec["note"]
104
+ if isinstance(note, dict):
105
+ cell.comment = Comment(note.get("text", ""),
106
+ note.get("author", "xlsx-skill"))
107
+ else:
108
+ cell.comment = Comment(str(note), "xlsx-skill")
109
+ if "format" in spec:
110
+ cell.number_format = spec["format"]
111
+ font_kw = {}
112
+ if spec.get("bold"):
113
+ font_kw["bold"] = True
114
+ if spec.get("italic"):
115
+ font_kw["italic"] = True
116
+ if "font_size" in spec:
117
+ font_kw["size"] = spec["font_size"]
118
+ if "font_color" in spec:
119
+ font_kw["color"] = spec["font_color"]
120
+ if font_kw:
121
+ cell.font = Font(**font_kw)
122
+ if "fill" in spec:
123
+ cell.fill = PatternFill("solid", fgColor=spec["fill"])
124
+ if "border" in spec:
125
+ side = Side(style=spec["border"])
126
+ cell.border = Border(left=side, right=side, top=side, bottom=side)
127
+ align_kw = {}
128
+ if "align" in spec:
129
+ align_kw["horizontal"] = spec["align"]
130
+ if "valign" in spec:
131
+ align_kw["vertical"] = spec["valign"]
132
+ if spec.get("wrap"):
133
+ align_kw["wrap_text"] = True
134
+ if align_kw:
135
+ cell.alignment = Alignment(**align_kw)
136
+ else:
137
+ cell.value = spec
138
+
139
+
140
+ def ref_from_range(ws, rng):
141
+ min_col, min_row, max_col, max_row = range_boundaries(rng)
142
+ return Reference(ws, min_col=min_col, min_row=min_row,
143
+ max_col=max_col, max_row=max_row)
144
+
145
+
146
+ def add_chart(ws, spec):
147
+ kind = spec.get("type", "bar")
148
+ chart = {"bar": BarChart, "line": LineChart, "pie": PieChart}[kind]()
149
+ if "title" in spec:
150
+ chart.title = spec["title"]
151
+ data = ref_from_range(ws, spec["data"])
152
+ chart.add_data(data, titles_from_data=spec.get("titles_from_data", True))
153
+ if "categories" in spec:
154
+ chart.set_categories(ref_from_range(ws, spec["categories"]))
155
+ ws.add_chart(chart, spec.get("anchor", "H2"))
156
+
157
+
158
+ def add_conditional(ws, spec):
159
+ rng = spec["range"]
160
+ kind = spec.get("type", "cell_is")
161
+ if kind == "color_scale":
162
+ rule = ColorScaleRule(
163
+ start_type="min", start_color=spec.get("start_color", "FFF8696B"),
164
+ end_type="max", end_color=spec.get("end_color", "FF63BE7B"))
165
+ else:
166
+ fill = PatternFill("solid", fgColor=spec.get("fill", "FFC7CE"))
167
+ rule = CellIsRule(operator=spec.get("operator", "greaterThan"),
168
+ formula=spec.get("formula", ["0"]), fill=fill)
169
+ ws.conditional_formatting.add(rng, rule)
170
+
171
+
172
+ def build_sheet(ws, spec):
173
+ for row in spec.get("rows", []):
174
+ values, styled = [], []
175
+ for item in row:
176
+ if isinstance(item, dict):
177
+ values.append(None)
178
+ styled.append(item)
179
+ else:
180
+ values.append(item)
181
+ styled.append(None)
182
+ ws.append(values)
183
+ r = ws.max_row
184
+ for idx, item in enumerate(styled, start=1):
185
+ if item is not None:
186
+ apply_cell(ws, ws.cell(row=r, column=idx).coordinate, item)
187
+ for coord, cell_spec in spec.get("cells", {}).items():
188
+ apply_cell(ws, coord, cell_spec)
189
+ for col, width in spec.get("column_widths", {}).items():
190
+ ws.column_dimensions[col].width = width
191
+ for row, height in spec.get("row_heights", {}).items():
192
+ ws.row_dimensions[int(row)].height = height
193
+ for rng in spec.get("merges", []):
194
+ ws.merge_cells(rng)
195
+ if spec.get("freeze_panes"):
196
+ ws.freeze_panes = spec["freeze_panes"]
197
+ if spec.get("autofilter"):
198
+ ws.auto_filter.ref = spec["autofilter"]
199
+ for cf in spec.get("conditional_formats", []):
200
+ add_conditional(ws, cf)
201
+ for ch in spec.get("charts", []):
202
+ add_chart(ws, ch)
203
+ for dv_spec in spec.get("validations", []):
204
+ dv = DataValidation(type=dv_spec.get("type", "list"),
205
+ formula1=dv_spec["formula1"],
206
+ allow_blank=dv_spec.get("allow_blank", True))
207
+ dv.add(dv_spec["range"])
208
+ ws.add_data_validation(dv)
209
+ for t_spec in spec.get("tables", []):
210
+ table = Table(displayName=t_spec["name"], ref=t_spec["range"])
211
+ table.tableStyleInfo = TableStyleInfo(
212
+ name=t_spec.get("style", "TableStyleMedium9"),
213
+ showRowStripes=t_spec.get("row_stripes", True),
214
+ showColumnStripes=t_spec.get("column_stripes", False))
215
+ ws.add_table(table)
216
+ prot = spec.get("protection")
217
+ if prot:
218
+ for rng in prot.get("unlock", []):
219
+ for row in ws[rng]:
220
+ for cell in row:
221
+ cell.protection = Protection(locked=False)
222
+ if prot.get("password"):
223
+ ws.protection.password = prot["password"]
224
+ ws.protection.sheet = True
225
+
226
+
227
+ def main(argv=None):
228
+ ap = argparse.ArgumentParser(description="Create .xlsx from a JSON spec.")
229
+ ap.add_argument("spec", help="path to JSON spec, or '-' for stdin")
230
+ ap.add_argument("output", help="output .xlsx path")
231
+ args = ap.parse_args(argv)
232
+
233
+ if args.spec == "-":
234
+ spec = json.load(sys.stdin)
235
+ else:
236
+ with open(args.spec, encoding="utf-8") as fh:
237
+ spec = json.load(fh)
238
+
239
+ wb = Workbook()
240
+ wb.remove(wb.active)
241
+ for sheet_spec in spec.get("sheets", []):
242
+ ws = wb.create_sheet(sheet_spec.get("name", "Sheet1"))
243
+ build_sheet(ws, sheet_spec)
244
+ for name, ref in spec.get("defined_names", {}).items():
245
+ wb.defined_names[name] = DefinedName(name, attr_text=ref)
246
+ if spec.get("full_calc_on_load"):
247
+ wb.calculation.fullCalcOnLoad = True
248
+ wb.save(args.output)
249
+ print(json.dumps({"ok": True, "output": args.output,
250
+ "sheets": wb.sheetnames}, ensure_ascii=False))
251
+ return 0
252
+
253
+
254
+ if __name__ == "__main__":
255
+ try:
256
+ sys.exit(main())
257
+ except Exception as exc: # noqa: BLE001
258
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
259
+ sys.exit(1)