@quario/xlsx 0.1.0 → 0.2.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,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-01
11
+
12
+ ### Added
13
+
14
+ - **The report default reaches every cell.** A report's top-level `style`
15
+ replaces this target's baseline, so a document declaring a face and size gets
16
+ them in cells that declare nothing of their own. It is the layer under the
17
+ band-role defaults — a report declaring `size: 12` still writes its report
18
+ header at 14 — and under each cell's own style.
19
+
20
+ - **`uppercase` is accepted and deliberately not read.** A spreadsheet font
21
+ has no text-transform, and writing capitals into the cell instead would turn
22
+ presentation into data — the cell would stop round-tripping and would sort
23
+ differently. The cell keeps the text you wrote; every other declaration on
24
+ it still applies. Same posture as the column widths this target withdrew.
25
+
26
+ - **A split is one row, its slots the cells across it.** The grid's own
27
+ reading of values placed beside each other. Slot `width` shares go unread,
28
+ on exactly the ground the table's column widths do — a worksheet's columns
29
+ are global to the sheet. The split's own style is what its slots sit under,
30
+ layering the way a table row's does, and a slot that renders nothing writes
31
+ an empty cell so the cells either side keep their columns.
32
+
33
+ ### Changed
34
+
35
+ - **Every cell is written at 10 points unless something declares otherwise.**
36
+ The size was previously left to the writer's own default of 11. Now that a
37
+ report can declare its own size, this target and the PDF carrying different
38
+ numbers for one declaration is exactly what the targets' agreement rule
39
+ forbids. A worksheet's row height follows its font size, so existing unstyled
40
+ sheets come out slightly tighter.
41
+
42
+ - **Display-text `Date`s join as ISO 8601 UTC.** A `Date` inside a mixed
43
+ cell (literal text plus interpolation) joined through `String(date)`, which
44
+ bakes the host's timezone and locale into the worksheet. It now joins
45
+ through the engine's shared display rule as `toISOString()` text. Typed
46
+ cells are untouched: a cell that is one bare `Date` interpolation still
47
+ writes a native date.
48
+
49
+ ### Fixed
50
+
51
+ - **Cells carry their font face explicitly.** Text declaring no `family` used
52
+ to inherit whatever the workbook writer defaulted to, which agreed with the
53
+ other targets only by coincidence. It now writes the same face that
54
+ `family: "sans"` resolves to, so the baseline is a rule rather than a
55
+ property of the library underneath.
56
+
10
57
  ## [0.1.0] - 2026-08-27
11
58
 
12
59
  ### Added
package/lib/image.js CHANGED
@@ -13,9 +13,6 @@
13
13
  /** @type {(bytes: Uint8Array, at: number) => number} */
14
14
  let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
15
15
 
16
- /** @type {(bytes: Uint8Array) => { width: number, height: number }} */
17
- let png = (bytes) => ({ width: word(bytes, 18), height: word(bytes, 22) });
18
-
19
16
  // The dimensions live in the frame header, the first SOFn marker: every
20
17
  // code in C0..CF except the three in that range that are not frames --
21
18
  // DHT, the JPG extension, and DAC.
@@ -42,6 +39,7 @@ let jpeg = (bytes) => {
42
39
  * @returns {{ width: number, height: number } | null} The size, if readable.
43
40
  */
44
41
  export let pixels = (bytes, format) => {
45
- let size = format === "png" ? png(bytes) : jpeg(bytes);
42
+ // A PNG carries the two numbers in its IHDR at a fixed offset.
43
+ let size = format === "png" ? { width: word(bytes, 18), height: word(bytes, 22) } : jpeg(bytes);
46
44
  return size.width > 0 && size.height > 0 ? size : null;
47
45
  };
package/lib/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { walk } from "quario";
19
19
  import { field } from "./cell.js";
20
+ import { merge as under } from "./style.js";
20
21
  import { pixels } from "./image.js";
21
22
  import { append, create, embed, freeze, mark, place, save, sheet } from "./workbook.js";
22
23
 
@@ -40,6 +41,14 @@ import { append, create, embed, freeze, mark, place, save, sheet } from "./workb
40
41
  /** @type {Record<string, any>} */
41
42
  let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
42
43
 
44
+ // This target's baseline type size, written into every cell rather than left
45
+ // to the writer's own 11. The PDF target carries the same 10, and once a report
46
+ // can declare its own `style.size` the two supplying different numbers for one
47
+ // declaration is exactly what docs/adr/0014's corollary forbids. A worksheet's
48
+ // row height follows its font size, so this is visible in the grid as well as
49
+ // in the type (docs/adr/0033).
50
+ let BASELINE = { size: 10 };
51
+
43
52
  // The unlicensed-output marking (LICENSE section 6): one styled banner row
44
53
  // above the report, written only on keyless renders through the same cell
45
54
  // path as every other row. This is the banner's look; the wording arrives on
@@ -71,22 +80,73 @@ export function xlsx(options) {
71
80
  // down a thousand rows is one copy in the file rather than a thousand.
72
81
  /** @type {Map<Uint8Array, any>} */
73
82
  let images = new Map();
83
+ // The layers under an event's own style, outermost first: this target's
84
+ // baseline, then the report default the author declared, then the band-role
85
+ // default if the role carries one. `base` is the first two, settled once
86
+ // per render off `report-start`.
87
+ let base = BASELINE;
88
+ // One cell, wearing everything beneath it. Every cell in the sheet goes
89
+ // through here rather than through `field` directly, so `base` -- this
90
+ // target's baseline plus the author's report default -- cannot be
91
+ // forgotten by a call site that means "nothing encloses this". The
92
+ // marking is the one deliberate exception, and says so where it is
93
+ // written.
94
+ let cell = (/** @type {any} */ value, /** @type {any} */ over = null) =>
95
+ field(value, under(base, over));
96
+ // The band-role default an event sits under, if its role carries one.
97
+ let roleOf = (/** @type {any} */ event) =>
98
+ Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null;
99
+ // The split being filled, if any: the fields its slots have written and
100
+ // the pictures owed an anchor, which is the row the whole split lands on.
101
+ // Splits never nest, so one is enough.
102
+ /** @type {{ fields: any[], images: any[], under: any } | null} */
103
+ let split = null;
74
104
  // A table row and a total row differ only in the style they sit under.
75
- let record = (/** @type {any} */ event, /** @type {any} */ under) =>
105
+ let record = (/** @type {any} */ event, /** @type {any} */ layer) =>
76
106
  append(
77
107
  worksheet,
78
- event.cells.map((/** @type {any} */ cell) => field(cell, under)),
108
+ event.cells.map((/** @type {any} */ each) => cell(each, layer)),
79
109
  );
80
110
  await walk(stream(data), {
81
111
  "report-start": (event) => {
112
+ // The report default over this target's baseline. The marking is
113
+ // written under the baseline alone: an author's `style` must not be
114
+ // able to resize it (docs/adr/0002).
115
+ if (event.style) base = under(BASELINE, event.style);
82
116
  if (event.marking) {
83
117
  mark(workbook, event.marking);
84
- append(worksheet, [field({ tokens: [{ literal: event.marking }], style: BANNER }, null)]);
118
+ append(worksheet, [
119
+ field({ tokens: [{ literal: event.marking }], style: BANNER }, BASELINE),
120
+ ]);
85
121
  }
86
122
  },
87
123
  item: (event) => {
88
- let role = Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null;
89
- append(worksheet, [field({ tokens: event.tokens, style: event.style }, role)]);
124
+ let written = cell(
125
+ { tokens: event.tokens, style: event.style },
126
+ split ? split.under : roleOf(event),
127
+ );
128
+ if (split) split.fields.push(written);
129
+ else append(worksheet, [written]);
130
+ },
131
+ // A split is one row, its slots the cells across it -- the grid's own
132
+ // reading of items placed beside each other. Slot `width` shares go
133
+ // unread on the same ground the table's column widths do: a worksheet's
134
+ // columns are global to the sheet. The split's own style is what its
135
+ // slots sit under, exactly as a table row's is.
136
+ "split-start": (event) => {
137
+ // The split's own style layers over its band-role default, so a styled
138
+ // split keeps the weight a plain item in the same band would have.
139
+ split = { fields: [], images: [], under: under(roleOf(event), event.style) };
140
+ },
141
+ "split-end": () => {
142
+ // The engine emits the bracket as one array, so a `split-end` always
143
+ // has its opening and `split` is never null here.
144
+ let owed = /** @type {{ fields: any[], images: any[] }} */ (split);
145
+ split = null;
146
+ let row = append(worksheet, owed.fields);
147
+ // A picture in a slot floats from the split's own row: the cell under
148
+ // it holds the slot so the fields either side keep their columns.
149
+ for (let known of owed.images) place(worksheet, known.id, known, row);
90
150
  },
91
151
  // An image is not a cell: it takes one row as its anchor and floats
92
152
  // over the sheet from there, at its own pixel size. `fit` is not read
@@ -106,12 +166,19 @@ export function xlsx(options) {
106
166
  known = { ...size, id: embed(workbook, event.bytes, event.format) };
107
167
  images.set(event.bytes, known);
108
168
  }
169
+ if (split) {
170
+ // The slot keeps its cell, empty, and the picture waits for the row
171
+ // the whole split lands on.
172
+ split.fields.push(cell({ tokens: [] }));
173
+ split.images.push(known);
174
+ return;
175
+ }
109
176
  // The anchor: a row of its own, with nothing written in it, which the
110
177
  // picture then floats over.
111
178
  place(worksheet, known.id, known, append(worksheet, []));
112
179
  },
113
180
  "table-start": (event) => {
114
- let headers = event.columns.map((/** @type {any} */ column) => field(column.header, null));
181
+ let headers = event.columns.map((/** @type {any} */ column) => cell(column.header));
115
182
  let row = append(worksheet, headers);
116
183
  if (!frozen) {
117
184
  freeze(worksheet, row);
package/lib/style.js CHANGED
@@ -11,14 +11,13 @@
11
11
  */
12
12
 
13
13
  /** @type {(value: any) => boolean} */
14
- let finite = (value) => typeof value === "number" && Number.isFinite(value);
14
+ let finite = (value) => Number.isFinite(value);
15
15
  let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
16
16
 
17
17
  // The generic families as the fonts spreadsheet apps ship with; any other
18
18
  // name passes through verbatim for the host application to resolve.
19
19
  /** @type {Record<string, string>} */
20
20
  let FAMILY = { sans: "Calibri", serif: "Times New Roman", mono: "Courier New" };
21
- let ALIGNMENTS = ["left", "center", "right"];
22
21
 
23
22
  // A declared colour as an ARGB string, or null when the value is not one.
24
23
  /** @type {(value: any) => string | null} */
@@ -41,9 +40,13 @@ let put = (out, key, value) => {
41
40
  return out;
42
41
  };
43
42
 
43
+ // The face a declared family names, or the baseline one an undeclared family
44
+ // resolves to: text that declares nothing renders in the same face
45
+ // `family: "sans"` does, written rather than left to the writer's own default
46
+ // (docs/adr/0014). One answer, in one place.
44
47
  /** @type {(family: any) => string} */
45
48
  let face = (family) => {
46
- if (typeof family !== "string" || !family) return "";
49
+ if (typeof family !== "string" || !family) return FAMILY.sans;
47
50
  let key = family.toLowerCase();
48
51
  // Own-key lookup: `constructor` must not resolve an inherited member.
49
52
  return Object.hasOwn(FAMILY, key) ? FAMILY[key] : family;
@@ -59,8 +62,8 @@ let tint = (value) => {
59
62
  return color ? { argb: color } : null;
60
63
  };
61
64
 
62
- // The font half of a resolved style, or null when the style declares none
63
- // of it.
65
+ // The font half of a resolved style. Never null: every cell carries the
66
+ // baseline face even when it declares nothing else.
64
67
  /** @type {(style: any) => any} */
65
68
  let font = (style) => {
66
69
  let out = put(null, "name", face(style.family));
@@ -70,6 +73,12 @@ let font = (style) => {
70
73
  out = put(out, "underline", flag(style.underline));
71
74
  // exceljs names the flag `strike`; the authoring surface says strikethrough.
72
75
  out = put(out, "strike", flag(style.strikethrough));
76
+ // `uppercase` is deliberately unread: a spreadsheet font has no
77
+ // text-transform (OOXML's font carries none, and neither does the writer's
78
+ // model), and uppercasing the string instead would make presentation into
79
+ // data -- the cell would stop round-tripping and sort differently. A
80
+ // mapping quario cannot honestly make is withdrawn rather than invented,
81
+ // exactly as the column widths were (docs/adr/0008).
73
82
  return put(out, "color", tint(style.color));
74
83
  };
75
84
 
@@ -79,7 +88,8 @@ let fill = (value) => {
79
88
  return color ? { type: "pattern", pattern: "solid", fgColor: { argb: color } } : null;
80
89
  };
81
90
  /** @type {(value: any) => any} */
82
- let align = (value) => (ALIGNMENTS.includes(value) ? { horizontal: value } : null);
91
+ let align = (value) =>
92
+ value === "left" || value === "center" || value === "right" ? { horizontal: value } : null;
83
93
 
84
94
  // The whole-cell reading: the font plus the parts only a cell can carry.
85
95
  /** @type {(style: any) => any} */
package/lib/workbook.js CHANGED
@@ -9,14 +9,13 @@
9
9
  */
10
10
  import ExcelJS from "exceljs";
11
11
 
12
- // Host metadata keys as the workbook properties they become.
13
- /** @type {Record<string, string>} */
14
- let META = { title: "title", author: "creator", subject: "subject" };
15
- /** @type {(workbook: any, meta: any) => any} */
12
+ // Host metadata keys as the workbook properties they become -- `author` is the
13
+ // only one exceljs names differently.
14
+ /** @type {(workbook: any, meta: any) => void} */
16
15
  let describe = (workbook, meta) => {
17
- if (!meta) return workbook;
18
- for (let from in META) if (typeof meta[from] === "string") workbook[META[from]] = meta[from];
19
- return workbook;
16
+ if (typeof meta.title === "string") workbook.title = meta.title;
17
+ if (typeof meta.author === "string") workbook.creator = meta.author;
18
+ if (typeof meta.subject === "string") workbook.subject = meta.subject;
20
19
  };
21
20
 
22
21
  // A fresh workbook. Never the current time: a timestamp would make the same
@@ -26,16 +25,18 @@ let create = (meta) => {
26
25
  let workbook = new ExcelJS.Workbook();
27
26
  workbook.created = new Date(0);
28
27
  workbook.modified = new Date(0);
29
- return describe(workbook, meta);
28
+ if (meta) describe(workbook, meta);
29
+ return workbook;
30
30
  };
31
31
 
32
32
  /** @type {(workbook: any) => any} */
33
33
  let sheet = (workbook) => workbook.addWorksheet("Report");
34
34
 
35
- let KEYS = ["font", "fill", "alignment"];
36
35
  /** @type {(cell: any, format: any) => void} */
37
36
  let paint = (cell, format) => {
38
- for (let key of KEYS) if (format[key]) cell[key] = format[key];
37
+ if (format.font) cell.font = format.font;
38
+ if (format.fill) cell.fill = format.fill;
39
+ if (format.alignment) cell.alignment = format.alignment;
39
40
  };
40
41
 
41
42
  // Write one worksheet row from cell descriptors; returns the row number.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/xlsx",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.1.0",
45
+ "quario": "^0.2.0",
46
46
  "size-limit": "^13.0.3",
47
47
  "typescript": "^7.0.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "quario": "^0.1.0"
50
+ "quario": "^0.2.0"
51
51
  },
52
52
  "size-limit": [
53
53
  {