@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 +47 -0
- package/lib/image.js +2 -4
- package/lib/index.js +73 -6
- package/lib/style.js +16 -6
- package/lib/workbook.js +11 -10
- package/package.json +3 -3
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
|
-
|
|
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} */
|
|
105
|
+
let record = (/** @type {any} */ event, /** @type {any} */ layer) =>
|
|
76
106
|
append(
|
|
77
107
|
worksheet,
|
|
78
|
-
event.cells.map((/** @type {any} */
|
|
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, [
|
|
118
|
+
append(worksheet, [
|
|
119
|
+
field({ tokens: [{ literal: event.marking }], style: BANNER }, BASELINE),
|
|
120
|
+
]);
|
|
85
121
|
}
|
|
86
122
|
},
|
|
87
123
|
item: (event) => {
|
|
88
|
-
let
|
|
89
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
|
63
|
-
//
|
|
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) =>
|
|
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
|
-
|
|
14
|
-
|
|
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 (
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
+
"quario": "^0.2.0"
|
|
51
51
|
},
|
|
52
52
|
"size-limit": [
|
|
53
53
|
{
|