# my-excel-generator
Agent-first toolkit for the workflow **read a spec → curate → canonical JSON → editable XLSX**, and the reverse: **read an existing workbook → JSON → edit → write it back**.
The agent is the editor. `mxg` is the compiler: it validates the JSON with
checks that catch what schema validation cannot (text clipped by a short row,
merges that silently fight each other, a print area that cuts off the form),
and writes a spreadsheet in which every cell, merge and border stays editable.
Pure Node; no Excel, no LibreOffice, no headless anything.
```
npm install
npx mxg build examples/minimal.json --out out/form.xlsx
npx mxg read some-existing.xlsx --out deck.json
```
## Why a region model
This is not a CV-only tool. It generates and edits any Excel workbook — an
invoice, a report, a data table, a form — from the same canonical JSON. The
one architectural choice that shapes everything else is: the unit of layout
is a **region**, a rectangle of the grid addressed the way Excel addresses it,
not a single cell.
That choice was proven against a worst case, not the common case: a Japanese
grid-form where every column is ~1.83 units wide and the layout comes
entirely from merging runs of narrow cells — **328 merged regions on one
sheet**, 189 on another, 227 distinct cell formats, not a single formula.
Addressing that cell by cell is unmaintainable. A plain data table is the easy
case for the same model — one region per cell, or one per row if you like —
so what handles the hard case handles everything simpler than it for free.
```jsonc
{ "ref": "H12:BH12", "value": "…", "style": { "wrap": true, "border": "thin" } }
```
A region spanning more than one cell merges by default. Set `"merge": false` to
style a block without merging it, which is how the checkbox grids are built.
## Commands
Run the CLI with `npx mxg` (or `node bin/mxg.js`); it is not installed globally.
| Command | Does |
|---|---|
| `npx mxg init book.json` | starter JSON |
| `npx mxg validate book.json [--fit] [--fit-all] [--json] [--strict]` | schema + design checks; exit 1 on errors |
| `npx mxg build book.json --out file.xlsx [--force]` | validate, then write the workbook |
| `npx mxg read file.xlsx [--sheet N] [--values] [--out f]` | existing workbook → canonical JSON |
| `npx mxg info file.xlsx [--json]` | what is in this file: sheets, merges, images, print area |
`read` → edit the JSON → `build` is a closed loop. On the sample CV it
round-trips **189/189 merges and 174/174 values unchanged**. Passing `--out`
also extracts any embedded images to real files beside the JSON, so a later
`build` finds them; reading to stdout instead leaves a `MISSING-*` placeholder
that `build` refuses with a clear error rather than a bare ENOENT.
`--fit` prints the per-region fit table, filtered by default to just the
regions that produced a finding — pass `--fit-all` for the unfiltered table
(one row per region with text, hundreds of rows on a dense form).
## Book JSON
```jsonc
{
"name": "Form",
"sheets": [{
"name": "経歴書",
"columnWidth": 1.83203125, // the uniform narrow grid; say it once
"columns": [{ "from": 4, "to": 4, "width": 2.5 }], // exceptions
"rows": [
{ "row": 2, "height": 30 },
{ "row": 6, "autoHeight": true } // measured from the text it holds
],
"gridLines": false,
"regions": [
{ "ref": "A2:BH2", "value": "情報処理技術者経歴書",
"style": { "size": 16, "bold": true, "align": "center", "valign": "middle" } },
{ "ref": "A4:H4", "value": "フリガナ",
"style": { "fill": "EEEEEE", "border": "thin" } },
{ "ref": "AQ18:AR18", "merge": false, "choices": ["□", "■"] }
],
"printArea": "A2:BH84",
"pageSetup": { "paperSize": 9, "orientation": "portrait", "scale": 66 }
}]
}
```
Style keys: `font`, `size`, `bold`, `italic`, `color`, `fill`, `align`,
`valign`, `wrap`, `shrink`, `indent`, `border`, `numFmt`. `border` takes either
a shorthand (`"thin"` = all four sides) or per-side
(`{ "top": "medium", "bottom": "hair" }`). Unknown keys are rejected, so a typo
fails at `validate` rather than vanishing into the file.
### Data validation
`choices` is the short way to say "this cell is a dropdown". Anything else — a
numeric range, a date window, the message Excel shows when someone breaks the
rule — goes in `validation`:
```jsonc
{ "ref": "C5:E9", "merge": false, "validation": {
"type": "whole", "operator": "between", "formulae": [1, 10],
"errorTitle": "Out of range", "error": "Score must be 1-10" } }
```
`type` is one of `list`, `whole`, `decimal`, `date`, `time`, `textLength` or
`custom`. `read` returns a plain dropdown as `choices` and everything else as
`validation`, including a `list` that carries a `prompt` — `choices` has nowhere
to put one, so downgrading it would quietly lose the message.
### Formulas
A region holds either a static `value` or a live `formula` — never both, and a
`formula` cannot carry a `link` (one cell value, and the link would overwrite
the formula). Write the formula without its leading `=`:
```jsonc
{ "ref": "F15", "formula": "SUM(F10:F13)", "result": 8360, "style": { "numFmt": "$#,##0.00" } }
```
`result` is the cached value Excel displays before it first recalculates, which
it does automatically on open. It is optional: omit it and the cell reads blank
until then. `read` returns formula cells as `formula`/`result`, so
`read → edit → build` keeps them live rather than freezing them to a constant.
## Checks
| Code | Severity | Meaning |
|---|---|---|
| `merge-overlap` | error | two merged regions cover the same cell; Excel keeps one and drops the other |
| `needs-wrap` | error | text needs more than one line but the cell does not wrap; it is clipped to one |
| `text-overflow` | error | wrapped text is taller than the rows it spans |
| `text-tight` | warning | text fills the row to within 4pt |
| `print-area-clips` | warning | content extends past the print area |
| `crlf-newline` | warning | CRLF in cell text; Excel expects LF |
| `small-text`, `empty-sheet` | warning | |
## Text measurement
Lines are measured by **summing glyph advances**, not by counting characters:
CJK glyphs are full-width (1.0 em) against ~0.5 em for Latin, detected
per-character so mixed-script cells measure correctly with no configuration.
A checker that counts characters believes twice as much Japanese fits per
line as really does, and passes sheets that are visibly clipped when opened.
For Latin-only content the same model just falls back to counting halves.
Accents are measured where they are drawn, not where they are stored. The same
Vietnamese name is one code point per accented letter when typed directly (NFC)
and a base letter plus combining marks when pasted from a web page (NFD);
combining marks and conjoining Hangul jamo advance the cursor by nothing, so
both forms measure the same and neither wraps an accent away from its letter.
No normalisation pass is needed, and text is written back in the form it came
in rather than being silently rewritten.
Row height comes out at ~18.8px per line of 11pt text, measured four
independent ways against the sample form's Meiryo UI text (113/6, 409/23,
130/7, 236/12 across its four prose rows). The model runs ~5% tall on other
fonts, which is the safe direction: a fit check that under-estimates height
passes files whose text is cut off. The default theme (`DEFAULT_THEME` in
`src/compile.js`) uses Calibri, Excel's own default; pass a theme naming a
CJK-capable family (e.g. `'Meiryo UI'`) for Japanese-style content.
## What is not preserved
`read` → `build` is a re-authoring loop, not a byte-for-byte copy. Reading an
existing workbook and writing it back **drops**:
- **Furigana (`rPh` phonetic runs)** — the sample stores 「性別」 with a separate
reading セイベツ. exceljs reads the base text correctly but does not write the
phonetic runs back. If the source relies on furigana, edit the original.
- **Printer settings** (`printerSettings*.bin`) — local driver state, harmless.
- **Manual page breaks** (`rowBreaks`) and MS security labels (`LabelInfo.xml`).
- **Conditional formatting** — colour scales and rule-driven fills are not read.
- **The formula of a cell that also carries a hyperlink** — exceljs surfaces
such a cell as its link alone, so the formula is lost. Rare: Excel's own UI
does not produce it. The cell and its link survive.
- **Frozen panes and split views** — a sheet frozen below its header band comes
back unfrozen. The schema has no field for it, so nothing warns you.
- **The author of a cell comment** — the text survives, the name attached to it
does not.
Everything else — merges, values, formulas, fonts, fills, borders, alignment,
wrap, row heights, column widths, data validations, cell comments, hyperlinks,
images, print area and page setup — survives.
## Development
```
npm test
```
Tests encode real defects found while building this against the sample form:
the CJK width model, kinsoku line-breaking, blank merged cells being dropped on
read, borders on merged vs unmerged blocks, and workbooks from other producers
that name their parts differently than exceljs expects.