quario 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **`STYLE_NAMES`**, every name in the style vocabulary as a read-only list,
15
+ in the order the specification's table lists them. A tool that offers the
16
+ vocabulary — the editor's style rail — reads it from here instead of
17
+ carrying a copy that has to learn each new name.
18
+ - **`span` lets a cell cover several table columns**, so a label reaches across
19
+ them instead of being pushed sideways by empty cells. A literal positive
20
+ integer; absent is 1. It is legal on the two cells a document writes: a
21
+ column's `header`, where the columns it covers omit their own `header`, and a
22
+ total row's cells, whose spans must sum to the column count exactly. A data
23
+ row's cells are the columns' own, so there is no cell there to carry one — a
24
+ line that runs across the table is a `split` item below it. A span covers and
25
+ does not vote: a cell over more than one column has no say in their widths,
26
+ and a column nothing votes on takes the cell-padding floor.
27
+ - **`valign`** joins the style vocabulary: `"top"`, `"middle"` or `"bottom"`,
28
+ a literal or an `=` expression like `align`. It is legal exactly where a box
29
+ is taller than its content asked for — table cells, headers, totals, `row`,
30
+ and split slots, text or image — and a definition error on a stacked item and
31
+ on a band image, whose boxes have no such slack. A row's or a split's layers
32
+ under its cells' or slots' own. Undeclared is not a declaration: each target
33
+ keeps its own default.
34
+
35
+ ### Changed
36
+
37
+ - **A table row's `style` now resolves onto that row's cells**, the box
38
+ included, instead of meaning something different on every output. Before,
39
+ padding and border on `detail.header`, `detail.row` or a total row's `style`
40
+ reached no cell at all: a fragment dropped them, a worksheet turned them into
41
+ an edge on each cell, and a page drew one box around the row. Now they layer
42
+ under each cell's own, the way `bold` and `valign` on a row already did, so
43
+ one declaration means one thing everywhere.
44
+ What this makes possible is declining the cell padding a row at a time:
45
+ `"paddingTop": 0, "paddingBottom": 0` on `detail.row` sets the pitch for
46
+ every data row, where before it had to be written on each cell.
47
+ Two things move for documents that already declared a box on a row.
48
+ A row's `borderLeft` is now an edge on each of the row's cells rather than
49
+ one at the row's outer left — write it on the first column's cells to get the
50
+ single edge back. And a row's border now occupies height, as a cell's border
51
+ always has, so a bordered row is taller by its border's width.
52
+ A border side is won whole by the cell: a cell naming any of that side's
53
+ three keys takes the side entirely, so a cell that failed soft on one name
54
+ does not inherit the row's other two.
55
+ On the event stream, a `row`, `total-row` or `table-start` `style` now
56
+ carries only what layers by ordinary means, and a row whose whole block was
57
+ box carries no `style` at all.
58
+
10
59
  ## [0.4.0] - 2026-09-03
11
60
 
12
61
  ### Changed
package/lib/index.d.ts CHANGED
@@ -87,6 +87,12 @@ export interface StyleDeclarations extends BoxDeclarations {
87
87
  background?: ExpressionValue<string>;
88
88
  /** Horizontal alignment within the cell. */
89
89
  align?: ExpressionValue<Align>;
90
+ /**
91
+ * Vertical alignment within a box taller than its content: a table cell or
92
+ * row, a split slot (text or image). A definition error on a stacked item
93
+ * and on a band image, whose boxes have no such slack.
94
+ */
95
+ valign?: ExpressionValue<VAlign>;
90
96
  /** How a number or date is presented. Locale stays on the instance. */
91
97
  format?: ExpressionValue<FormatKind>;
92
98
  /** Blank space before this item, in points. */
@@ -104,6 +110,7 @@ export interface SortKey {
104
110
  export type CellValue = string;
105
111
 
106
112
  export type Align = "left" | "center" | "right";
113
+ export type VAlign = "top" | "middle" | "bottom";
107
114
  export type FormatKind = "number" | "currency" | "percent" | "date";
108
115
 
109
116
  export interface Cell {
@@ -185,10 +192,19 @@ export interface TableHeader {
185
192
  value: CellValue;
186
193
  /** Literal only — never an expression. */
187
194
  style?: StyleDeclarations;
195
+ /**
196
+ * How many columns this header covers — a literal integer >= 1, never an
197
+ * expression; absent is 1. The columns it covers omit their own `header`.
198
+ */
199
+ span?: number;
188
200
  }
189
201
 
190
202
  export interface TableColumn extends Cell {
191
- header: string | TableHeader;
203
+ /**
204
+ * Absent exactly when a neighbour's header `span` covers this column: every
205
+ * column's header slot is filled once, by its own or by a span.
206
+ */
207
+ header?: string | TableHeader;
192
208
  /** Column width as a percentage of the table width (0 < width <= 100). */
193
209
  width?: number;
194
210
  }
@@ -198,8 +214,20 @@ export interface TableRow {
198
214
  style?: StyleDeclarations;
199
215
  }
200
216
 
217
+ /**
218
+ * One cell of a total row: an ordinary cell, plus the columns it covers. The
219
+ * spans in a row must sum to the column count exactly.
220
+ */
221
+ export type TotalCell = Cell & {
222
+ /**
223
+ * How many columns this cell covers — a literal integer >= 1, never an
224
+ * expression; absent is 1.
225
+ */
226
+ span?: number;
227
+ };
228
+
201
229
  export interface TotalRow {
202
- cells: [Cell, ...Cell[]];
230
+ cells: [TotalCell, ...TotalCell[]];
203
231
  visible?: ExpressionValue<boolean>;
204
232
  style?: StyleDeclarations;
205
233
  }
@@ -357,6 +385,11 @@ export type Token = LiteralToken | ValueToken;
357
385
  export interface EventCell {
358
386
  tokens: Token[];
359
387
  style?: Record<string, unknown>;
388
+ /**
389
+ * How many columns this cell covers, present only when more than one: a
390
+ * span of 1 is the absence of one and never reaches the stream.
391
+ */
392
+ span?: number;
360
393
  /**
361
394
  * The definition's schema path, on cells that belong to one: a `row` cell
362
395
  * carries its column's (`detail.columns[i]`), a `total-row` cell its
@@ -490,7 +523,8 @@ export interface TableStartEvent {
490
523
  type: "table-start";
491
524
  /** Always `detail` — the table definition's schema path. */
492
525
  path: string;
493
- columns: { header: EventCell; path: string; width?: number }[];
526
+ /** One entry per column. `header` is absent when a neighbour's span covers it. */
527
+ columns: { header?: EventCell; path: string; width?: number }[];
494
528
  /** The header-row box from `detail.header`, when declared. */
495
529
  style?: Record<string, unknown>;
496
530
  }
@@ -721,3 +755,10 @@ export function typed(
721
755
  * one: the question is whose band a role names, not what a walk can hand over.
722
756
  */
723
757
  export function isReportBand(role: string | undefined): boolean;
758
+
759
+ /**
760
+ * Every name in the closed style vocabulary, in the order SCHEMA.md's table
761
+ * lists them. A tool that offers the vocabulary imports this rather than
762
+ * keeping a copy.
763
+ */
764
+ export const STYLE_NAMES: readonly string[];
package/lib/index.js CHANGED
@@ -25,6 +25,7 @@ import { opt } from "./stream.js";
25
25
  // imports them from the package, not from a file inside it.
26
26
  export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
27
27
  export { format } from "./format.js";
28
+ export { STYLE_NAMES } from "./style.js";
28
29
  export { isDiagnostic } from "./locate.js";
29
30
 
30
31
  /** @typedef {import("./scope.js").Scope} Scope */
package/lib/license.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // reassigns them. A key is valid for every version released inside its window
12
12
  // (LICENSE section 7), so validity compares ISO date strings and never reads
13
13
  // a clock — accepted output stays accepted.
14
- let RELEASE = "2026-09-03"; // x-release-please-date
14
+ let RELEASE = "2026-09-05"; // x-release-please-date
15
15
  // The verifying half of the signing pair: the 65-byte uncompressed P-256
16
16
  // point, base64url. The private half never enters the repo;
17
17
  // scripts/license/sign.mjs mints keys against it.
package/lib/plan.js CHANGED
@@ -54,8 +54,11 @@ import {
54
54
  checkReportStyle,
55
55
  checkRowStyle,
56
56
  checkSlotImageStyle,
57
+ checkItemStyle,
57
58
  checkSlotStyle,
58
59
  checkStyle,
60
+ fanBox,
61
+ isBoxName,
59
62
  } from "./style.js";
60
63
 
61
64
  // Tolerance on the column-width sum. Widths are literal numbers an author may
@@ -67,11 +70,29 @@ let WIDTH_EPS = 1e-9;
67
70
  // plan rather than riding the state a plan accumulates.
68
71
  /** @type {any} */
69
72
  let NIL = () => null;
73
+ // The two halves a table row's `style` compiles to, and the pair a row that
74
+ // declares none wears.
75
+ /** @typedef {{ box: (scope: Scope) => any, rest: (scope: Scope) => any }} RowStyles */
76
+ /** @type {RowStyles} */
77
+ let NO_ROW_STYLE = { box: NIL, rest: NIL };
70
78
  /** @type {any} */
71
79
  let EMPTY = () => [];
72
80
  // What `$.params` reads when a report declares none.
73
81
  let NONE = Object.freeze({});
74
82
 
83
+ // How many table columns one cell covers. A literal, for the same reason a
84
+ // column `width` is: the column geometry is allocated before any row is laid
85
+ // out, so it cannot depend on one. Absent is 1, and an unreadable span reads
86
+ // as 1 so the coverage walk above it reports where the author actually went
87
+ // wrong instead of cascading. `spanned` is the emit side: a span of 1 is the
88
+ // absence of one, and never reaches the stream.
89
+ /** @type {(span: any) => number} */
90
+ let spanValue = (span) => (Number.isInteger(span) && span >= 1 ? span : 1);
91
+ /** @type {(span: any) => boolean} */
92
+ let validSpan = (span) => span == null || spanValue(span) === span;
93
+ /** @type {(span: number) => number} */
94
+ let spanned = (span) => (span > 1 ? span : 0);
95
+
75
96
  /** @type {(value: any) => boolean} */
76
97
  let isExpr = (value) => typeof value === "string" && value[0] === "=";
77
98
  /** @type {(from: Iterable<string>, into: Set<string>) => void} */
@@ -564,21 +585,56 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
564
585
  for (let [name, value] of entries) style[name] = value(scope);
565
586
  return style;
566
587
  };
567
- /** @type {(block: any) => boolean} */
568
- let isComputed = (block) => Object.values(block).some(isExpr);
569
588
  /**
570
589
  * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
571
590
  * => (scope: Scope) => any}
572
591
  */
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);
592
+ // A compiled block, constant-folded when nothing in it defers to render, so
593
+ // a literal style is resolved once and every node that wears it is handed
594
+ // the same frozen object.
595
+ /** @type {(block: any, entries: [string, Eval][]) => (scope: Scope) => any} */
596
+ let folded = (block, entries) => {
576
597
  if (!entries.length) return NIL;
577
598
  let resolve = styleFn(entries);
578
- if (isComputed(block)) return resolve;
599
+ if (entries.some(([name]) => isExpr(block[name]))) return resolve;
579
600
  let constant = Object.freeze(resolve({}));
580
601
  return () => constant;
581
602
  };
603
+ /**
604
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
605
+ * => [string, Eval][]}
606
+ */
607
+ let styleEntries = (block, path, check) => {
608
+ let entries = readStyles(block, path, check);
609
+ for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
610
+ return entries;
611
+ };
612
+ /**
613
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
614
+ * => (scope: Scope) => any}
615
+ */
616
+ let compileStyles = (block, path, check) => folded(block, styleEntries(block, path, check));
617
+ // A table row's block is split where it is compiled rather than at every
618
+ // row: its box half resolves onto the row's cells and the rest stays on the
619
+ // row (SCHEMA.md, "Style declarations"). Both halves fold like any other
620
+ // block, so a literal row style is partitioned once per table.
621
+ /**
622
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
623
+ * => RowStyles}
624
+ */
625
+ let compileRowStyles = (block, path, check) => {
626
+ let entries = styleEntries(block, path, check);
627
+ return {
628
+ box: folded(
629
+ block,
630
+ entries.filter(([name]) => isBoxName(name)),
631
+ ),
632
+ rest: folded(
633
+ block,
634
+ entries.filter(([name]) => !isBoxName(name)),
635
+ ),
636
+ };
637
+ };
582
638
  /**
583
639
  * @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
584
640
  * => (scope: Scope) => any}
@@ -589,6 +645,14 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
589
645
  if (block == null || !obj(block, stylePath)) return NIL;
590
646
  return compileStyles(block, stylePath, check);
591
647
  };
648
+ /** The same, for the three blocks that sit on a table row. */
649
+ /** @type {(def: any, path: string) => RowStyles} */
650
+ let rowStylesOf = (def, path) => {
651
+ let block = def.style;
652
+ let stylePath = pathTo(path, "style");
653
+ if (block == null || !obj(block, stylePath)) return NO_ROW_STYLE;
654
+ return compileRowStyles(block, stylePath, checkRowStyle);
655
+ };
592
656
  // A cell value: one sjabloon template. Emphasis is the cell's own `style`;
593
657
  // markup in literal text is meaningful only to the HTML target.
594
658
  /** @type {(value: any, path: string) => (scope: Scope, opts?: any) => any} */
@@ -703,6 +767,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
703
767
  columnsOf,
704
768
  takeOf,
705
769
  stylesOf,
770
+ rowStylesOf,
706
771
  cellValue,
707
772
  foldsOf,
708
773
  sortOf,
@@ -738,17 +803,53 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
738
803
  return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
739
804
  };
740
805
 
806
+ /** @type {(def: any, path: string) => number} */
807
+ let spanOf = (def, path) => {
808
+ if (validSpan(def.span)) return spanValue(def.span);
809
+ bad(path + ".span", "expected a positive integer (span >= 1)");
810
+ return 1;
811
+ };
812
+
741
813
  // A cell without a row, so `@` stays unbound. A bare string is shorthand for
742
814
  // `{ value }` and keeps its authored path, so problems locate where written.
743
- /** @type {(def: any, path: string) => (scope: Scope) => any} */
815
+ // A span needs the object form -- a bare string has nowhere to carry one.
816
+ // Absent is a problem here: a column that legitimately declares no header is
817
+ // one a neighbour's span covers, and `headerSlot` answers that before this
818
+ // is reached.
819
+ /**
820
+ * @type {(def: any, path: string) => { cell: (scope: Scope) => any, span: number }}
821
+ */
744
822
  let headerOf = (def, path) => {
745
- if (typeof def === "string") return cellShape(cellValue(def, path), null, NIL);
823
+ if (typeof def === "string")
824
+ return { cell: cellShape(cellValue(def, path), null, NIL), span: 1 };
746
825
  if (!record(def)) {
747
826
  bad(path, "expected a template string or object");
748
- return cellShape(EMPTY, null, NIL);
827
+ return { cell: cellShape(EMPTY, null, NIL), span: 1 };
749
828
  }
750
- keys(def, ["value", "style"], path);
751
- return cellOf(def, path);
829
+ keys(def, ["value", "style", "span"], path);
830
+ return { cell: cellOf(def, path), span: spanOf(def, path) };
831
+ };
832
+
833
+ // A column a neighbour's span covers declares no header of its own. One that
834
+ // declares one anyway is the overlap half of the coverage rule.
835
+ /** @type {(def: any, path: string) => null} */
836
+ let covered = (def, path) => {
837
+ if (def != null) bad(path, "covered by an earlier header's span");
838
+ return null;
839
+ };
840
+ // The per-column half of the coverage rule. `headerCover` decides which slot
841
+ // a column is in; this reports it, in the column's own turn, so header
842
+ // problems still come out in the documented key order.
843
+ /**
844
+ * @type {(def: any, path: string, slot: string) =>
845
+ * { cell: (scope: Scope) => any, span: number } | null}
846
+ */
847
+ let headerSlot = (def, path, slot) => {
848
+ if (slot === "unreached") return null;
849
+ if (slot === "covered") return covered(def, path);
850
+ let header = headerOf(def, path);
851
+ if (slot === "over") bad(path + ".span", "reaches past the last column");
852
+ return header;
752
853
  };
753
854
 
754
855
  /** @type {(width: any) => boolean} */
@@ -759,11 +860,11 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
759
860
  bad(path + ".width", "expected a percentage number (0 < width <= 100)");
760
861
  return NaN;
761
862
  };
762
- /** @type {(def: any, path: string) => any} */
763
- let columnOf = (def, path) => {
863
+ /** @type {(def: any, path: string, slot: string) => any} */
864
+ let columnOf = (def, path, slot) => {
764
865
  if (!obj(def, path)) return null;
765
866
  keys(def, ["header", "value", "visible", "style", "width"], path);
766
- let header = headerOf(def.header, path + ".header");
867
+ let header = headerSlot(def.header, path + ".header", slot);
767
868
  let tpl = cellValue(def.value, path + ".value");
768
869
  let visible = visibleOf(def, path);
769
870
  let styles = stylesOf(def, path, checkCellStyle);
@@ -773,11 +874,14 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
773
874
  return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
774
875
  };
775
876
 
776
- /** @type {(def: any, path: string) => ((scope: Scope) => any) | null} */
877
+ /**
878
+ * @type {(def: any, path: string) =>
879
+ * { cell: (scope: Scope) => any, span: number } | null}
880
+ */
777
881
  let totalOf = (def, path) => {
778
882
  if (!obj(def, path)) return null;
779
- keys(def, ["value", "visible", "style"], path);
780
- return cellOf(def, path);
883
+ keys(def, ["value", "visible", "style", "span"], path);
884
+ return { cell: cellOf(def, path), span: spanOf(def, path) };
781
885
  };
782
886
 
783
887
  // A text item: a cell with no slot to keep, so a hidden one drops itself
@@ -793,7 +897,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
793
897
  let shape = cellShape(
794
898
  tpl,
795
899
  inSlot ? visible : null,
796
- stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
900
+ stylesOf(def, path, inSlot ? checkSlotStyle : checkItemStyle),
797
901
  );
798
902
  // The cell shape first, then the item's own fields, so a cell reads
799
903
  // the same wherever it appears in the stream.
@@ -998,7 +1102,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
998
1102
  */
999
1103
  let bands = (
1000
1104
  { bad, runNames },
1001
- { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, stylesOf, foldsOf, sortOf },
1105
+ { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, rowStylesOf, foldsOf, sortOf },
1002
1106
  { columnOf, itemsOf, totalOf },
1003
1107
  ) => {
1004
1108
  // Stacked items, one pass per row.
@@ -1014,30 +1118,71 @@ let bands = (
1014
1118
  }
1015
1119
  };
1016
1120
 
1017
- /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: (scope: Scope) => any }} */
1121
+ /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: RowStyles }} */
1018
1122
  let rowLook = (value) => {
1019
1123
  if (value.row == null || !obj(value.row, "detail.row"))
1020
- return { rowVisible: null, rowStyles: NIL };
1124
+ return { rowVisible: null, rowStyles: NO_ROW_STYLE };
1021
1125
  keys(value.row, ["visible", "style"], "detail.row");
1022
1126
  return {
1023
1127
  rowVisible: visibleOf(value.row, "detail.row"),
1024
- rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
1128
+ rowStyles: rowStylesOf(value.row, "detail.row"),
1025
1129
  };
1026
1130
  };
1027
1131
  // The header-row box: `style` only, a different path from each column's
1028
1132
  // `header` cell. Absent when the table declares none.
1029
- /** @type {(value: any) => (scope: Scope) => any} */
1133
+ /** @type {(value: any) => RowStyles} */
1030
1134
  let headerLook = (value) => {
1031
- if (value.header == null || !obj(value.header, "detail.header")) return NIL;
1135
+ if (value.header == null || !obj(value.header, "detail.header")) return NO_ROW_STYLE;
1032
1136
  keys(value.header, ["style"], "detail.header");
1033
- return stylesOf(value.header, "detail.header", checkRowStyle);
1137
+ return rowStylesOf(value.header, "detail.header");
1138
+ };
1139
+ // Which slot each column's header sits in -- the same invariant a total row's
1140
+ // cells carry, walked positionally rather than summed because the diagnostic
1141
+ // has to name the column it broke at. Read off the raw definitions, before
1142
+ // any column is parsed, so `columnOf` can report its verdict in the column's
1143
+ // own turn. Spans are read leniently: an unreadable one covers a single
1144
+ // column and gets its own diagnostic when the column is parsed.
1145
+ //
1146
+ // `own` fills its own slot, `covered` is filled by a neighbour's span, `over`
1147
+ // reaches past the last column, and everything after an `over` is
1148
+ // `unreached` -- one broken span should not also report every column behind
1149
+ // it as headerless.
1150
+ // How many columns a definition's header claims, read leniently: a malformed
1151
+ // column, a missing header and an unreadable span all claim one here, and
1152
+ // each gets its own diagnostic when the column is parsed.
1153
+ /** @type {(def: any) => number} */
1154
+ let headerSpan = (def) => {
1155
+ let header = record(def) ? def.header : null;
1156
+ return record(header) ? spanValue(header.span) : 1;
1157
+ };
1158
+ /** @type {(slots: string[], from: number, to: number, slot: string) => void} */
1159
+ let fill = (slots, from, to, slot) => {
1160
+ for (let at = from; at < to; at++) slots[at] = slot;
1161
+ };
1162
+ /** @type {(defs: any[]) => string[]} */
1163
+ let headerCover = (defs) => {
1164
+ let slots = defs.map(() => "own");
1165
+ let at = 0;
1166
+ while (at < defs.length) {
1167
+ let span = headerSpan(defs[at]);
1168
+ if (at + span > defs.length) {
1169
+ slots[at] = "over";
1170
+ fill(slots, at + 1, defs.length, "unreached");
1171
+ return slots;
1172
+ }
1173
+ fill(slots, at + 1, at + span, "covered");
1174
+ at += span;
1175
+ }
1176
+ return slots;
1034
1177
  };
1035
1178
  /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
1036
1179
  let tableColumns = (value) => {
1037
1180
  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 + "]"))
1181
+ let list = defs || [];
1182
+ if (defs && !list.length) bad("detail.columns", "expected at least one column");
1183
+ let slots = headerCover(list);
1184
+ let columns = list
1185
+ .map((def, i) => columnOf(def, "detail.columns[" + i + "]", slots[i]))
1041
1186
  .filter((column) => column != null);
1042
1187
  checkShares(bad, columns, "detail.columns", "column");
1043
1188
  return { defs, columns };
@@ -1047,8 +1192,8 @@ let bands = (
1047
1192
  totals
1048
1193
  .map((def, i) => {
1049
1194
  let path = prefix + "[" + i + "]";
1050
- let cell = totalOf(def, path);
1051
- return cell && { cell, path };
1195
+ let total = totalOf(def, path);
1196
+ return total && { cell: total.cell, span: total.span, path };
1052
1197
  })
1053
1198
  .filter((entry) => entry != null);
1054
1199
  /** @type {(rows: any[] | null) => any[]} */
@@ -1058,7 +1203,7 @@ let bands = (
1058
1203
  };
1059
1204
  /**
1060
1205
  * @type {(value: any, defs: any[] | null) =>
1061
- * { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null}
1206
+ * { styles: RowStyles, visible: any, cells: any[] }[] | null}
1062
1207
  */
1063
1208
  let tableTotals = (value, defs) =>
1064
1209
  value.total == null
@@ -1066,76 +1211,91 @@ let bands = (
1066
1211
  : nonempty(arr(value.total, "detail.total", true)).map((def, r) =>
1067
1212
  totalRowOf(def, "detail.total[" + r + "]", defs),
1068
1213
  );
1214
+ // Read leniently: a cell whose span is unreadable, or which is not an object
1215
+ // at all, covers one column here, and its own diagnostic comes from parsing
1216
+ // it. Otherwise one mistake would report twice.
1069
1217
  /** @type {(cells: any[], defs: any[] | null) => boolean} */
1070
- let aligned = (cells, defs) => !defs || cells.length === defs.length;
1218
+ let covering = (cells, defs) =>
1219
+ !defs ||
1220
+ cells.reduce((all, cell) => all + spanValue(record(cell) ? cell.span : null), 0) ===
1221
+ defs.length;
1071
1222
  /** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
1072
1223
  let counted = (cells, defs, path) => {
1073
1224
  if (!cells) return [];
1074
- if (!aligned(cells, defs)) bad(path + ".cells", "expected one cell per column");
1225
+ if (!covering(cells, defs)) bad(path + ".cells", "expected cells covering every column");
1075
1226
  return cells;
1076
1227
  };
1077
1228
  /**
1078
1229
  * @type {(def: any, path: string, defs: any[] | null) =>
1079
- * { styles: (scope: Scope) => any, visible: any, cells: any[] }}
1230
+ * { styles: RowStyles, visible: any, cells: any[] }}
1080
1231
  */
1081
1232
  let totalRowOf = (def, path, defs) => {
1082
- if (!obj(def, path)) return { styles: NIL, visible: null, cells: [] };
1233
+ if (!obj(def, path)) return { styles: NO_ROW_STYLE, visible: null, cells: [] };
1083
1234
  keys(def, ["cells", "style", "visible"], path);
1084
1235
  let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
1085
1236
  return {
1086
- styles: stylesOf(def, path, checkRowStyle),
1237
+ styles: rowStylesOf(def, path),
1087
1238
  visible: visibleOf(def, path),
1088
1239
  cells: mapTotals(cells, path + ".cells"),
1089
1240
  };
1090
1241
  };
1091
1242
  /**
1092
1243
  * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
1093
- * rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Generator<any>}
1244
+ * rowVisible: Eval | null, rowStyles: RowStyles) => Generator<any>}
1094
1245
  */
1095
1246
  function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles) {
1096
1247
  for (let row of rows) {
1097
1248
  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
- );
1249
+ if (hidden(rowVisible, scopeOfRow)) continue;
1250
+ let cells = columns.map((column) =>
1251
+ Object.assign(column.cell(scopeOfRow), { path: column.path }),
1252
+ );
1253
+ fanBox(rowStyles.box(scopeOfRow), cells);
1254
+ yield opt({ type: "row", cells }, { style: rowStyles.rest(scopeOfRow), run: scopeOfRow.run });
1108
1255
  }
1109
1256
  }
1110
1257
  /**
1111
- * @type {(total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null,
1258
+ * @type {(total: { styles: RowStyles, visible: any, cells: any[] }[] | null,
1112
1259
  * scope: Scope) => Generator<any>}
1113
1260
  */
1114
1261
  function* emitTotals(total, scope) {
1115
1262
  if (!total) return;
1116
1263
  for (let row of total) {
1117
1264
  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) },
1265
+ let cells = row.cells.map(({ cell, span, path }) =>
1266
+ opt(Object.assign(cell(scope), { path }), { span: spanned(span) }),
1124
1267
  );
1268
+ fanBox(row.styles.box(scope), cells);
1269
+ yield opt({ type: "total-row", cells }, { style: row.styles.rest(scope) });
1125
1270
  }
1126
1271
  }
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} */
1272
+ /** @type {(columns: any[], total: { styles: RowStyles, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: RowStyles, headerStyles: RowStyles) => Band} */
1128
1273
  let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) =>
1129
1274
  function* (rows, scope, runners) {
1275
+ // A covered column carries no `header` at all: the header row is the
1276
+ // cells the columns declare, which is fewer than the columns whenever
1277
+ // one spans. A consumer that reads no `span` still gets one cell per
1278
+ // uncovered column, which is the shape this had before spans existed.
1279
+ let cols = columns.map((column) =>
1280
+ opt(
1281
+ {},
1282
+ {
1283
+ header:
1284
+ column.header &&
1285
+ opt(column.header.cell(scope), { span: spanned(column.header.span) }),
1286
+ path: column.path,
1287
+ width: column.width,
1288
+ },
1289
+ ),
1290
+ );
1291
+ // The header-row box fans onto the cells that are there, which for the
1292
+ // same reason is fewer than the columns whenever one spans. The list is
1293
+ // built only when there is a box to put on it.
1294
+ let box = headerStyles.box(scope);
1295
+ if (box) fanBox(box, cols.map((column) => column.header).filter(Boolean));
1130
1296
  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) },
1297
+ { type: "table-start", path: "detail", columns: cols },
1298
+ { style: headerStyles.rest(scope) },
1139
1299
  );
1140
1300
  yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
1141
1301
  yield* emitTotals(total, scope);
@@ -1329,15 +1489,13 @@ let headerHeightOf = (height, bad) => {
1329
1489
  else if (!posPts(height)) bad("header.height", "expected a positive number of points");
1330
1490
  return posPts(height) ? height : null;
1331
1491
  };
1332
- /** @type {(itemsOf: any) => { items: any, height: number | null }} */
1333
- let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
1334
1492
  /** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
1335
1493
  let reportHeaderOf = (value, keys, itemsOf, bad) => {
1336
1494
  if (value == null || Array.isArray(value))
1337
1495
  return { items: itemsOf(value, "header", "report-header"), height: null };
1338
1496
  if (!record(value)) {
1339
1497
  bad("header", "expected an array of items or { height, items }");
1340
- return emptyHeader(itemsOf);
1498
+ return { items: itemsOf(null, "header", "report-header"), height: null };
1341
1499
  }
1342
1500
  keys(value, ["height", "items"], "header");
1343
1501
  return {
package/lib/stream.js CHANGED
@@ -38,8 +38,6 @@ let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefin
38
38
  let RFC3339 =
39
39
  /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2})))?$/;
40
40
 
41
- /** @type {(year: string, month: string) => number} */
42
- let monthLength = (year, month) => new Date(Date.UTC(+year, +month, 0)).getUTCDate();
43
41
  // An absent group is the timestamp half of an optional match, not a breach.
44
42
  /** @type {(bound: [string | undefined, number, number]) => boolean} */
45
43
  let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
@@ -69,7 +67,7 @@ export let reviveDate = (value) => {
69
67
  /** @type {[string | undefined, number, number][]} */
70
68
  let bounds = [
71
69
  [month, 1, 12],
72
- [day, 1, monthLength(year, month)],
70
+ [day, 1, new Date(Date.UTC(+year, +month, 0)).getUTCDate()],
73
71
  [hour, 0, 23],
74
72
  [minute, 0, 59],
75
73
  [second, 0, 59],
package/lib/style.js CHANGED
@@ -1,12 +1,21 @@
1
1
  /**
2
- * The closed style vocabulary, as the traversal checks it: target-neutral names
3
- * with typed literal values. Unknown names and mistyped literals are definition
4
- * errors; `=` results stay lenient at render. Each target maps these names to
5
- * its own formatting model, and nothing here knows about any of them.
2
+ * The closed style vocabulary target-neutral names with typed literal values —
3
+ * and the resolutions the event stream is defined in terms of. Two things, and
4
+ * the split between them is the file's organising claim.
6
5
  *
7
- * `finite`/`HEX` are restated per target on purpose: those coercions are each
8
- * target's own edge, never shared engine code unlike the stream rules
9
- * (`typed`), which every target reads from `./stream.js`.
6
+ * The **checks** are what the traversal runs: unknown names and mistyped
7
+ * literals are definition errors; `=` results stay lenient at render.
8
+ *
9
+ * The **resolutions** are what a declaration means before any target sees it.
10
+ * A row's box is the one there is (SCHEMA.md, "Style declarations"): it says
11
+ * what the document means rather than what a surface paints, so every target
12
+ * is handed the answer instead of reaching its own. That is the same line
13
+ * `./stream.js` sits on, and the opposite side of it from `finite`/`HEX`,
14
+ * which are restated per target on purpose because a coercion is each target's
15
+ * own edge.
16
+ *
17
+ * Past those, each target maps these names to its own formatting model, and
18
+ * nothing here knows about any of them.
10
19
  */
11
20
  /** @type {(value: any) => boolean} */
12
21
  let finite = (value) => typeof value === "number" && Number.isFinite(value);
@@ -19,6 +28,8 @@ let spec = (msg, ok) => ({ ok, msg });
19
28
  let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
20
29
  let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
21
30
  let ALIGNMENTS = ["left", "center", "right"];
31
+ // "middle", not "center": that word is already the horizontal one.
32
+ let VALIGNMENTS = ["top", "middle", "bottom"];
22
33
  let LINES = ["solid", "dashed", "dotted"];
23
34
  let SIDES = ["Top", "Right", "Bottom", "Left"];
24
35
  let BORDER_PARTS = ["Width", "Style", "Color"];
@@ -49,6 +60,7 @@ let STYLES = {
49
60
  color: COLOR,
50
61
  background: COLOR,
51
62
  align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
63
+ valign: spec("expected top, middle, or bottom", (value) => VALIGNMENTS.includes(value)),
52
64
  format: spec("expected number, currency, percent, or date", (value) => FORMATS.includes(value)),
53
65
  };
54
66
  let FORMATS = ["number", "currency", "percent", "date"];
@@ -73,6 +85,27 @@ for (let name of FLOW) {
73
85
  STYLES[name] = POINTS;
74
86
  IMAGE_STYLES.push(name);
75
87
  }
88
+ // The vocabulary as a list, in the order SCHEMA.md's table reads it: the text
89
+ // names, the flow, then the box one part at a time across the four sides. A
90
+ // tool that offers the vocabulary (the editor's style rail) imports this
91
+ // rather than keeping a copy that has to be told about every new name.
92
+ /** @type {readonly string[]} */
93
+ // The box half of the vocabulary, generated once from the same two tables the
94
+ // checks read. `isBoxName` is the only place the question "does this name
95
+ // belong to the box?" is answered, so a name that does not start with
96
+ // `padding` or `border` could join without every asker being told separately.
97
+ let BOX_NAMES = [
98
+ ...SIDES.map((side) => "padding" + side),
99
+ ...BORDER_PARTS.flatMap((part) => SIDES.map((side) => "border" + side + part)),
100
+ ];
101
+ let BOX = new Set(BOX_NAMES);
102
+ /** @type {(name: string) => boolean} */
103
+ export let isBoxName = (name) => BOX.has(name);
104
+ export let STYLE_NAMES = Object.freeze([
105
+ ...Object.keys(STYLES).filter((name) => !FLOW.includes(name) && !isBoxName(name)),
106
+ ...FLOW,
107
+ ...BOX_NAMES,
108
+ ]);
76
109
  // One check for both the validating traversal and the compile path: an error
77
110
  // message for a declaration, or null when it is acceptable (expressions defer
78
111
  // to render).
@@ -150,9 +183,80 @@ let withoutFlow = (check, subject) => withoutNames(FLOW_NAMES, check, subject);
150
183
 
151
184
  export let checkCellStyle = withoutFlow(checkStyle, "a table cell");
152
185
  export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
153
- export let checkSlotImageStyle = withoutFlow(checkImageStyle, "a split slot");
186
+ // `valign` is legal exactly where a box has height it did not ask for
187
+ // (CONTEXT.md "Box": slack). A slot's box is the split's height, so a slot --
188
+ // text or image -- reads it; a band image's box is its own, so it does not.
189
+ // That is the one name an image accepts in a slot and nowhere else, the same
190
+ // shape of reason `width` reaches an item only there (docs/adr/0029).
191
+ let SLOT_IMAGE_STYLES = [...IMAGE_STYLES, "valign"];
192
+ export let checkSlotImageStyle = withoutFlow(
193
+ narrowed(SLOT_IMAGE_STYLES, "an image"),
194
+ "a split slot",
195
+ );
196
+ // A stacked text item's box is exactly its content and padding: no slack, so
197
+ // `valign` is refused rather than left unread.
198
+ /** @type {Record<string, true>} */
199
+ let STACKED_NAMES = { valign: true };
200
+ export let checkItemStyle = withoutNames(STACKED_NAMES, checkStyle, "a stacked item");
154
201
  // A row box has no cell value, so `format` is refused there the way flow
155
202
  // spacing is — it is a presentation of a value, not of a band of cells.
156
203
  /** @type {Record<string, true>} */
157
204
  let ROW_NAMES = { ...FLOW_NAMES, format: true };
158
205
  export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
206
+
207
+ // --- resolution --------------------------------------------------------------
208
+
209
+ // A row's `style` resolves onto its cells (SCHEMA.md, "Style declarations").
210
+ // The box is the half that cannot arrive any other way: a table row is not a
211
+ // box on any surface -- not a `<tr>`, not a worksheet row, not a band of the
212
+ // PDF's own -- so it is resolved here, once, and no target is left to have an
213
+ // opinion about it. Every other declaration on a row reaches its cells by the
214
+ // layering each target already does, which is why only the box moves.
215
+ //
216
+ // Which names are the box is decided where the block is compiled, not here: a
217
+ // literal row style is partitioned once per table rather than once per row.
218
+ // What is left for render is the per-cell layering below.
219
+
220
+ // The three names of each border side, built once. `layer` reaches for them
221
+ // per cell, and rebuilding twelve strings per cell was most of what this cost.
222
+ /** @type {Record<string, string[]>} */
223
+ let BORDER_NAMES = Object.fromEntries(
224
+ SIDES.map((side) => [side, BORDER_PARTS.map((part) => "border" + side + part)]),
225
+ );
226
+
227
+ /** @type {(block: any, side: string) => boolean} */
228
+ let ownsSide = (block, side) => BORDER_NAMES[side].some((name) => Object.hasOwn(block, name));
229
+
230
+ // The spread has already written every name the cell declared, so taking a
231
+ // side is dropping what is left of the row's: the names of that side the cell
232
+ // did not name itself.
233
+ /** @type {(out: any, over: any, side: string) => void} */
234
+ let takeSide = (out, over, side) => {
235
+ for (let name of BORDER_NAMES[side]) if (!Object.hasOwn(over, name)) delete out[name];
236
+ };
237
+
238
+ // A border side is won whole by the cell: a cell that named any of the three
239
+ // keys takes that side entirely, so a cell that failed-soft on one name does
240
+ // not pick up the row's other two and become a stroke nobody declared. Only
241
+ // the sides the row's box actually declares can be taken from it, so the
242
+ // caller settles which those are once and hands them down.
243
+ /** @type {(under: any, over: any, sides: string[]) => any} */
244
+ let layer = (under, over, sides) => {
245
+ if (!over) return under;
246
+ let out = { ...under, ...over };
247
+ for (let side of sides) if (ownsSide(over, side)) takeSide(out, over, side);
248
+ return out;
249
+ };
250
+
251
+ /**
252
+ * Resolve a row's box onto the row's cells. The cells are written in place:
253
+ * both they and the box were built for this one event and nothing else holds
254
+ * them yet. A row that declares no box is the common case and costs one test.
255
+ *
256
+ * @type {(box: any, cells: any[]) => void}
257
+ */
258
+ export let fanBox = (box, cells) => {
259
+ if (!box) return;
260
+ let sides = SIDES.filter((side) => ownsSide(box, side));
261
+ for (let cell of cells) cell.style = layer(box, cell.style, sides);
262
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -51,7 +51,7 @@
51
51
  "sjabloon",
52
52
  "padvinder"
53
53
  ],
54
- "limit": "10 kB"
54
+ "limit": "11 kB"
55
55
  }
56
56
  ],
57
57
  "engines": {