@quario/xlsx 0.4.0 → 0.6.0

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **A date cell's number format follows the `form` its report declares.** The
15
+ four forms map to `dd/mm/yy`, `dd mmm yyyy`, `dd mmmm yyyy` and
16
+ `dddd, dd mmmm yyyy`. The shape is the document's; the language a month or
17
+ weekday name is spelled in stays the reader's, because that is what a
18
+ spreadsheet application supplies and pinning it would stop the file reading
19
+ naturally for whoever opens it.
20
+
21
+ - **A declared digit count builds the pattern.** A `number` cell asking for
22
+ three digits writes `#,##0.000`, and a `currency` cell asking for none writes
23
+ `"EUR"#,##0` — the same count the page presents, taken off the declaration
24
+ the engine resolved rather than computed here.
25
+
26
+ - **A cell's own `currency` code picks the number format**, ahead of the
27
+ instance's default: `"JPY"#,##0` where the row says `JPY`, since the code
28
+ decides the digits too. The cell stays a number either way. A code the engine
29
+ could not accept writes no number format at all, rather than labelling the
30
+ cell in the instance's currency.
31
+
32
+ ### Changed
33
+
34
+ - **A `date` cell with no declared form now writes `dd mmm yyyy`, not
35
+ `yyyy-mm-dd`.** A bare `date` means the `medium` form throughout, so the grid
36
+ agrees with the page. A report that wants the ISO shape has no form for it;
37
+ the nearest is `{ "kind": "date", "form": "short" }`, which writes
38
+ `dd/mm/yy`. The cell is still a real typed date either way.
39
+
40
+ - **A cell's number format now comes from the engine's fraction-digit count
41
+ rather than from four patterns this target held of its own.** The visible
42
+ change is `currency`: the pattern followed the currency's own minor units,
43
+ so an amount in a currency with none — JPY — is `"JPY"#,##0` where it was
44
+ `"JPY"#,##0.00`, and one with three is `"BHD"#,##0.000`. `number`
45
+ (`#,##0.00`), `percent` (`0.00%`) and `date` (`yyyy-mm-dd`) are unchanged in
46
+ a worksheet; what changed for those is that HTML and PDF now show the same
47
+ decimals the grid always did, instead of their own.
48
+
49
+ - A `currency` code that is not a readable currency no longer produces a number
50
+ format at all. The cell keeps its value; before, the code was pasted straight
51
+ into a pattern while every other target fell back to the unformatted value.
52
+
53
+ Only the digit count is shared. The grouping separator and the symbol's
54
+ position still come from the application that opens the file, which is what
55
+ lets one workbook read naturally wherever it is opened.
56
+
57
+ ### Fixed
58
+
59
+ - A `format` kind reaching this target from an `=` expression is now looked up
60
+ as an own key. Before, a kind resolving to the name of a built-in object
61
+ member threw out of the render (`valueOf`, `hasOwnProperty`) or wrote
62
+ `[object Undefined]` into the cell's number format (`toString`). A literal
63
+ was never affected: it is checked against the four kinds.
64
+
65
+ ### Fixed
66
+
67
+ - **An image whose size cannot be read now names the item that asked for it.**
68
+ The failure said only that the size could not be read from the bytes, so a
69
+ report with two pictures gave no way to tell which one was bad. The message
70
+ now begins with the item's `source` path, as every other render error does
71
+ and as it does from every other target.
72
+
73
+ ## [0.5.0] - 2026-09-05
74
+
75
+ ### Added
76
+
77
+ - **Chrome merges, data does not.** A cell spanning several columns in the
78
+ header row or a total row is written into the first column it covers, the
79
+ rest of the range is written empty, and the range is merged. Nothing in the
80
+ data region merges, so sort, filter and column selection keep working over
81
+ it.
82
+
83
+ - **`valign`** writes the cell's vertical alignment for table cells and split
84
+ slots. Undeclared writes nothing, so the spreadsheet application keeps its
85
+ own default.
86
+
10
87
  ## [0.4.0] - 2026-09-03
11
88
 
12
89
  ### Changed
package/README.md CHANGED
@@ -74,10 +74,13 @@ host. Render-time failures reject with located errors.
74
74
 
75
75
  One worksheet, named `Report`, with the banded walk flattened onto it as rows in render order:
76
76
  report-header items, then per group instance its header items, nested content, and footer items,
77
- then the table's header row, data rows, and total row, then report-footer items. Grouping shows up
78
- as that order alone. Group boundaries add no rows of their own, and `break: "page"` is ignored.
77
+ then the table's header row, data rows, and total rows, then report-footer items. Grouping shows up
78
+ as that order alone. Group boundaries add no rows of their own, and `break: "page"` and
79
+ `reset: "page"` are ignored, as are `page.margin` and a report header's `height`.
79
80
 
80
- Each item takes one row with its cell in the first column, unmerged. Two band roles carry an
81
+ Each item takes one row with its cell in the first column, unmerged; a split takes one row with its
82
+ slots as the cells across it (slot widths unread, a slot rendering nothing an empty cell), and a
83
+ spanning header or total cell is a merged range. Two band roles carry an
81
84
  omakase default that the author's own style always overrides: report-header items render bold at
82
85
  size 14, group-header items render bold.
83
86
 
@@ -92,11 +95,23 @@ inert literal text: no formula-injection surface, and no apostrophe mangling eit
92
95
 
93
96
  A hidden cell keeps its column slot as an empty cell that retains its style.
94
97
 
98
+ ### Number formats
99
+
100
+ `format` writes a number format so the grid presents what the other targets do. `date` builds
101
+ its pattern from the cell's `form` (`dd mmm yyyy` undeclared), with month and weekday names
102
+ spelled by the reader's application; the number kinds build theirs from the declaration's
103
+ fraction-digit count — an undeclared `number` is `#,##0.00`, `percent` is `0.00%`,
104
+ `{ "kind": "number", "digits": 3 }` is `#,##0.000` — and `currency` is the code the cell wears
105
+ — its own `currency` when it declares one, else the instance's — over that currency's minor
106
+ units unless `digits` says otherwise: `"USD"#,##0.00`, `"JPY"#,##0`, `"BHD"#,##0.000`. A kind
107
+ on the wrong type, or a code the engine cannot read, writes no format.
108
+
95
109
  ### Images
96
110
 
97
111
  An image item is not a cell: it takes one worksheet row as its anchor and floats over the sheet as a
98
112
  drawing at its natural pixel size. Placement here is best-effort. `fit`, `alt`, and `style` go
99
- unread, and all of it may change without a breaking change.
113
+ unread, and all of it may change without a breaking change. Bytes too short to carry a size reject
114
+ the render rather than vanish.
100
115
 
101
116
  ### Styles
102
117
 
@@ -106,8 +121,14 @@ unread, and all of it may change without a breaking change.
106
121
  | `size`, `bold`, `italic`, `underline`, `strikethrough` | Font (`strikethrough` → writer's `strike`) |
107
122
  | `color`, `background` | Font colour, solid fill |
108
123
  | `align` | Horizontal alignment |
109
-
110
- Style blocks layer row under cell, as everywhere in quario.
124
+ | `valign` | Vertical alignment; undeclared writes nothing, so the spreadsheet application keeps its own default |
125
+ | `border*` | Cell borders (`solid` → `thin`, `dashed`, `dotted`; width approximated); a row's borders arrive on its cells already |
126
+ | `format`, `currency` | A number format — see [Number formats](#number-formats) |
127
+ | `uppercase`, `padding*`, `spaceBefore`, `spaceAfter` | Not read: a grid has no text-transform, no cell inset of its own, and no flow |
128
+
129
+ Style blocks layer row under cell, as everywhere in quario, with the report default outermost.
130
+ Every cell is written at 10 points unless something declares otherwise, and a cell whose display
131
+ contains a newline wraps.
111
132
 
112
133
  ### Freeze
113
134
 
package/lib/cell.js CHANGED
@@ -6,39 +6,79 @@
6
6
  * cell-level formatting from the style module. Display text that contains a
7
7
  * newline gains `wrapText` so the break is visible in the grid.
8
8
  */
9
- import { text, typed } from "quario";
10
- import { format as look, sides } from "./style.js";
9
+ import { currencyOf, text, typed } from "quario";
10
+ import { format as look, merge } from "./style.js";
11
+
12
+ // The digit count is the engine's, never this target's: it rides on the
13
+ // resolved declaration the stream carries, the same one HTML and PDF
14
+ // stringify from, so a cell shows the same decimals in the grid as it does on
15
+ // the page (docs/adr/0054, docs/adr/0056). What stays this target's own is
16
+ // the form — the reader's application supplies the grouping separator, the
17
+ // symbol's place, and the language a month name is spelled in.
18
+ /** @type {(digits: number | undefined) => string} */
19
+ let places = (digits) => (digits ? "." + "0".repeat(digits) : "");
11
20
 
12
21
  /** @type {(value: any, fmt: string) => string | null} */
13
22
  let whenNum = (value, fmt) => (typeof value === "number" ? fmt : null);
14
- /** @type {(value: any) => string | null} */
15
- let whenDate = (value) => (value instanceof Date ? "yyyy-mm-dd" : null);
16
- /** @type {(value: any, currency: any) => string | null} */
17
- let whenMoney = (value, currency) =>
18
- typeof value === "number" && typeof currency === "string" && currency
19
- ? '"' + currency + '"#,##0.00'
20
- : null;
21
- /** @type {Record<string, (value: any, currency: any) => string | null>} */
22
- let NUMFMT = {
23
- number: (value) => whenNum(value, "#,##0.00"),
24
- percent: (value) => whenNum(value, "0.00%"),
25
- date: (value) => whenDate(value),
26
- currency: (value, currency) => whenMoney(value, currency),
23
+ // A date's form is honoured as far as a sheet can honour one: the *shape* is
24
+ // the author's, and the language of a month or weekday name is the reader's,
25
+ // because Excel spells `mmmm` and `dddd` in whoever opens the file's
26
+ // application language. Pinning that would take a `[$-...]` locale prefix and
27
+ // would stop the sheet following its reader, which docs/adr/0054 refuses. The
28
+ // day-first ordering is the author's here too, and that is the second half of
29
+ // the same approximation (CONTEXT.md, "Form").
30
+ /** @type {Record<string, string>} */
31
+ let DATE_FMT = {
32
+ short: "dd/mm/yy",
33
+ medium: "dd mmm yyyy",
34
+ long: "dd mmmm yyyy",
35
+ full: "dddd, dd mmmm yyyy",
27
36
  };
28
- /** @type {(kind: any, value: any, currency: any) => string | null} */
29
- let numFmtOf = (kind, value, currency) => {
30
- let rule = NUMFMT[kind];
31
- return rule ? rule(value, currency) : null;
37
+ /** @type {(value: any, decl: any) => string | null} */
38
+ let whenDate = (value, decl) =>
39
+ value instanceof Date && Object.hasOwn(DATE_FMT, decl.form) ? DATE_FMT[decl.form] : null;
40
+ /** @type {(value: any, decl: any, currency: any) => string | null} */
41
+ let whenMoney = (value, decl, currency) => {
42
+ // The value first: a text cell in a money column would otherwise pay for a
43
+ // pattern it cannot wear. Then the count — a code the engine will not read
44
+ // resolved to no digits at all, so the cell keeps its value and gains no
45
+ // number format, the same withdrawal `format()` makes.
46
+ if (typeof value !== "number" || decl.digits === undefined) return null;
47
+ return '"' + currency + '"#,##0' + places(decl.digits);
32
48
  };
49
+ /** @type {Record<string, (value: any, decl: any, currency: any) => string | null>} */
50
+ let NUMFMT = {
51
+ number: (value, decl) => whenNum(value, "#,##0" + places(decl.digits)),
52
+ percent: (value, decl) => whenNum(value, "0" + places(decl.digits) + "%"),
53
+ date: whenDate,
54
+ currency: whenMoney,
55
+ };
56
+ // Own-key lookup, as everywhere a resolved declaration indexes a table: the
57
+ // declaration reaches here having been resolved from author data, and
58
+ // `valueOf` or `toString` as a kind would otherwise reach a member of
59
+ // `Object.prototype` and either throw out of the render or write its return
60
+ // value as a number format.
61
+ /** @type {(decl: any, value: any, currency: any) => string | null} */
62
+ let numFmtOf = (decl, value, currency) =>
63
+ !!decl && typeof decl === "object" && Object.hasOwn(NUMFMT, decl.kind)
64
+ ? NUMFMT[decl.kind](value, decl, currency)
65
+ : null;
33
66
 
34
- /** @type {(cell: { tokens: any[] }, kind: any) => any} */
35
- let shownOf = (cell, kind) => {
36
- let value = typed(cell.tokens, kind);
67
+ /** @type {(cell: { tokens: any[] }, decl: any) => any} */
68
+ let shownOf = (cell, decl) => {
69
+ let value = typed(cell.tokens, decl);
37
70
  return value !== undefined ? value : text(cell.tokens);
38
71
  };
72
+ // The code is spliced into the format string rather than looked up as a
73
+ // symbol, so it must be a code and nothing else. It is: the engine gates a
74
+ // resolved `currency` before it crosses, which is why an author-data code
75
+ // (`currency: "=@.ccy"`) can reach a number format at all (docs/adr/0041).
76
+ // Which code wins is the engine's rule too, taken from `currencyOf` rather
77
+ // than restated: this target needs the code itself where the display targets
78
+ // need presented text, and the two must not drift apart about a denomination.
39
79
  /** @type {(out: any, resolved: any, shown: any, intl: any) => void} */
40
80
  let paintFmt = (out, resolved, shown, intl) => {
41
- let numFmt = numFmtOf(resolved?.format, shown, intl?.currency);
81
+ let numFmt = numFmtOf(resolved?.format, shown, currencyOf(resolved, intl));
42
82
  if (numFmt) out.numFmt = numFmt;
43
83
  };
44
84
  /** @type {(out: any, shown: any) => void} */
@@ -54,7 +94,7 @@ let paintWrap = (out, shown) => {
54
94
  * @type {(cell: { tokens: any[], style?: any }, under: any, intl?: any) => { value: any, format: any }}
55
95
  */
56
96
  let field = (cell, under, intl) => {
57
- let resolved = sides(under, cell.style);
97
+ let resolved = merge(under, cell.style);
58
98
  let shown = shownOf(cell, resolved?.format);
59
99
  let out = look(resolved);
60
100
  paintFmt(out, resolved, shown, intl);
package/lib/index.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * the packing time. Byte-for-byte output is a goal for the writer that will
16
16
  * replace `workbook.js`.
17
17
  */
18
- import { walk } from "quario";
18
+ import { imageError, walk } from "quario";
19
19
  import { field } from "./cell.js";
20
- import { sides as under } from "./style.js";
20
+ import { merge as under } from "./style.js";
21
21
  import { pixels } from "./image.js";
22
- import { append, create, embed, freeze, mark, place, save, sheet } from "./workbook.js";
22
+ import { append, create, embed, freeze, mark, place, save, sheet, span } from "./workbook.js";
23
23
 
24
24
  // The options are described once, in the hand-written public declarations, and
25
25
  // read back here — a second copy in JSDoc is a copy that drifts.
@@ -39,7 +39,10 @@ import { append, create, embed, freeze, mark, place, save, sheet } from "./workb
39
39
  // them. `@quario/html/style.css` and `packages/viewer/lib/style.js` supply them for
40
40
  // that target; keep all four in agreement.
41
41
  /** @type {Record<string, any>} */
42
- let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
42
+ let ROLES = {
43
+ "report-header": { bold: true, size: 14 },
44
+ "group-header": { bold: true },
45
+ };
43
46
 
44
47
  // This target's baseline type size, written into every cell rather than left
45
48
  // to the writer's own 11. The PDF target carries the same 10, and once a report
@@ -109,12 +112,40 @@ export function xlsx(options) {
109
112
  worksheet,
110
113
  event.cells.map((/** @type {any} */ each) => cell(each, layer)),
111
114
  );
115
+ // Chrome merges, data does not (docs/adr/0047). A cell covering several
116
+ // columns writes its value into the first of them, the rest are written
117
+ // empty, and the range is merged -- so the label stays where the document
118
+ // put it. Reached by the header row and the total rows; a data row cannot
119
+ // span, so `record` above stays the plain path it was.
120
+ /** @type {(each: any, layer: any) => any[]} */
121
+ let covering = (each, layer) => [
122
+ cell(each, layer),
123
+ ...Array.from({ length: (each.span || 1) - 1 }, () => cell({ tokens: [] }, layer)),
124
+ ];
125
+ /** @type {(cells: any[], layer: any) => number} */
126
+ let merged = (cells, layer) => {
127
+ /** @type {any[]} */
128
+ let fields = [];
129
+ /** @type {{ at: number, width: number }[]} */
130
+ let ranges = [];
131
+ for (let each of cells) {
132
+ if (each.span > 1) ranges.push({ at: fields.length + 1, width: each.span });
133
+ fields.push(...covering(each, layer));
134
+ }
135
+ let row = append(worksheet, fields);
136
+ for (let range of ranges) span(worksheet, row, range.at, range.width);
137
+ return row;
138
+ };
112
139
  await walk(stream(data), {
113
140
  "report-start": (event) => {
114
141
  // The report default over this target's baseline. The marking is
115
142
  // written under the baseline alone: an author's `style` must not be
116
143
  // able to resize it (docs/adr/0002).
117
- intl = { locale: event.locale, currency: event.currency, timeZone: event.timeZone };
144
+ intl = {
145
+ locale: event.locale,
146
+ currency: event.currency,
147
+ timeZone: event.timeZone,
148
+ };
118
149
  if (event.style) base = under(BASELINE, event.style);
119
150
  if (event.marking) {
120
151
  mark(workbook, event.marking);
@@ -139,7 +170,11 @@ export function xlsx(options) {
139
170
  "split-start": (event) => {
140
171
  // The split's own style layers over its band-role default, so a styled
141
172
  // split keeps the weight a plain item in the same band would have.
142
- split = { fields: [], images: [], under: under(roleOf(event), event.style) };
173
+ split = {
174
+ fields: [],
175
+ images: [],
176
+ under: under(roleOf(event), event.style),
177
+ };
143
178
  },
144
179
  "split-end": () => {
145
180
  // The engine emits the bracket as one array, so a `split-end` always
@@ -164,8 +199,10 @@ export function xlsx(options) {
164
199
  // The engine vouched for the magic numbers, not for the rest of the
165
200
  // file. A header too short to carry a size leaves nothing to place a
166
201
  // drawing at, and failing beats dropping the picture in silence
167
- // (SCHEMA.md, "Image item").
168
- if (!size) throw Error("image: could not read the image's size from its bytes");
202
+ // (SCHEMA.md, "Image item"). Named on the item through the engine's
203
+ // own mint, so this failure reads the same from here as it does from
204
+ // a target that paints or embeds.
205
+ if (!size) throw imageError(event.path, "could not read the image's size from its bytes");
169
206
  known = { ...size, id: embed(workbook, event.bytes, event.format) };
170
207
  images.set(event.bytes, known);
171
208
  }
@@ -181,17 +218,14 @@ export function xlsx(options) {
181
218
  place(worksheet, known.id, known, append(worksheet, []));
182
219
  },
183
220
  "table-start": (event) => {
184
- let headers = event.columns.map((/** @type {any} */ column) =>
185
- cell(column.header, event.style),
186
- );
187
- let row = append(worksheet, headers);
221
+ let row = merged(event.header.cells, event.header.style);
188
222
  if (!frozen) {
189
223
  freeze(worksheet, row);
190
224
  frozen = true;
191
225
  }
192
226
  },
193
227
  row: (event) => record(event, event.style),
194
- "total-row": (event) => record(event, event.style),
228
+ "total-row": (event) => merged(event.cells, event.style),
195
229
  });
196
230
  return save(workbook);
197
231
  };
package/lib/style.js CHANGED
@@ -10,11 +10,8 @@
10
10
  * target's own edge, never shared engine code.
11
11
  */
12
12
 
13
- /** @type {(value: any) => boolean} */
14
- let finite = (value) => Number.isFinite(value);
15
13
  let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
16
14
  let SIDES = ["Top", "Right", "Bottom", "Left"];
17
- let BORDER_PARTS = ["Width", "Style", "Color"];
18
15
  /** @type {Record<string, string>} */
19
16
  let LINE = { solid: "thin", dashed: "dashed", dotted: "dotted" };
20
17
 
@@ -33,28 +30,14 @@ let argb = (value) => {
33
30
  return "FF" + digits.toUpperCase();
34
31
  };
35
32
 
36
- // Style blocks layer outward-in: row under cell. A border side is won whole
37
- // by the inner block when that block named any of its three keys, so a cell
38
- // that failed-soft on a side does not pick up the row's other two names.
33
+ // Style blocks layer outward-in: the enclosing block under the cell's own.
34
+ // A row's block reaches here with no box in it -- the engine resolved that
35
+ // half onto the cells themselves, border sides won whole and all (SCHEMA.md,
36
+ // "Style declarations") -- so what layers here is a plain overwrite. The only
37
+ // other block that sits under a cell is a band-role default, which declares
38
+ // no box either.
39
39
  /** @type {(under: any, over: any) => any} */
40
40
  let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
41
- /** @type {(style: any, side: string) => boolean} */
42
- let owns = (style, side) =>
43
- !!style && BORDER_PARTS.some((part) => Object.hasOwn(style, "border" + side + part));
44
- /** @type {(out: any, over: any, side: string) => void} */
45
- let take = (out, over, side) => {
46
- for (let part of BORDER_PARTS) {
47
- let name = "border" + side + part;
48
- if (Object.hasOwn(over, name)) out[name] = over[name];
49
- else delete out[name];
50
- }
51
- };
52
- /** @type {(under: any, over: any) => any} */
53
- let sides = (under, over) => {
54
- let out = merge(under, over);
55
- if (over) for (let side of SIDES) if (owns(over, side)) take(out, over, side);
56
- return out;
57
- };
58
41
 
59
42
  /** @type {(out: any, key: string, value: any) => any} */
60
43
  let put = (out, key, value) => {
@@ -76,7 +59,7 @@ let face = (family) => {
76
59
  };
77
60
 
78
61
  /** @type {(value: any) => number | null} */
79
- let size = (value) => (finite(value) && value > 0 ? value : null);
62
+ let size = (value) => (Number.isFinite(value) && value > 0 ? value : null);
80
63
  /** @type {(value: any) => true | null} */
81
64
  let flag = (value) => (value ? true : null);
82
65
  /** @type {(value: any) => { argb: string } | null} */
@@ -113,9 +96,17 @@ let fill = (value) => {
113
96
  /** @type {(value: any) => any} */
114
97
  let align = (value) =>
115
98
  value === "left" || value === "center" || value === "right" ? { horizontal: value } : null;
99
+ // The writer's own words for the vertical half, one to one. Undeclared is
100
+ // left to the host application (Excel's bottom) rather than pinned: a
101
+ // spreadsheet user expects its default, and an author who cares declares one.
102
+ let VERTICAL = ["top", "middle", "bottom"];
103
+ /** @type {(value: any) => any} */
104
+ let valign = (value) => (VERTICAL.includes(value) ? value : null);
105
+ /** @type {(style: any) => any} */
106
+ let alignment = (style) => put(align(style.align), "vertical", valign(style.valign));
116
107
 
117
108
  /** @type {(width: any) => boolean} */
118
- let isStroke = (width) => finite(width) && width > 0;
109
+ let isStroke = (width) => Number.isFinite(width) && width > 0;
119
110
  /** @type {(named: any) => string | null} */
120
111
  let lineOf = (named) => {
121
112
  if (typeof named !== "string") return null;
@@ -144,7 +135,7 @@ let format = (style) => {
144
135
  let out = put(null, "font", font(style));
145
136
  out = put(out, "fill", fill(style.background));
146
137
  out = put(out, "border", border(style));
147
- return put(out, "alignment", align(style.align));
138
+ return put(out, "alignment", alignment(style));
148
139
  };
149
140
 
150
- export { format, sides };
141
+ export { format, merge };
package/lib/workbook.js CHANGED
@@ -68,6 +68,14 @@ let place = (worksheet, id, size, row) => {
68
68
  });
69
69
  };
70
70
 
71
+ // One merged range across `width` columns of a row, from column `at` (1-based).
72
+ // Chrome merges and data does not (docs/adr/0047), so this reaches the header
73
+ // row and the total rows only.
74
+ /** @type {(worksheet: any, row: number, at: number, width: number) => void} */
75
+ let span = (worksheet, row, at, width) => {
76
+ worksheet.mergeCells(row, at, row, at + width - 1);
77
+ };
78
+
71
79
  // Freeze everything above the body so the header row stays visible.
72
80
  /** @type {(worksheet: any, row: number) => void} */
73
81
  let freeze = (worksheet, row) => {
@@ -93,4 +101,4 @@ let save = async (workbook) => {
93
101
  : new Uint8Array(out);
94
102
  };
95
103
 
96
- export { append, create, embed, freeze, mark, place, save, sheet };
104
+ export { append, create, embed, freeze, mark, place, save, sheet, span };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/xlsx",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The spreadsheet render target for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -42,12 +42,12 @@
42
42
  "@arethetypeswrong/cli": "^0.18.3",
43
43
  "@size-limit/preset-small-lib": "^13.0.3",
44
44
  "@types/node": "^22.20.1",
45
- "quario": "^0.4.0",
45
+ "quario": "^0.6.0",
46
46
  "size-limit": "^13.0.3",
47
47
  "typescript": "^7.0.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "quario": "^0.4.0"
50
+ "quario": "^0.6.0"
51
51
  },
52
52
  "size-limit": [
53
53
  {
@@ -56,7 +56,7 @@
56
56
  "quario",
57
57
  "exceljs"
58
58
  ],
59
- "limit": "2.4 kB"
59
+ "limit": "3 kB"
60
60
  }
61
61
  ],
62
62
  "engines": {