@quario/xlsx 0.3.0 → 0.5.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,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **Chrome merges, data does not.** A cell spanning several columns in the
15
+ header row or a total row is written into the first column it covers, the
16
+ rest of the range is written empty, and the range is merged. Nothing in the
17
+ data region merges, so sort, filter and column selection keep working over
18
+ it.
19
+
20
+ - **`valign`** writes the cell's vertical alignment for table cells and split
21
+ slots. Undeclared writes nothing, so the spreadsheet application keeps its
22
+ own default.
23
+
24
+ ## [0.4.0] - 2026-09-03
25
+
26
+ ### Changed
27
+
28
+ - **A date string under `format: "date"` now reaches the grid as a date.**
29
+ The cell carries a real date value under the `yyyy-mm-dd` number format
30
+ instead of the author's text, so it sorts and computes as a date. Only a
31
+ cell declaring the kind is affected; a string in a form the engine does not
32
+ read stays text. See the `quario` changelog for the forms.
33
+
10
34
  ## [0.3.0] - 2026-09-02
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -106,6 +106,7 @@ unread, and all of it may change without a breaking change.
106
106
  | `size`, `bold`, `italic`, `underline`, `strikethrough` | Font (`strikethrough` → writer's `strike`) |
107
107
  | `color`, `background` | Font colour, solid fill |
108
108
  | `align` | Horizontal alignment |
109
+ | `valign` | Vertical alignment; undeclared writes nothing, so the spreadsheet application keeps its own default |
109
110
 
110
111
  Style blocks layer row under cell, as everywhere in quario.
111
112
 
package/lib/cell.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * newline gains `wrapText` so the break is visible in the grid.
8
8
  */
9
9
  import { text, typed } from "quario";
10
- import { format as look, sides } from "./style.js";
10
+ import { format as look, merge } from "./style.js";
11
11
 
12
12
  /** @type {(value: any, fmt: string) => string | null} */
13
13
  let whenNum = (value, fmt) => (typeof value === "number" ? fmt : null);
@@ -31,9 +31,9 @@ let numFmtOf = (kind, value, currency) => {
31
31
  return rule ? rule(value, currency) : null;
32
32
  };
33
33
 
34
- /** @type {(cell: { tokens: any[] }) => any} */
35
- let shownOf = (cell) => {
36
- let value = typed(cell.tokens);
34
+ /** @type {(cell: { tokens: any[] }, kind: any) => any} */
35
+ let shownOf = (cell, kind) => {
36
+ let value = typed(cell.tokens, kind);
37
37
  return value !== undefined ? value : text(cell.tokens);
38
38
  };
39
39
  /** @type {(out: any, resolved: any, shown: any, intl: any) => void} */
@@ -54,8 +54,8 @@ let paintWrap = (out, shown) => {
54
54
  * @type {(cell: { tokens: any[], style?: any }, under: any, intl?: any) => { value: any, format: any }}
55
55
  */
56
56
  let field = (cell, under, intl) => {
57
- let resolved = sides(under, cell.style);
58
- let shown = shownOf(cell);
57
+ let resolved = merge(under, cell.style);
58
+ let shown = shownOf(cell, resolved?.format);
59
59
  let out = look(resolved);
60
60
  paintFmt(out, resolved, shown, intl);
61
61
  paintWrap(out, shown);
package/lib/index.js CHANGED
@@ -17,9 +17,9 @@
17
17
  */
18
18
  import { 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.
@@ -109,6 +109,30 @@ export function xlsx(options) {
109
109
  worksheet,
110
110
  event.cells.map((/** @type {any} */ each) => cell(each, layer)),
111
111
  );
112
+ // Chrome merges, data does not (docs/adr/0047). A cell covering several
113
+ // columns writes its value into the first of them, the rest are written
114
+ // empty, and the range is merged -- so the label stays where the document
115
+ // put it. Reached by the header row and the total rows; a data row cannot
116
+ // span, so `record` above stays the plain path it was.
117
+ /** @type {(each: any, layer: any) => any[]} */
118
+ let covering = (each, layer) => [
119
+ cell(each, layer),
120
+ ...Array.from({ length: (each.span || 1) - 1 }, () => cell({ tokens: [] }, layer)),
121
+ ];
122
+ /** @type {(cells: any[], layer: any) => number} */
123
+ let merged = (cells, layer) => {
124
+ /** @type {any[]} */
125
+ let fields = [];
126
+ /** @type {{ at: number, width: number }[]} */
127
+ let ranges = [];
128
+ for (let each of cells) {
129
+ if (each.span > 1) ranges.push({ at: fields.length + 1, width: each.span });
130
+ fields.push(...covering(each, layer));
131
+ }
132
+ let row = append(worksheet, fields);
133
+ for (let range of ranges) span(worksheet, row, range.at, range.width);
134
+ return row;
135
+ };
112
136
  await walk(stream(data), {
113
137
  "report-start": (event) => {
114
138
  // The report default over this target's baseline. The marking is
@@ -181,17 +205,19 @@ export function xlsx(options) {
181
205
  place(worksheet, known.id, known, append(worksheet, []));
182
206
  },
183
207
  "table-start": (event) => {
184
- let headers = event.columns.map((/** @type {any} */ column) =>
185
- cell(column.header, event.style),
208
+ let row = merged(
209
+ event.columns
210
+ .filter((/** @type {any} */ column) => column.header)
211
+ .map((/** @type {any} */ column) => column.header),
212
+ event.style,
186
213
  );
187
- let row = append(worksheet, headers);
188
214
  if (!frozen) {
189
215
  freeze(worksheet, row);
190
216
  frozen = true;
191
217
  }
192
218
  },
193
219
  row: (event) => record(event, event.style),
194
- "total-row": (event) => record(event, event.style),
220
+ "total-row": (event) => merged(event.cells, event.style),
195
221
  });
196
222
  return save(workbook);
197
223
  };
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.3.0",
3
+ "version": "0.5.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.3.0",
45
+ "quario": "^0.5.0",
46
46
  "size-limit": "^13.0.3",
47
47
  "typescript": "^7.0.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "quario": "^0.3.0"
50
+ "quario": "^0.5.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": {