quario 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/plan.js CHANGED
@@ -31,21 +31,32 @@
31
31
  // Each engine's own predicate, not the union `locate.js` exports: every call
32
32
  // site knows which engine it just called, and asking that one is what stops a
33
33
  // sjabloon-shaped error being trusted where an xprsn error was expected.
34
- import { isDiagnostic as isQueryDiagnostic, query } from "padvinder";
35
- import { compile, isDiagnostic as isXprsnDiagnostic } from "xprsn";
36
- import { isDiagnostic as isTemplateDiagnostic, template } from "sjabloon";
37
- import { err, locate } from "./locate.js";
34
+ import { isDiagnostic as isQueryDiagnostic, query, relocate as relocateQuery } from "padvinder";
35
+ import {
36
+ compile,
37
+ isDiagnostic as isXprsnDiagnostic,
38
+ relocate as relocateXprsn,
39
+ signatures,
40
+ } from "xprsn";
41
+ import {
42
+ isDiagnostic as isTemplateDiagnostic,
43
+ relocate as relocateTemplate,
44
+ template,
45
+ } from "sjabloon";
46
+ import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
38
47
  import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
39
48
  import { AGG, REDUCERS, RUN, aggregateValue, withRow } from "./scope.js";
40
49
  import { opt, sniff } from "./stream.js";
41
- import { checkImageStyle, checkStyle } from "./style.js";
42
-
43
- // Default traversal budget for the data query. Render input is untrusted, so
44
- // without a bound a deep document exhausts the native stack as an opaque
45
- // RangeError; with it, padvinder throws a typed PADVINDER_MAX_DEPTH diagnostic
46
- // long before that. Hosts override (or effectively disable) it per key through
47
- // `options.query`. 500 is ~4x inside the stack limit and far beyond report data.
48
- let QUERY = { maxDepth: 500 };
50
+ import {
51
+ checkBorderSides,
52
+ checkCellStyle,
53
+ checkImageStyle,
54
+ checkReportStyle,
55
+ checkRowStyle,
56
+ checkSlotImageStyle,
57
+ checkSlotStyle,
58
+ checkStyle,
59
+ } from "./style.js";
49
60
 
50
61
  // Tolerance on the column-width sum. Widths are literal numbers an author may
51
62
  // write as thirds, so an exact comparison rejects 33.3 x 3 for a rounding error
@@ -67,19 +78,58 @@ let isExpr = (value) => typeof value === "string" && value[0] === "=";
67
78
  let collect = (from, into) => {
68
79
  for (let name of from) into.add(name);
69
80
  };
70
- /** @type {(from: Iterable<string>, bound: Set<string>, into: Set<string>) => void} */
71
- let collectFree = (from, bound, into) => {
72
- for (let name of from) {
73
- // oxlint-disable-next-line no-unused-expressions
74
- bound.has(name) || into.add(name);
75
- }
76
- };
77
81
  /** @type {(visible: any, scope: any) => any} */
78
82
  let hidden = (visible, scope) => visible && visible(scope) === false;
83
+
84
+ // A text item's closed key set, and the same set inside a split, where the
85
+ // slot's width share joins it.
86
+ let TEXT_KEYS = ["type", "value", "visible", "style"];
87
+ let TEXT_SLOT_KEYS = [...TEXT_KEYS, "width"];
88
+ let IMAGE_KEYS = ["type", "source", "fit", "alt", "visible", "style"];
89
+ let IMAGE_SLOT_KEYS = [...IMAGE_KEYS, "width"];
90
+
91
+ // Which detail events carry the row's running values: the ones holding a cell.
92
+ // A split's bracket holds none, so run rides the item and image events inside
93
+ // it rather than the bracket around them.
94
+ /** @type {(event: any) => boolean} */
95
+ let carriesRun = (event) => event.type === "item" || event.type === "image";
79
96
  /** @type {(value: any, path: string, role: string, itemsOf: any) => any} */
80
97
  let maybeItems = (value, path, role, itemsOf) =>
81
98
  value != null ? itemsOf(value, path, role) : null;
82
99
 
100
+ // The width-share rule, shared by a table's columns and a split's slots: the
101
+ // two carry the same percentages under the same arithmetic, and one reader for
102
+ // both is what keeps them from drifting into two rules wearing one name.
103
+ /** @type {(sized: boolean, noun: string) => string} */
104
+ let shareMsg = (sized, noun) =>
105
+ sized
106
+ ? "expected " + noun + " widths to total at most 100"
107
+ : "expected " +
108
+ noun +
109
+ " widths to total under 100, leaving room for the " +
110
+ noun +
111
+ "s without one";
112
+ // The authored shares worth summing, or null when there is nothing to decide.
113
+ // Nothing authored is nothing to check; a width out of range (a NaN, per the
114
+ // width verdict) already carries its own problem, and one nobody can read
115
+ // leaves it undecidable whether its part is one of those needing room.
116
+ /** @type {(parts: { width?: any }[]) => number[] | null} */
117
+ let sharesOf = (parts) => {
118
+ let shares = parts.map((part) => part.width).filter((width) => width != null);
119
+ return !shares.length || shares.some(Number.isNaN) ? null : shares;
120
+ };
121
+ // Authored shares must leave room for the parts without one. Widths are
122
+ // literal, so the sum is known here; a tolerance keeps three at 33.3 from
123
+ // summing to 100.00000000000001 and failing on IEEE 754 alone.
124
+ /** @type {(bad: any, parts: { width?: any }[], path: string, noun: string) => void} */
125
+ let checkShares = (bad, parts, path, noun) => {
126
+ let shares = sharesOf(parts);
127
+ if (!shares) return;
128
+ let total = shares.reduce((all, width) => all + width, 0);
129
+ let sized = shares.length === parts.length;
130
+ if (total > (sized ? 100 + WIDTH_EPS : 100 - WIDTH_EPS)) bad(path, shareMsg(sized, noun));
131
+ };
132
+
83
133
  // One pass over a report's constants: it checks each value, copies it, and
84
134
  // freezes the copy. Copying is what keeps a consumer from reaching back into
85
135
  // the compiled schema through a nested object; freezing is what stops them
@@ -202,18 +252,42 @@ let planState = (schema, funcs) => {
202
252
  let BND = new Set(ANCHORS);
203
253
  bindHandles(schema, BND);
204
254
 
205
- /** @type {string[]} */
255
+ // Structured problems: the path was a live value at every site that reports
256
+ // one, so it is kept as a field rather than recovered from the message. The
257
+ // message stays the whole located string — `validate()`'s view is
258
+ // `problems.map((p) => p.message)`, unchanged.
259
+ /** @type {{ path: string, source?: string, message: string, diagnostic?: any }[]} */
206
260
  let problems = [];
207
261
  // Kept so `events()` rethrows the engine's located error, not a bare one.
208
262
  /** @type {any} */
209
263
  let firstDiag = null;
264
+ // Where a caught error says it happened: `locate()` stamps that on the copy
265
+ // it throws, so nothing here parses a message back apart.
266
+ /** @type {(error: any) => { path: string, source?: any }} */
267
+ let where = (error) => (error && error[LOCATION]) || { path: "" };
268
+ // A caught error carries its location as data (locate.js), so the problem
269
+ // keeps the path and source structurally; the diagnostic rides along per
270
+ // problem — every problem keeps its offsets, not only the first.
271
+ /** @type {(msg: string, error?: any) => any} */
272
+ let located = (msg, error) => {
273
+ let at = where(error);
274
+ /** @type {any} */
275
+ let problem = { path: at.path, message: msg };
276
+ if (at.source !== undefined) problem.source = String(at.source);
277
+ if (isDiagnostic(error)) problem.diagnostic = error;
278
+ return problem;
279
+ };
210
280
  /** @type {(msg: string, error?: any) => void} */
211
281
  let add = (msg, error) => {
212
- if (!problems.length && error && Object.hasOwn(error, "code")) firstDiag = error;
213
- problems.push(msg);
282
+ // Identity, not shape: locate() copies an untrusted host throw with
283
+ // `new error.constructor(...)`, so a host class that stamps `code` on
284
+ // itself produces a copy shaped like a diagnostic. Only an error the
285
+ // engines authenticated is worth rethrowing as one (quario-lkz).
286
+ if (!problems.length && isDiagnostic(error)) firstDiag = error;
287
+ problems.push(located(msg, error));
214
288
  };
215
289
  /** @type {(path: string, msg: string) => void} */
216
- let bad = (path, msg) => add(path + ": " + msg);
290
+ let bad = (path, msg) => problems.push({ path, message: path + ": " + msg });
217
291
  // Record a throwing compile step and stand in a stub, so the traversal reports
218
292
  // the whole definition rather than only its first fault.
219
293
  /** @type {<T>(fn: () => T, fallback: T) => T} */
@@ -231,6 +305,22 @@ let planState = (schema, funcs) => {
231
305
  /** @type {Set<string>} */
232
306
  let runNames = new Set();
233
307
 
308
+ // Which anchors each compiled source actually reads, keyed by its schema
309
+ // path. Retention, not computation: the engines report every root-name read
310
+ // in `.reads` — the unfiltered view `bound` keeps out of `.names` — and this
311
+ // keeps the anchor-and-handle subset a consumer placing a node needs.
312
+ /** @type {Map<string, Set<string>>} */
313
+ let anchors = new Map();
314
+ /** @type {(path: string, reads: { name: string }[]) => void} */
315
+ let anchorsOf = (path, reads) => {
316
+ for (let { name } of reads) {
317
+ if (!BND.has(name)) continue;
318
+ let set = anchors.get(path);
319
+ if (!set) anchors.set(path, (set = new Set()));
320
+ set.add(name);
321
+ }
322
+ };
323
+
234
324
  return {
235
325
  FNS,
236
326
  names,
@@ -238,6 +328,8 @@ let planState = (schema, funcs) => {
238
328
  BND,
239
329
  problems,
240
330
  runNames,
331
+ anchors,
332
+ anchorsOf,
241
333
  add,
242
334
  bad,
243
335
  attempt,
@@ -255,7 +347,7 @@ let compileProp = (source, path, FNS, BND) => {
255
347
  try {
256
348
  return compile(source.slice(1), FNS, { bound: BND });
257
349
  } catch (error) {
258
- locate(path, source, error, 1, isXprsnDiagnostic(error));
350
+ locate(path, source, error, isXprsnDiagnostic(error) && relocateXprsn, 1);
259
351
  }
260
352
  };
261
353
  /** @type {(expression: any, path: string, source: any) => Eval} */
@@ -263,23 +355,23 @@ let runProp = (expression, path, source) => (scope) => {
263
355
  try {
264
356
  return expression(scope);
265
357
  } catch (error) {
266
- locate(path, source, error, 1, expression.isDiagnostic(error));
358
+ locate(path, source, error, expression.isDiagnostic(error) && relocateXprsn, 1);
267
359
  }
268
360
  };
269
- /** @type {(src: string, path: string, source: any, FNS: any) => any} */
270
- let compileCell = (src, path, source, FNS) => {
361
+ /** @type {(src: string, path: string, source: any, FNS: any, BND: Set<string>) => any} */
362
+ let compileCell = (src, path, source, FNS, BND) => {
271
363
  try {
272
- return template(src, FNS);
364
+ return template(src, FNS, { bound: BND });
273
365
  } catch (error) {
274
- locate(path, source, error, 0, isTemplateDiagnostic(error));
366
+ locate(path, source, error, isTemplateDiagnostic(error) && relocateTemplate);
275
367
  }
276
368
  };
277
- /** @type {(tokens: any, path: string, source: any) => (scope: Scope, opts?: any) => any} */
278
- let runCell = (tokens, path, source) => (scope, opts) => {
369
+ /** @type {(tokens: any, path: string, source: any) => (scope: Scope) => any} */
370
+ let runCell = (tokens, path, source) => (scope) => {
279
371
  try {
280
- return tokens(scope, opts);
372
+ return tokens.scoped(scope);
281
373
  } catch (error) {
282
- locate(path, source, error, 0, isTemplateDiagnostic(error));
374
+ locate(path, source, error, tokens.isDiagnostic(error) && relocateTemplate);
283
375
  }
284
376
  };
285
377
  /** @type {(source: any) => string} */
@@ -293,16 +385,16 @@ let splitFold = (spec) => {
293
385
  /** @type {(colon: number, path: string) => void} */
294
386
  let checkCount = (colon, path) => {
295
387
  // oxlint-disable-next-line no-unused-expressions
296
- colon < 0 || err(path + ": count does not take an expression");
388
+ colon < 0 || fault(path, "count does not take an expression");
297
389
  };
298
390
  /** @type {(src: string, fn: string, path: string) => void} */
299
391
  let checkExprFold = (src, fn, path) => {
300
392
  // oxlint-disable-next-line no-unused-expressions
301
- (src.startsWith("=") && src.length > 1) || err(path + ': expected "' + fn + ':=<expression>"');
393
+ (src.startsWith("=") && src.length > 1) || fault(path, 'expected "' + fn + ':=<expression>"');
302
394
  };
303
395
 
304
396
  /** @param {State} state */
305
- let primitives = ({ FNS, BND, names, functions }) => {
397
+ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
306
398
  // A computed property: a literal, or an `=` xprsn expression. xprsn's
307
399
  // `bound` keeps engine-provided anchors/handles out of the reported `names`.
308
400
  /** @type {(spec: any, path: string) => Eval} */
@@ -311,23 +403,27 @@ let primitives = ({ FNS, BND, names, functions }) => {
311
403
  let expression = compileProp(source, path, FNS, BND);
312
404
  collect(expression.names, names);
313
405
  collect(expression.functions, functions);
406
+ anchorsOf(path, expression.reads);
314
407
  return runProp(expression, path, source);
315
408
  };
316
409
 
317
- // A text cell: a sjabloon template, compiled with the token edition. Rendered
318
- // with distinct `{ root, item }` anchors, so `@` is the current row and `$`
319
- // is the report root. sjabloon already excludes its own loop vars / `$` / `@`;
320
- // we drop quario's engine names (run, group handles) from the reported
321
- // `names` too. A render yields the cell's public token stream: literal runs
322
- // verbatim plus each interpolation's pre-stringify value. Templates have no
323
- // raw form — a `{{{ }}}` tag is a located SJABLOON_RAW_TAG definition error —
324
- // so escaping is entirely a render-target concern at the markup edge,
325
- // with no exceptions to carry through the stream.
326
- /** @type {(str: any, path: string) => (scope: Scope, opts?: any) => any} */
410
+ // A text cell: a sjabloon template, compiled with the token edition and
411
+ // rendered over quario's own scope chain, which already binds the anchors:
412
+ // `$` at the render base, `@` on the detail row, neither anywhere else — so
413
+ // a group or report band leaves `@` unbound and a stray `@.field` throws.
414
+ // sjabloon's `bound` excludes its loop vars / `$` / `@` and quario's engine
415
+ // names alike from the reported `names`. A render yields the cell's public
416
+ // token stream: literal runs verbatim plus each interpolation's
417
+ // pre-stringify value. Templates have no raw form — a `{{{ }}}` tag is a
418
+ // located SJABLOON_RAW_TAG definition error — so escaping is entirely a
419
+ // render-target concern at the markup edge, with no exceptions to carry
420
+ // through the stream.
421
+ /** @type {(str: any, path: string) => (scope: Scope) => any} */
327
422
  let cell = (source, path) => {
328
- let tokens = compileCell(asText(source), path, source, FNS);
329
- collectFree(tokens.names, BND, names);
423
+ let tokens = compileCell(asText(source), path, source, FNS, BND);
424
+ collect(tokens.names, names);
330
425
  collect(tokens.functions, functions);
426
+ anchorsOf(path, tokens.reads);
331
427
  return runCell(tokens, path, source);
332
428
  };
333
429
 
@@ -337,7 +433,7 @@ let primitives = ({ FNS, BND, names, functions }) => {
337
433
  let parseFold = (spec, path, allowed) => {
338
434
  let { fn, src, colon } = splitFold(spec);
339
435
  // oxlint-disable-next-line no-unused-expressions
340
- allowed.has(fn) || err(path + ': unknown reducer "' + fn + '"');
436
+ allowed.has(fn) || fault(path, 'unknown reducer "' + fn + '"');
341
437
  if (fn === "count") checkCount(colon, path);
342
438
  else checkExprFold(src, fn, path);
343
439
  return { fn, per: src ? prop(src, path) : null };
@@ -371,10 +467,16 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
371
467
  };
372
468
  // Every schema object is a closed set, so typos and misplaced fields
373
469
  // (page.size, sort on a table detail) fail loud instead of doing nothing.
470
+ // One schema path from a parent's and a key. The document root's path is the
471
+ // empty string, so the dot is conditional -- `style.family`, never
472
+ // `.style.family`. Every reader that can be reached with the root joins
473
+ // through here rather than restating the rule.
474
+ /** @type {(path: string, key: string) => string} */
475
+ let pathTo = (path, key) => (path ? path + "." + key : key);
374
476
  /** @type {(value: any, allowed: string[], path: string) => void} */
375
477
  let keys = (value, allowed, path) => {
376
478
  for (let key of Object.keys(value))
377
- if (!allowed.includes(key)) bad(path ? path + "." + key : key, 'unknown key "' + key + '"');
479
+ if (!allowed.includes(key)) bad(pathTo(path, key), 'unknown key "' + key + '"');
378
480
  };
379
481
  /** @type {(name: string, path: string, reserved?: Set<string> | null) => void} */
380
482
  let checkName = (name, path, reserved) => {
@@ -448,7 +550,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
448
550
  /** @type {[string, Eval][]} */
449
551
  let entries = [];
450
552
  for (let [name, value] of Object.entries(block)) {
451
- let stylePath = path + ".style." + name;
553
+ let stylePath = path + "." + name;
452
554
  let problem = check(name, value);
453
555
  if (problem) bad(stylePath, problem);
454
556
  else entries.push([name, attempt(() => prop(value, stylePath), NIL)]);
@@ -470,6 +572,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
470
572
  */
471
573
  let compileStyles = (block, path, check) => {
472
574
  let entries = readStyles(block, path, check);
575
+ for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
473
576
  if (!entries.length) return NIL;
474
577
  let resolve = styleFn(entries);
475
578
  if (isComputed(block)) return resolve;
@@ -482,8 +585,9 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
482
585
  */
483
586
  let stylesOf = (def, path, check = checkStyle) => {
484
587
  let block = def.style;
485
- if (block == null || !obj(block, path + ".style")) return NIL;
486
- return compileStyles(block, path, check);
588
+ let stylePath = pathTo(path, "style");
589
+ if (block == null || !obj(block, stylePath)) return NIL;
590
+ return compileStyles(block, stylePath, check);
487
591
  };
488
592
  // A cell value: one sjabloon template. Emphasis is the cell's own `style`;
489
593
  // markup in literal text is meaningful only to the HTML target.
@@ -617,28 +721,26 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
617
721
  let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, stylesOf }) => {
618
722
  // `tokens` plus its truthy `style`. A hidden cell keeps its slot with no
619
723
  // tokens so a column keeps its alignment; items pass `visible` null and drop
620
- // themselves instead, having no slot to keep.
724
+ // themselves instead, having no slot to keep. The anchor story lives on
725
+ // `cell` in primitives: the scope chain itself carries `$` and `@`.
621
726
  /**
622
727
  * @type {(tpl: any, visible: Eval | null, styles: (scope: Scope) => any)
623
- * => (scope: Scope, item?: any) => any}
728
+ * => (scope: Scope) => any}
624
729
  */
625
- let cellShape = (tpl, visible, styles) => (scope, item) =>
626
- opt(
627
- { tokens: hidden(visible, scope) ? [] : tpl(scope, { root: scope["$"], item }) },
628
- { style: styles(scope) },
629
- );
730
+ let cellShape = (tpl, visible, styles) => (scope) =>
731
+ opt({ tokens: hidden(visible, scope) ? [] : tpl(scope) }, { style: styles(scope) });
630
732
 
631
733
  // A table cell: the shape of a total cell and of an object column header.
632
- /** @type {(def: any, path: string, valuePath?: string) => (scope: Scope, item?: any) => any} */
734
+ /** @type {(def: any, path: string, valuePath?: string) => (scope: Scope) => any} */
633
735
  let cellOf = (def, path, valuePath = path + ".value") => {
634
736
  let tpl = cellValue(def.value, valuePath);
635
737
  let visible = visibleOf(def, path);
636
- return cellShape(tpl, visible, stylesOf(def, path));
738
+ return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
637
739
  };
638
740
 
639
741
  // A cell without a row, so `@` stays unbound. A bare string is shorthand for
640
742
  // `{ value }` and keeps its authored path, so problems locate where written.
641
- /** @type {(def: any, path: string) => (scope: Scope, item?: any) => any} */
743
+ /** @type {(def: any, path: string) => (scope: Scope) => any} */
642
744
  let headerOf = (def, path) => {
643
745
  if (typeof def === "string") return cellShape(cellValue(def, path), null, NIL);
644
746
  if (!record(def)) {
@@ -664,14 +766,14 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
664
766
  let header = headerOf(def.header, path + ".header");
665
767
  let tpl = cellValue(def.value, path + ".value");
666
768
  let visible = visibleOf(def, path);
667
- let styles = stylesOf(def, path);
769
+ let styles = stylesOf(def, path, checkCellStyle);
668
770
  // A width is a percentage of the table width, resolved by every target.
669
771
  // The range verdict is owned here: an unreadable width comes back as NaN,
670
772
  // so the total check below reads the verdict instead of re-deciding it.
671
- return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path) };
773
+ return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
672
774
  };
673
775
 
674
- /** @type {(def: any, path: string) => ((scope: Scope, item?: any) => any) | null} */
776
+ /** @type {(def: any, path: string) => ((scope: Scope) => any) | null} */
675
777
  let totalOf = (def, path) => {
676
778
  if (!obj(def, path)) return null;
677
779
  keys(def, ["value", "visible", "style"], path);
@@ -679,29 +781,42 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
679
781
  };
680
782
 
681
783
  // A text item: a cell with no slot to keep, so a hidden one drops itself
682
- // instead of keeping empty tokens the way a table cell does.
683
- /** @type {(def: any, path: string, role: string) => (scope: Scope, item?: any) => any} */
684
- let textOf = (def, path, role) => {
685
- keys(def, ["type", "value", "visible", "style"], path);
686
- // The fallback shape, so an unknown `type` is reported here -- after this
687
- // node's key set, in the documented key order -- and then read as text, so
688
- // the rest of its keys are checked rather than left behind the one problem.
689
- if (def.type !== "text")
690
- bad(path + ".type", def.type == null ? "required" : 'unknown item type "' + def.type + '"');
784
+ // instead of keeping empty tokens the way a table cell does. Inside a split
785
+ // it is the other way round -- a slot holds its place with empty tokens, so
786
+ // the line's geometry does not move with the data (SCHEMA.md, "Split item").
787
+ /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
788
+ let textOf = (def, path, role, inSlot = false) => {
789
+ keys(def, inSlot ? TEXT_SLOT_KEYS : TEXT_KEYS, path);
790
+ checkTextType(def.type, path);
691
791
  let tpl = cellValue(def.value, path + ".value");
692
792
  let visible = visibleOf(def, path);
693
- let shape = cellShape(tpl, null, stylesOf(def, path));
793
+ let shape = cellShape(
794
+ tpl,
795
+ inSlot ? visible : null,
796
+ stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
797
+ );
694
798
  // The cell shape first, then the item's own fields, so a cell reads
695
799
  // the same wherever it appears in the stream.
696
- return (scope, item) => {
697
- if (hidden(visible, scope)) return null;
698
- let event = shape(scope, item);
800
+ return (scope) => {
801
+ if (!inSlot && hidden(visible, scope)) return null;
802
+ let event = shape(scope);
699
803
  event.type = "item";
700
804
  event.role = role;
805
+ event.path = path;
701
806
  return event;
702
807
  };
703
808
  };
704
809
 
810
+ // The fallback shape's verdict on a `type` it does not name, reported after
811
+ // this node's key set and in the documented key order -- and then read as
812
+ // text, so the rest of its keys are checked rather than left behind the one
813
+ // problem.
814
+ /** @type {(type: any, path: string) => void} */
815
+ let checkTextType = (type, path) => {
816
+ if (type === "text") return;
817
+ bad(path + ".type", type == null ? "required" : 'unknown item type "' + type + '"');
818
+ };
819
+
705
820
  /** @type {(fit: any, path: string) => void} */
706
821
  let checkFit = (fit, path) => {
707
822
  if (fit == null || fit === "natural" || fit === "width") return;
@@ -709,10 +824,10 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
709
824
  };
710
825
  /** @type {(def: any, path: string) => any} */
711
826
  let altOf = (def, path) => (def.alt != null ? cellValue(def.alt, path + ".alt") : null);
712
- /** @type {(alt: any, scope: Scope, item?: any) => any} */
713
- let imageAlt = (alt, scope, item) => alt && alt(scope, { root: scope["$"], item });
714
- /** @type {(fields: any, scope: Scope, item: any, bytes: any) => any} */
715
- let imageEvent = (fields, scope, item, bytes) => {
827
+ /** @type {(alt: any, scope: Scope) => any} */
828
+ let imageAlt = (alt, scope) => alt && alt(scope);
829
+ /** @type {(fields: any, scope: Scope, bytes: any) => any} */
830
+ let imageEvent = (fields, scope, bytes) => {
716
831
  // The bytes stay bytes. Sniffing reads their magic numbers -- it never
717
832
  // decodes the image, and nothing here turns them into a string -- and
718
833
  // the answer rides on the event so no target sniffs a second time.
@@ -728,52 +843,127 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
728
843
  TypeError("expected a Uint8Array of PNG or JPEG bytes"),
729
844
  );
730
845
  return opt(
731
- { type: "image", role: fields.role, bytes, format, fit: fields.fit },
732
- { alt: imageAlt(fields.alt, scope, item), style: fields.styles(scope) },
846
+ { type: "image", role: fields.role, bytes, format, fit: fields.fit, path: fields.path },
847
+ { alt: imageAlt(fields.alt, scope), style: fields.styles(scope) },
733
848
  );
734
849
  };
735
- /** @type {(fields: any) => (scope: Scope, item?: any) => any} */
736
- let renderImage = (fields) => (scope, item) => {
850
+ /** @type {(fields: any) => (scope: Scope) => any} */
851
+ let renderImage = (fields) => (scope) => {
737
852
  if (hidden(fields.visible, scope)) return null;
738
853
  let bytes = fields.source(scope);
739
854
  // Nullish is the missing-data twin of `visible: false`: no event and no
740
855
  // error, so a report whose input sometimes lacks a logo needs no guard.
741
856
  if (bytes == null) return null;
742
- return imageEvent(fields, scope, item, bytes);
857
+ return imageEvent(fields, scope, bytes);
743
858
  };
744
859
  // An image item: bytes an `=` expression yields, sized by `fit`, with an
745
860
  // optional textual stand-in. JSON has no way to write bytes, so `source` is
746
861
  // expression-only; the vocabulary an image accepts is `style.js`'s to narrow.
747
- /** @type {(def: any, path: string, role: string) => (scope: Scope, item?: any) => any} */
748
- let imageOf = (def, path, role) => {
749
- keys(def, ["type", "source", "fit", "alt", "visible", "style"], path);
862
+ /** @type {(inSlot: boolean) => (name: string, value: any) => string | null} */
863
+ let imageLook = (inSlot) => (inSlot ? checkSlotImageStyle : checkImageStyle);
864
+ /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
865
+ let imageOf = (def, path, role, inSlot = false) => {
866
+ keys(def, inSlot ? IMAGE_SLOT_KEYS : IMAGE_KEYS, path);
750
867
  let source = expression(def.source, path + ".source");
751
868
  checkFit(def.fit, path + ".fit");
752
- return renderImage({
869
+ // Read in the documented key order, and each read once: `stylesOf` reports
870
+ // as it reads, so compiling it twice would report this node's style
871
+ // problems twice, and reading it early would report them out of order.
872
+ let alt = altOf(def, path);
873
+ let visible = visibleOf(def, path);
874
+ let styles = stylesOf(def, path, imageLook(inSlot));
875
+ let render = renderImage({
753
876
  source,
754
877
  fit: def.fit ?? "natural",
755
- alt: altOf(def, path),
756
- visible: visibleOf(def, path),
757
- styles: stylesOf(def, path, checkImageStyle),
878
+ alt,
879
+ visible,
880
+ styles,
758
881
  path,
759
882
  sourceSpec: def.source,
760
883
  role,
761
884
  });
885
+ if (!inSlot) return render;
886
+ // A slot keeps its place whatever the data does: a hidden image, or a
887
+ // nullish source, leaves the same empty-token placeholder a hidden text
888
+ // slot leaves, so the widths either side of it do not move. `cellShape`
889
+ // with no template is that placeholder, so the two cannot drift.
890
+ let empty = cellShape(EMPTY, null, styles);
891
+ return (scope) => render(scope) ?? { ...empty(scope), type: "item", role };
892
+ };
893
+
894
+ // One slot of a split: an ordinary item, plus the width share that is a slot
895
+ // property rather than an item one -- which is why `width` reaches an item's
896
+ // key set only here (docs/adr/0029).
897
+ /**
898
+ * @type {(def: any, path: string, role: string)
899
+ * => { render: (scope: Scope) => any, width: any } | null}
900
+ */
901
+ let slotOf = (def, path, role) => {
902
+ if (!obj(def, path)) return null;
903
+ // Placement inside placement is the coordinate system quario does not
904
+ // have, so the nesting verdict is named rather than left to read as an
905
+ // unknown item type.
906
+ if (def.type === "split") {
907
+ bad(path + ".type", "a split cannot nest");
908
+ return null;
909
+ }
910
+ return {
911
+ render: (def.type === "image" ? imageOf : textOf)(def, path, role, true),
912
+ width: widthOf(def.width, path),
913
+ };
914
+ };
915
+
916
+ // A split: slots placed across the content width, bracketed in the stream so
917
+ // every slot crosses as the ordinary item or image event it already is.
918
+ /** @type {(def: any, path: string, role: string) => (scope: Scope) => any} */
919
+ let splitOf = (def, path, role) => {
920
+ keys(def, ["type", "slots", "visible", "style"], path);
921
+ let list = arr(def.slots, path + ".slots", true);
922
+ let defs = list || [];
923
+ // Two is the floor: a one-slot split is a text item wearing more syntax,
924
+ // and permitting it would be a second way to say one thing. Read off the
925
+ // array `arr` accepted, so a missing `slots` reports "required" once
926
+ // rather than that and this.
927
+ if (list && defs.length < 2) bad(path + ".slots", "expected at least two slots");
928
+ let slots = defs
929
+ .map((slot, i) => slotOf(slot, path + ".slots[" + i + "]", role))
930
+ .filter((slot) => slot != null);
931
+ checkShares(bad, slots, path + ".slots", "slot");
932
+ let visible = visibleOf(def, path);
933
+ let styles = stylesOf(def, path);
934
+ // Every width is a literal the traversal already resolved, so the geometry
935
+ // the bracket carries is the same array on every render -- built here
936
+ // rather than rebuilt per row in a detail band. Frozen because one array
937
+ // now reaches every render: a consumer must not be able to reach back
938
+ // through an event and change what a later render sees.
939
+ let geometry = Object.freeze(
940
+ slots.map((slot) => Object.freeze(opt({}, { width: slot.width }))),
941
+ );
942
+ return (scope) => {
943
+ if (hidden(visible, scope)) return null;
944
+ let out = [opt({ type: "split-start", role, slots: geometry }, { style: styles(scope) })];
945
+ for (let slot of slots) out.push(slot.render(scope));
946
+ out.push({ type: "split-end" });
947
+ return out;
948
+ };
762
949
  };
763
950
 
764
- /** @type {(def: any, itemPath: string, role: string) => ((scope: Scope, item?: any) => any) | null} */
951
+ /** @type {(def: any, itemPath: string, role: string) => ((scope: Scope) => any) | null} */
765
952
  let itemOf = (def, itemPath, role) => {
766
953
  if (!obj(def, itemPath)) return null;
767
954
  // `type` picks the item's shape; `textOf` owns the verdict on the ones
768
955
  // it does not name, having the closed key set that reports first.
956
+ if (def.type === "split") return splitOf(def, itemPath, role);
769
957
  return (def.type === "image" ? imageOf : textOf)(def, itemPath, role);
770
958
  };
771
- /** @type {(list: ((scope: Scope, item?: any) => any)[]) => (scope: Scope, item?: any) => Generator<any>} */
959
+ /** @type {(list: ((scope: Scope) => any)[]) => (scope: Scope) => Generator<any>} */
772
960
  let yieldItems = (list) =>
773
- function* (scope, item) {
961
+ function* (scope) {
774
962
  for (let render of list) {
775
- let event = render(scope, item);
776
- if (event) yield event;
963
+ let event = render(scope);
964
+ // A split renders to its whole bracket; every other item to one event.
965
+ if (Array.isArray(event)) yield* event;
966
+ else if (event) yield event;
777
967
  }
778
968
  };
779
969
 
@@ -781,10 +971,10 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
781
971
  // `q-<role>` class, which print CSS targets for styling and breaks.
782
972
  /**
783
973
  * @type {(value: any, path: string, role: string)
784
- * => (scope: Scope, item?: any) => Generator<any>}
974
+ * => (scope: Scope) => Generator<any>}
785
975
  */
786
976
  let itemsOf = (value, path, role) => {
787
- /** @type {((scope: Scope, item?: any) => any)[]} */
977
+ /** @type {((scope: Scope) => any)[]} */
788
978
  let list = [];
789
979
  for (let [i, def] of (arr(value, path) || []).entries()) {
790
980
  let render = itemOf(def, path + "[" + i + "]", role);
@@ -812,14 +1002,15 @@ let bands = (
812
1002
  { columnOf, itemsOf, totalOf },
813
1003
  ) => {
814
1004
  // Stacked items, one pass per row.
815
- /** @type {(items: (scope: Scope, item?: any) => Generator<any>) => Band} */
1005
+ /** @type {(items: (scope: Scope) => Generator<any>) => Band} */
816
1006
  let itemsBand = (items) =>
817
1007
  function* (rows, scope, runners) {
818
1008
  for (let row of rows) {
819
1009
  let scopeOfRow = runners.bind(scope, row);
820
1010
  // Detail items carry the row's run values as data; group and report
821
1011
  // band items never do (they have no current row).
822
- for (let event of items(scopeOfRow, row)) yield opt(event, { run: scopeOfRow.run });
1012
+ for (let event of items(scopeOfRow))
1013
+ yield carriesRun(event) ? opt(event, { run: scopeOfRow.run }) : event;
823
1014
  }
824
1015
  };
825
1016
 
@@ -830,32 +1021,16 @@ let bands = (
830
1021
  keys(value.row, ["visible", "style"], "detail.row");
831
1022
  return {
832
1023
  rowVisible: visibleOf(value.row, "detail.row"),
833
- rowStyles: stylesOf(value.row, "detail.row"),
1024
+ rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
834
1025
  };
835
1026
  };
836
- /** @type {(sized: boolean) => string} */
837
- let shareMsg = (sized) =>
838
- sized
839
- ? "expected column widths to total at most 100"
840
- : "expected column widths to total under 100, leaving room for the columns without one";
841
- /** @type {(columns: any[], shares: number[]) => void} */
842
- let warnShares = (columns, shares) => {
843
- let total = shares.reduce((all, width) => all + width, 0);
844
- let sized = shares.length === columns.length;
845
- if (total > (sized ? 100 + WIDTH_EPS : 100 - WIDTH_EPS)) bad("detail.columns", shareMsg(sized));
846
- };
847
- /** @type {(columns: any[]) => void} */
848
- let checkShares = (columns) => {
849
- // Authored shares must leave room for the columns without one. Widths are
850
- // literal, so the sum is known here; a tolerance keeps three columns at
851
- // 33.3 from summing to 100.00000000000001 and failing on IEEE 754 alone.
852
- // Skipped outright when any authored width is out of range (a NaN, per
853
- // columnOf's verdict): the document already carries that problem, and a
854
- // width nobody can read leaves it undecidable whether its column is one
855
- // of the ones needing room.
856
- let shares = columns.map((column) => column.width).filter((width) => width != null);
857
- if (!shares.length || shares.some(Number.isNaN)) return;
858
- warnShares(columns, shares);
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);
859
1034
  };
860
1035
  /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
861
1036
  let tableColumns = (value) => {
@@ -864,22 +1039,54 @@ let bands = (
864
1039
  let columns = (defs || [])
865
1040
  .map((def, i) => columnOf(def, "detail.columns[" + i + "]"))
866
1041
  .filter((column) => column != null);
867
- checkShares(columns);
1042
+ checkShares(bad, columns, "detail.columns", "column");
868
1043
  return { defs, columns };
869
1044
  };
870
- /** @type {(totals: any[] | null, defs: any[] | null) => void} */
871
- let checkTotalCount = (totals, defs) => {
872
- if (totals && defs && totals.length !== defs.length)
873
- bad("detail.total", "expected one cell per column");
1045
+ /** @type {(totals: any[], prefix: string) => any[]} */
1046
+ let mapTotals = (totals, prefix) =>
1047
+ totals
1048
+ .map((def, i) => {
1049
+ let path = prefix + "[" + i + "]";
1050
+ let cell = totalOf(def, path);
1051
+ return cell && { cell, path };
1052
+ })
1053
+ .filter((entry) => entry != 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 || [];
874
1058
  };
875
- /** @type {(totals: any[]) => any[]} */
876
- let mapTotals = (totals) =>
877
- totals.map((def, i) => totalOf(def, "detail.total[" + i + "]")).filter((cell) => cell != null);
878
- /** @type {(value: any, defs: any[] | null) => any[] | null} */
879
- let tableTotals = (value, defs) => {
880
- let totals = arr(value.total, "detail.total");
881
- checkTotalCount(totals, defs);
882
- return totals ? mapTotals(totals) : null;
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
+ };
883
1090
  };
884
1091
  /**
885
1092
  * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
@@ -890,30 +1097,57 @@ let bands = (
890
1097
  let scopeOfRow = runners.bind(scope, row);
891
1098
  if (!hidden(rowVisible, scopeOfRow))
892
1099
  yield opt(
893
- { type: "row", cells: columns.map((column) => column.cell(scopeOfRow, row)) },
1100
+ {
1101
+ type: "row",
1102
+ cells: columns.map((column) =>
1103
+ Object.assign(column.cell(scopeOfRow), { path: column.path }),
1104
+ ),
1105
+ },
894
1106
  { style: rowStyles(scopeOfRow), run: scopeOfRow.run },
895
1107
  );
896
1108
  }
897
1109
  }
898
- /** @type {(columns: any[], total: any[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Band} */
899
- 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) =>
900
1129
  function* (rows, scope, runners) {
901
- yield {
902
- type: "table-start",
903
- columns: columns.map((column) =>
904
- opt({ header: column.header(scope) }, { width: column.width }),
905
- ),
906
- };
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
+ );
907
1140
  yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
908
- if (total) yield { type: "total-row", cells: total.map((cell) => cell(scope)) };
1141
+ yield* emitTotals(total, scope);
909
1142
  yield { type: "table-end" };
910
1143
  };
911
1144
  /** @type {(value: any) => Band} */
912
1145
  let tableOf = (value) => {
913
- keys(value, ["row", "columns", "total"], "detail");
1146
+ keys(value, ["header", "row", "columns", "total"], "detail");
1147
+ let headerStyles = headerLook(value);
914
1148
  let { rowVisible, rowStyles } = rowLook(value);
915
1149
  let { defs, columns } = tableColumns(value);
916
- return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles);
1150
+ return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles, headerStyles);
917
1151
  };
918
1152
 
919
1153
  // The detail band is polymorphic: an array of items stacks them per row, a
@@ -983,7 +1217,14 @@ let bands = (
983
1217
  // A group band has no current row, so `@` stays unbound here.
984
1218
  let { groupScope, aggregates, rows: ordered } = openInstance(ctx, key, groupRows, scope);
985
1219
  yield opt(
986
- { type: "group-start", name: ctx.name, depth: ctx.index, key, aggregates },
1220
+ {
1221
+ type: "group-start",
1222
+ name: ctx.name,
1223
+ depth: ctx.index,
1224
+ key,
1225
+ aggregates,
1226
+ path: ctx.path,
1227
+ },
987
1228
  { break: ctx.pageBreak, reset: ctx.reset, columns: ctx.columns },
988
1229
  );
989
1230
  yield* ctx.header(groupScope);
@@ -1027,6 +1268,7 @@ let bands = (
1027
1268
  footer: itemsOf(def.footer, path + ".footer", "group-footer"),
1028
1269
  inner: bandOf(defs, index + 1, detail, columned || columns != null),
1029
1270
  name: def.name,
1271
+ path,
1030
1272
  pageBreak: def.break,
1031
1273
  reset: def.reset,
1032
1274
  columns,
@@ -1053,9 +1295,12 @@ let bands = (
1053
1295
  /** @type {(source: string, options: any) => any} */
1054
1296
  let compileQuery = (source, options) => {
1055
1297
  try {
1056
- return query(source, undefined, { ...QUERY, ...(options && options.query) });
1298
+ // padvinder itself defaults maxDepth to 500 (safety by construction since
1299
+ // 0.6.0), so untrusted deep documents throw typed with no quario-side
1300
+ // budget; hosts override per key — Infinity opts a budget out.
1301
+ return query(source, undefined, options && options.query);
1057
1302
  } catch (error) {
1058
- locate("data", source, error, 0, isQueryDiagnostic(error));
1303
+ locate("data", source, error, isQueryDiagnostic(error) && relocateQuery);
1059
1304
  }
1060
1305
  };
1061
1306
  /** @type {(schema: any, obj: any, named: any, bad: any) => any} */
@@ -1074,17 +1319,87 @@ let selectOf = (schema, bad, attempt, options) => {
1074
1319
  };
1075
1320
  /** @type {(value: any, path: string, expression: any) => any} */
1076
1321
  let maybeExpr = (value, path, expression) => (value != null ? expression(value, path) : null);
1077
- /** @type {(schema: any, obj: any, keys: any, itemsOf: any) => { pageHeader: any, pageFooter: any }} */
1078
- 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) => {
1079
1357
  if (schema.page == null || !obj(schema.page, "page"))
1080
- return { pageHeader: null, pageFooter: null };
1081
- keys(schema.page, ["header", "footer"], "page");
1358
+ return { pageHeader: null, pageFooter: null, margin: null };
1359
+ keys(schema.page, ["header", "footer", "margin"], "page");
1082
1360
  return {
1083
1361
  pageHeader: maybeItems(schema.page.header, "page.header", "page-header", itemsOf),
1084
1362
  pageFooter: maybeItems(schema.page.footer, "page.footer", "page-footer", itemsOf),
1363
+ margin: pageMarginOf(schema.page.margin, bad),
1085
1364
  };
1086
1365
  };
1087
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
+
1088
1403
  /**
1089
1404
  * The root descent, in the schema's documented key order. `params` and `data`
1090
1405
  * are checked here the way every other key is — by compiling what they declare
@@ -1102,7 +1417,7 @@ let pageBands = (schema, obj, keys, itemsOf) => {
1102
1417
  */
1103
1418
  let traverse = (
1104
1419
  { add, bad, attempt, runNames },
1105
- { arr, obj, keys, named, expression, columnsOf, takeOf, foldsOf, sortOf },
1420
+ { arr, obj, keys, named, expression, columnsOf, takeOf, foldsOf, sortOf, stylesOf },
1106
1421
  { itemsOf },
1107
1422
  { bandOf },
1108
1423
  schema,
@@ -1122,6 +1437,7 @@ let traverse = (
1122
1437
  "take",
1123
1438
  "aggregates",
1124
1439
  "run",
1440
+ "style",
1125
1441
  "header",
1126
1442
  "empty",
1127
1443
  "columns",
@@ -1142,7 +1458,14 @@ let traverse = (
1142
1458
  let take = takeOf(schema.take, "take");
1143
1459
  let aggs = foldsOf(schema.aggregates, "aggregates", AGG, new Set(["params", "input"]));
1144
1460
  let running = foldsOf(schema.run, "run", RUN, null, runNames);
1145
- let header = itemsOf(schema.header, "header", "report-header");
1461
+ // The report default: one style block for the whole document, narrowed to
1462
+ // the declarations a typeface is made of. It resolves once per render in
1463
+ // report scope rather than per node, which is why it crosses the seam as a
1464
+ // `report-start` field instead of being merged into every item's style --
1465
+ // nothing here allocates per cell (docs/adr/0033).
1466
+ let style = stylesOf(schema, "", checkReportStyle);
1467
+ let { items: header, height: headerHeight } = reportHeaderOf(schema.header, keys, itemsOf, bad);
1468
+ refusePinnedLead(schema, headerHeight, bad);
1146
1469
  // Null when undeclared, so the render decides on the compiled band rather
1147
1470
  // than reading `schema.empty` a second time from outside the traversal.
1148
1471
  let empty = maybeItems(schema.empty, "empty", "empty", itemsOf);
@@ -1151,7 +1474,8 @@ let traverse = (
1151
1474
  let footer = itemsOf(schema.footer, "footer", "report-footer");
1152
1475
  // Page bands compile like any other band, but render per page, so the
1153
1476
  // stream hands paginated targets closures instead of events.
1154
- let { pageHeader, pageFooter } = pageBands(schema, obj, keys, itemsOf);
1477
+ let { pageHeader, pageFooter, margin } = pageBands(schema, obj, keys, itemsOf, bad);
1478
+ needPinnedMargin(headerHeight, schema, bad);
1155
1479
  return {
1156
1480
  select,
1157
1481
  params,
@@ -1160,13 +1484,16 @@ let traverse = (
1160
1484
  take,
1161
1485
  aggs,
1162
1486
  running,
1487
+ style,
1163
1488
  header,
1489
+ headerHeight,
1164
1490
  empty,
1165
1491
  columns,
1166
1492
  footer,
1167
1493
  band,
1168
1494
  pageHeader,
1169
1495
  pageFooter,
1496
+ margin,
1170
1497
  };
1171
1498
  };
1172
1499
 
@@ -1189,8 +1516,15 @@ export let plan = (schema, funcs, options) => {
1189
1516
  return {
1190
1517
  compiled: traverse(state, read, node, band, schema, options),
1191
1518
  problems: state.problems,
1519
+ anchors: state.anchors,
1192
1520
  names: state.names,
1193
- functions: state.functions,
1521
+ // Registry metadata comes from xprsn's one signature convention, described
1522
+ // over just the called subset — every called name resolved from the
1523
+ // registry at compile time, and the rebuilt object keeps the flat name
1524
+ // set's call-first-seen order.
1525
+ functions: signatures(
1526
+ Object.fromEntries(Array.from(state.functions, (name) => [name, state.FNS[name]])),
1527
+ ),
1194
1528
  diagnostic: state.diagnostic,
1195
1529
  };
1196
1530
  };