quario 0.5.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.
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
  }
package/lib/index.d.ts CHANGED
@@ -93,8 +93,20 @@ export interface StyleDeclarations extends BoxDeclarations {
93
93
  * and on a band image, whose boxes have no such slack.
94
94
  */
95
95
  valign?: ExpressionValue<VAlign>;
96
- /** How a number or date is presented. Locale stays on the instance. */
97
- format?: ExpressionValue<FormatKind>;
96
+ /**
97
+ * How a number or date is presented: a kind, or an object naming one and
98
+ * carrying the single modifier that kind understands — `digits` for the
99
+ * three number kinds, `form` for `date`. Locale stays on the instance.
100
+ */
101
+ format?: ExpressionValue<FormatDeclaration>;
102
+ /**
103
+ * What this cell's money is denominated in, under `format: "currency"`:
104
+ * a three-letter ISO 4217 code, beating the instance's default. A literal
105
+ * that is not one is a definition error; an `=` result that is not one is
106
+ * the cell's own answer still, so the cell presents as display text rather
107
+ * than falling back to the instance's currency. Unread without the kind.
108
+ */
109
+ currency?: ExpressionValue<string>;
98
110
  /** Blank space before this item, in points. */
99
111
  spaceBefore?: ExpressionValue<number>;
100
112
  /** Blank space after this item, in points. */
@@ -113,6 +125,32 @@ export type Align = "left" | "center" | "right";
113
125
  export type VAlign = "top" | "middle" | "bottom";
114
126
  export type FormatKind = "number" | "currency" | "percent" | "date";
115
127
 
128
+ /** The four named date forms, Intl's own `dateStyle`. A `date` that names
129
+ * none is `"medium"`. */
130
+ export type DateForm = "short" | "medium" | "long" | "full";
131
+
132
+ /**
133
+ * How an author writes `format`: the kind alone, or the kind carrying its one
134
+ * modifier. `digits` is a whole number from 0 to 20 — the range both a
135
+ * presenting target and a worksheet number format can honour — and it beats
136
+ * the count the kind would otherwise present, a currency's minor units
137
+ * included. A modifier on a kind that does not take it is a definition error.
138
+ */
139
+ export type FormatDeclaration =
140
+ | FormatKind
141
+ | { kind: "number" | "currency" | "percent"; digits?: number }
142
+ | { kind: "date"; form?: DateForm };
143
+
144
+ /**
145
+ * What `format` resolves to on the event stream: never the shorthand, always
146
+ * the kind with the answer it presents. A number kind carries `digits` unless
147
+ * it has no count at all — a `currency` whose code cannot be read — and a
148
+ * `date` always carries its `form`.
149
+ */
150
+ export type ResolvedFormat =
151
+ | { kind: "number" | "currency" | "percent"; digits?: number }
152
+ | { kind: "date"; form: DateForm };
153
+
116
154
  export interface Cell {
117
155
  value: CellValue;
118
156
  visible?: ExpressionValue<boolean>;
@@ -162,12 +200,32 @@ export interface ImageItem {
162
200
  style?: ImageStyleDeclarations;
163
201
  }
164
202
 
203
+ /**
204
+ * What a text slot declares: the vocabulary less flow spacing, which is a
205
+ * band-item pad and does not apply inside a split.
206
+ */
207
+ export type SlotStyleDeclarations = Omit<StyleDeclarations, "spaceBefore" | "spaceAfter">;
208
+
209
+ /**
210
+ * What an image slot declares: an image item's names less flow spacing, plus
211
+ * `valign` — the one name an image accepts in a slot and nowhere else, because
212
+ * a slot's box is the split's height rather than the picture's own.
213
+ */
214
+ export type SlotImageStyleDeclarations = Omit<
215
+ ImageStyleDeclarations,
216
+ "spaceBefore" | "spaceAfter"
217
+ > &
218
+ Pick<StyleDeclarations, "valign">;
219
+
165
220
  /**
166
221
  * One slot of a split: an ordinary item, plus the width share that is a slot
167
222
  * property rather than an item one. A split may not stand in a slot —
168
223
  * placement inside placement is the coordinate system quario does not have.
169
224
  */
170
- export type SplitSlot = (TextItem | ImageItem) & {
225
+ export type SplitSlot = (
226
+ | (Omit<TextItem, "style"> & { style?: SlotStyleDeclarations })
227
+ | (Omit<ImageItem, "style"> & { style?: SlotImageStyleDeclarations })
228
+ ) & {
171
229
  /** Slot width as a percentage of the content width (0 < width <= 100). */
172
230
  width?: number;
173
231
  };
@@ -232,6 +290,16 @@ export interface TotalRow {
232
290
  style?: StyleDeclarations;
233
291
  }
234
292
 
293
+ /**
294
+ * The table's summary block: the rows, plus what belongs to the block rather
295
+ * than to any one of them. Its `style` is the layer below each row's — box
296
+ * under box, everything else under everything else — and exact `false` on its
297
+ * `visible` omits the block whole, its rows never evaluated.
298
+ */
299
+ export interface TotalBlock extends TableRow {
300
+ rows: [TotalRow, ...TotalRow[]];
301
+ }
302
+
235
303
  /**
236
304
  * The header row's own box — distinct from `TableHeader`, which is one
237
305
  * column's header cell. This is the box the row wears, replayed with the
@@ -245,7 +313,7 @@ export interface TableDetail {
245
313
  header?: TableHeaderBox;
246
314
  row?: TableRow;
247
315
  columns: [TableColumn, ...TableColumn[]];
248
- total?: [TotalRow, ...TotalRow[]];
316
+ total?: TotalBlock;
249
317
  }
250
318
 
251
319
  export interface Group {
@@ -351,7 +419,10 @@ export interface QuarioOptions {
351
419
  license?: string;
352
420
  /** Locale for `format`. Defaults to `"en-US"` when a kind is presented. */
353
421
  locale?: string;
354
- /** Currency code for `format: "currency"`. Absent, that kind contributes nothing. */
422
+ /**
423
+ * Default currency code for `format: "currency"`, overridden by a cell's own
424
+ * `style.currency`. Absent from both, that kind contributes nothing.
425
+ */
355
426
  currency?: string;
356
427
  /** Timezone for `format: "date"`. Defaults to `"UTC"`. */
357
428
  timeZone?: string;
@@ -393,7 +464,7 @@ export interface EventCell {
393
464
  /**
394
465
  * The definition's schema path, on cells that belong to one: a `row` cell
395
466
  * carries its column's (`detail.columns[i]`), a `total-row` cell its
396
- * `detail.total[r].cells[i]` entry. Absent on page-band cells reached by closure.
467
+ * `detail.total.rows[r].cells[i]` entry. Absent on page-band cells reached by closure.
397
468
  */
398
469
  path?: string;
399
470
  }
@@ -406,14 +477,21 @@ export interface PageInfo {
406
477
  total: number;
407
478
  }
408
479
 
480
+ /**
481
+ * What a page band closure hands back: the band's items as the walk would
482
+ * emit them, wearing the `page-header`/`page-footer` role — a text item, an
483
+ * image, or a split's bracket with one item per slot between it.
484
+ */
485
+ export type PageBandEvent = ItemEvent | ImageEvent | SplitStartEvent | SplitEndEvent;
486
+
409
487
  /**
410
488
  * Per-render page band closures on `report-start` (present only when the
411
489
  * schema declares page bands). A paginated target calls them once per page;
412
- * each call returns resolved `page-header`/`page-footer` item events.
490
+ * each call returns that page's resolved band events.
413
491
  */
414
492
  export interface PageBandRenderers {
415
- header?: (page: PageInfo) => (ItemEvent | ImageEvent)[];
416
- footer?: (page: PageInfo) => (ItemEvent | ImageEvent)[];
493
+ header?: (page: PageInfo) => PageBandEvent[];
494
+ footer?: (page: PageInfo) => PageBandEvent[];
417
495
  }
418
496
 
419
497
  export interface ReportStartEvent {
@@ -523,10 +601,15 @@ export interface TableStartEvent {
523
601
  type: "table-start";
524
602
  /** Always `detail` — the table definition's schema path. */
525
603
  path: string;
526
- /** One entry per column. `header` is absent when a neighbour's span covers it. */
527
- columns: { header?: EventCell; path: string; width?: number }[];
528
- /** The header-row box from `detail.header`, when declared. */
529
- style?: Record<string, unknown>;
604
+ /**
605
+ * The header row: one cell per column a neighbour's span does not cover,
606
+ * so fewer cells than columns whenever one spans. Shaped like a `row`
607
+ * payload, `style` being what is left of `detail.header`'s block once its
608
+ * box has landed on these cells.
609
+ */
610
+ header: { cells: EventCell[]; style?: Record<string, unknown> };
611
+ /** The column geometry, one entry per column. */
612
+ columns: { width?: number }[];
530
613
  }
531
614
 
532
615
  export interface RowEvent {
@@ -706,6 +789,22 @@ export function validate(schema: unknown, functions?: FunctionRegistry): string[
706
789
  */
707
790
  export function isDiagnostic(e: unknown): e is QuarioDiagnostic;
708
791
 
792
+ /**
793
+ * Mint an image failure named on the item that asked for the bytes — for a
794
+ * **render target**, which is where every image failure past the magic
795
+ * numbers happens: the engine vouches for those and no further.
796
+ *
797
+ * `path` is the image item's, from a stream event or a display-list op;
798
+ * `.source` is appended, that being the key an author would change. `said` is
799
+ * what failed in your own words, and a library's own message belongs after it
800
+ * rather than instead of it. The result is `cause`'s class, so an embedder's
801
+ * `RangeError` stays a `RangeError`, with `cause` set.
802
+ *
803
+ * Not a diagnostic: `isDiagnostic` does not answer for it, a decoder that
804
+ * refused a file being nobody's delegated engine.
805
+ */
806
+ export function imageError(path: string | undefined, said: string, cause?: unknown): Error;
807
+
709
808
  /**
710
809
  * Join a token stream to display text: literals verbatim, values through
711
810
  * `display()`. Re-exported from sjabloon.
@@ -722,29 +821,62 @@ export function text(tokens: readonly Token[]): string;
722
821
  export function display(value: unknown): string;
723
822
 
724
823
  /**
725
- * Present a token for a resolved `format` kind. Returns nothing when the kind
726
- * does not apply, so the caller falls back to `display()`. Locale defaults to
727
- * `"en-US"`, dates to UTC.
824
+ * Which currency code a cell wears under `format: "currency"`: its own
825
+ * `style.currency` when it declares one — the gate's `null` included, which is
826
+ * why an unusable code presents nothing rather than the instance's currency —
827
+ * else the instance default. Exported for a target that needs the code itself
828
+ * rather than the presented text, as the XLSX one does.
829
+ */
830
+ export function currencyOf(
831
+ style?: { currency?: unknown } | null,
832
+ options?: { currency?: string } | null,
833
+ ): string | null;
834
+
835
+ /**
836
+ * Present a token for a cell's resolved `style`. Reads the `format` kind and,
837
+ * under `"currency"`, the cell's own `currency` code; returns nothing when the
838
+ * kind does not apply, so the caller falls back to `display()`. Locale
839
+ * defaults to `"en-US"`, dates to UTC.
728
840
  */
729
841
  export function format(
730
842
  value: unknown,
731
- kind: unknown,
843
+ style?: { format?: unknown; currency?: unknown } | null,
732
844
  options?: { locale?: string; currency?: string; timeZone?: string } | null,
733
845
  ): string | undefined;
734
846
 
847
+ /**
848
+ * How many fraction digits a cell presents. Takes the whole style, as
849
+ * `format()` and `currencyOf()` do. `number` and `percent` present a fixed
850
+ * count, `currency` its currency's minor units, and a declared `digits` beats
851
+ * either — so the digits converge wherever the cell is rendered.
852
+ *
853
+ * A resolved style carries the answer on `style.format.digits` and a target
854
+ * reads it there; this is for a host holding a style the stream has not
855
+ * resolved, and the shorthand is widened here first.
856
+ *
857
+ * Returns nothing when the cell presents no count: `date`, no `format`, an
858
+ * unknown kind, and a `currency` with no code or one Intl will not read. A
859
+ * caller that gets nothing presents some other way — `format()` falls back to
860
+ * `display()`, and a worksheet writes no number format.
861
+ */
862
+ export function fractionDigits(
863
+ style?: { format?: unknown; currency?: unknown } | null,
864
+ options?: { currency?: string } | null,
865
+ ): number | undefined;
866
+
735
867
  /**
736
868
  * The typed-cell seam: exactly one value token holding a finite number, a
737
869
  * boolean, or a valid Date keeps its pre-stringify value; anything else —
738
870
  * including a lone null — reports `undefined` and joins to display text.
739
871
  *
740
- * Passing the cell's resolved `format` kind opts into the seam's one
741
- * coercion: under `"date"`, a string in either accepted RFC 3339 form —
872
+ * Passing the cell's resolved `format` declaration opts into the seam's one
873
+ * coercion: under the `date` kind, a string in either accepted RFC 3339 form —
742
874
  * `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
743
875
  * names. Omitting the argument leaves the seam as it was.
744
876
  */
745
877
  export function typed(
746
878
  tokens: readonly Token[],
747
- kind?: unknown,
879
+ decl?: ResolvedFormat | FormatKind,
748
880
  ): number | boolean | Date | undefined;
749
881
 
750
882
  /**
@@ -762,3 +894,18 @@ export function isReportBand(role: string | undefined): boolean;
762
894
  * keeping a copy.
763
895
  */
764
896
  export const STYLE_NAMES: readonly string[];
897
+
898
+ /**
899
+ * The `format` vocabulary as data, for a tool that offers it rather than
900
+ * checks it — a style rail's kind picker and its modifier control — so no
901
+ * surface keeps a copy that has to be told about a change.
902
+ */
903
+ export const FORMAT_VOCABULARY: {
904
+ readonly kinds: readonly FormatKind[];
905
+ readonly forms: readonly DateForm[];
906
+ /** Which modifier each kind takes, so a surface offering the pair need not
907
+ * know that three kinds share `digits` and `date` does not. */
908
+ readonly modifiers: Readonly<Record<FormatKind, "digits" | "form">>;
909
+ readonly defaultForm: DateForm;
910
+ readonly maxDigits: number;
911
+ };
package/lib/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * siblings beside it, one seam each. See SCHEMA.md for the document shape.
14
14
  */
15
15
  import { relocate as relocateQuery } from "padvinder";
16
+ import { LEAD_REFUSED, isLead, leadPath } from "./band-height.js";
16
17
  import { MARKING, verify } from "./license.js";
17
18
  import { err, fault, locate } from "./locate.js";
18
19
  import { BLOCKED, NAME, record } from "./names.js";
@@ -25,8 +26,8 @@ import { opt } from "./stream.js";
25
26
  // imports them from the package, not from a file inside it.
26
27
  export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
27
28
  export { format } from "./format.js";
28
- export { STYLE_NAMES } from "./style.js";
29
- export { isDiagnostic } from "./locate.js";
29
+ export { currencyOf, FORMAT_VOCABULARY, fractionDigits, STYLE_NAMES } from "./style.js";
30
+ export { imageError, isDiagnostic } from "./locate.js";
30
31
 
31
32
  /** @typedef {import("./scope.js").Scope} Scope */
32
33
 
@@ -134,16 +135,19 @@ function events(schema, funcs, options, marking) {
134
135
  /** @type {(event: any) => boolean} */
135
136
  let occupying = (event) =>
136
137
  event.type === "item" || event.type === "image" || event.type === "split-start";
137
- /** @type {(n: any) => boolean} */
138
- let nonzeroLead = (n) => Number.isFinite(n) && n !== 0;
139
- /** @type {(event: any) => string} */
140
- let eventPath = (event) => event.path || "header";
141
138
  /** @type {(event: any) => void} */
142
139
  let refuseLead = (event) => {
143
- if (nonzeroLead(event.style?.spaceBefore))
144
- fault(eventPath(event) + ".style.spaceBefore", "must be 0 after a height-declared header");
140
+ if (isLead(event.style?.spaceBefore)) fault(leadPath(event.path), LEAD_REFUSED);
145
141
  };
146
- /** @type {(events: Iterable<any>) => Generator<any>} */
142
+ /**
143
+ * Refuse the lead the document could not settle. `plan()` has already refused
144
+ * every candidate the document itself decides (./band-height.js); what reaches
145
+ * here is the rest — a lead that is an expression, or an item whose occupancy
146
+ * only the data knows — so the first occupying event of the band under the
147
+ * pinned box is the one this sees, whichever band that turned out to be.
148
+ *
149
+ * @type {(events: Iterable<any>) => Generator<any>}
150
+ */
147
151
  function* guardPin(events) {
148
152
  let pending = true;
149
153
  for (let event of events) {
package/lib/license.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // reassigns them. A key is valid for every version released inside its window
12
12
  // (LICENSE section 7), so validity compares ISO date strings and never reads
13
13
  // a clock — accepted output stays accepted.
14
- let RELEASE = "2026-09-05"; // x-release-please-date
14
+ let RELEASE = "2026-09-07"; // x-release-please-date
15
15
  // The verifying half of the signing pair: the 65-byte uncompressed P-256
16
16
  // point, base64url. The private half never enters the repo;
17
17
  // scripts/license/sign.mjs mints keys against it.
package/lib/locate.js CHANGED
@@ -37,6 +37,38 @@ export let fault = (path, msg) => {
37
37
  throw Object.assign(Error(path + ": " + msg), { [LOCATION]: { path } });
38
38
  };
39
39
 
40
+ /**
41
+ * An image failure a **target** raises, named on the item that asked for the
42
+ * bytes. The engine vouches for an image's magic numbers and no further
43
+ * (SCHEMA.md, "Image item"), so every failure past that point belongs to
44
+ * whichever target was sizing or embedding the file — and each of them was
45
+ * inventing its own wording, down to a corrupt PNG reaching a host as
46
+ * pdf-lib's bare "Invalid typed array length: 0" (quario-e0bz).
47
+ *
48
+ * Here rather than in a target because three of them raise it and no two of
49
+ * them may depend on each other; the engine is what they all already have.
50
+ * Minted rather than thrown, unlike `fault` beside it, so a caller can raise
51
+ * it from a `catch` or a rejection in whichever style its own code is.
52
+ *
53
+ * The shape is the one every render error has: the path prefixes the message
54
+ * and the failing class survives behind it. It carries no `LOCATION` and is no
55
+ * `isDiagnostic` diagnostic — the traversal never sees it, and a decoder that
56
+ * refused a file is not a delegated engine.
57
+ *
58
+ * @type {(path: string | undefined, said: string, cause?: unknown) => Error}
59
+ */
60
+ export let imageError = (path, said, cause) => {
61
+ let error = TypeError((path ? path + ".source: " : "") + said, { cause });
62
+ // Wear the failing class rather than construct it. Keeping
63
+ // `e instanceof RangeError` true matters — a host may already be catching
64
+ // one — but the class is a library's and its constructor need not take
65
+ // `(message, options)` at all: one taking other arguments would mangle the
66
+ // message and swallow `cause`, turning a located failure back into the
67
+ // stray throw this exists to replace. Reparenting asks the class for
68
+ // nothing, so there is no call to check and nothing it can refuse.
69
+ if (cause instanceof Error) Object.setPrototypeOf(error, Object.getPrototypeOf(cause));
70
+ return error;
71
+ };
40
72
  /**
41
73
  * Re-throw `error` located at `path`, with `src` — the offending author
42
74
  * source — in the message.