@quario/xlsx 0.1.0 → 0.3.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 +74 -0
- package/lib/cell.js +49 -10
- package/lib/image.js +2 -4
- package/lib/index.js +79 -7
- package/lib/style.js +66 -8
- package/lib/workbook.js +10 -10
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`format` maps to a number format; the cell stays typed.** `number` is
|
|
15
|
+
`#,##0.00`, `percent` `0.00%`, `date` `yyyy-mm-dd`, `currency` the
|
|
16
|
+
instance currency code as `"USD"#,##0.00`. A kind on the wrong type
|
|
17
|
+
contributes nothing.
|
|
18
|
+
|
|
19
|
+
- **Cell borders map; padding and flow spacing do not.** `solid` is exceljs
|
|
20
|
+
`thin`; `dashed` and `dotted` keep their names. A header-row, row, or
|
|
21
|
+
total-row box fans onto that row's cells; a cell that named any of a
|
|
22
|
+
side's three keys owns that side whole. Padding is unread: a worksheet
|
|
23
|
+
cell has no inset. Flow spacing is unread: a grid has no flow.
|
|
24
|
+
|
|
25
|
+
- **`page.margin` and report-header `height` are unread.** A grid has no
|
|
26
|
+
page top to pin from.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **A table total emits N rows.** Each `total-row` is one worksheet row, as
|
|
31
|
+
each data row is.
|
|
32
|
+
|
|
33
|
+
- **A cell whose display contains a newline wraps.** `wrapText` is this
|
|
34
|
+
target's mapping of a literal newline as a line break, so `"one\ntwo"` is
|
|
35
|
+
two lines in the grid. Single-line cells are unchanged.
|
|
36
|
+
|
|
37
|
+
## [0.2.0] - 2026-09-01
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **The report default reaches every cell.** A report's top-level `style`
|
|
42
|
+
replaces this target's baseline, so a document declaring a face and size gets
|
|
43
|
+
them in cells that declare nothing of their own. It is the layer under the
|
|
44
|
+
band-role defaults — a report declaring `size: 12` still writes its report
|
|
45
|
+
header at 14 — and under each cell's own style.
|
|
46
|
+
|
|
47
|
+
- **`uppercase` is accepted and deliberately not read.** A spreadsheet font
|
|
48
|
+
has no text-transform, and writing capitals into the cell instead would turn
|
|
49
|
+
presentation into data — the cell would stop round-tripping and would sort
|
|
50
|
+
differently. The cell keeps the text you wrote; every other declaration on
|
|
51
|
+
it still applies. Same posture as the column widths this target withdrew.
|
|
52
|
+
|
|
53
|
+
- **A split is one row, its slots the cells across it.** The grid's own
|
|
54
|
+
reading of values placed beside each other. Slot `width` shares go unread,
|
|
55
|
+
on exactly the ground the table's column widths do — a worksheet's columns
|
|
56
|
+
are global to the sheet. The split's own style is what its slots sit under,
|
|
57
|
+
layering the way a table row's does, and a slot that renders nothing writes
|
|
58
|
+
an empty cell so the cells either side keep their columns.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **Every cell is written at 10 points unless something declares otherwise.**
|
|
63
|
+
The size was previously left to the writer's own default of 11. Now that a
|
|
64
|
+
report can declare its own size, this target and the PDF carrying different
|
|
65
|
+
numbers for one declaration is exactly what the targets' agreement rule
|
|
66
|
+
forbids. A worksheet's row height follows its font size, so existing unstyled
|
|
67
|
+
sheets come out slightly tighter.
|
|
68
|
+
|
|
69
|
+
- **Display-text `Date`s join as ISO 8601 UTC.** A `Date` inside a mixed
|
|
70
|
+
cell (literal text plus interpolation) joined through `String(date)`, which
|
|
71
|
+
bakes the host's timezone and locale into the worksheet. It now joins
|
|
72
|
+
through the engine's shared display rule as `toISOString()` text. Typed
|
|
73
|
+
cells are untouched: a cell that is one bare `Date` interpolation still
|
|
74
|
+
writes a native date.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **Cells carry their font face explicitly.** Text declaring no `family` used
|
|
79
|
+
to inherit whatever the workbook writer defaulted to, which agreed with the
|
|
80
|
+
other targets only by coincidence. It now writes the same face that
|
|
81
|
+
`family: "sans"` resolves to, so the baseline is a rule rather than a
|
|
82
|
+
property of the library underneath.
|
|
83
|
+
|
|
10
84
|
## [0.1.0] - 2026-08-27
|
|
11
85
|
|
|
12
86
|
### Added
|
package/lib/cell.js
CHANGED
|
@@ -3,24 +3,63 @@
|
|
|
3
3
|
* the engine's `typed` stream rule. `value` is a native number, boolean, or
|
|
4
4
|
* Date when the cell is one bare interpolation (so numbers stay computable in
|
|
5
5
|
* the sheet), and the display-text join otherwise. `format` is the resolved
|
|
6
|
-
* cell-level formatting from the style module.
|
|
6
|
+
* cell-level formatting from the style module. Display text that contains a
|
|
7
|
+
* newline gains `wrapText` so the break is visible in the grid.
|
|
7
8
|
*/
|
|
8
9
|
import { text, typed } from "quario";
|
|
9
|
-
import { format,
|
|
10
|
+
import { format as look, sides } from "./style.js";
|
|
11
|
+
|
|
12
|
+
/** @type {(value: any, fmt: string) => string | null} */
|
|
13
|
+
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),
|
|
27
|
+
};
|
|
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;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @type {(cell: { tokens: any[] }) => any} */
|
|
35
|
+
let shownOf = (cell) => {
|
|
36
|
+
let value = typed(cell.tokens);
|
|
37
|
+
return value !== undefined ? value : text(cell.tokens);
|
|
38
|
+
};
|
|
39
|
+
/** @type {(out: any, resolved: any, shown: any, intl: any) => void} */
|
|
40
|
+
let paintFmt = (out, resolved, shown, intl) => {
|
|
41
|
+
let numFmt = numFmtOf(resolved?.format, shown, intl?.currency);
|
|
42
|
+
if (numFmt) out.numFmt = numFmt;
|
|
43
|
+
};
|
|
44
|
+
/** @type {(out: any, shown: any) => void} */
|
|
45
|
+
let paintWrap = (out, shown) => {
|
|
46
|
+
if (typeof shown === "string" && /[\r\n]/.test(shown))
|
|
47
|
+
out.alignment = { ...out.alignment, wrapText: true };
|
|
48
|
+
};
|
|
10
49
|
|
|
11
50
|
/**
|
|
12
51
|
* Resolve one event cell against its enclosing style (a row's style, or an
|
|
13
52
|
* item's band-role default — the layer under the cell's own).
|
|
14
53
|
*
|
|
15
|
-
* @type {(cell: { tokens: any[], style?: any }, under: any) => { value: any, format: any }}
|
|
54
|
+
* @type {(cell: { tokens: any[], style?: any }, under: any, intl?: any) => { value: any, format: any }}
|
|
16
55
|
*/
|
|
17
|
-
let field = (cell, under) => {
|
|
18
|
-
let resolved =
|
|
19
|
-
let
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
};
|
|
56
|
+
let field = (cell, under, intl) => {
|
|
57
|
+
let resolved = sides(under, cell.style);
|
|
58
|
+
let shown = shownOf(cell);
|
|
59
|
+
let out = look(resolved);
|
|
60
|
+
paintFmt(out, resolved, shown, intl);
|
|
61
|
+
paintWrap(out, shown);
|
|
62
|
+
return { value: shown, format: out };
|
|
24
63
|
};
|
|
25
64
|
|
|
26
65
|
export { field };
|
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 { sides 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,76 @@ 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
|
+
/** @type {{ locale?: string, currency?: string, timeZone?: string } | null} */
|
|
89
|
+
let intl = null;
|
|
90
|
+
// One cell, wearing everything beneath it. Every cell in the sheet goes
|
|
91
|
+
// through here rather than through `field` directly, so `base` -- this
|
|
92
|
+
// target's baseline plus the author's report default -- cannot be
|
|
93
|
+
// forgotten by a call site that means "nothing encloses this". The
|
|
94
|
+
// marking is the one deliberate exception, and says so where it is
|
|
95
|
+
// written.
|
|
96
|
+
let cell = (/** @type {any} */ value, /** @type {any} */ over = null) =>
|
|
97
|
+
field(value, under(base, over), intl);
|
|
98
|
+
// The band-role default an event sits under, if its role carries one.
|
|
99
|
+
let roleOf = (/** @type {any} */ event) =>
|
|
100
|
+
Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null;
|
|
101
|
+
// The split being filled, if any: the fields its slots have written and
|
|
102
|
+
// the pictures owed an anchor, which is the row the whole split lands on.
|
|
103
|
+
// Splits never nest, so one is enough.
|
|
104
|
+
/** @type {{ fields: any[], images: any[], under: any } | null} */
|
|
105
|
+
let split = null;
|
|
74
106
|
// A table row and a total row differ only in the style they sit under.
|
|
75
|
-
let record = (/** @type {any} */ event, /** @type {any} */
|
|
107
|
+
let record = (/** @type {any} */ event, /** @type {any} */ layer) =>
|
|
76
108
|
append(
|
|
77
109
|
worksheet,
|
|
78
|
-
event.cells.map((/** @type {any} */
|
|
110
|
+
event.cells.map((/** @type {any} */ each) => cell(each, layer)),
|
|
79
111
|
);
|
|
80
112
|
await walk(stream(data), {
|
|
81
113
|
"report-start": (event) => {
|
|
114
|
+
// The report default over this target's baseline. The marking is
|
|
115
|
+
// written under the baseline alone: an author's `style` must not be
|
|
116
|
+
// able to resize it (docs/adr/0002).
|
|
117
|
+
intl = { locale: event.locale, currency: event.currency, timeZone: event.timeZone };
|
|
118
|
+
if (event.style) base = under(BASELINE, event.style);
|
|
82
119
|
if (event.marking) {
|
|
83
120
|
mark(workbook, event.marking);
|
|
84
|
-
append(worksheet, [
|
|
121
|
+
append(worksheet, [
|
|
122
|
+
field({ tokens: [{ literal: event.marking }], style: BANNER }, BASELINE),
|
|
123
|
+
]);
|
|
85
124
|
}
|
|
86
125
|
},
|
|
87
126
|
item: (event) => {
|
|
88
|
-
let
|
|
89
|
-
|
|
127
|
+
let written = cell(
|
|
128
|
+
{ tokens: event.tokens, style: event.style },
|
|
129
|
+
split ? split.under : roleOf(event),
|
|
130
|
+
);
|
|
131
|
+
if (split) split.fields.push(written);
|
|
132
|
+
else append(worksheet, [written]);
|
|
133
|
+
},
|
|
134
|
+
// A split is one row, its slots the cells across it -- the grid's own
|
|
135
|
+
// reading of items placed beside each other. Slot `width` shares go
|
|
136
|
+
// unread on the same ground the table's column widths do: a worksheet's
|
|
137
|
+
// columns are global to the sheet. The split's own style is what its
|
|
138
|
+
// slots sit under, exactly as a table row's is.
|
|
139
|
+
"split-start": (event) => {
|
|
140
|
+
// The split's own style layers over its band-role default, so a styled
|
|
141
|
+
// split keeps the weight a plain item in the same band would have.
|
|
142
|
+
split = { fields: [], images: [], under: under(roleOf(event), event.style) };
|
|
143
|
+
},
|
|
144
|
+
"split-end": () => {
|
|
145
|
+
// The engine emits the bracket as one array, so a `split-end` always
|
|
146
|
+
// has its opening and `split` is never null here.
|
|
147
|
+
let owed = /** @type {{ fields: any[], images: any[] }} */ (split);
|
|
148
|
+
split = null;
|
|
149
|
+
let row = append(worksheet, owed.fields);
|
|
150
|
+
// A picture in a slot floats from the split's own row: the cell under
|
|
151
|
+
// it holds the slot so the fields either side keep their columns.
|
|
152
|
+
for (let known of owed.images) place(worksheet, known.id, known, row);
|
|
90
153
|
},
|
|
91
154
|
// An image is not a cell: it takes one row as its anchor and floats
|
|
92
155
|
// over the sheet from there, at its own pixel size. `fit` is not read
|
|
@@ -106,12 +169,21 @@ export function xlsx(options) {
|
|
|
106
169
|
known = { ...size, id: embed(workbook, event.bytes, event.format) };
|
|
107
170
|
images.set(event.bytes, known);
|
|
108
171
|
}
|
|
172
|
+
if (split) {
|
|
173
|
+
// The slot keeps its cell, empty, and the picture waits for the row
|
|
174
|
+
// the whole split lands on.
|
|
175
|
+
split.fields.push(cell({ tokens: [] }));
|
|
176
|
+
split.images.push(known);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
109
179
|
// The anchor: a row of its own, with nothing written in it, which the
|
|
110
180
|
// picture then floats over.
|
|
111
181
|
place(worksheet, known.id, known, append(worksheet, []));
|
|
112
182
|
},
|
|
113
183
|
"table-start": (event) => {
|
|
114
|
-
let headers = event.columns.map((/** @type {any} */ column) =>
|
|
184
|
+
let headers = event.columns.map((/** @type {any} */ column) =>
|
|
185
|
+
cell(column.header, event.style),
|
|
186
|
+
);
|
|
115
187
|
let row = append(worksheet, headers);
|
|
116
188
|
if (!frozen) {
|
|
117
189
|
freeze(worksheet, row);
|
|
@@ -119,7 +191,7 @@ export function xlsx(options) {
|
|
|
119
191
|
}
|
|
120
192
|
},
|
|
121
193
|
row: (event) => record(event, event.style),
|
|
122
|
-
"total-row": (event) => record(event,
|
|
194
|
+
"total-row": (event) => record(event, event.style),
|
|
123
195
|
});
|
|
124
196
|
return save(workbook);
|
|
125
197
|
};
|
package/lib/style.js
CHANGED
|
@@ -11,14 +11,17 @@
|
|
|
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
|
+
let SIDES = ["Top", "Right", "Bottom", "Left"];
|
|
17
|
+
let BORDER_PARTS = ["Width", "Style", "Color"];
|
|
18
|
+
/** @type {Record<string, string>} */
|
|
19
|
+
let LINE = { solid: "thin", dashed: "dashed", dotted: "dotted" };
|
|
16
20
|
|
|
17
21
|
// The generic families as the fonts spreadsheet apps ship with; any other
|
|
18
22
|
// name passes through verbatim for the host application to resolve.
|
|
19
23
|
/** @type {Record<string, string>} */
|
|
20
24
|
let FAMILY = { sans: "Calibri", serif: "Times New Roman", mono: "Courier New" };
|
|
21
|
-
let ALIGNMENTS = ["left", "center", "right"];
|
|
22
25
|
|
|
23
26
|
// A declared colour as an ARGB string, or null when the value is not one.
|
|
24
27
|
/** @type {(value: any) => string | null} */
|
|
@@ -30,9 +33,28 @@ let argb = (value) => {
|
|
|
30
33
|
return "FF" + digits.toUpperCase();
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
// Style blocks layer outward-in: row under cell.
|
|
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.
|
|
34
39
|
/** @type {(under: any, over: any) => any} */
|
|
35
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
|
+
};
|
|
36
58
|
|
|
37
59
|
/** @type {(out: any, key: string, value: any) => any} */
|
|
38
60
|
let put = (out, key, value) => {
|
|
@@ -41,9 +63,13 @@ let put = (out, key, value) => {
|
|
|
41
63
|
return out;
|
|
42
64
|
};
|
|
43
65
|
|
|
66
|
+
// The face a declared family names, or the baseline one an undeclared family
|
|
67
|
+
// resolves to: text that declares nothing renders in the same face
|
|
68
|
+
// `family: "sans"` does, written rather than left to the writer's own default
|
|
69
|
+
// (docs/adr/0014). One answer, in one place.
|
|
44
70
|
/** @type {(family: any) => string} */
|
|
45
71
|
let face = (family) => {
|
|
46
|
-
if (typeof family !== "string" || !family) return
|
|
72
|
+
if (typeof family !== "string" || !family) return FAMILY.sans;
|
|
47
73
|
let key = family.toLowerCase();
|
|
48
74
|
// Own-key lookup: `constructor` must not resolve an inherited member.
|
|
49
75
|
return Object.hasOwn(FAMILY, key) ? FAMILY[key] : family;
|
|
@@ -59,8 +85,8 @@ let tint = (value) => {
|
|
|
59
85
|
return color ? { argb: color } : null;
|
|
60
86
|
};
|
|
61
87
|
|
|
62
|
-
// The font half of a resolved style
|
|
63
|
-
//
|
|
88
|
+
// The font half of a resolved style. Never null: every cell carries the
|
|
89
|
+
// baseline face even when it declares nothing else.
|
|
64
90
|
/** @type {(style: any) => any} */
|
|
65
91
|
let font = (style) => {
|
|
66
92
|
let out = put(null, "name", face(style.family));
|
|
@@ -70,6 +96,12 @@ let font = (style) => {
|
|
|
70
96
|
out = put(out, "underline", flag(style.underline));
|
|
71
97
|
// exceljs names the flag `strike`; the authoring surface says strikethrough.
|
|
72
98
|
out = put(out, "strike", flag(style.strikethrough));
|
|
99
|
+
// `uppercase` is deliberately unread: a spreadsheet font has no
|
|
100
|
+
// text-transform (OOXML's font carries none, and neither does the writer's
|
|
101
|
+
// model), and uppercasing the string instead would make presentation into
|
|
102
|
+
// data -- the cell would stop round-tripping and sort differently. A
|
|
103
|
+
// mapping quario cannot honestly make is withdrawn rather than invented,
|
|
104
|
+
// exactly as the column widths were (docs/adr/0008).
|
|
73
105
|
return put(out, "color", tint(style.color));
|
|
74
106
|
};
|
|
75
107
|
|
|
@@ -79,14 +111,40 @@ let fill = (value) => {
|
|
|
79
111
|
return color ? { type: "pattern", pattern: "solid", fgColor: { argb: color } } : null;
|
|
80
112
|
};
|
|
81
113
|
/** @type {(value: any) => any} */
|
|
82
|
-
let align = (value) =>
|
|
114
|
+
let align = (value) =>
|
|
115
|
+
value === "left" || value === "center" || value === "right" ? { horizontal: value } : null;
|
|
116
|
+
|
|
117
|
+
/** @type {(width: any) => boolean} */
|
|
118
|
+
let isStroke = (width) => finite(width) && width > 0;
|
|
119
|
+
/** @type {(named: any) => string | null} */
|
|
120
|
+
let lineOf = (named) => {
|
|
121
|
+
if (typeof named !== "string") return null;
|
|
122
|
+
return Object.hasOwn(LINE, named) ? LINE[named] : null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** @type {(style: any, side: string) => any} */
|
|
126
|
+
let edge = (style, side) => {
|
|
127
|
+
let line = lineOf(style["border" + side + "Style"]);
|
|
128
|
+
let color = argb(style["border" + side + "Color"]);
|
|
129
|
+
if (!isStroke(style["border" + side + "Width"]) || !line || !color) return null;
|
|
130
|
+
return { style: line, color: { argb: color } };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** @type {(style: any) => any} */
|
|
134
|
+
let border = (style) => {
|
|
135
|
+
let out = null;
|
|
136
|
+
for (let side of SIDES) out = put(out, side.toLowerCase(), edge(style, side));
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
83
139
|
|
|
84
140
|
// The whole-cell reading: the font plus the parts only a cell can carry.
|
|
141
|
+
// Padding is unread: a worksheet cell has no inset (docs/adr/0008).
|
|
85
142
|
/** @type {(style: any) => any} */
|
|
86
143
|
let format = (style) => {
|
|
87
144
|
let out = put(null, "font", font(style));
|
|
88
145
|
out = put(out, "fill", fill(style.background));
|
|
146
|
+
out = put(out, "border", border(style));
|
|
89
147
|
return put(out, "alignment", align(style.align));
|
|
90
148
|
};
|
|
91
149
|
|
|
92
|
-
export { format,
|
|
150
|
+
export { format, sides };
|
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,17 @@ 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
|
|
35
|
+
let SLOTS = ["font", "fill", "border", "alignment", "numFmt"];
|
|
36
36
|
/** @type {(cell: any, format: any) => void} */
|
|
37
37
|
let paint = (cell, format) => {
|
|
38
|
-
for (let key of
|
|
38
|
+
for (let key of SLOTS) if (format[key]) cell[key] = format[key];
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
// 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.3.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.3.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.3.0"
|
|
51
51
|
},
|
|
52
52
|
"size-limit": [
|
|
53
53
|
{
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"quario",
|
|
57
57
|
"exceljs"
|
|
58
58
|
],
|
|
59
|
-
"limit": "2 kB"
|
|
59
|
+
"limit": "2.4 kB"
|
|
60
60
|
}
|
|
61
61
|
],
|
|
62
62
|
"engines": {
|