quario 0.4.0 → 0.6.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The band-height rule: what a report header's declared `height` forbids of
3
+ * whatever follows its box (docs/adr/0038, docs/adr/0052).
4
+ *
5
+ * A pinned header ends at a fixed offset from the page top, so the band under
6
+ * it starts there and an authored lead would push it off the pin. The rule is
7
+ * therefore about one item — the first occupying item of the band that follows
8
+ * — and it is enforced twice, because which item that is can be a fact about
9
+ * the document or a fact about the render. `plan.js` refuses the ones the
10
+ * document settles; `index.js` refuses the rest as the stream produces them.
11
+ * Both read this file, so the two never disagree about what a lead is or which
12
+ * node a fault names.
13
+ *
14
+ * The traversal half is a guard the descent carries rather than a second walk
15
+ * of the schema: `plan.js` offers it each band as it compiles it, in the order
16
+ * it already visits them, so a fault lands in the documented key order beside
17
+ * every other problem and the group chain is read once
18
+ * (docs/agents/semantics.md, "One traversal, read two ways").
19
+ */
20
+
21
+ import { isExpr, positivePts } from "./style.js";
22
+
23
+ /**
24
+ * A lead: authored flow spacing before an item that a target would actually
25
+ * paint. A positive number of points — the style vocabulary's own shape, less
26
+ * the zero it admits, which is the value the pin asks for. `spaceOf` in
27
+ * @quario/layout applies the same test; @quario/html's `isPad` is deliberately
28
+ * wider, admitting the `0` that is harmless to emit as CSS. They are restated
29
+ * per package on purpose: the engine is their peer dependency, so it cannot
30
+ * import either.
31
+ *
32
+ * @type {(n: any) => boolean}
33
+ */
34
+ export let isLead = positivePts;
35
+
36
+ /** The one wording, so both enforcement sites report the same sentence. */
37
+ export let LEAD_REFUSED = "must be 0 after a height-declared header";
38
+
39
+ /**
40
+ * The path of the key a lead fault names: the offending item's own
41
+ * `style.spaceBefore`.
42
+ *
43
+ * @param {string} path The item definition's schema path.
44
+ * @returns {string} The located key.
45
+ */
46
+ export let leadPath = (path) => path + ".style.spaceBefore";
47
+
48
+ /**
49
+ * An occupancy the document leaves to the data: a `visible` written as an
50
+ * expression, or an image, whose bytes are expression-only and whose event is
51
+ * absent when its `source` yields nothing (SCHEMA.md, "Event stream"). Neither
52
+ * is a defect — they are how an author writes an optional item — but they mean
53
+ * the traversal cannot say which item comes first, so it says nothing rather
54
+ * than guessing (docs/adr/0052).
55
+ *
56
+ * @type {(def: any) => boolean}
57
+ */
58
+ let dataDecides = (def) => isExpr(def?.visible) || def?.type === "image";
59
+
60
+ /**
61
+ * The first item of a band the document settles as occupying, `undefined` when
62
+ * only the render settles it, and null for a band settled to occupy nothing.
63
+ *
64
+ * @type {(list: any, path: string) => { def: any, path: string } | undefined | null}
65
+ */
66
+ let firstOccupying = (list, path) => {
67
+ if (!Array.isArray(list)) return null;
68
+ let i = list.findIndex((def) => def?.visible !== false);
69
+ if (i < 0) return null;
70
+ return dataDecides(list[i]) ? undefined : { def: list[i], path: path + "[" + i + "]" };
71
+ };
72
+
73
+ /**
74
+ * The guard the traversal carries while it descends.
75
+ *
76
+ * Which band sits under the pinned box is partly the document's answer and
77
+ * partly the data's. With rows, every group level opens nested before any
78
+ * detail row, so the first group header carrying an occupying item is that
79
+ * band, and `detail` is it only when no group header has one — which is the
80
+ * order `bandOf` already compiles them in, so `body` takes the first band that
81
+ * says anything and ignores the rest. With no rows the `empty` band replaces
82
+ * all of it, so it is an independent candidate that `instead` judges on its
83
+ * own. Both outcomes are reachable for any report declaring both, and the
84
+ * author declared each, so a lead on either is refused.
85
+ *
86
+ * A table `detail` reaches neither, and needs no case of its own:
87
+ * `table-start` and `row` are not occupying events, and the style vocabulary
88
+ * refuses `spaceBefore` on a table, a table row and a table cell alike, so
89
+ * there is no lead a table could carry.
90
+ *
91
+ * @param {(path: string, message: string) => void} bad The traversal's collector.
92
+ * @returns {{ arm: (height: number | null) => void,
93
+ * instead: (list: any, path: string) => void,
94
+ * body: (list: any, path: string) => void }} The guard.
95
+ */
96
+ export let pinGuard = (bad) => {
97
+ let pinned = false;
98
+ let settled = true;
99
+ /** @type {(found: { def: any, path: string } | undefined | null) => void} */
100
+ let refuse = (found) => {
101
+ if (found && isLead(found.def.style?.spaceBefore)) bad(leadPath(found.path), LEAD_REFUSED);
102
+ };
103
+ return {
104
+ arm: (/** @type {number | null} */ height) => {
105
+ pinned = height != null;
106
+ settled = !pinned;
107
+ },
108
+ instead: (/** @type {any} */ list, /** @type {string} */ path) => {
109
+ if (pinned) refuse(firstOccupying(list, path));
110
+ },
111
+ body: (/** @type {any} */ list, /** @type {string} */ path) => {
112
+ if (settled) return;
113
+ let found = firstOccupying(list, path);
114
+ // A band settled to occupy nothing is not the one under the box, so the
115
+ // descent keeps looking; one only the render settles ends the search
116
+ // without a verdict, because whether the band below is next is exactly
117
+ // what it left open.
118
+ if (found === null) return;
119
+ settled = true;
120
+ refuse(found);
121
+ },
122
+ };
123
+ };
package/lib/format.js CHANGED
@@ -1,66 +1,174 @@
1
1
  /**
2
2
  * Present a cell's raw token as `number` / `currency` / `percent` / `date`.
3
- * Targets import this instead of each other: the stream keeps the kind on
4
- * `style` and the token raw, and HTML/PDF stringify at the edge. A kind on
5
- * the wrong type, or on null, contributes nothing — the caller falls back to
6
- * `display()`. Invalid locale or currency codes fail the same way.
3
+ * Targets import this instead of each other: the stream keeps the resolved
4
+ * declaration on `style` and the token raw, and HTML/PDF stringify at the
5
+ * edge. A kind on the wrong type, or on null, contributes nothing — the
6
+ * caller falls back to `display()`. Invalid locale or currency codes fail the
7
+ * same way. Under the `currency` kind a cell's own `style.currency` names the
8
+ * denomination and beats the instance's default code.
7
9
  *
8
10
  * Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
9
11
  * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
12
+ *
13
+ * The kind, its digit count, and a date's form all come off the one resolved
14
+ * declaration `formatOf` answers — the same object the stream carries and the
15
+ * XLSX target builds its number formats from — so a cell shows the same
16
+ * digits wherever it is rendered (docs/adr/0054, docs/adr/0056). A kind that
17
+ * carries no count presents nothing here and the caller falls back to
18
+ * `display()`.
10
19
  */
20
+ import { currencyOf, formatOf } from "./style.js";
11
21
  import { finiteDate, finiteNum, reviveDate } from "./stream.js";
12
22
  /** @type {(options: any) => string} */
13
23
  let localeOf = (options) => options?.locale || "en-US";
14
24
  /** @type {(options: any) => string} */
15
25
  let zoneOf = (options) => options?.timeZone || "UTC";
16
26
 
17
- /** @type {(value: any, locale: string) => string | undefined} */
18
- let asNumber = (value, locale) =>
19
- finiteNum(value) ? new Intl.NumberFormat(locale).format(value) : undefined;
20
- /** @type {(value: any, locale: string) => string | undefined} */
21
- let asPercent = (value, locale) =>
22
- finiteNum(value) ? new Intl.NumberFormat(locale, { style: "percent" }).format(value) : undefined;
23
- /** @type {(options: any) => string | null} */
24
- let currencyOf = (options) => {
25
- let currency = options?.currency;
26
- return typeof currency === "string" && currency ? currency : null;
27
+ // Building an `Intl` formatter costs about 22 µs; using one costs 0.3 µs. A
28
+ // report presents cell after cell from a handful of distinct formats, so
29
+ // constructing per token spent 3.2 s on a hundred thousand presented cells
30
+ // where 0.2 s does (a report of mixed currencies rendered to HTML), and the
31
+ // formatters are memoised.
32
+ //
33
+ // **The key is everything the formatter is made from**, and it is values
34
+ // rather than an object identity because no identity survives both paths: a
35
+ // literal style block folds to one frozen declaration every cell shares, while
36
+ // a block holding an `=` expression resolves a fresh one per cell (`plan.js`).
37
+ // Keying on values hits in both. Get a piece of it wrong and one cell presents
38
+ // under another's formatter -- a USD row reading as EUR -- which is the
39
+ // failure `test/semantics.test.js` pins a pair for, one piece at a time. Each
40
+ // pair must differ in **only** the piece it is there for: EUR against JPY
41
+ // would prove nothing about the code, because their digit counts already
42
+ // differ and that piece would separate them on its own.
43
+ //
44
+ // **The cap is the part to read**, because the key space is not the host's to
45
+ // bound. A currency code reaches this from author data through
46
+ // `currency: "=@.ccy"`, gated only to three uppercase letters, and `digits`
47
+ // can be an `=` result too: 17,576 codes times 21 counts is 369,000 keys for a
48
+ // single locale. Measured at 244 bytes retained per entry, that is about 90 MB
49
+ // held for the life of the process on data nobody vetted.
50
+ //
51
+ // So the map is bounded, and past the bound it is emptied rather than grown.
52
+ // A workload presenting more distinct formats than the cap pays what it paid
53
+ // before any of this and never more, which is the one cost that needs no
54
+ // argument -- and it can never hand back a formatter built for another cell,
55
+ // because clearing forgets rather than reuses.
56
+ //
57
+ // 2048 leaves the margin a threshold like this wants on both sides: about
58
+ // 500 KB at the cap, against a real report reaching a small multiple of the
59
+ // ISO codes actually in circulation, which is under two hundred. Raising it
60
+ // costs bytes and lowering it costs rebuilt formatters; neither is a
61
+ // correctness knob.
62
+ //
63
+ // Memoising a fact that cannot change within an ICU version, like
64
+ // `fractionDigits`' own minor-units map (docs/adr/0025).
65
+ let CAP = 2048;
66
+ /** @type {Map<string, any>} */
67
+ let formatters = new Map();
68
+ // A construction that throws -- an unreadable locale, zone, or currency code
69
+ // -- caches nothing: it reaches `format()`'s own catch, and a later cell asks
70
+ // again rather than finding nothing sitting in the map under a good key.
71
+ // Missing on `undefined` rather than `has()`, which is what `precision.js`
72
+ // needs because *it* caches an absent answer; a formatter is never one.
73
+ /** @type {(key: string, make: () => any) => any} */
74
+ let memo = (key, make) => {
75
+ let formatter = formatters.get(key);
76
+ if (formatter !== undefined) return formatter;
77
+ formatter = make();
78
+ if (formatters.size >= CAP) formatters.clear();
79
+ formatters.set(key, formatter);
80
+ return formatter;
27
81
  };
28
- /** @type {(value: any, locale: string, options: any) => string | undefined} */
29
- let asMoney = (value, locale, options) => {
30
- let currency = currencyOf(options);
31
- if (!finiteNum(value) || !currency) return;
32
- return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
82
+
83
+ // The kind leads, so a number's key can never read as a date's; the currency
84
+ // is empty for the two kinds that wear none.
85
+ /** @type {(locale: string, decl: any, rest?: any) => string} */
86
+ let numberKey = (locale, decl, rest) =>
87
+ decl.kind + "|" + locale + "|" + decl.digits + "|" + (rest?.currency || "");
88
+
89
+ /** @type {(value: any, locale: string, decl: any, rest?: any) => string | undefined} */
90
+ let asFixed = (value, locale, decl, rest) => {
91
+ if (!finiteNum(value) || decl.digits === undefined) return;
92
+ return memo(
93
+ numberKey(locale, decl, rest),
94
+ () =>
95
+ new Intl.NumberFormat(locale, {
96
+ ...rest,
97
+ minimumFractionDigits: decl.digits,
98
+ maximumFractionDigits: decl.digits,
99
+ }),
100
+ ).format(value);
101
+ };
102
+
103
+ /** @type {(value: any, locale: string, decl: any) => string | undefined} */
104
+ let asNumber = (value, locale, decl) => asFixed(value, locale, decl);
105
+ /** @type {(value: any, locale: string, decl: any) => string | undefined} */
106
+ let asPercent = (value, locale, decl) =>
107
+ // `style: "percent"` multiplies by 100 and the digits count the *displayed*
108
+ // places, which is what Excel's `0.00%` already means too — so the count
109
+ // crosses to the sheet with no translation (docs/adr/0056).
110
+ asFixed(value, locale, decl, { style: "percent" });
111
+ // The code the cell actually wears is what the digit count was asked about, not
112
+ // the instance's: minor units are a property of the currency, so a row that
113
+ // names JPY presents no decimals beside one naming EUR that presents two
114
+ // (docs/adr/0041, docs/adr/0054).
115
+ /** @type {(value: any, locale: string, decl: any, options: any, style: any) => string | undefined} */
116
+ let asMoney = (value, locale, decl, options, style) => {
117
+ let currency = currencyOf(style, options);
118
+ return currency ? asFixed(value, locale, decl, { style: "currency", currency }) : undefined;
33
119
  };
34
120
  // A string in an accepted RFC 3339 form revives to the Date a host would have
35
121
  // injected, then presents like any other Date — including in the instance's
36
122
  // timezone, so a calendar date under a western zone presents as the day
37
123
  // before, exactly as an injected `new Date("2026-08-14")` does today. The two
38
124
  // spellings never disagree, which is the point (ADR 0041).
39
- /** @type {(value: any, locale: string, options: any) => string | undefined} */
40
- let asDate = (value, locale, options) => {
125
+ //
126
+ // The form is Intl's own `dateStyle`, and it is always present: a declaration
127
+ // that named none resolved to `medium` (docs/adr/0056). This is the half that
128
+ // does not converge — the worksheet approximates it (CONTEXT.md, "Form").
129
+ // `date` leads for the same reason the number kinds do, so the two key shapes
130
+ // live beside each other and "one piece at a time" is auditable in one place.
131
+ /** @type {(locale: string, decl: any, zone: string) => string} */
132
+ let dateKey = (locale, decl, zone) => "date|" + locale + "|" + decl.form + "|" + zone;
133
+
134
+ /** @type {(value: any, locale: string, decl: any, options: any) => string | undefined} */
135
+ let asDate = (value, locale, decl, options) => {
41
136
  let date = finiteDate(value) ? value : reviveDate(value);
42
- return date
43
- ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(date)
44
- : undefined;
137
+ if (!date) return;
138
+ let zone = zoneOf(options);
139
+ return memo(
140
+ dateKey(locale, decl, zone),
141
+ () => new Intl.DateTimeFormat(locale, { timeZone: zone, dateStyle: decl.form }),
142
+ ).format(date);
45
143
  };
46
144
 
47
- /** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
145
+ /** @type {Record<string, (value: any, locale: string, decl: any, options: any, style: any) => string | undefined>} */
48
146
  let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
49
147
 
50
148
  /**
149
+ * The helper takes the whole resolved `style`, not just the declaration, so
150
+ * the question "which declarations does presentation read?" is answered here
151
+ * once rather than once per target (docs/adr/0014).
152
+ *
51
153
  * @param {unknown} value The interpolation's pre-stringify token.
52
- * @param {unknown} kind A resolved `format` declaration.
154
+ * @param {{ format?: unknown, currency?: unknown } | null} [style] The cell's
155
+ * resolved style block: its `format` declaration, and its own `currency`
156
+ * code when it declares one.
53
157
  * @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
54
- * The instance's locale, currency code, and timezone.
158
+ * The instance's locale, default currency code, and timezone.
55
159
  * @returns {string | undefined} The presented text, or nothing when this
56
160
  * kind does not apply.
57
161
  */
58
- export function format(value, kind, options) {
59
- if (typeof kind !== "string") return;
60
- let present = KINDS[kind];
61
- if (!present) return;
162
+ export function format(value, style, options) {
163
+ // A style the stream resolved already carries its declaration whole; one a
164
+ // host built by hand may still hold the shorthand, so it is widened here
165
+ // rather than refused. No own-key guard on the lookup: `formatOf` answers a
166
+ // kind only from the closed vocabulary, which is exactly `KINDS`' own keys,
167
+ // so nothing an `=` expression resolved to can reach this table.
168
+ let decl = formatOf(style, options);
169
+ if (!decl) return;
62
170
  try {
63
- return present(value, localeOf(options), options);
171
+ return KINDS[decl.kind](value, localeOf(options), decl, options, style);
64
172
  } catch {
65
173
  return;
66
174
  }