quario 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/plan.js CHANGED
@@ -43,9 +43,12 @@ import {
43
43
  relocate as relocateTemplate,
44
44
  template,
45
45
  } from "sjabloon";
46
+ import { pinGuard } from "./band-height.js";
46
47
  import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
47
48
  import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
48
- import { AGG, REDUCERS, RUN, aggregateValue, withRow } from "./scope.js";
49
+ import { MATH } from "./math.js";
50
+ import { REDUCERS, reducerFor } from "./reducers.js";
51
+ import { aggregateValue, withRow } from "./scope.js";
49
52
  import { opt, sniff } from "./stream.js";
50
53
  import {
51
54
  checkBorderSides,
@@ -54,8 +57,18 @@ import {
54
57
  checkReportStyle,
55
58
  checkRowStyle,
56
59
  checkSlotImageStyle,
60
+ checkItemStyle,
57
61
  checkSlotStyle,
58
62
  checkStyle,
63
+ boxReaching,
64
+ coveredNames,
65
+ fanBox,
66
+ guarded,
67
+ isBoxName,
68
+ isExpr,
69
+ positivePts,
70
+ resolveFormat,
71
+ SKIP,
59
72
  } from "./style.js";
60
73
 
61
74
  // Tolerance on the column-width sum. Widths are literal numbers an author may
@@ -67,13 +80,46 @@ let WIDTH_EPS = 1e-9;
67
80
  // plan rather than riding the state a plan accumulates.
68
81
  /** @type {any} */
69
82
  let NIL = () => null;
83
+ // The two halves a table row's `style` compiles to, and the pair a row that
84
+ // declares none wears.
85
+ /** @typedef {{ box: (scope: Scope) => any, names: string[], rest: (scope: Scope) => any }} RowStyles */
86
+ /** @typedef {{ styles: RowStyles, visible: any, cells: any[], fan: (string[] | null)[] }} TotalRow */
87
+ /** @typedef {{ visible: any, rows: readonly TotalRow[] }} TotalBlock */
88
+ // One declaration compiled: its name, what it resolves to, and whether that
89
+ // defers to render. The third is what lets two blocks compose without the
90
+ // documents they came from -- see `underRow`.
91
+ /** @typedef {[string, Eval, boolean]} StyleEntry */
92
+ /** @typedef {{ box: StyleEntry[], rest: StyleEntry[] }} RowEntries */
93
+ /** @type {RowEntries} */
94
+ let NO_ROW_ENTRIES = { box: [], rest: [] };
95
+ /** @type {RowStyles} */
96
+ let NO_ROW_STYLE = { box: NIL, names: [], rest: NIL };
97
+ // A cell that declares no readable style block: nothing to resolve, and no
98
+ // box name to keep any of its row's box off it.
99
+ let NO_CELL_LOOK = { styles: NIL, box: /** @type {string[]} */ ([]) };
100
+ // An absent `total` is the block with no rows. `rows: []` is a definition
101
+ // error, so an empty one can only have come from absence and nothing has to
102
+ // tell the two apart downstream.
103
+ /** @type {TotalBlock} */
104
+ let NO_TOTALS = Object.freeze({ visible: null, rows: Object.freeze([]) });
70
105
  /** @type {any} */
71
106
  let EMPTY = () => [];
72
107
  // What `$.params` reads when a report declares none.
73
108
  let NONE = Object.freeze({});
74
109
 
75
- /** @type {(value: any) => boolean} */
76
- let isExpr = (value) => typeof value === "string" && value[0] === "=";
110
+ // How many table columns one cell covers. A literal, for the same reason a
111
+ // column `width` is: the column geometry is allocated before any row is laid
112
+ // out, so it cannot depend on one. Absent is 1, and an unreadable span reads
113
+ // as 1 so the coverage walk above it reports where the author actually went
114
+ // wrong instead of cascading. `spanned` is the emit side: a span of 1 is the
115
+ // absence of one, and never reaches the stream.
116
+ /** @type {(span: any) => number} */
117
+ let spanValue = (span) => (Number.isInteger(span) && span >= 1 ? span : 1);
118
+ /** @type {(span: any) => boolean} */
119
+ let validSpan = (span) => span == null || spanValue(span) === span;
120
+ /** @type {(span: number) => number} */
121
+ let spanned = (span) => (span > 1 ? span : 0);
122
+
77
123
  /** @type {(from: Iterable<string>, into: Set<string>) => void} */
78
124
  let collect = (from, into) => {
79
125
  for (let name of from) into.add(name);
@@ -204,6 +250,7 @@ let jsonCopy = (value, path, bad, seen) => {
204
250
  /** @typedef {import("./scope.js").Scope} Scope */
205
251
  /** @typedef {import("./scope.js").Eval} Eval */
206
252
  /** @typedef {import("./scope.js").Fold} Fold */
253
+ /** @typedef {import("./reducers.js").Role} Role */
207
254
  /** @typedef {import("./scope.js").RunnerSet} RunnerSet */
208
255
  /** @typedef {(rows: any[], scope: Scope, runners: RunnerSet) => Generator<any>} Band */
209
256
  /** @typedef {ReturnType<typeof planState>} State */
@@ -240,7 +287,10 @@ let bindHandles = (schema, bound) => {
240
287
  */
241
288
  let planState = (schema, funcs) => {
242
289
  /** @type {Record<string, (...args: any[]) => any>} */
243
- let FNS = /** @type {any} */ ({ ...REDUCERS, ...funcs });
290
+ // Built-in functions first, the host's registry last: a `funcs` entry of the
291
+ // same name shadows a built-in, which is the rule SCHEMA.md states for the
292
+ // reducers and which the scalar built-ins join rather than complicate.
293
+ let FNS = /** @type {any} */ ({ ...REDUCERS, ...MATH, ...funcs });
244
294
  /** @type {Set<string>} */
245
295
  let names = new Set();
246
296
  /** @type {Set<string>} */
@@ -333,6 +383,10 @@ let planState = (schema, funcs) => {
333
383
  add,
334
384
  bad,
335
385
  attempt,
386
+ // The band-height rule, carried by the descent rather than walked
387
+ // separately: `traverse` arms it from the report header and offers it each
388
+ // band below (./band-height.js).
389
+ pin: pinGuard(bad),
336
390
  diagnostic: () => firstDiag,
337
391
  };
338
392
  };
@@ -382,10 +436,14 @@ let splitFold = (spec) => {
382
436
  if (colon < 0) return { fn: spec, src: "", colon };
383
437
  return { fn: spec.slice(0, colon), src: spec.slice(colon + 1), colon };
384
438
  };
385
- /** @type {(colon: number, path: string) => void} */
386
- let checkCount = (colon, path) => {
439
+ // The two spellings a reducer's declared form takes, one per `expr` flag on its
440
+ // registry entry — `checkBareFold` for a false one, `checkExprFold` for a true.
441
+ // Neither names a reducer: `count` is only the entry that happens to carry
442
+ // `expr: false` today, and another would be refused the same way.
443
+ /** @type {(colon: number, fn: string, path: string) => void} */
444
+ let checkBareFold = (colon, fn, path) => {
387
445
  // oxlint-disable-next-line no-unused-expressions
388
- colon < 0 || fault(path, "count does not take an expression");
446
+ colon < 0 || fault(path, fn + " does not take an expression");
389
447
  };
390
448
  /** @type {(src: string, fn: string, path: string) => void} */
391
449
  let checkExprFold = (src, fn, path) => {
@@ -427,15 +485,17 @@ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
427
485
  return runCell(tokens, path, source);
428
486
  };
429
487
 
430
- // Parse "reducer:=expr" into a reducer name and a compiled per-row expression
431
- // (null for `count`, which takes no expression).
432
- /** @type {(spec: string, path: string, allowed: Set<string>) => Fold} */
433
- let parseFold = (spec, path, allowed) => {
488
+ // Parse "reducer:=expr" into a reducer name and a compiled per-row expression,
489
+ // null where the entry's `expr` flag says the form carries none.
490
+ /** @type {(spec: string, path: string, role: Role) => Fold} */
491
+ let parseFold = (spec, path, role) => {
434
492
  let { fn, src, colon } = splitFold(spec);
435
- // oxlint-disable-next-line no-unused-expressions
436
- allowed.has(fn) || fault(path, 'unknown reducer "' + fn + '"');
437
- if (fn === "count") checkCount(colon, path);
438
- else checkExprFold(src, fn, path);
493
+ // The role decides the vocabulary: a reducer with no whole-array fold is a
494
+ // running value only, so `prev` is an unknown reducer under `aggregates`.
495
+ let reducer = reducerFor(role, fn);
496
+ if (!reducer) fault(path, 'unknown reducer "' + fn + '"');
497
+ if (reducer.expr) checkExprFold(src, fn, path);
498
+ else checkBareFold(colon, fn, path);
439
499
  return { fn, per: src ? prop(src, path) : null };
440
500
  };
441
501
 
@@ -451,7 +511,7 @@ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
451
511
  * @param {State} state
452
512
  * @param {Primitives} primitives
453
513
  */
454
- let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
514
+ let readers = ({ bad, attempt }, { prop, cell, parseFold }, /** @type {any} */ options) => {
455
515
  /** @type {(value: any, path: string) => boolean} */
456
516
  let obj = (value, path) => {
457
517
  if (record(value)) return true;
@@ -544,51 +604,152 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
544
604
  // CSS). An all-literal block folds to one frozen object at compile time.
545
605
  /**
546
606
  * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
547
- * => [string, Eval][]}
607
+ * => StyleEntry[]}
548
608
  */
549
609
  let readStyles = (block, path, check) => {
550
- /** @type {[string, Eval][]} */
610
+ /** @type {StyleEntry[]} */
551
611
  let entries = [];
552
612
  for (let [name, value] of Object.entries(block)) {
553
613
  let stylePath = path + "." + name;
554
614
  let problem = check(name, value);
555
615
  if (problem) bad(stylePath, problem);
556
- else entries.push([name, attempt(() => prop(value, stylePath), NIL)]);
616
+ else {
617
+ // A literal has already passed `check` above and can never fail at
618
+ // render, so only an `=` result is wrapped in the rule its name
619
+ // answers to. `style.js` owns which rule that is (docs/adr/0055).
620
+ let resolve = attempt(() => prop(value, stylePath), NIL);
621
+ let expr = isExpr(value);
622
+ entries.push([name, expr ? guarded(name, resolve) : resolve, expr]);
623
+ }
557
624
  }
558
625
  return entries;
559
626
  };
560
- /** @type {(entries: [string, Eval][]) => (scope: Scope) => Scope} */
627
+ // The names are written in declaration order, which the stream promises, so
628
+ // a guarded name that answers is written where it was declared rather than
629
+ // after the ones that never needed guarding.
630
+ //
631
+ // `format` resolves after the loop rather than inside it, and the order is
632
+ // what makes that necessary: a `currency` kind takes its digit count from
633
+ // the code the cell wears, so the code has to have been written -- or
634
+ // voided by `guarded` -- before the kind can be resolved over it. Doing it
635
+ // per entry would make one declaration depend on where another sits in the
636
+ // block. The instance's options come with it, for the cell that declares no
637
+ // code of its own and takes the instance's (docs/adr/0056).
638
+ /** @type {(entries: StyleEntry[]) => (scope: Scope) => Scope} */
561
639
  let styleFn = (entries) => (scope) => {
562
640
  /** @type {Scope} */
563
641
  let style = {};
564
- for (let [name, value] of entries) style[name] = value(scope);
565
- return style;
642
+ for (let [name, value] of entries) {
643
+ let resolved = value(scope);
644
+ if (resolved !== SKIP) style[name] = resolved;
645
+ }
646
+ return resolveFormat(style, options);
566
647
  };
567
- /** @type {(block: any) => boolean} */
568
- let isComputed = (block) => Object.values(block).some(isExpr);
569
648
  /**
570
649
  * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
571
650
  * => (scope: Scope) => any}
572
651
  */
573
- let compileStyles = (block, path, check) => {
574
- let entries = readStyles(block, path, check);
575
- for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
652
+ // A compiled block, constant-folded when nothing in it defers to render, so
653
+ // a literal style is resolved once and every node that wears it is handed
654
+ // the same frozen object.
655
+ /** @type {(entries: StyleEntry[]) => (scope: Scope) => any} */
656
+ let folded = (entries) => {
576
657
  if (!entries.length) return NIL;
577
658
  let resolve = styleFn(entries);
578
- if (isComputed(block)) return resolve;
659
+ if (entries.some((entry) => entry[2])) return resolve;
579
660
  let constant = Object.freeze(resolve({}));
580
661
  return () => constant;
581
662
  };
582
663
  /**
583
- * @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
664
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
665
+ * => StyleEntry[]}
666
+ */
667
+ let styleEntries = (block, path, check) => {
668
+ let entries = readStyles(block, path, check);
669
+ for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
670
+ return entries;
671
+ };
672
+ /**
673
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
584
674
  * => (scope: Scope) => any}
585
675
  */
586
- let stylesOf = (def, path, check = checkStyle) => {
676
+ // A table row's block is split where it is compiled rather than at every
677
+ // row: its box half resolves onto the row's cells and the rest stays on the
678
+ // row (SCHEMA.md, "Style declarations"). Both halves fold like any other
679
+ // block, so a literal row style is partitioned once per table.
680
+ /** @type {(def: any, path: string) => RowEntries} */
681
+ let rowEntriesOf = (def, path) => {
682
+ let block = def.style;
683
+ let stylePath = pathTo(path, "style");
684
+ if (block == null || !obj(block, stylePath)) return NO_ROW_ENTRIES;
685
+ let entries = styleEntries(block, stylePath, checkRowStyle);
686
+ return {
687
+ box: entries.filter(([name]) => isBoxName(name)),
688
+ rest: entries.filter(([name]) => !isBoxName(name)),
689
+ };
690
+ };
691
+ /**
692
+ * One row block under another: the totals block's under a total row's. The
693
+ * row's names win, and its box takes any border side it speaks for whole --
694
+ * the same rule a cell applies over a row, settled here on the names rather
695
+ * than at render on the values, so the block leaves no third level behind
696
+ * (docs/adr/0049). A row that composes to all-literal folds to one frozen
697
+ * object like any other block.
698
+ *
699
+ * @type {(under: RowEntries, over: RowEntries) => RowEntries}
700
+ */
701
+ let underRow = (under, over) => {
702
+ if (!under.box.length && !under.rest.length) return over;
703
+ let box = coveredNames(over.box.map(([name]) => name));
704
+ let rest = new Set(over.rest.map(([name]) => name));
705
+ return {
706
+ box: [...under.box.filter(([name]) => !box.has(name)), ...over.box],
707
+ rest: [...under.rest.filter(([name]) => !rest.has(name)), ...over.rest],
708
+ };
709
+ };
710
+ /** @type {(entries: RowEntries) => RowStyles} */
711
+ let foldRow = ({ box, rest }) => ({
712
+ box: folded(box),
713
+ names: box.map(([name]) => name),
714
+ rest: folded(rest),
715
+ });
716
+ // The entries a node's `style` compiles to, or none when it declares no
717
+ // readable block. Every caller folds them; a table cell keeps their box
718
+ // names first.
719
+ /**
720
+ * @type {(def: any, path: string, check: (name: string, value: any) => string | null)
721
+ * => StyleEntry[] | null}
722
+ */
723
+ let entriesOf = (def, path, check) => {
587
724
  let block = def.style;
588
725
  let stylePath = pathTo(path, "style");
589
- if (block == null || !obj(block, stylePath)) return NIL;
590
- return compileStyles(block, stylePath, check);
726
+ if (block == null || !obj(block, stylePath)) return null;
727
+ return styleEntries(block, stylePath, check);
591
728
  };
729
+ /**
730
+ * @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
731
+ * => (scope: Scope) => any}
732
+ */
733
+ let stylesOf = (def, path, check = checkStyle) => {
734
+ let entries = entriesOf(def, path, check);
735
+ return entries ? folded(entries) : NIL;
736
+ };
737
+ // A cell's compiled style with the box names it declared kept beside it.
738
+ // Those names are what settles which of its row's box reaches it -- the
739
+ // question `layer` used to ask of the resolved object at render, asked once
740
+ // here instead (docs/adr/0056).
741
+ /** @type {(def: any, path: string) => { styles: (scope: Scope) => any, box: string[] }} */
742
+ let cellLook = (def, path) => {
743
+ let entries = entriesOf(def, path, checkCellStyle);
744
+ if (!entries) return NO_CELL_LOOK;
745
+ return {
746
+ styles: folded(entries),
747
+ box: entries.filter(([name]) => isBoxName(name)).map(([name]) => name),
748
+ };
749
+ };
750
+ /** The same, for the blocks that sit on a table row. */
751
+ /** @type {(def: any, path: string) => RowStyles} */
752
+ let rowStylesOf = (def, path) => foldRow(rowEntriesOf(def, path));
592
753
  // A cell value: one sjabloon template. Emphasis is the cell's own `style`;
593
754
  // markup in literal text is meaningful only to the HTML target.
594
755
  /** @type {(value: any, path: string) => (scope: Scope, opts?: any) => any} */
@@ -603,43 +764,43 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
603
764
  if (seen.has(name)) bad(foldPath, 'duplicate run name "' + name + '"');
604
765
  else seen.add(name);
605
766
  };
606
- /** @type {(spec: any, foldPath: string, allowed: Set<string>) => Fold | null} */
607
- let readFold = (spec, foldPath, allowed) => {
767
+ /** @type {(spec: any, foldPath: string, role: Role) => Fold | null} */
768
+ let readFold = (spec, foldPath, role) => {
608
769
  if (typeof spec !== "string") {
609
770
  bad(foldPath, "expected a reducer specification");
610
771
  return null;
611
772
  }
612
- return attempt(() => parseFold(spec, foldPath, allowed), null);
773
+ return attempt(() => parseFold(spec, foldPath, role), null);
613
774
  };
614
- /** @type {(name: string, spec: any, path: string, allowed: Set<string>, reserved: Set<string> | null, seen?: Set<string>) => [string, Fold] | null} */
615
- let oneFold = (name, spec, path, allowed, reserved, seen) => {
775
+ /** @type {(name: string, spec: any, path: string, role: Role, reserved: Set<string> | null, seen?: Set<string>) => [string, Fold] | null} */
776
+ let oneFold = (name, spec, path, role, reserved, seen) => {
616
777
  let foldPath = path + "." + name;
617
778
  named(name, foldPath, reserved);
618
779
  noteRun(seen, name, foldPath);
619
- let fold = readFold(spec, foldPath, allowed);
780
+ let fold = readFold(spec, foldPath, role);
620
781
  return fold && [name, fold];
621
782
  };
622
783
  /**
623
- * @type {(value: any, path: string, allowed: Set<string>,
784
+ * @type {(value: any, path: string, role: Role,
624
785
  * reserved: Set<string> | null, seen?: Set<string>) => [string, Fold][]}
625
786
  */
626
- let readFolds = (value, path, allowed, reserved, seen) => {
787
+ let readFolds = (value, path, role, reserved, seen) => {
627
788
  /** @type {[string, Fold][]} */
628
789
  let out = [];
629
790
  for (let [name, spec] of Object.entries(value)) {
630
- let fold = oneFold(name, spec, path, allowed, reserved, seen);
791
+ let fold = oneFold(name, spec, path, role, reserved, seen);
631
792
  if (fold) out.push(fold);
632
793
  }
633
794
  return out;
634
795
  };
635
796
  // Declared aggregates and running values, both spelled "reducer:=expr".
636
797
  /**
637
- * @type {(value: any, path: string, allowed: Set<string>,
798
+ * @type {(value: any, path: string, role: Role,
638
799
  * reserved: Set<string> | null, seen?: Set<string>) => [string, Fold][]}
639
800
  */
640
- let foldsOf = (value, path, allowed, reserved, seen) => {
801
+ let foldsOf = (value, path, role, reserved, seen) => {
641
802
  if (value == null || !obj(value, path)) return [];
642
- return readFolds(value, path, allowed, reserved, seen);
803
+ return readFolds(value, path, role, reserved, seen);
643
804
  };
644
805
  /** @type {(dir: any, path: string) => void} */
645
806
  let checkDir = (dir, path) => {
@@ -703,6 +864,11 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
703
864
  columnsOf,
704
865
  takeOf,
705
866
  stylesOf,
867
+ cellLook,
868
+ rowStylesOf,
869
+ rowEntriesOf,
870
+ underRow,
871
+ foldRow,
706
872
  cellValue,
707
873
  foldsOf,
708
874
  sortOf,
@@ -718,7 +884,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
718
884
  * @param {State} state
719
885
  * @param {Readers} readers
720
886
  */
721
- let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, stylesOf }) => {
887
+ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, stylesOf, cellLook }) => {
722
888
  // `tokens` plus its truthy `style`. A hidden cell keeps its slot with no
723
889
  // tokens so a column keeps its alignment; items pass `visible` null and drop
724
890
  // themselves instead, having no slot to keep. The anchor story lives on
@@ -731,24 +897,65 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
731
897
  opt({ tokens: hidden(visible, scope) ? [] : tpl(scope) }, { style: styles(scope) });
732
898
 
733
899
  // A table cell: the shape of a total cell and of an object column header.
734
- /** @type {(def: any, path: string, valuePath?: string) => (scope: Scope) => any} */
900
+ /**
901
+ * @type {(def: any, path: string, valuePath?: string) =>
902
+ * { cell: (scope: Scope) => any, box: string[] }}
903
+ */
735
904
  let cellOf = (def, path, valuePath = path + ".value") => {
736
905
  let tpl = cellValue(def.value, valuePath);
737
906
  let visible = visibleOf(def, path);
738
- return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
907
+ let look = cellLook(def, path);
908
+ return { cell: cellShape(tpl, visible, look.styles), box: look.box };
909
+ };
910
+
911
+ /** @type {(def: any, path: string) => number} */
912
+ let spanOf = (def, path) => {
913
+ if (validSpan(def.span)) return spanValue(def.span);
914
+ bad(path + ".span", "expected a positive integer (span >= 1)");
915
+ return 1;
739
916
  };
740
917
 
741
918
  // A cell without a row, so `@` stays unbound. A bare string is shorthand for
742
919
  // `{ value }` and keeps its authored path, so problems locate where written.
743
- /** @type {(def: any, path: string) => (scope: Scope) => any} */
920
+ // A span needs the object form -- a bare string has nowhere to carry one.
921
+ // Absent is a problem here: a column that legitimately declares no header is
922
+ // one a neighbour's span covers, and `headerSlot` answers that before this
923
+ // is reached.
924
+ /**
925
+ * @type {(def: any, path: string) =>
926
+ * { cell: (scope: Scope) => any, span: number, box: string[] }}
927
+ */
744
928
  let headerOf = (def, path) => {
745
- if (typeof def === "string") return cellShape(cellValue(def, path), null, NIL);
929
+ if (typeof def === "string")
930
+ return { cell: cellShape(cellValue(def, path), null, NIL), span: 1, box: NO_CELL_LOOK.box };
746
931
  if (!record(def)) {
747
932
  bad(path, "expected a template string or object");
748
- return cellShape(EMPTY, null, NIL);
933
+ return { cell: cellShape(EMPTY, null, NIL), span: 1, box: NO_CELL_LOOK.box };
749
934
  }
750
- keys(def, ["value", "style"], path);
751
- return cellOf(def, path);
935
+ keys(def, ["value", "style", "span"], path);
936
+ return { ...cellOf(def, path), span: spanOf(def, path) };
937
+ };
938
+
939
+ // A column a neighbour's span covers declares no header of its own. One that
940
+ // declares one anyway is the overlap half of the coverage rule.
941
+ /** @type {(def: any, path: string) => null} */
942
+ let covered = (def, path) => {
943
+ if (def != null) bad(path, "covered by an earlier header's span");
944
+ return null;
945
+ };
946
+ // The per-column half of the coverage rule. `headerCover` decides which slot
947
+ // a column is in; this reports it, in the column's own turn, so header
948
+ // problems still come out in the documented key order.
949
+ /**
950
+ * @type {(def: any, path: string, slot: string) =>
951
+ * { cell: (scope: Scope) => any, span: number, box: string[] } | null}
952
+ */
953
+ let headerSlot = (def, path, slot) => {
954
+ if (slot === "unreached") return null;
955
+ if (slot === "covered") return covered(def, path);
956
+ let header = headerOf(def, path);
957
+ if (slot === "over") bad(path + ".span", "reaches past the last column");
958
+ return header;
752
959
  };
753
960
 
754
961
  /** @type {(width: any) => boolean} */
@@ -759,25 +966,34 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
759
966
  bad(path + ".width", "expected a percentage number (0 < width <= 100)");
760
967
  return NaN;
761
968
  };
762
- /** @type {(def: any, path: string) => any} */
763
- let columnOf = (def, path) => {
969
+ /** @type {(def: any, path: string, slot: string) => any} */
970
+ let columnOf = (def, path, slot) => {
764
971
  if (!obj(def, path)) return null;
765
972
  keys(def, ["header", "value", "visible", "style", "width"], path);
766
- let header = headerOf(def.header, path + ".header");
973
+ let header = headerSlot(def.header, path + ".header", slot);
767
974
  let tpl = cellValue(def.value, path + ".value");
768
975
  let visible = visibleOf(def, path);
769
- let styles = stylesOf(def, path, checkCellStyle);
976
+ let look = cellLook(def, path);
770
977
  // A width is a percentage of the table width, resolved by every target.
771
978
  // The range verdict is owned here: an unreadable width comes back as NaN,
772
979
  // so the total check below reads the verdict instead of re-deciding it.
773
- return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
980
+ return {
981
+ header,
982
+ cell: cellShape(tpl, visible, look.styles),
983
+ box: look.box,
984
+ width: widthOf(def.width, path),
985
+ path,
986
+ };
774
987
  };
775
988
 
776
- /** @type {(def: any, path: string) => ((scope: Scope) => any) | null} */
989
+ /**
990
+ * @type {(def: any, path: string) =>
991
+ * { cell: (scope: Scope) => any, span: number, box: string[] } | null}
992
+ */
777
993
  let totalOf = (def, path) => {
778
994
  if (!obj(def, path)) return null;
779
- keys(def, ["value", "visible", "style"], path);
780
- return cellOf(def, path);
995
+ keys(def, ["value", "visible", "style", "span"], path);
996
+ return { ...cellOf(def, path), span: spanOf(def, path) };
781
997
  };
782
998
 
783
999
  // A text item: a cell with no slot to keep, so a hidden one drops itself
@@ -793,7 +1009,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
793
1009
  let shape = cellShape(
794
1010
  tpl,
795
1011
  inSlot ? visible : null,
796
- stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
1012
+ stylesOf(def, path, inSlot ? checkSlotStyle : checkItemStyle),
797
1013
  );
798
1014
  // The cell shape first, then the item's own fields, so a cell reads
799
1015
  // the same wherever it appears in the stream.
@@ -843,7 +1059,14 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
843
1059
  TypeError("expected a Uint8Array of PNG or JPEG bytes"),
844
1060
  );
845
1061
  return opt(
846
- { type: "image", role: fields.role, bytes, format, fit: fields.fit, path: fields.path },
1062
+ {
1063
+ type: "image",
1064
+ role: fields.role,
1065
+ bytes,
1066
+ format,
1067
+ fit: fields.fit,
1068
+ path: fields.path,
1069
+ },
847
1070
  { alt: imageAlt(fields.alt, scope), style: fields.styles(scope) },
848
1071
  );
849
1072
  };
@@ -941,7 +1164,9 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
941
1164
  );
942
1165
  return (scope) => {
943
1166
  if (hidden(visible, scope)) return null;
944
- let out = [opt({ type: "split-start", role, slots: geometry }, { style: styles(scope) })];
1167
+ let out = [
1168
+ opt({ type: "split-start", role, path, slots: geometry }, { style: styles(scope) }),
1169
+ ];
945
1170
  for (let slot of slots) out.push(slot.render(scope));
946
1171
  out.push({ type: "split-end" });
947
1172
  return out;
@@ -997,8 +1222,23 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
997
1222
  * @param {Nodes} nodes
998
1223
  */
999
1224
  let bands = (
1000
- { bad, runNames },
1001
- { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, stylesOf, foldsOf, sortOf },
1225
+ { bad, pin, runNames },
1226
+ {
1227
+ arr,
1228
+ obj,
1229
+ keys,
1230
+ named,
1231
+ expression,
1232
+ visibleOf,
1233
+ columnsOf,
1234
+ takeOf,
1235
+ rowStylesOf,
1236
+ rowEntriesOf,
1237
+ underRow,
1238
+ foldRow,
1239
+ foldsOf,
1240
+ sortOf,
1241
+ },
1002
1242
  { columnOf, itemsOf, totalOf },
1003
1243
  ) => {
1004
1244
  // Stacked items, one pass per row.
@@ -1014,30 +1254,71 @@ let bands = (
1014
1254
  }
1015
1255
  };
1016
1256
 
1017
- /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: (scope: Scope) => any }} */
1257
+ /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: RowStyles }} */
1018
1258
  let rowLook = (value) => {
1019
1259
  if (value.row == null || !obj(value.row, "detail.row"))
1020
- return { rowVisible: null, rowStyles: NIL };
1260
+ return { rowVisible: null, rowStyles: NO_ROW_STYLE };
1021
1261
  keys(value.row, ["visible", "style"], "detail.row");
1022
1262
  return {
1023
1263
  rowVisible: visibleOf(value.row, "detail.row"),
1024
- rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
1264
+ rowStyles: rowStylesOf(value.row, "detail.row"),
1025
1265
  };
1026
1266
  };
1027
1267
  // The header-row box: `style` only, a different path from each column's
1028
1268
  // `header` cell. Absent when the table declares none.
1029
- /** @type {(value: any) => (scope: Scope) => any} */
1269
+ /** @type {(value: any) => RowStyles} */
1030
1270
  let headerLook = (value) => {
1031
- if (value.header == null || !obj(value.header, "detail.header")) return NIL;
1271
+ if (value.header == null || !obj(value.header, "detail.header")) return NO_ROW_STYLE;
1032
1272
  keys(value.header, ["style"], "detail.header");
1033
- return stylesOf(value.header, "detail.header", checkRowStyle);
1273
+ return rowStylesOf(value.header, "detail.header");
1274
+ };
1275
+ // Which slot each column's header sits in -- the same invariant a total row's
1276
+ // cells carry, walked positionally rather than summed because the diagnostic
1277
+ // has to name the column it broke at. Read off the raw definitions, before
1278
+ // any column is parsed, so `columnOf` can report its verdict in the column's
1279
+ // own turn. Spans are read leniently: an unreadable one covers a single
1280
+ // column and gets its own diagnostic when the column is parsed.
1281
+ //
1282
+ // `own` fills its own slot, `covered` is filled by a neighbour's span, `over`
1283
+ // reaches past the last column, and everything after an `over` is
1284
+ // `unreached` -- one broken span should not also report every column behind
1285
+ // it as headerless.
1286
+ // How many columns a definition's header claims, read leniently: a malformed
1287
+ // column, a missing header and an unreadable span all claim one here, and
1288
+ // each gets its own diagnostic when the column is parsed.
1289
+ /** @type {(def: any) => number} */
1290
+ let headerSpan = (def) => {
1291
+ let header = record(def) ? def.header : null;
1292
+ return record(header) ? spanValue(header.span) : 1;
1293
+ };
1294
+ /** @type {(slots: string[], from: number, to: number, slot: string) => void} */
1295
+ let fill = (slots, from, to, slot) => {
1296
+ for (let at = from; at < to; at++) slots[at] = slot;
1297
+ };
1298
+ /** @type {(defs: any[]) => string[]} */
1299
+ let headerCover = (defs) => {
1300
+ let slots = defs.map(() => "own");
1301
+ let at = 0;
1302
+ while (at < defs.length) {
1303
+ let span = headerSpan(defs[at]);
1304
+ if (at + span > defs.length) {
1305
+ slots[at] = "over";
1306
+ fill(slots, at + 1, defs.length, "unreached");
1307
+ return slots;
1308
+ }
1309
+ fill(slots, at + 1, at + span, "covered");
1310
+ at += span;
1311
+ }
1312
+ return slots;
1034
1313
  };
1035
1314
  /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
1036
1315
  let tableColumns = (value) => {
1037
1316
  let defs = arr(value.columns, "detail.columns", true);
1038
- if (defs && !defs.length) bad("detail.columns", "expected at least one column");
1039
- let columns = (defs || [])
1040
- .map((def, i) => columnOf(def, "detail.columns[" + i + "]"))
1317
+ let list = defs || [];
1318
+ if (defs && !list.length) bad("detail.columns", "expected at least one column");
1319
+ let slots = headerCover(list);
1320
+ let columns = list
1321
+ .map((def, i) => columnOf(def, "detail.columns[" + i + "]", slots[i]))
1041
1322
  .filter((column) => column != null);
1042
1323
  checkShares(bad, columns, "detail.columns", "column");
1043
1324
  return { defs, columns };
@@ -1047,100 +1328,134 @@ let bands = (
1047
1328
  totals
1048
1329
  .map((def, i) => {
1049
1330
  let path = prefix + "[" + i + "]";
1050
- let cell = totalOf(def, path);
1051
- return cell && { cell, path };
1331
+ let total = totalOf(def, path);
1332
+ return total && { cell: total.cell, span: total.span, box: total.box, path };
1052
1333
  })
1053
1334
  .filter((entry) => entry != null);
1054
1335
  /** @type {(rows: any[] | null) => any[]} */
1055
1336
  let nonempty = (rows) => {
1056
- if (rows && !rows.length) bad("detail.total", "expected at least one total row");
1337
+ if (rows && !rows.length) bad("detail.total.rows", "expected at least one total row");
1057
1338
  return rows || [];
1058
1339
  };
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
- );
1340
+ // The totals block is the one place a table's rows are authored as a list,
1341
+ // so it is also the one that needs a level above them to say what belongs to
1342
+ // the block rather than to a row: its `style` and `visible` are the header
1343
+ // row's, one level out (docs/adr/0049).
1344
+ /** @type {(value: any, defs: any[] | null) => TotalBlock} */
1345
+ let tableTotals = (value, defs) => {
1346
+ if (value.total == null || !obj(value.total, "detail.total")) return NO_TOTALS;
1347
+ keys(value.total, ["style", "visible", "rows"], "detail.total");
1348
+ let block = rowEntriesOf(value.total, "detail.total");
1349
+ return {
1350
+ visible: visibleOf(value.total, "detail.total"),
1351
+ rows: nonempty(arr(value.total.rows, "detail.total.rows", true)).map((def, r) =>
1352
+ totalRowOf(def, "detail.total.rows[" + r + "]", defs, block),
1353
+ ),
1354
+ };
1355
+ };
1356
+ // Read leniently: a cell whose span is unreadable, or which is not an object
1357
+ // at all, covers one column here, and its own diagnostic comes from parsing
1358
+ // it. Otherwise one mistake would report twice.
1069
1359
  /** @type {(cells: any[], defs: any[] | null) => boolean} */
1070
- let aligned = (cells, defs) => !defs || cells.length === defs.length;
1360
+ let covering = (cells, defs) =>
1361
+ !defs ||
1362
+ cells.reduce((all, cell) => all + spanValue(record(cell) ? cell.span : null), 0) ===
1363
+ defs.length;
1071
1364
  /** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
1072
1365
  let counted = (cells, defs, path) => {
1073
1366
  if (!cells) return [];
1074
- if (!aligned(cells, defs)) bad(path + ".cells", "expected one cell per column");
1367
+ if (!covering(cells, defs)) bad(path + ".cells", "expected cells covering every column");
1075
1368
  return cells;
1076
1369
  };
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: [] };
1370
+ /** @type {(def: any, path: string, defs: any[] | null, block: RowEntries) => TotalRow} */
1371
+ let totalRowOf = (def, path, defs, block) => {
1372
+ if (!obj(def, path)) return { styles: foldRow(block), visible: null, cells: [], fan: [] };
1083
1373
  keys(def, ["cells", "style", "visible"], path);
1084
1374
  let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
1375
+ let styles = foldRow(underRow(block, rowEntriesOf(def, path)));
1376
+ let mapped = mapTotals(cells, path + ".cells");
1085
1377
  return {
1086
- styles: stylesOf(def, path, checkRowStyle),
1378
+ styles,
1087
1379
  visible: visibleOf(def, path),
1088
- cells: mapTotals(cells, path + ".cells"),
1380
+ cells: mapped,
1381
+ fan: mapped.map((entry) => boxReaching(styles.names, entry.box)),
1089
1382
  };
1090
1383
  };
1091
1384
  /**
1092
1385
  * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
1093
- * rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Generator<any>}
1386
+ * rowVisible: Eval | null, rowStyles: RowStyles,
1387
+ * fan: (string[] | null)[]) => Generator<any>}
1094
1388
  */
1095
- function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles) {
1389
+ function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles, fan) {
1096
1390
  for (let row of rows) {
1097
1391
  let scopeOfRow = runners.bind(scope, row);
1098
- if (!hidden(rowVisible, scopeOfRow))
1099
- yield opt(
1100
- {
1101
- type: "row",
1102
- cells: columns.map((column) =>
1103
- Object.assign(column.cell(scopeOfRow), { path: column.path }),
1104
- ),
1105
- },
1106
- { style: rowStyles(scopeOfRow), run: scopeOfRow.run },
1107
- );
1392
+ if (hidden(rowVisible, scopeOfRow)) continue;
1393
+ let cells = columns.map((column) =>
1394
+ Object.assign(column.cell(scopeOfRow), { path: column.path }),
1395
+ );
1396
+ fanBox(rowStyles.box(scopeOfRow), cells, fan);
1397
+ yield opt({ type: "row", cells }, { style: rowStyles.rest(scopeOfRow), run: scopeOfRow.run });
1108
1398
  }
1109
1399
  }
1110
- /**
1111
- * @type {(total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null,
1112
- * scope: Scope) => Generator<any>}
1113
- */
1400
+ // One cell that may cover several columns: its value against this scope, the
1401
+ // path naming the definition behind it, and a `span` only when it covers more
1402
+ // than one. The header row and the total rows are both authored as cells like
1403
+ // this; a data row's are the columns' own and cannot span (SCHEMA.md,
1404
+ // "Span"), which is why `tableRows` builds its own without a span at all.
1405
+ /** @type {(cell: any, span: any, path: string, scope: Scope) => any} */
1406
+ let spanCell = (cell, span, path, scope) =>
1407
+ opt(Object.assign(cell(scope), { path }), { span: spanned(span) });
1408
+ // One emitted total row. Its `styles` already carry the totals block's,
1409
+ // composed in at compile time, so this is a row like any other.
1410
+ /** @type {(row: TotalRow, scope: Scope) => any} */
1411
+ let totalRow = (row, scope) => {
1412
+ let cells = row.cells.map(({ cell, span, path }) => spanCell(cell, span, path, scope));
1413
+ fanBox(row.styles.box(scope), cells, row.fan);
1414
+ return opt({ type: "total-row", cells }, { style: row.styles.rest(scope) });
1415
+ };
1416
+ // A hidden block is the whole thing gone -- its rows are never reached, so
1417
+ // nothing is evaluated and nothing votes on a column width, exactly as an
1418
+ // absent `total` behaves.
1419
+ /** @type {(total: TotalBlock, scope: Scope) => Generator<any>} */
1114
1420
  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
- }
1421
+ if (hidden(total.visible, scope)) return;
1422
+ for (let row of total.rows) if (!hidden(row.visible, scope)) yield totalRow(row, scope);
1126
1423
  }
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) =>
1129
- function* (rows, scope, runners) {
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) },
1424
+ /** @type {(columns: any[], total: TotalBlock, rowVisible: Eval | null, rowStyles: RowStyles, headerStyles: RowStyles) => Band} */
1425
+ let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) => {
1426
+ // Which of each row box reaches which cell, settled once per table over
1427
+ // the names both sides declared -- so nothing at render reads a resolved
1428
+ // object to find out who owns a border side (docs/adr/0056).
1429
+ let rowFan = columns.map((column) => boxReaching(rowStyles.names, column.box));
1430
+ let headers = columns.filter((column) => column.header);
1431
+ let headerFan = headers.map((column) => boxReaching(headerStyles.names, column.header.box));
1432
+ return function* (rows, scope, runners) {
1433
+ // The header row, built once here rather than reconstructed by every
1434
+ // consumer from a per-column `header` (docs/adr/0053). A covered column
1435
+ // declares none, so this is fewer cells than columns whenever one spans
1436
+ // -- and a header cell carries its column's `path`, as a row cell does,
1437
+ // so every rendered cell can name the definition behind it.
1438
+ let cells = headers.map((column) =>
1439
+ spanCell(column.header.cell, column.header.span, column.path, scope),
1139
1440
  );
1140
- yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
1441
+ // The header row's box lands on its cells, exactly as a data row's does,
1442
+ // which is why what is left of the block rides beside them.
1443
+ fanBox(headerStyles.box(scope), cells, headerFan);
1444
+ // `columns` is what is left once the header row is its own: the
1445
+ // geometry, one entry per column, carrying a `width` share when the
1446
+ // document authored one.
1447
+ let cols = columns.map((column) => opt({}, { width: column.width }));
1448
+ yield {
1449
+ type: "table-start",
1450
+ path: "detail",
1451
+ header: opt({ cells }, { style: headerStyles.rest(scope) }),
1452
+ columns: cols,
1453
+ };
1454
+ yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles, rowFan);
1141
1455
  yield* emitTotals(total, scope);
1142
1456
  yield { type: "table-end" };
1143
1457
  };
1458
+ };
1144
1459
  /** @type {(value: any) => Band} */
1145
1460
  let tableOf = (value) => {
1146
1461
  keys(value, ["header", "row", "columns", "total"], "detail");
@@ -1154,6 +1469,7 @@ let bands = (
1154
1469
  // `{ columns, total }` object is a real table.
1155
1470
  /** @type {(value: any) => Band} */
1156
1471
  let detailOf = (value) => {
1472
+ pin.body(value, "detail");
1157
1473
  if (Array.isArray(value) || value == null) return itemsBand(itemsOf(value, "detail", "detail"));
1158
1474
  if (!record(value)) {
1159
1475
  bad("detail", "expected an array of items or a table object");
@@ -1207,7 +1523,11 @@ let bands = (
1207
1523
  }
1208
1524
  groupScope[ctx.name] = handle;
1209
1525
  let rows = (ctx.sort ? ctx.sort(groupRows, groupScope) : groupRows).slice(0, ctx.take);
1210
- return { groupScope, aggregates: applyAggs(ctx.aggs, rows, groupScope, handle), rows };
1526
+ return {
1527
+ groupScope,
1528
+ aggregates: applyAggs(ctx.aggs, rows, groupScope, handle),
1529
+ rows,
1530
+ };
1211
1531
  };
1212
1532
  /** @type {(ctx: any) => Band} */
1213
1533
  let groupWalk = (ctx) =>
@@ -1262,9 +1582,9 @@ let bands = (
1262
1582
  by,
1263
1583
  sort: sortOf(def.sort, path + ".sort"),
1264
1584
  take: takeOf(def.take, path + ".take"),
1265
- aggs: foldsOf(def.aggregates, path + ".aggregates", AGG, new Set(["key"])),
1266
- running: foldsOf(def.run, path + ".run", RUN, null, runNames),
1267
- header: itemsOf(def.header, path + ".header", "group-header"),
1585
+ aggs: foldsOf(def.aggregates, path + ".aggregates", "aggregate", new Set(["key"])),
1586
+ running: foldsOf(def.run, path + ".run", "run", null, runNames),
1587
+ header: pinBand(def.header, path + ".header"),
1268
1588
  footer: itemsOf(def.footer, path + ".footer", "group-footer"),
1269
1589
  inner: bandOf(defs, index + 1, detail, columned || columns != null),
1270
1590
  name: def.name,
@@ -1276,6 +1596,15 @@ let bands = (
1276
1596
  });
1277
1597
  };
1278
1598
 
1599
+ // A group's header, offered to the band-height guard on the way past: the
1600
+ // chain compiles outermost first, so the first one that occupies is the band
1601
+ // under a pinned box (./band-height.js).
1602
+ /** @type {(value: any, path: string) => (scope: Scope) => Generator<any>} */
1603
+ let pinBand = (value, path) => {
1604
+ pin.body(value, path);
1605
+ return itemsOf(value, path, "group-header");
1606
+ };
1607
+
1279
1608
  // The band chain, outermost group first: each group partitions its rows and
1280
1609
  // recurses; the detail band sits at the leaf.
1281
1610
  /** @type {(defs: any[], index: number, detail: any, columned?: boolean) => Band} */
@@ -1319,25 +1648,22 @@ let selectOf = (schema, bad, attempt, options) => {
1319
1648
  };
1320
1649
  /** @type {(value: any, path: string, expression: any) => any} */
1321
1650
  let maybeExpr = (value, path, expression) => (value != null ? expression(value, path) : null);
1322
- /** @type {(n: any) => boolean} */
1323
- let posPts = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
1651
+
1324
1652
  /** @type {(n: any) => boolean} */
1325
1653
  let padPts = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
1326
1654
  /** @type {(height: any, bad: any) => number | null} */
1327
1655
  let headerHeightOf = (height, bad) => {
1328
1656
  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;
1657
+ else if (!positivePts(height)) bad("header.height", "expected a positive number of points");
1658
+ return positivePts(height) ? height : null;
1331
1659
  };
1332
- /** @type {(itemsOf: any) => { items: any, height: number | null }} */
1333
- let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
1334
1660
  /** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
1335
1661
  let reportHeaderOf = (value, keys, itemsOf, bad) => {
1336
1662
  if (value == null || Array.isArray(value))
1337
1663
  return { items: itemsOf(value, "header", "report-header"), height: null };
1338
1664
  if (!record(value)) {
1339
1665
  bad("header", "expected an array of items or { height, items }");
1340
- return emptyHeader(itemsOf);
1666
+ return { items: itemsOf(null, "header", "report-header"), height: null };
1341
1667
  }
1342
1668
  keys(value, ["height", "items"], "header");
1343
1669
  return {
@@ -1364,36 +1690,6 @@ let pageBands = (schema, obj, keys, itemsOf, bad) => {
1364
1690
  };
1365
1691
  };
1366
1692
 
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
1693
  /** @type {(headerHeight: number | null, schema: any, bad: any) => void} */
1398
1694
  let needPinnedMargin = (headerHeight, schema, bad) => {
1399
1695
  if (headerHeight != null && schema.page?.margin == null)
@@ -1416,7 +1712,7 @@ let needPinnedMargin = (headerHeight, schema, bad) => {
1416
1712
  * render may traverse, never what this descent reports.
1417
1713
  */
1418
1714
  let traverse = (
1419
- { add, bad, attempt, runNames },
1715
+ { add, bad, attempt, pin, runNames },
1420
1716
  { arr, obj, keys, named, expression, columnsOf, takeOf, foldsOf, sortOf, stylesOf },
1421
1717
  { itemsOf },
1422
1718
  { bandOf },
@@ -1456,8 +1752,8 @@ let traverse = (
1456
1752
  let where = maybeExpr(schema.where, "where", expression);
1457
1753
  let sort = sortOf(schema.sort, "sort");
1458
1754
  let take = takeOf(schema.take, "take");
1459
- let aggs = foldsOf(schema.aggregates, "aggregates", AGG, new Set(["params", "input"]));
1460
- let running = foldsOf(schema.run, "run", RUN, null, runNames);
1755
+ let aggs = foldsOf(schema.aggregates, "aggregates", "aggregate", new Set(["params", "input"]));
1756
+ let running = foldsOf(schema.run, "run", "run", null, runNames);
1461
1757
  // The report default: one style block for the whole document, narrowed to
1462
1758
  // the declarations a typeface is made of. It resolves once per render in
1463
1759
  // report scope rather than per node, which is why it crosses the seam as a
@@ -1465,9 +1761,14 @@ let traverse = (
1465
1761
  // nothing here allocates per cell (docs/adr/0033).
1466
1762
  let style = stylesOf(schema, "", checkReportStyle);
1467
1763
  let { items: header, height: headerHeight } = reportHeaderOf(schema.header, keys, itemsOf, bad);
1468
- refusePinnedLead(schema, headerHeight, bad);
1764
+ // A declared height arms the band-height guard, which every band below is
1765
+ // offered as the descent reaches it (./band-height.js).
1766
+ pin.arm(headerHeight);
1469
1767
  // Null when undeclared, so the render decides on the compiled band rather
1470
- // than reading `schema.empty` a second time from outside the traversal.
1768
+ // than reading `schema.empty` a second time from outside the traversal. The
1769
+ // guard is offered it here, where the traversal already holds it: `empty`
1770
+ // replaces the body rather than following it, so it is judged on its own.
1771
+ pin.instead(schema.empty, "empty");
1471
1772
  let empty = maybeItems(schema.empty, "empty", "empty", itemsOf);
1472
1773
  let columns = columnsOf(schema.columns, "columns");
1473
1774
  let band = bandOf(arr(schema.groups, "groups") || [], 0, schema.detail, columns != null);
@@ -1510,7 +1811,7 @@ let traverse = (
1510
1811
  export let plan = (schema, funcs, options) => {
1511
1812
  let state = planState(schema, funcs);
1512
1813
  let prim = primitives(state);
1513
- let read = readers(state, prim);
1814
+ let read = readers(state, prim, options);
1514
1815
  let node = nodes(state, read);
1515
1816
  let band = bands(state, read, node);
1516
1817
  return {