quario 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,59 @@ 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` is a closed style name.** `number` / `currency` / `percent` /
15
+ `date` on text items, column cells, headers, and totals. Not images, not
16
+ `row.style`, not the report default. Locale, currency code, and timezone
17
+ live on `quario({ locale, currency, timeZone })`. The public `format()`
18
+ helper is how targets present a kind; a kind on the wrong type contributes
19
+ nothing (`docs/adr/0041`).
20
+
21
+ - **The report header may pin a `height` from the page top.** Dual-shaped
22
+ like `detail`: an item array, or `{ height, items }`. `page.margin` is a
23
+ document field (one number, all four sides), required with `height` and
24
+ legal without. Authored `spaceBefore` on the first occupying item of the
25
+ next band is refused (`docs/adr/0038`).
26
+
27
+ - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
28
+ before or after a band item, in points, including band images and splits as
29
+ band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
30
+ and split slots refuse the names. `spaceBefore` drops at a fresh body page
31
+ or strip top; page-band items keep it. Leading and inset stay cut
32
+ (`docs/adr/0037`).
33
+
34
+ - **Per-side padding and border on the closed style vocabulary.**
35
+ `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
36
+ `border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
37
+ `border*Color` (`#rgb` / `#rrggbb`). A border side is all three names or
38
+ none; width `0` is no stroke; an incomplete literal is a definition error,
39
+ and an incomplete expression result at render contributes nothing rather
40
+ than a solid black stroke. The names are legal wherever `background` is,
41
+ including images, plus `row.style`. The report default still takes only
42
+ `family` and `size`. Column `%` widths are border-box.
43
+
44
+ - **`detail.header` is the header-row box.** `{ style }` only, a different
45
+ path from `columns[i].header`. It crosses the seam as `table-start.style`.
46
+
47
+ ### Changed
48
+
49
+ - **A table total is N rows.** Before, `total` was one row: a flat cell
50
+ array or `{ style, cells }`. After, it is absent or a non-empty array of
51
+ `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
52
+ `total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
53
+ The stream yields one `total-row` per emitted row (`docs/adr/0042`).
54
+
55
+ - **A visible text item occupies a line at its own `size`.** Empty display
56
+ used to take the report default's leading in PDF and collapse in HTML;
57
+ `"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
58
+ now occupies at least one line set in that item's `size`, and a literal
59
+ newline is a line break, on every target that can show a line. Empty
60
+ table cells stay contentless for height. This is not a spacing primitive;
61
+ `visible: false` is still how an item leaves the layout.
62
+
10
63
  ## [0.2.0] - 2026-09-01
11
64
 
12
65
  ### Added
package/lib/format.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
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.
7
+ *
8
+ * Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
9
+ * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
10
+ */
11
+ import { finiteDate, finiteNum } from "./stream.js";
12
+ /** @type {(options: any) => string} */
13
+ let localeOf = (options) => options?.locale || "en-US";
14
+ /** @type {(options: any) => string} */
15
+ let zoneOf = (options) => options?.timeZone || "UTC";
16
+
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
+ };
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);
33
+ };
34
+ /** @type {(value: any, locale: string, options: any) => string | undefined} */
35
+ let asDate = (value, locale, options) =>
36
+ finiteDate(value)
37
+ ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(value)
38
+ : undefined;
39
+
40
+ /** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
41
+ let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
42
+
43
+ /**
44
+ * @param {unknown} value The interpolation's pre-stringify token.
45
+ * @param {unknown} kind A resolved `format` declaration.
46
+ * @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
47
+ * The instance's locale, currency code, and timezone.
48
+ * @returns {string | undefined} The presented text, or nothing when this
49
+ * kind does not apply.
50
+ */
51
+ export function format(value, kind, options) {
52
+ if (typeof kind !== "string") return;
53
+ let present = KINDS[kind];
54
+ if (!present) return;
55
+ try {
56
+ return present(value, localeOf(options), options);
57
+ } catch {
58
+ return;
59
+ }
60
+ }
package/lib/index.d.ts CHANGED
@@ -53,6 +53,12 @@ export interface StyleDeclarations {
53
53
  background?: ExpressionValue<string>;
54
54
  /** Horizontal alignment within the cell. */
55
55
  align?: ExpressionValue<Align>;
56
+ /** How a number or date is presented. Locale stays on the instance. */
57
+ format?: ExpressionValue<FormatKind>;
58
+ /** Blank space before this item, in points. */
59
+ spaceBefore?: ExpressionValue<number>;
60
+ /** Blank space after this item, in points. */
61
+ spaceAfter?: ExpressionValue<number>;
56
62
  }
57
63
 
58
64
  export interface SortKey {
@@ -64,6 +70,7 @@ export interface SortKey {
64
70
  export type CellValue = string;
65
71
 
66
72
  export type Align = "left" | "center" | "right";
73
+ export type FormatKind = "number" | "currency" | "percent" | "date";
67
74
 
68
75
  export interface Cell {
69
76
  value: CellValue;
@@ -85,7 +92,10 @@ export type ImageFit = "natural" | "width";
85
92
  * error. A narrowing of the one vocabulary rather than a second list of its
86
93
  * own, so the two cannot drift.
87
94
  */
88
- export type ImageStyleDeclarations = Pick<StyleDeclarations, "background" | "align">;
95
+ export type ImageStyleDeclarations = Pick<
96
+ StyleDeclarations,
97
+ "background" | "align" | "spaceBefore" | "spaceAfter"
98
+ >;
89
99
 
90
100
  /**
91
101
  * What a report default declares: the two declarations a typeface is made of.
@@ -153,10 +163,16 @@ export interface TableRow {
153
163
  style?: StyleDeclarations;
154
164
  }
155
165
 
166
+ export interface TotalRow {
167
+ cells: [Cell, ...Cell[]];
168
+ visible?: ExpressionValue<boolean>;
169
+ style?: StyleDeclarations;
170
+ }
171
+
156
172
  export interface TableDetail {
157
173
  row?: TableRow;
158
174
  columns: [TableColumn, ...TableColumn[]];
159
- total?: Cell[];
175
+ total?: [TotalRow, ...TotalRow[]];
160
176
  }
161
177
 
162
178
  export interface Group {
@@ -195,6 +211,8 @@ export interface Group {
195
211
  export interface PageBands {
196
212
  header?: Item[];
197
213
  footer?: Item[];
214
+ /** Inset on all four sides, in points. Required when the report header declares `height`. */
215
+ margin?: number;
198
216
  }
199
217
 
200
218
  export interface ReportSchema {
@@ -216,7 +234,11 @@ export interface ReportSchema {
216
234
  * declared `size` does not change a report header's headline size.
217
235
  */
218
236
  style?: ReportStyleDeclarations;
219
- header?: Item[];
237
+ /**
238
+ * Dual-shaped like `detail`: an item array, or `{ height, items }` when the
239
+ * header pins a flow box from the page top.
240
+ */
241
+ header?: Item[] | { height: number; items: Item[] };
220
242
  empty?: Item[];
221
243
  /**
222
244
  * Flow the report body in this many page columns (integer >= 2). Columned
@@ -254,6 +276,12 @@ export interface QuarioOptions {
254
276
  * report this instance compiles marks its output as unlicensed.
255
277
  */
256
278
  license?: string;
279
+ /** Locale for `format`. Defaults to `"en-US"` when a kind is presented. */
280
+ locale?: string;
281
+ /** Currency code for `format: "currency"`. Absent, that kind contributes nothing. */
282
+ currency?: string;
283
+ /** Timezone for `format: "date"`. Defaults to `"UTC"`. */
284
+ timeZone?: string;
257
285
  }
258
286
 
259
287
  /** The settled result of an instance's license key verification. */
@@ -287,7 +315,7 @@ export interface EventCell {
287
315
  /**
288
316
  * The definition's schema path, on cells that belong to one: a `row` cell
289
317
  * carries its column's (`detail.columns[i]`), a `total-row` cell its
290
- * `detail.total[i]` entry. Absent on page-band cells reached by closure.
318
+ * `detail.total[r].cells[i]` entry. Absent on page-band cells reached by closure.
291
319
  */
292
320
  path?: string;
293
321
  }
@@ -334,6 +362,16 @@ export interface ReportStartEvent {
334
362
  * states it so no target has to know it.
335
363
  */
336
364
  marking?: string;
365
+ /** The document's `page.margin`, when declared. */
366
+ margin?: number;
367
+ /** The report header's authored `height`, when the object form is used. */
368
+ headerHeight?: number;
369
+ /** Present when the instance was constructed with `locale`. */
370
+ locale?: string;
371
+ /** Present when the instance was constructed with `currency`. */
372
+ currency?: string;
373
+ /** Present when the instance was constructed with `timeZone`. */
374
+ timeZone?: string;
337
375
  }
338
376
 
339
377
  export interface ItemEvent {
@@ -408,6 +446,8 @@ export interface TableStartEvent {
408
446
  /** Always `detail` — the table definition's schema path. */
409
447
  path: string;
410
448
  columns: { header: EventCell; path: string; width?: number }[];
449
+ /** The header-row box from `detail.header`, when declared. */
450
+ style?: Record<string, unknown>;
411
451
  }
412
452
 
413
453
  export interface RowEvent {
@@ -420,6 +460,8 @@ export interface RowEvent {
420
460
  export interface TotalRowEvent {
421
461
  type: "total-row";
422
462
  cells: EventCell[];
463
+ /** The total-row box from that row's `style`, when declared. */
464
+ style?: Record<string, unknown>;
423
465
  }
424
466
 
425
467
  export interface TableEndEvent {
@@ -600,6 +642,17 @@ export function text(tokens: readonly Token[]): string;
600
642
  */
601
643
  export function display(value: unknown): string;
602
644
 
645
+ /**
646
+ * Present a token for a resolved `format` kind. Returns nothing when the kind
647
+ * does not apply, so the caller falls back to `display()`. Locale defaults to
648
+ * `"en-US"`, dates to UTC.
649
+ */
650
+ export function format(
651
+ value: unknown,
652
+ kind: unknown,
653
+ options?: { locale?: string; currency?: string; timeZone?: string } | null,
654
+ ): string | undefined;
655
+
603
656
  /**
604
657
  * The typed-cell seam: exactly one value token holding a finite number, a
605
658
  * boolean, or a valid Date keeps its pre-stringify value; anything else —
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { relocate as relocateQuery } from "padvinder";
16
16
  import { MARKING, verify } from "./license.js";
17
- import { err, locate } from "./locate.js";
17
+ import { err, fault, locate } from "./locate.js";
18
18
  import { BLOCKED, NAME, record } from "./names.js";
19
19
  import { plan } from "./plan.js";
20
20
  import { aggregateValue, startRunners, withPage, withRow } from "./scope.js";
@@ -24,6 +24,7 @@ import { opt } from "./stream.js";
24
24
  // diagnostic predicate in ./locate.js. Re-exported here because a consumer
25
25
  // imports them from the package, not from a file inside it.
26
26
  export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
27
+ export { format } from "./format.js";
27
28
  export { isDiagnostic } from "./locate.js";
28
29
 
29
30
  /** @typedef {import("./scope.js").Scope} Scope */
@@ -126,7 +127,31 @@ function events(schema, funcs, options, marking) {
126
127
  let planned = plan(schema, funcs, options);
127
128
  if (planned.problems.length)
128
129
  throw planned.diagnostic() || SyntaxError(planned.problems[0].message);
129
- return assemble(planned, schema, marking);
130
+ return assemble(planned, schema, marking, options);
131
+ }
132
+
133
+ /** @type {(event: any) => boolean} */
134
+ let occupying = (event) =>
135
+ event.type === "item" || event.type === "image" || event.type === "split-start";
136
+ /** @type {(n: any) => boolean} */
137
+ let nonzeroLead = (n) => Number.isFinite(n) && n !== 0;
138
+ /** @type {(event: any) => string} */
139
+ let eventPath = (event) => event.path || "header";
140
+ /** @type {(event: any) => void} */
141
+ let refuseLead = (event) => {
142
+ if (nonzeroLead(event.style?.spaceBefore))
143
+ fault(eventPath(event) + ".style.spaceBefore", "must be 0 after a height-declared header");
144
+ };
145
+ /** @type {(events: Iterable<any>) => Generator<any>} */
146
+ function* guardPin(events) {
147
+ let pending = true;
148
+ for (let event of events) {
149
+ if (pending && occupying(event)) {
150
+ pending = false;
151
+ refuseLead(event);
152
+ }
153
+ yield event;
154
+ }
130
155
  }
131
156
 
132
157
  /**
@@ -137,11 +162,14 @@ function events(schema, funcs, options, marking) {
137
162
  * @param {ReturnType<typeof plan>} planned The finished traversal.
138
163
  * @param {any} schema The report document, for locating `data` faults.
139
164
  * @param {() => string | false} marking This render's unlicensed marking.
165
+ * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
166
+ * locale?: string, currency?: string, timeZone?: string} | undefined} options
167
+ * Host controls closed over this compile.
140
168
  * @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
141
169
  * functions: { name: string, arity: number, doc?: string }[],
142
170
  * paths: readonly any[]}} Event stream factory.
143
171
  */
144
- function assemble(planned, schema, marking) {
172
+ function assemble(planned, schema, marking, options) {
145
173
  // Null only for a non-object schema, which is itself a collected problem.
146
174
  // `select` and `params` come from the traversal too: it checked them, so
147
175
  // nothing here compiles or clones a second time.
@@ -161,6 +189,8 @@ function assemble(planned, schema, marking) {
161
189
  band,
162
190
  pageHeader,
163
191
  pageFooter,
192
+ headerHeight,
193
+ margin,
164
194
  } = /** @type {NonNullable<typeof planned.compiled>} */ (planned.compiled);
165
195
 
166
196
  // The runner's own faults are located per render: a budget is spent while
@@ -203,15 +233,29 @@ function assemble(planned, schema, marking) {
203
233
  // only where it goes. Read as the event is yielded, so a render drained
204
234
  // before verification settles counts as unlicensed (SCHEMA.md, "License
205
235
  // keys").
236
+ /** @type {(options: any) => { locale?: string, currency?: string, timeZone?: string }} */
237
+ let intlOf = (host) => ({
238
+ locale: host?.locale,
239
+ currency: host?.currency,
240
+ timeZone: host?.timeZone,
241
+ });
206
242
  /** @type {() => Generator<object, void, undefined>} */
207
243
  function* walked() {
208
244
  yield opt(
209
245
  { type: "report-start", params: root.params, aggregates },
210
- { page, columns, style: style(base), marking: marking() },
246
+ {
247
+ page,
248
+ columns,
249
+ style: style(base),
250
+ marking: marking(),
251
+ ...intlOf(options),
252
+ margin,
253
+ headerHeight,
254
+ },
211
255
  );
212
256
  yield* header(base);
213
- if (!rows.length && empty) yield* empty(base);
214
- else yield* band(rows, base, runners);
257
+ let body = !rows.length && empty ? empty(base) : band(rows, base, runners);
258
+ yield* headerHeight != null ? guardPin(body) : body;
215
259
  yield* footer(base);
216
260
  yield { type: "report-end" };
217
261
  }
@@ -236,8 +280,10 @@ function assemble(planned, schema, marking) {
236
280
  * ("Instances and targets").
237
281
  *
238
282
  * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
239
- * license?: string, trust?: {publicKey?: string, release?: string}}} [options]
283
+ * license?: string, trust?: {publicKey?: string, release?: string},
284
+ * locale?: string, currency?: string, timeZone?: string}} [options]
240
285
  * Host configuration. `trust` is internal, not API — see ./license.js.
286
+ * `locale` / `currency` / `timeZone` present `format` (docs/adr/0041).
241
287
  * @returns {{license: Promise<LicenseInfo>,
242
288
  * report: (schema: any, funcs?: Record<string, Function>) => any,
243
289
  * plan: (schema: any, funcs?: Record<string, Function>) => any}} The instance.
@@ -281,7 +327,7 @@ export function quario(options) {
281
327
  plan(schema, funcs) {
282
328
  let planned = plan(schema, funcs, options);
283
329
  return {
284
- report: planned.problems.length ? null : wrap(assemble(planned, schema, marking)),
330
+ report: planned.problems.length ? null : wrap(assemble(planned, schema, marking, options)),
285
331
  problems: freezeProblems(planned.problems),
286
332
  anchors: freezeAnchors(planned.anchors),
287
333
  };
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-01"; // x-release-please-date
14
+ let RELEASE = "2026-09-02"; // 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/plan.js CHANGED
@@ -47,7 +47,16 @@ import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
47
47
  import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
48
48
  import { AGG, REDUCERS, RUN, aggregateValue, withRow } from "./scope.js";
49
49
  import { opt, sniff } from "./stream.js";
50
- import { checkImageStyle, checkReportStyle, checkStyle } from "./style.js";
50
+ import {
51
+ checkBorderSides,
52
+ checkCellStyle,
53
+ checkImageStyle,
54
+ checkReportStyle,
55
+ checkRowStyle,
56
+ checkSlotImageStyle,
57
+ checkSlotStyle,
58
+ checkStyle,
59
+ } from "./style.js";
51
60
 
52
61
  // Tolerance on the column-width sum. Widths are literal numbers an author may
53
62
  // write as thirds, so an exact comparison rejects 33.3 x 3 for a rounding error
@@ -563,6 +572,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
563
572
  */
564
573
  let compileStyles = (block, path, check) => {
565
574
  let entries = readStyles(block, path, check);
575
+ for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
566
576
  if (!entries.length) return NIL;
567
577
  let resolve = styleFn(entries);
568
578
  if (isComputed(block)) return resolve;
@@ -725,7 +735,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
725
735
  let cellOf = (def, path, valuePath = path + ".value") => {
726
736
  let tpl = cellValue(def.value, valuePath);
727
737
  let visible = visibleOf(def, path);
728
- return cellShape(tpl, visible, stylesOf(def, path));
738
+ return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
729
739
  };
730
740
 
731
741
  // A cell without a row, so `@` stays unbound. A bare string is shorthand for
@@ -756,7 +766,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
756
766
  let header = headerOf(def.header, path + ".header");
757
767
  let tpl = cellValue(def.value, path + ".value");
758
768
  let visible = visibleOf(def, path);
759
- let styles = stylesOf(def, path);
769
+ let styles = stylesOf(def, path, checkCellStyle);
760
770
  // A width is a percentage of the table width, resolved by every target.
761
771
  // The range verdict is owned here: an unreadable width comes back as NaN,
762
772
  // so the total check below reads the verdict instead of re-deciding it.
@@ -780,7 +790,11 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
780
790
  checkTextType(def.type, path);
781
791
  let tpl = cellValue(def.value, path + ".value");
782
792
  let visible = visibleOf(def, path);
783
- let shape = cellShape(tpl, inSlot ? visible : null, stylesOf(def, path));
793
+ let shape = cellShape(
794
+ tpl,
795
+ inSlot ? visible : null,
796
+ stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
797
+ );
784
798
  // The cell shape first, then the item's own fields, so a cell reads
785
799
  // the same wherever it appears in the stream.
786
800
  return (scope) => {
@@ -845,6 +859,8 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
845
859
  // An image item: bytes an `=` expression yields, sized by `fit`, with an
846
860
  // optional textual stand-in. JSON has no way to write bytes, so `source` is
847
861
  // expression-only; the vocabulary an image accepts is `style.js`'s to narrow.
862
+ /** @type {(inSlot: boolean) => (name: string, value: any) => string | null} */
863
+ let imageLook = (inSlot) => (inSlot ? checkSlotImageStyle : checkImageStyle);
848
864
  /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
849
865
  let imageOf = (def, path, role, inSlot = false) => {
850
866
  keys(def, inSlot ? IMAGE_SLOT_KEYS : IMAGE_KEYS, path);
@@ -855,7 +871,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
855
871
  // problems twice, and reading it early would report them out of order.
856
872
  let alt = altOf(def, path);
857
873
  let visible = visibleOf(def, path);
858
- let styles = stylesOf(def, path, checkImageStyle);
874
+ let styles = stylesOf(def, path, imageLook(inSlot));
859
875
  let render = renderImage({
860
876
  source,
861
877
  fit: def.fit ?? "natural",
@@ -1005,9 +1021,17 @@ let bands = (
1005
1021
  keys(value.row, ["visible", "style"], "detail.row");
1006
1022
  return {
1007
1023
  rowVisible: visibleOf(value.row, "detail.row"),
1008
- rowStyles: stylesOf(value.row, "detail.row"),
1024
+ rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
1009
1025
  };
1010
1026
  };
1027
+ // The header-row box: `style` only, a different path from each column's
1028
+ // `header` cell. Absent when the table declares none.
1029
+ /** @type {(value: any) => (scope: Scope) => any} */
1030
+ let headerLook = (value) => {
1031
+ if (value.header == null || !obj(value.header, "detail.header")) return NIL;
1032
+ keys(value.header, ["style"], "detail.header");
1033
+ return stylesOf(value.header, "detail.header", checkRowStyle);
1034
+ };
1011
1035
  /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
1012
1036
  let tableColumns = (value) => {
1013
1037
  let defs = arr(value.columns, "detail.columns", true);
@@ -1018,24 +1042,51 @@ let bands = (
1018
1042
  checkShares(bad, columns, "detail.columns", "column");
1019
1043
  return { defs, columns };
1020
1044
  };
1021
- /** @type {(totals: any[] | null, defs: any[] | null) => void} */
1022
- let checkTotalCount = (totals, defs) => {
1023
- if (totals && defs && totals.length !== defs.length)
1024
- bad("detail.total", "expected one cell per column");
1025
- };
1026
- /** @type {(totals: any[]) => any[]} */
1027
- let mapTotals = (totals) =>
1045
+ /** @type {(totals: any[], prefix: string) => any[]} */
1046
+ let mapTotals = (totals, prefix) =>
1028
1047
  totals
1029
1048
  .map((def, i) => {
1030
- let cell = totalOf(def, "detail.total[" + i + "]");
1031
- return cell && { cell, path: "detail.total[" + i + "]" };
1049
+ let path = prefix + "[" + i + "]";
1050
+ let cell = totalOf(def, path);
1051
+ return cell && { cell, path };
1032
1052
  })
1033
1053
  .filter((entry) => entry != null);
1034
- /** @type {(value: any, defs: any[] | null) => any[] | null} */
1035
- let tableTotals = (value, defs) => {
1036
- let totals = arr(value.total, "detail.total");
1037
- checkTotalCount(totals, defs);
1038
- return totals ? mapTotals(totals) : null;
1054
+ /** @type {(rows: any[] | null) => any[]} */
1055
+ let nonempty = (rows) => {
1056
+ if (rows && !rows.length) bad("detail.total", "expected at least one total row");
1057
+ return rows || [];
1058
+ };
1059
+ /**
1060
+ * @type {(value: any, defs: any[] | null) =>
1061
+ * { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null}
1062
+ */
1063
+ let tableTotals = (value, defs) =>
1064
+ value.total == null
1065
+ ? null
1066
+ : nonempty(arr(value.total, "detail.total", true)).map((def, r) =>
1067
+ totalRowOf(def, "detail.total[" + r + "]", defs),
1068
+ );
1069
+ /** @type {(cells: any[], defs: any[] | null) => boolean} */
1070
+ let aligned = (cells, defs) => !defs || cells.length === defs.length;
1071
+ /** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
1072
+ let counted = (cells, defs, path) => {
1073
+ if (!cells) return [];
1074
+ if (!aligned(cells, defs)) bad(path + ".cells", "expected one cell per column");
1075
+ return cells;
1076
+ };
1077
+ /**
1078
+ * @type {(def: any, path: string, defs: any[] | null) =>
1079
+ * { styles: (scope: Scope) => any, visible: any, cells: any[] }}
1080
+ */
1081
+ let totalRowOf = (def, path, defs) => {
1082
+ if (!obj(def, path)) return { styles: NIL, visible: null, cells: [] };
1083
+ keys(def, ["cells", "style", "visible"], path);
1084
+ let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
1085
+ return {
1086
+ styles: stylesOf(def, path, checkRowStyle),
1087
+ visible: visibleOf(def, path),
1088
+ cells: mapTotals(cells, path + ".cells"),
1089
+ };
1039
1090
  };
1040
1091
  /**
1041
1092
  * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
@@ -1056,30 +1107,47 @@ let bands = (
1056
1107
  );
1057
1108
  }
1058
1109
  }
1059
- /** @type {(columns: any[], total: any[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Band} */
1060
- let tableBand = (columns, total, rowVisible, rowStyles) =>
1110
+ /**
1111
+ * @type {(total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null,
1112
+ * scope: Scope) => Generator<any>}
1113
+ */
1114
+ function* emitTotals(total, scope) {
1115
+ if (!total) return;
1116
+ for (let row of total) {
1117
+ if (hidden(row.visible, scope)) continue;
1118
+ yield opt(
1119
+ {
1120
+ type: "total-row",
1121
+ cells: row.cells.map(({ cell, path }) => Object.assign(cell(scope), { path })),
1122
+ },
1123
+ { style: row.styles(scope) },
1124
+ );
1125
+ }
1126
+ }
1127
+ /** @type {(columns: any[], total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any, headerStyles: (scope: Scope) => any) => Band} */
1128
+ let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) =>
1061
1129
  function* (rows, scope, runners) {
1062
- yield {
1063
- type: "table-start",
1064
- path: "detail",
1065
- columns: columns.map((column) =>
1066
- opt({ header: column.header(scope), path: column.path }, { width: column.width }),
1067
- ),
1068
- };
1130
+ yield opt(
1131
+ {
1132
+ type: "table-start",
1133
+ path: "detail",
1134
+ columns: columns.map((column) =>
1135
+ opt({ header: column.header(scope), path: column.path }, { width: column.width }),
1136
+ ),
1137
+ },
1138
+ { style: headerStyles(scope) },
1139
+ );
1069
1140
  yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
1070
- if (total)
1071
- yield {
1072
- type: "total-row",
1073
- cells: total.map(({ cell, path }) => Object.assign(cell(scope), { path })),
1074
- };
1141
+ yield* emitTotals(total, scope);
1075
1142
  yield { type: "table-end" };
1076
1143
  };
1077
1144
  /** @type {(value: any) => Band} */
1078
1145
  let tableOf = (value) => {
1079
- keys(value, ["row", "columns", "total"], "detail");
1146
+ keys(value, ["header", "row", "columns", "total"], "detail");
1147
+ let headerStyles = headerLook(value);
1080
1148
  let { rowVisible, rowStyles } = rowLook(value);
1081
1149
  let { defs, columns } = tableColumns(value);
1082
- return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles);
1150
+ return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles, headerStyles);
1083
1151
  };
1084
1152
 
1085
1153
  // The detail band is polymorphic: an array of items stacks them per row, a
@@ -1251,17 +1319,87 @@ let selectOf = (schema, bad, attempt, options) => {
1251
1319
  };
1252
1320
  /** @type {(value: any, path: string, expression: any) => any} */
1253
1321
  let maybeExpr = (value, path, expression) => (value != null ? expression(value, path) : null);
1254
- /** @type {(schema: any, obj: any, keys: any, itemsOf: any) => { pageHeader: any, pageFooter: any }} */
1255
- let pageBands = (schema, obj, keys, itemsOf) => {
1322
+ /** @type {(n: any) => boolean} */
1323
+ let posPts = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
1324
+ /** @type {(n: any) => boolean} */
1325
+ let padPts = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
1326
+ /** @type {(height: any, bad: any) => number | null} */
1327
+ let headerHeightOf = (height, bad) => {
1328
+ if (height == null) bad("header.height", "required");
1329
+ else if (!posPts(height)) bad("header.height", "expected a positive number of points");
1330
+ return posPts(height) ? height : null;
1331
+ };
1332
+ /** @type {(itemsOf: any) => { items: any, height: number | null }} */
1333
+ let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
1334
+ /** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
1335
+ let reportHeaderOf = (value, keys, itemsOf, bad) => {
1336
+ if (value == null || Array.isArray(value))
1337
+ return { items: itemsOf(value, "header", "report-header"), height: null };
1338
+ if (!record(value)) {
1339
+ bad("header", "expected an array of items or { height, items }");
1340
+ return emptyHeader(itemsOf);
1341
+ }
1342
+ keys(value, ["height", "items"], "header");
1343
+ return {
1344
+ items: itemsOf(value.items, "header.items", "report-header"),
1345
+ height: headerHeightOf(value.height, bad),
1346
+ };
1347
+ };
1348
+
1349
+ /** @type {(margin: any, bad: any) => number | null} */
1350
+ let pageMarginOf = (margin, bad) => {
1351
+ if (margin != null && !padPts(margin))
1352
+ bad("page.margin", "expected a non-negative number of points");
1353
+ return padPts(margin) ? margin : null;
1354
+ };
1355
+ /** @type {(schema: any, obj: any, keys: any, itemsOf: any, bad: any) => { pageHeader: any, pageFooter: any, margin: number | null }} */
1356
+ let pageBands = (schema, obj, keys, itemsOf, bad) => {
1256
1357
  if (schema.page == null || !obj(schema.page, "page"))
1257
- return { pageHeader: null, pageFooter: null };
1258
- keys(schema.page, ["header", "footer"], "page");
1358
+ return { pageHeader: null, pageFooter: null, margin: null };
1359
+ keys(schema.page, ["header", "footer", "margin"], "page");
1259
1360
  return {
1260
1361
  pageHeader: maybeItems(schema.page.header, "page.header", "page-header", itemsOf),
1261
1362
  pageFooter: maybeItems(schema.page.footer, "page.footer", "page-footer", itemsOf),
1363
+ margin: pageMarginOf(schema.page.margin, bad),
1262
1364
  };
1263
1365
  };
1264
1366
 
1367
+ /** @type {(def: any) => boolean} */
1368
+ let shown = (def) => !(def && def.visible === false);
1369
+ /** @type {(list: any, path: string) => [any, string] | null} */
1370
+ let firstOccupying = (list, path) => {
1371
+ if (!Array.isArray(list)) return null;
1372
+ let i = list.findIndex(shown);
1373
+ return i < 0 ? null : [list[i], path + "[" + i + "]"];
1374
+ };
1375
+ /** @type {(schema: any) => any} */
1376
+ let firstGroupHeader = (schema) => {
1377
+ let groups = schema.groups;
1378
+ return Array.isArray(groups) && groups[0] ? groups[0].header : null;
1379
+ };
1380
+ /** @type {(schema: any) => [any, string] | null} */
1381
+ let nextAfterHeader = (schema) =>
1382
+ firstOccupying(firstGroupHeader(schema), "groups[0].header") ||
1383
+ firstOccupying(schema.detail, "detail");
1384
+ /** @type {(n: any) => boolean} */
1385
+ let nonzeroLead = (n) => typeof n === "number" && n !== 0;
1386
+ /** @type {(next: [any, string], bad: any) => void} */
1387
+ let refuseLiteralLead = (next, bad) => {
1388
+ if (nonzeroLead(next[0].style?.spaceBefore))
1389
+ bad(next[1] + ".style.spaceBefore", "must be 0 after a height-declared header");
1390
+ };
1391
+ /** @type {(schema: any, headerHeight: number | null, bad: any) => void} */
1392
+ let refusePinnedLead = (schema, headerHeight, bad) => {
1393
+ if (headerHeight == null) return;
1394
+ let next = nextAfterHeader(schema);
1395
+ if (next) refuseLiteralLead(next, bad);
1396
+ };
1397
+ /** @type {(headerHeight: number | null, schema: any, bad: any) => void} */
1398
+ let needPinnedMargin = (headerHeight, schema, bad) => {
1399
+ if (headerHeight != null && schema.page?.margin == null)
1400
+ bad("page.margin", "required when header declares height");
1401
+ };
1402
+
1265
1403
  /**
1266
1404
  * The root descent, in the schema's documented key order. `params` and `data`
1267
1405
  * are checked here the way every other key is — by compiling what they declare
@@ -1326,7 +1464,8 @@ let traverse = (
1326
1464
  // `report-start` field instead of being merged into every item's style --
1327
1465
  // nothing here allocates per cell (docs/adr/0033).
1328
1466
  let style = stylesOf(schema, "", checkReportStyle);
1329
- let header = itemsOf(schema.header, "header", "report-header");
1467
+ let { items: header, height: headerHeight } = reportHeaderOf(schema.header, keys, itemsOf, bad);
1468
+ refusePinnedLead(schema, headerHeight, bad);
1330
1469
  // Null when undeclared, so the render decides on the compiled band rather
1331
1470
  // than reading `schema.empty` a second time from outside the traversal.
1332
1471
  let empty = maybeItems(schema.empty, "empty", "empty", itemsOf);
@@ -1335,7 +1474,8 @@ let traverse = (
1335
1474
  let footer = itemsOf(schema.footer, "footer", "report-footer");
1336
1475
  // Page bands compile like any other band, but render per page, so the
1337
1476
  // stream hands paginated targets closures instead of events.
1338
- let { pageHeader, pageFooter } = pageBands(schema, obj, keys, itemsOf);
1477
+ let { pageHeader, pageFooter, margin } = pageBands(schema, obj, keys, itemsOf, bad);
1478
+ needPinnedMargin(headerHeight, schema, bad);
1339
1479
  return {
1340
1480
  select,
1341
1481
  params,
@@ -1346,12 +1486,14 @@ let traverse = (
1346
1486
  running,
1347
1487
  style,
1348
1488
  header,
1489
+ headerHeight,
1349
1490
  empty,
1350
1491
  columns,
1351
1492
  footer,
1352
1493
  band,
1353
1494
  pageHeader,
1354
1495
  pageFooter,
1496
+ margin,
1355
1497
  };
1356
1498
  };
1357
1499
 
package/lib/stream.js CHANGED
@@ -14,9 +14,9 @@
14
14
  export { display, text } from "sjabloon";
15
15
 
16
16
  /** @type {(value: any) => boolean} */
17
- let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
17
+ export let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
18
18
  /** @type {(value: any) => boolean} */
19
- let finiteDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
19
+ export let finiteDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
20
20
  /** @type {(value: any) => number | boolean | Date | undefined} */
21
21
  let asTyped = (value) => {
22
22
  if (finiteNum(value)) return value;
package/lib/style.js CHANGED
@@ -19,11 +19,14 @@ let spec = (msg, ok) => ({ ok, msg });
19
19
  let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
20
20
  let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
21
21
  let ALIGNMENTS = ["left", "center", "right"];
22
- // The declarations an image item accepts. The rest of the vocabulary describes
23
- // text, which an image does not have, so anything else on one is a definition
24
- // error rather than a silent no-op (SCHEMA.md, "Image item"). The subset lives
25
- // beside the vocabulary it narrows, and `checkImageStyle` below is how the
26
- // traversal asks for it -- it keeps no second list of its own.
22
+ let LINES = ["solid", "dashed", "dotted"];
23
+ let SIDES = ["Top", "Right", "Bottom", "Left"];
24
+ let BORDER_PARTS = ["Width", "Style", "Color"];
25
+ // The declarations an image item accepts. Text names are refused; the box and
26
+ // flow spacing are legal, because a band image has a box and sits in the flow
27
+ // the way a text item does (SCHEMA.md, "Image item"). The subset lives beside
28
+ // the vocabulary it narrows, and `checkImageStyle` below is how the traversal
29
+ // asks for it -- it keeps no second list of its own.
27
30
  let IMAGE_STYLES = ["align", "background"];
28
31
  // The declarations a report default accepts. A report default states what the
29
32
  // document is set in, so it carries only the two declarations that describe a
@@ -46,7 +49,30 @@ let STYLES = {
46
49
  color: COLOR,
47
50
  background: COLOR,
48
51
  align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
52
+ format: spec("expected number, currency, percent, or date", (value) => FORMATS.includes(value)),
49
53
  };
54
+ let FORMATS = ["number", "currency", "percent", "date"];
55
+ let POINTS = spec(
56
+ "expected a non-negative number of points",
57
+ (value) => finite(value) && value >= 0,
58
+ );
59
+ let LINE = spec("expected solid, dashed, or dotted", (value) => LINES.includes(value));
60
+ /** @type {Record<string, StyleSpec>} */
61
+ let BORDER = { Width: POINTS, Style: LINE, Color: COLOR };
62
+ for (let side of SIDES) {
63
+ STYLES["padding" + side] = POINTS;
64
+ IMAGE_STYLES.push("padding" + side);
65
+ for (let part of BORDER_PARTS) {
66
+ let name = "border" + side + part;
67
+ STYLES[name] = BORDER[part];
68
+ IMAGE_STYLES.push(name);
69
+ }
70
+ }
71
+ let FLOW = ["spaceBefore", "spaceAfter"];
72
+ for (let name of FLOW) {
73
+ STYLES[name] = POINTS;
74
+ IMAGE_STYLES.push(name);
75
+ }
50
76
  // One check for both the validating traversal and the compile path: an error
51
77
  // message for a declaration, or null when it is acceptable (expressions defer
52
78
  // to render).
@@ -62,6 +88,29 @@ export let checkStyle = (name, value) => {
62
88
  return rule.ok(value) ? null : rule.msg;
63
89
  };
64
90
 
91
+ // A border side is width, style, and colour together, or none. Incomplete
92
+ // literals are a definition error; a side that still has an expression defers
93
+ // to render, where an incomplete result contributes nothing rather than
94
+ // becoming a solid black stroke. Per-name checks run first; a side that
95
+ // already has a located problem is left alone so the author sees one cause.
96
+ /** @type {(present: string[]) => boolean} */
97
+ let incomplete = (present) => present.length > 0 && present.length < 3;
98
+ /** @type {(block: any, check: (name: string, value: any) => string | null, name: string) => boolean} */
99
+ let deferred = (block, check, name) => isExpr(block[name]) || !!check(name, block[name]);
100
+
101
+ /** @type {(block: any, check?: (name: string, value: any) => string | null) => [string, string][]} */
102
+ export let checkBorderSides = (block, check = checkStyle) => {
103
+ /** @type {[string, string][]} */
104
+ let out = [];
105
+ for (let side of SIDES) {
106
+ let names = BORDER_PARTS.map((part) => "border" + side + part);
107
+ let present = names.filter((name) => Object.hasOwn(block, name));
108
+ if (incomplete(present) && !present.some((name) => deferred(block, check, name)))
109
+ out.push([present[0], "border" + side + " needs width, style, and color together"]);
110
+ }
111
+ return out;
112
+ };
113
+
65
114
  // The same check narrowed to a subset of the vocabulary, so a declaration a
66
115
  // node cannot wear is refused as pointedly as an unknown name rather than
67
116
  // silently doing nothing. A node that takes part of the vocabulary names the
@@ -79,3 +128,31 @@ export let checkImageStyle = narrowed(IMAGE_STYLES, "an image");
79
128
  // "a report default" rather than the key's name: the key is `style` like every
80
129
  // other one, so the message has to say which `style` refused it.
81
130
  export let checkReportStyle = narrowed(REPORT_STYLES, "a report default");
131
+
132
+ // Flow spacing is a band-item pad. A table cell, a row box, and a split slot
133
+ // have no flow, so the names are refused there the way a text declaration is
134
+ // on an image (docs/adr/0037).
135
+ /** @type {Record<string, true>} */
136
+ let FLOW_NAMES = Object.fromEntries(FLOW.map((name) => [name, true]));
137
+ /**
138
+ * @type {(names: Record<string, true>, check: (name: string, value: any) => string | null, subject: string) =>
139
+ * (name: string, value: any) => string | null}
140
+ */
141
+ let withoutNames = (names, check, subject) => (name, value) =>
142
+ Object.hasOwn(names, name)
143
+ ? 'style "' + name + '" does not apply to ' + subject
144
+ : check(name, value);
145
+ /**
146
+ * @type {(check: (name: string, value: any) => string | null, subject: string) =>
147
+ * (name: string, value: any) => string | null}
148
+ */
149
+ let withoutFlow = (check, subject) => withoutNames(FLOW_NAMES, check, subject);
150
+
151
+ export let checkCellStyle = withoutFlow(checkStyle, "a table cell");
152
+ export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
153
+ export let checkSlotImageStyle = withoutFlow(checkImageStyle, "a split slot");
154
+ // A row box has no cell value, so `format` is refused there the way flow
155
+ // spacing is — it is a presentation of a value, not of a band of cells.
156
+ /** @type {Record<string, true>} */
157
+ let ROW_NAMES = { ...FLOW_NAMES, format: true };
158
+ export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -51,7 +51,7 @@
51
51
  "sjabloon",
52
52
  "padvinder"
53
53
  ],
54
- "limit": "9 kB"
54
+ "limit": "10 kB"
55
55
  }
56
56
  ],
57
57
  "engines": {