@quario/xlsx 0.2.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 CHANGED
@@ -7,6 +7,33 @@ 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
+
10
37
  ## [0.2.0] - 2026-09-01
11
38
 
12
39
  ### 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, merge } from "./style.js";
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 = merge(under, cell.style);
19
- let value = typed(cell.tokens);
20
- return {
21
- value: value !== undefined ? value : text(cell.tokens),
22
- format: format(resolved),
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/index.js CHANGED
@@ -17,7 +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
+ import { sides as under } from "./style.js";
21
21
  import { pixels } from "./image.js";
22
22
  import { append, create, embed, freeze, mark, place, save, sheet } from "./workbook.js";
23
23
 
@@ -85,6 +85,8 @@ export function xlsx(options) {
85
85
  // default if the role carries one. `base` is the first two, settled once
86
86
  // per render off `report-start`.
87
87
  let base = BASELINE;
88
+ /** @type {{ locale?: string, currency?: string, timeZone?: string } | null} */
89
+ let intl = null;
88
90
  // One cell, wearing everything beneath it. Every cell in the sheet goes
89
91
  // through here rather than through `field` directly, so `base` -- this
90
92
  // target's baseline plus the author's report default -- cannot be
@@ -92,7 +94,7 @@ export function xlsx(options) {
92
94
  // marking is the one deliberate exception, and says so where it is
93
95
  // written.
94
96
  let cell = (/** @type {any} */ value, /** @type {any} */ over = null) =>
95
- field(value, under(base, over));
97
+ field(value, under(base, over), intl);
96
98
  // The band-role default an event sits under, if its role carries one.
97
99
  let roleOf = (/** @type {any} */ event) =>
98
100
  Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null;
@@ -112,6 +114,7 @@ export function xlsx(options) {
112
114
  // The report default over this target's baseline. The marking is
113
115
  // written under the baseline alone: an author's `style` must not be
114
116
  // able to resize it (docs/adr/0002).
117
+ intl = { locale: event.locale, currency: event.currency, timeZone: event.timeZone };
115
118
  if (event.style) base = under(BASELINE, event.style);
116
119
  if (event.marking) {
117
120
  mark(workbook, event.marking);
@@ -178,7 +181,9 @@ export function xlsx(options) {
178
181
  place(worksheet, known.id, known, append(worksheet, []));
179
182
  },
180
183
  "table-start": (event) => {
181
- let headers = event.columns.map((/** @type {any} */ column) => cell(column.header));
184
+ let headers = event.columns.map((/** @type {any} */ column) =>
185
+ cell(column.header, event.style),
186
+ );
182
187
  let row = append(worksheet, headers);
183
188
  if (!frozen) {
184
189
  freeze(worksheet, row);
@@ -186,7 +191,7 @@ export function xlsx(options) {
186
191
  }
187
192
  },
188
193
  row: (event) => record(event, event.style),
189
- "total-row": (event) => record(event, null),
194
+ "total-row": (event) => record(event, event.style),
190
195
  });
191
196
  return save(workbook);
192
197
  };
package/lib/style.js CHANGED
@@ -13,6 +13,10 @@
13
13
  /** @type {(value: any) => boolean} */
14
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.
@@ -29,9 +33,28 @@ let argb = (value) => {
29
33
  return "FF" + digits.toUpperCase();
30
34
  };
31
35
 
32
- // 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.
33
39
  /** @type {(under: any, over: any) => any} */
34
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
+ };
35
58
 
36
59
  /** @type {(out: any, key: string, value: any) => any} */
37
60
  let put = (out, key, value) => {
@@ -91,12 +114,37 @@ let fill = (value) => {
91
114
  let align = (value) =>
92
115
  value === "left" || value === "center" || value === "right" ? { horizontal: value } : null;
93
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
+ };
139
+
94
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).
95
142
  /** @type {(style: any) => any} */
96
143
  let format = (style) => {
97
144
  let out = put(null, "font", font(style));
98
145
  out = put(out, "fill", fill(style.background));
146
+ out = put(out, "border", border(style));
99
147
  return put(out, "alignment", align(style.align));
100
148
  };
101
149
 
102
- export { format, merge };
150
+ export { format, sides };
package/lib/workbook.js CHANGED
@@ -32,11 +32,10 @@ let create = (meta) => {
32
32
  /** @type {(workbook: any) => any} */
33
33
  let sheet = (workbook) => workbook.addWorksheet("Report");
34
34
 
35
+ let SLOTS = ["font", "fill", "border", "alignment", "numFmt"];
35
36
  /** @type {(cell: any, format: any) => void} */
36
37
  let paint = (cell, format) => {
37
- if (format.font) cell.font = format.font;
38
- if (format.fill) cell.fill = format.fill;
39
- if (format.alignment) cell.alignment = format.alignment;
38
+ for (let key of SLOTS) if (format[key]) cell[key] = format[key];
40
39
  };
41
40
 
42
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.2.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.2.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.2.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": {