@quario/layout 0.3.0 → 0.4.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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **Per-run typography, decoration and highlights.** A styled run resolves its
15
+ own face, size and colour, and a line takes the largest size among its runs.
16
+ `underline` and `strikethrough` are drawn per run — over that run's width, in
17
+ that run's colour — where they used to stroke the whole line in the first
18
+ piece's colour. A run's `background` paints a highlight rectangle behind its
19
+ text; a cell's own background is still painted once, at cell scope.
20
+
21
+ - **`checkFonts` is public.** It checks the shape of a `fonts` mapping without
22
+ loading a parser, and takes an optional name to prefix a failure with, the
23
+ way `pageBox` does — so a surface validating its own `fonts` property can
24
+ report the mistake against that property rather than against
25
+ `options.fonts`.
26
+
27
+ ### Fixed
28
+
29
+ - **An outline `Mark` carries only what it declares.** Every mark shipped an
30
+ extra `titled` boolean — bookkeeping for whether a header had claimed the
31
+ entry — which was never part of the `Mark` interface and which nothing
32
+ reads. It is the open group instance's own state now, and no longer travels
33
+ on the object a consumer receives.
34
+
10
35
  ## [0.3.0] - 2026-09-07
11
36
 
12
37
  ### Added
package/lib/canvas.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * `y` descending, the way a screen reads. Points throughout.
25
25
  */
26
26
  import { baseSans } from "./fonts.js";
27
- import { BLACK, dressed, shift } from "./style.js";
27
+ import { BLACK, shift } from "./style.js";
28
28
 
29
29
  /** @typedef {import('./page.js').Frame} Frame */
30
30
  /** @typedef {import('./text.js').Line} Line */
@@ -71,27 +71,55 @@ import { BLACK, dressed, shift } from "./style.js";
71
71
  */
72
72
  let blank = (box, render) => ({ ...box, settings: render, y: 0, fresh: true });
73
73
 
74
- // Text decoration in the text colour. Thickness and offset come from the
75
- // line's ascender (the face metric already measured for baseline placement).
76
- // Empty lines (no width) draw nothing. Shared by the listing adapter and the
77
- // layout suite's recorder so both exercise the same path.
78
- /** @type {(line: Line) => Color} */
79
- let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
74
+ // A styled run's `background`, painted behind its text as a highlight over
75
+ // that run's width and the line's full height -- the same rectangle a cell's
76
+ // own background is, one stretch narrower. All of a line's highlights are
77
+ // painted before any of its glyphs, so a rectangle behind one run can never
78
+ // land on top of the run before it. Shared with the layout suite's recorder
79
+ // for the reason `decorateLine` is.
80
+ /**
81
+ * @type {(rect: (color: Color, x: number, y: number, w: number, h: number) => void,
82
+ * line: Line, left: number, yTop: number) => void}
83
+ */
84
+ let highlightLine = (rect, line, left, yTop) => {
85
+ let cursor = left;
86
+ for (let piece of line.pieces) {
87
+ if (piece.bg) rect(piece.bg, cursor, yTop - line.h, piece.w, line.h);
88
+ cursor += piece.w;
89
+ }
90
+ };
80
91
 
81
- /** @type {(line: Line) => boolean} */
82
- let wantsDeco = (line) => !!line.w && dressed(line);
92
+ // Text decoration, drawn per piece: over that piece's width, in that piece's
93
+ // colour. A rule across the whole line in the first piece's colour is what
94
+ // this used to be, and it is exactly what "underline a word" cannot mean
95
+ // (ADR 0061). Thickness and offset stay the line's, from the ascender already
96
+ // measured for baseline placement, so one line's rules sit at one height
97
+ // whatever sizes it mixes. Empty lines (no width) draw nothing. Shared by the
98
+ // listing adapter and the layout suite's recorder so both exercise the same
99
+ // path.
100
+ /**
101
+ * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
102
+ * piece: Line['pieces'][number], line: Line, left: number, baseline: number) => void}
103
+ */
104
+ let decoratePiece = (stroke, piece, line, left, baseline) => {
105
+ let thickness = Math.max(line.asc / 12, 0.5);
106
+ let right = left + piece.w;
107
+ let color = piece.color || BLACK;
108
+ if (piece.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
109
+ if (piece.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
110
+ };
83
111
 
84
112
  /**
85
113
  * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
86
114
  * line: Line, left: number, baseline: number) => void}
87
115
  */
88
116
  let decorateLine = (stroke, line, left, baseline) => {
89
- if (!wantsDeco(line)) return;
90
- let thickness = Math.max(line.asc / 12, 0.5);
91
- let right = left + line.w;
92
- let color = ink(line);
93
- if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
94
- if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
117
+ if (!line.w) return;
118
+ let cursor = left;
119
+ for (let piece of line.pieces) {
120
+ decoratePiece(stroke, piece, line, cursor, baseline);
121
+ cursor += piece.w;
122
+ }
95
123
  };
96
124
 
97
125
  /**
@@ -172,6 +200,7 @@ let listing = (box, render) => {
172
200
  /** @type {Canvas['drawLine']} */
173
201
  let drawLine = (line, x, yTop, avail, align) => {
174
202
  let left = x + shift(align, avail - line.w);
203
+ highlightLine(rect, line, left, yTop);
175
204
  let cursor = left;
176
205
  for (let piece of line.pieces) {
177
206
  writePiece(piece, line, cursor, yTop);
@@ -307,4 +336,4 @@ let stamp = (canvas, text) => {
307
336
  };
308
337
  };
309
338
 
310
- export { adopt, decorateLine, listing, measuring, stamp };
339
+ export { adopt, decorateLine, highlightLine, listing, measuring, stamp };
package/lib/fonts.js CHANGED
@@ -161,9 +161,9 @@ let useFontkit = async () => {
161
161
  }
162
162
  };
163
163
 
164
- /** @type {(name: string, def: any) => string} */
165
- let asFamily = (name, def) => {
166
- let path = "options.fonts." + name;
164
+ /** @type {(name: string, def: any, at: string) => string} */
165
+ let asFamily = (name, def, at) => {
166
+ let path = at + "." + name;
167
167
  if (!def || typeof def !== "object")
168
168
  throw Error(path + ": expected { regular, bold?, italic?, boldItalic? }");
169
169
  if (def.regular == null) throw Error(path + ".regular: required");
@@ -241,7 +241,9 @@ let remembered = (source, key, make) => {
241
241
  // resolvable once the family is named.
242
242
  /** @type {(fontkit: any, name: string, def: any) => Face[]} */
243
243
  let familyOf = (fontkit, name, def) => {
244
- let path = asFamily(name, def);
244
+ // Always the factory's own option here: loading happens behind `layout()`,
245
+ // and a surface that named it something else has already validated it.
246
+ let path = asFamily(name, def, "options.fonts");
245
247
  let family = name.toLowerCase();
246
248
  /** @type {Face[]} */
247
249
  let faces = [];
@@ -278,15 +280,23 @@ export async function loadFonts(custom) {
278
280
  }
279
281
 
280
282
  /**
281
- * Check the shape of a host's font mapping without loading a parser: the
282
- * factory call is where a malformed option is reported.
283
+ * Check the shape of a host's font mapping without loading a parser.
284
+ *
285
+ * `at` names the property in the message, the way `pageBox` takes one: the
286
+ * factory call reports `options.fonts`, but a surface element validating its
287
+ * own `fonts` property before any render has a different name for the same
288
+ * value, and a host mistake is named on the property that carries it.
289
+ *
290
+ * Only the shape. A face that will not parse, or a missing parser, is found
291
+ * while the report is measured and is a render failure there.
283
292
  *
284
293
  * @param {any} custom `options.fonts`, or null.
294
+ * @param {string} [at] What to call it in the message.
285
295
  */
286
- export let checkFonts = (custom) => {
296
+ export let checkFonts = (custom, at = "options.fonts") => {
287
297
  if (custom == null) return;
288
- if (typeof custom !== "object") throw Error("options.fonts: expected a record of families");
289
- for (let [name, def] of Object.entries(custom)) asFamily(name, def);
298
+ if (typeof custom !== "object") throw Error(at + ": expected a record of families");
299
+ for (let [name, def] of Object.entries(custom)) asFamily(name, def, at);
290
300
  };
291
301
 
292
302
  // What a declared `family` normalises to before it is looked up: lower case,
package/lib/index.d.ts CHANGED
@@ -232,3 +232,13 @@ export function pageBox(
232
232
  page?: LayoutPage,
233
233
  at?: string,
234
234
  ): { width: number; height: number; margin: number };
235
+
236
+ /**
237
+ * Check the shape of a `fonts` mapping, throwing where it is malformed. `at`
238
+ * prefixes a failure with the option's name, so a surface validating its own
239
+ * `fonts` property names that rather than `options.fonts`.
240
+ *
241
+ * Only the shape: a face that will not parse, or a missing parser, is found
242
+ * while the report is measured and is a render failure there.
243
+ */
244
+ export function checkFonts(fonts?: LayoutFonts | null, at?: string): void;
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import { settings } from "./settings.js";
29
29
 
30
30
  export { PX_PER_POINT, hit, paint } from "./paint.js";
31
31
  export { pageBox } from "./page.js";
32
+ export { checkFonts } from "./fonts.js";
32
33
 
33
34
  // The options are described once, in the hand-written public declarations, and
34
35
  // read back here — a second copy in JSDoc is a copy that drifts.
package/lib/layout.js CHANGED
@@ -23,12 +23,33 @@
23
23
  */
24
24
  import { display, imageError, isReportBand, text } from "quario";
25
25
  import { balance } from "./balance.js";
26
+ /** `Measured` is the measured table's own type, and the flow passes one
27
+ * through: the region buffer measures early and the replay hands it back.
28
+ * @import { Measured, TableRow } from './measure.js' */
29
+
30
+ /** One table being emitted: what `carryOver`, `sliced` and `put` all need. It
31
+ * holds the flow, so it is the band flow's own — the measured half never sees
32
+ * a cursor.
33
+ * @typedef {{ state: Flow, head: TableRow,
34
+ * xOffsets: number[], widths: number[], x: number, avail: number }} Grid */
35
+ import { gridOf, measureTable, sum, tableOf } from "./measure.js";
26
36
  import { adopt, measuring } from "./canvas.js";
27
37
  import { frame } from "./page.js";
28
38
  import { intrinsic } from "./image.js";
29
39
  import { CELL_PAD, NO_PAD, WHOLE, insetOf, isWhole, paintBox, sliceInset, unbox } from "./box.js";
30
- import { BAND, col, GUTTER, LEAD, merge, roled, shift, sizeOf, vshift } from "./style.js";
31
- import { atoms, dress, heightOf, wrap } from "./text.js";
40
+ import {
41
+ BAND,
42
+ col,
43
+ GUTTER,
44
+ instanceGap,
45
+ merge,
46
+ roled,
47
+ rowHeight,
48
+ shift,
49
+ sizeOf,
50
+ vshift,
51
+ } from "./style.js";
52
+ import { atoms, heightOf, wrap } from "./text.js";
32
53
 
33
54
  /** @typedef {import('./balance.js').Unit} Unit */
34
55
  /** @typedef {import('./canvas.js').Canvas} Canvas */
@@ -45,7 +66,7 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
45
66
  // indexes where a `reset: "page"` sequence begins (the document itself is the
46
67
  // sequence that starts at page 0).
47
68
  /**
48
- * @typedef {{ held: any[], blocks: Block[], gap: number, skip: boolean }} Group
69
+ * @typedef {{ pending: any[], blocks: Block[], gap: number, skip: boolean, titled: boolean }} Group
49
70
  */
50
71
  /**
51
72
  * @typedef {{ canvas: Canvas, open: Group[], gap: number, marks: any[],
@@ -149,7 +170,7 @@ let blockOf = (canvas, event, avail, under = null) => {
149
170
  };
150
171
  }
151
172
  let size = sizeOf(text, canvas.settings.base);
152
- let lines = dress(wrap(atoms(canvas.settings, event.tokens, text), inner, size), text);
173
+ let lines = wrap(atoms(canvas.settings, event.tokens, text), inner, size);
153
174
  return {
154
175
  lines,
155
176
  bg,
@@ -686,7 +707,7 @@ let stripWidth = (state, count) => (state.canvas.content - GUTTER * (count - 1))
686
707
  //
687
708
  // It opens buffering: what the strips do with the content depends on how much
688
709
  // of it there is, and that is not known until the region ends or outgrows a
689
- // page. `held` is that buffer; `null` means the region has committed.
710
+ // page. `held` is that buffer; `null` means the region has decided.
690
711
  /** @type {(state: Flow) => void} */
691
712
  let openRegion = (state) => {
692
713
  let owed = state.pending;
@@ -808,13 +829,15 @@ let stripHeight = (state, region, own) =>
808
829
  ? null
809
830
  : balance(unitsOf(state, own), region.count, roomOf(state, region), rowHeight(state.canvas));
810
831
 
811
- // Commit the region: decide the strip height, stop holding, and hand the held
832
+ // Decide the region: settle the strip height, stop holding, and hand the held
812
833
  // entries back in the order they arrived, for `flow`'s `replay` to walk.
834
+ // Not `commit`: the editor's session already spends that word on a gesture
835
+ // ending, and git spends it on a commit.
813
836
  //
814
837
  // Marking the spans is also what picks out the region's own children, and the
815
838
  // height is decided from those — so it runs before the replay, not with it.
816
839
  /** @type {(state: Flow) => any[]} */
817
- let commit = (state) => {
840
+ let decide = (state) => {
818
841
  let region = /** @type {Region} */ (state.region);
819
842
  let held = /** @type {any[]} */ (region.held);
820
843
  region.height = stripHeight(state, region, spans(held));
@@ -1031,7 +1054,7 @@ let anchor = (state) => {
1031
1054
  };
1032
1055
 
1033
1056
  /** @type {(state: Flow) => Group[]} */
1034
- let pending = (state) => state.open.filter((group) => group.held.length);
1057
+ let pending = (state) => state.open.filter((group) => group.pending.length);
1035
1058
 
1036
1059
  /** @type {(state: Flow, extra: number, runs: Block[][]) => number} */
1037
1060
  let needed = (state, extra, runs) =>
@@ -1042,12 +1065,12 @@ let needed = (state, extra, runs) =>
1042
1065
  // for good: every other path drew its headers inside the room a page still
1043
1066
  // had, so a run always leaves space for the content it introduces.
1044
1067
  /** @type {(state: Flow, holding: Group[]) => void} */
1045
- let dropHeld = (state, holding) => {
1046
- let held = holding.flatMap((group) => group.held);
1047
- for (let group of holding) group.held = [];
1068
+ let dropPending = (state, holding) => {
1069
+ let waiting = holding.flatMap((group) => group.pending);
1070
+ for (let group of holding) group.pending = [];
1048
1071
  state.gap = 0;
1049
1072
  anchor(state);
1050
- for (let entry of held) item(state, entry.event, entry.block);
1073
+ for (let entry of waiting) item(state, entry.event, entry.block);
1051
1074
  };
1052
1075
 
1053
1076
  /** @type {(state: Flow, need: number) => void} */
@@ -1065,12 +1088,12 @@ let settle = (state, need) => {
1065
1088
  // — the turn above already did — so an instance taking its blocks the moment
1066
1089
  // they are drawn can never have them replayed on top of themselves.
1067
1090
  /** @type {(state: Flow, holding: Group[], runs: Block[][], avail: number) => void} */
1068
- let drawHeld = (state, holding, runs, avail) => {
1091
+ let drawPending = (state, holding, runs, avail) => {
1069
1092
  let x = originOf(state);
1070
1093
  for (let [i, group] of holding.entries()) {
1071
1094
  for (let block of runs[i]) drawBlock(state.canvas, block, x, avail);
1072
1095
  group.blocks.push(...runs[i]);
1073
- group.held = [];
1096
+ group.pending = [];
1074
1097
  }
1075
1098
  };
1076
1099
 
@@ -1086,14 +1109,14 @@ let flush = (state, extra) => {
1086
1109
  // at this same width, so a replayed run reuses those rather than wrapping
1087
1110
  // the same text a second time (ADR 0027).
1088
1111
  let runs = runsFor.map((group) =>
1089
- group.held.map((entry) => entry.block || blockOf(canvas, entry.event, avail)),
1112
+ group.pending.map((entry) => entry.block || blockOf(canvas, entry.event, avail)),
1090
1113
  );
1091
1114
  let need = needed(state, extra, runs);
1092
1115
  // Against what a fresh column has left once the page has replayed what it
1093
1116
  // carries. Inside a region that is a strip, and the replay sits above it.
1094
- if (need > ceilOf(state) - carried(state) - floorOf(state)) return dropHeld(state, runsFor);
1117
+ if (need > ceilOf(state) - carried(state) - floorOf(state)) return dropPending(state, runsFor);
1095
1118
  settle(state, need);
1096
- drawHeld(state, runsFor, runs, avail);
1119
+ drawPending(state, runsFor, runs, avail);
1097
1120
  };
1098
1121
 
1099
1122
  // Enough of an item that its group header is never left introducing nothing.
@@ -1126,7 +1149,7 @@ let keepBlock = (state, event, width) =>
1126
1149
  // Is anything waiting on this item's arrival — a header run, or the gap an
1127
1150
  // instance opens with?
1128
1151
  /** @type {(state: Flow) => boolean} */
1129
- let holding = (state) => state.open.some((group) => group.held.length) || state.gap > 0;
1152
+ let holding = (state) => state.open.some((group) => group.pending.length) || state.gap > 0;
1130
1153
 
1131
1154
  // A half-line gap before each instance (dropped at a page top by `flush`)
1132
1155
  // keeps groups reading as blocks. Every instance becomes an outline entry.
@@ -1159,7 +1182,7 @@ let openGroup = (state, event) => {
1159
1182
  let skip = state.skipGap;
1160
1183
  state.gap = skip ? 0 : Math.max(state.gap, instanceGap(state.canvas));
1161
1184
  state.skipGap = false;
1162
- state.open.push({ held: [], blocks: [], gap, skip });
1185
+ state.open.push({ pending: [], blocks: [], gap, skip, titled: false });
1163
1186
  state.marks.push(markFor(event));
1164
1187
  };
1165
1188
 
@@ -1195,7 +1218,6 @@ let markFor = (event) => ({
1195
1218
  // display(), not String(): a Date group key must title its outline bookmark
1196
1219
  // with the same ISO 8601 UTC text its cells render, on every machine.
1197
1220
  title: event.name + ": " + display(event.key),
1198
- titled: false,
1199
1221
  depth: event.depth,
1200
1222
  page: -1,
1201
1223
  y: 0,
@@ -1205,24 +1227,24 @@ let markFor = (event) => ({
1205
1227
  // page bottom. The instance's first header titles its outline entry.
1206
1228
  /** @type {(state: Flow, event: any, title: string | null, block?: Block | null) => void} */
1207
1229
  let holdHeader = (state, event, title, block = null) => {
1230
+ let group = state.open[state.open.length - 1];
1208
1231
  let mark = state.marks[state.marks.length - 1];
1232
+ // Whether a header has claimed this entry is the open instance's own fact,
1233
+ // not the outline entry's: a Mark is what a consumer receives, and it
1234
+ // carries only what `index.d.ts` declares. The two records correspond here
1235
+ // because `openGroup` pushes them together and this only ever runs inside
1236
+ // that instance's own header band, before any nested group opens -- groups
1237
+ // pop from `open` while their marks stay, so nowhere else may assume it.
1238
+ //
1209
1239
  // A picture has no words to title an outline entry with, so it holds its
1210
1240
  // place in the run and leaves the title to the next header that has some.
1211
- if (mark && !mark.titled && title != null) {
1212
- mark.titled = true;
1241
+ if (mark && !group.titled && title != null) {
1242
+ group.titled = true;
1213
1243
  mark.title = title;
1214
1244
  }
1215
- state.open[state.open.length - 1].held.push({ event, block });
1245
+ group.pending.push({ event, block });
1216
1246
  };
1217
1247
 
1218
- // A table row's floor height and the gap a group instance opens with: named
1219
- // because the region's estimator reads both, and a constant with two readers
1220
- // and no name drifts between them.
1221
- /** @type {(canvas: Canvas) => number} */
1222
- let rowHeight = (canvas) => LEAD * canvas.settings.base;
1223
- /** @type {(canvas: Canvas) => number} */
1224
- let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
1225
-
1226
1248
  // What a fresh strip has to hold to take an instance: the span less the
1227
1249
  // opening gap, which `settle` drops at a strip head. This is the one fact the
1228
1250
  // balance model and the placement rule have to agree on, so both read it here
@@ -1232,60 +1254,6 @@ let headOf = (canvas, span) => span - instanceGap(canvas);
1232
1254
 
1233
1255
  // --- tables -----------------------------------------------------------------
1234
1256
 
1235
- // Pre-measure one cell: wrapped lines per column width come later; natural
1236
- // width first (no wrapping except hard breaks). `mt`/`mb` go unread: SCHEMA.md
1237
- // gives flow spacing no meaning inside a table row, in either target.
1238
- /** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
1239
- let cellOf = (canvas, cell, rowStyle) => {
1240
- // A row's block reaches its cells with no box in it: the engine resolved
1241
- // that half onto the cells themselves before the event was emitted
1242
- // (SCHEMA.md, "Style declarations"), which is why the inset and the ink
1243
- // both read the cell and a row is never asked for a box it cannot have.
1244
- let style = merge(rowStyle, unbox(cell.style));
1245
- let inset = insetOf(cell.style, CELL_PAD);
1246
- let list = atoms(canvas.settings, cell.tokens, style);
1247
- let natural = wrap(list, Infinity, sizeOf(style, canvas.settings.base)).reduce(
1248
- (widest, line) => Math.max(widest, line.w),
1249
- 0,
1250
- );
1251
- return {
1252
- list,
1253
- natural: natural + inset.l + inset.r,
1254
- align: style.align,
1255
- valign: style.valign,
1256
- bg: col(style.background),
1257
- underline: !!style.underline,
1258
- strikethrough: !!style.strikethrough,
1259
- inset,
1260
- style: cell.style,
1261
- path: cell.path,
1262
- };
1263
- };
1264
-
1265
- /** @type {(canvas: Canvas, cells: any[], widths: number[]) => { cells: any[], h: number }} */
1266
- let rowOf = (canvas, cells, widths) => {
1267
- let h = rowHeight(canvas);
1268
- /** @type {number[]} */
1269
- let owns = [];
1270
- let out = cells.map((cell, i) => {
1271
- let inner = Math.max(widths[i] - cell.inset.l - cell.inset.r, 1);
1272
- // A glyph or a hard break occupies; spaces alone are an empty cell.
1273
- let lines = cell.list.some(
1274
- (/** @type {{ hard: boolean, space: boolean }} */ atom) => atom.hard || !atom.space,
1275
- )
1276
- ? dress(wrap(cell.list, inner, cell.list[0].size), cell)
1277
- : [];
1278
- let own = heightOf(lines) + cell.inset.t + cell.inset.b;
1279
- if (own > h) h = own;
1280
- owns.push(own);
1281
- return { ...cell, lines };
1282
- });
1283
- // The row's height is known only now, so the slack each cell's `valign`
1284
- // reads is measured here, as `splitBlock` measures a slot's.
1285
- for (let [i, cell] of out.entries()) cell.drop = vshift(cell.valign, h - owns[i]);
1286
- return { cells: out, h };
1287
- };
1288
-
1289
1257
  /** @type {(canvas: Canvas, cell: any, bg: any, x: number, y: number, w: number, h: number) => void} */
1290
1258
  let fillCell = (canvas, cell, bg, x, y, w, h) => {
1291
1259
  if (cell.bg && cell.bg !== bg) canvas.rect(cell.bg, x, y, w, h);
@@ -1345,107 +1313,7 @@ let drawRow = (canvas, row, xOffsets, widths, bg) => {
1345
1313
  );
1346
1314
  };
1347
1315
 
1348
- // The column geometry one row sees. A cell covering several columns starts
1349
- // where the first of them starts and is as wide as all of them together; a row
1350
- // that spans nothing sees the columns themselves, and pays nothing for the
1351
- // feature. `null` spans is that row -- every data row, and every total row
1352
- // whose cells each cover one column.
1353
- /** @type {(cells: any[]) => number[] | null} */
1354
- let spansOf = (cells) =>
1355
- cells.some((cell) => cell.span > 1) ? cells.map((cell) => cell.span || 1) : null;
1356
- // One value per cell, walking the columns each of them covers. Reached only
1357
- // for a row that spans, so the closure it takes costs nothing per data row.
1358
- /** @type {(spans: number[], pick: (at: number, span: number) => number) => number[]} */
1359
- let overSpans = (spans, pick) => {
1360
- /** @type {number[]} */
1361
- let out = [];
1362
- let at = 0;
1363
- for (let span of spans) {
1364
- out.push(pick(at, span));
1365
- at += span;
1366
- }
1367
- return out;
1368
- };
1369
- /** @type {(widths: number[], spans: number[] | null) => number[]} */
1370
- let spanWidths = (widths, spans) =>
1371
- spans ? overSpans(spans, (at, span) => sum(widths.slice(at, at + span))) : widths;
1372
1316
  /** @type {(xOffsets: number[], spans: number[] | null) => number[]} */
1373
- let spanOffsets = (xOffsets, spans) => (spans ? overSpans(spans, (at) => xOffsets[at]) : xOffsets);
1374
-
1375
- // The widest of each column's header, rows and totals, padding included.
1376
- //
1377
- // A cell covering more than one column has no say in their widths (SCHEMA.md):
1378
- // what a span states is which columns a cell reaches across, never how wide
1379
- // they are. So a column can end up with no voter at all -- every cell above it
1380
- // spans over it -- which only an empty table reaches, since a data row never
1381
- // spans. It opens at the padding floor rather than at nothing, so an empty
1382
- // table still shows the geometry it promises.
1383
- /**
1384
- * @typedef {{ cells: any[], spans: number[] | null }} Voting
1385
- */
1386
- /** @type {(cols: any[], header: Voting, rows: Voting[], totals: Voting[]) => number[]} */
1387
- let naturalWidths = (cols, header, rows, totals) => {
1388
- /** @type {(number | null)[]} */
1389
- let widest = cols.map(() => null);
1390
- /** @type {(at: number, natural: number) => void} */
1391
- let widen = (at, natural) => {
1392
- let held = widest[at];
1393
- if (held === null || natural > held) widest[at] = natural;
1394
- };
1395
- /** @type {(row: Voting) => void} */
1396
- let vote = (row) => {
1397
- let at = 0;
1398
- for (let [i, cell] of row.cells.entries()) {
1399
- let span = row.spans ? row.spans[i] : 1;
1400
- if (span === 1) widen(at, cell.natural);
1401
- at += span;
1402
- }
1403
- };
1404
- vote(header);
1405
- for (let row of rows) vote(row);
1406
- for (let row of totals) vote(row);
1407
- return widest.map((width) => (width === null ? CELL_PAD.l + CELL_PAD.r : width));
1408
- };
1409
-
1410
- /** @type {(values: number[]) => number} */
1411
- let sum = (values) => values.reduce((total, value) => total + value, 0);
1412
-
1413
- // An authored `width` percentage fixes its column; the rest share what is left
1414
- // in proportion to their natural widths. Three cases, one return each — the
1415
- // fall-through is all-authored within budget, honoured exactly.
1416
- //
1417
- // There is no over-commitment case: the engine rejects a document whose fixed
1418
- // shares leave the width-less columns nothing, so `left` is positive whenever
1419
- // an auto column exists and the fixed shares never exceed the content width.
1420
- // That invariant is asserted rather than trusted — falling through with auto
1421
- // columns unplaced would draw them at zero width, an invisible failure — the
1422
- // same fail-loud posture as the measuring canvas's `newPage`.
1423
- /** @type {(cols: any[], natural: number[], avail: number) => number[]} */
1424
- let columnWidths = (cols, natural, avail) => {
1425
- let fixed = cols.map((column) => Number.isFinite(column.width));
1426
- if (!fixed.some(Boolean)) {
1427
- // Natural widths always carry the cell padding, so the sum is never zero.
1428
- let wanted = sum(natural);
1429
- return natural.map((width) => (width * avail) / wanted);
1430
- }
1431
- let widths = cols.map((col, i) => (fixed[i] ? (col.width / 100) * avail : 0));
1432
- let auto = natural.map((width, i) => (fixed[i] ? 0 : width));
1433
- let left = avail - sum(widths);
1434
- let wanted = sum(auto);
1435
- // Room for the auto columns: they share what the fixed ones left.
1436
- if (wanted > 0) {
1437
- if (left <= 0) throw new Error("over-committed column widths reached the layout");
1438
- return widths.map((width, i) => (fixed[i] ? width : (auto[i] * left) / wanted));
1439
- }
1440
- return widths;
1441
- };
1442
-
1443
- // One table being emitted: what `carryOver`, `sliced` and `put` all need.
1444
- /**
1445
- * @typedef {{ cells: any[], h: number, style?: any, spans?: number[] | null }} TableRow
1446
- * @typedef {{ state: Flow, head: TableRow,
1447
- * xOffsets: number[], widths: number[], x: number, avail: number }} Grid
1448
- */
1449
1317
 
1450
1318
  // Continue in a fresh column: a table that spans columns or pages restates its
1451
1319
  // headings, under whatever headers the open groups replay above them. Both
@@ -1458,18 +1326,6 @@ let columnWidths = (cols, natural, avail) => {
1458
1326
  /** @type {(row: { style?: any }) => any} */
1459
1327
  let rowFill = (row) => col((row.style || {}).background);
1460
1328
 
1461
- // What this row draws against: the grid's own columns, or the merged geometry
1462
- // a spanning row sees. Read at draw time rather than kept on the row, because
1463
- // `rebase` moves the offsets under it every time the table crosses a strip.
1464
- /** @type {(grid: Grid, row: TableRow) => { xOffsets: number[], widths: number[] }} */
1465
- let gridOf = (grid, row) =>
1466
- row.spans
1467
- ? {
1468
- xOffsets: spanOffsets(grid.xOffsets, row.spans),
1469
- widths: spanWidths(grid.widths, row.spans),
1470
- }
1471
- : grid;
1472
-
1473
1329
  /** @type {(grid: Grid) => void} */
1474
1330
  let carryOver = (grid) => {
1475
1331
  advance(grid.state);
@@ -1611,70 +1467,6 @@ let emitRows = (grid, laid, totalRows) => {
1611
1467
  for (let row of totalRows) put(grid, row, rowFill(row), 0);
1612
1468
  };
1613
1469
 
1614
- // A buffered table, in the one shape `table` lays out: its columns come from
1615
- // the opening event, its rows are the row events themselves, and its total is
1616
- // the total row's cells. Both readers build it here — the replay, collecting
1617
- // events as they arrive, and the region buffer, reading them back off what it
1618
- // held — so a new table event kind cannot reach one and miss the other.
1619
- /** @type {(events: any[]) => any} */
1620
- let tableOf = (events) => {
1621
- let opening = events[0];
1622
- return {
1623
- columns: opening.columns.map((/** @type {any} */ col) => ({ ...col })),
1624
- // The header row arrives whole (docs/adr/0053), so both readers -- the
1625
- // replay and the region buffer -- take the same list rather than each
1626
- // rebuilding it from the columns.
1627
- header: opening.header.cells,
1628
- headerStyle: opening.header.style,
1629
- rows: events.filter((event) => event.type === "row"),
1630
- // The events themselves, as `rows` are: a total row is a row, and the
1631
- // buffer files each corrected height under the event it was measured from.
1632
- totals: events.filter((event) => event.type === "total-row"),
1633
- };
1634
- };
1635
-
1636
- /**
1637
- * @typedef {{ widths: number[], head: any, laid: any[], totalRows: any[] }} Measured
1638
- */
1639
- // Measure a buffered table at a given width: the column widths every cell has
1640
- // a say in, then each row laid out against them. Everything here is
1641
- // arithmetic over the cells, so the region's buffer can ask for it before
1642
- // anything is drawn — which is the only way a row's real height is known
1643
- // early enough to balance on (bead `quario-cgk`).
1644
- /** @type {(canvas: Canvas, buffered: any, avail: number) => Measured} */
1645
- let measureTable = (canvas, buffered, avail) => {
1646
- let cols = /** @type {any[]} */ (buffered.columns);
1647
- /** @type {(cells: any[], style: any, spans: number[] | null) => Voting & { style: any }} */
1648
- let measured = (cells, style, spans) => ({
1649
- cells: cells.map((/** @type {any} */ cell) => cellOf(canvas, cell, style)),
1650
- spans,
1651
- style,
1652
- });
1653
- let header = measured(buffered.header, buffered.headerStyle, spansOf(buffered.header));
1654
- // A data row's cells are the columns' own, so nothing in one can span
1655
- // (SCHEMA.md, "Span"). Asking each row anyway would scan every cell of the
1656
- // table to rediscover what the schema already guarantees.
1657
- let rows = buffered.rows.map((/** @type {any} */ row) => measured(row.cells, row.style, null));
1658
- let totals = buffered.totals.map((/** @type {any} */ row) =>
1659
- measured(row.cells, row.style, spansOf(row.cells)),
1660
- );
1661
- let widths = columnWidths(cols, naturalWidths(cols, header, rows, totals), avail);
1662
- // Each row wraps against the geometry it sees, which is the columns' own
1663
- // unless one of its cells spans.
1664
- /** @type {(row: Voting & { style: any }) => any} */
1665
- let laidOut = (row) => ({
1666
- ...rowOf(canvas, row.cells, spanWidths(widths, row.spans)),
1667
- spans: row.spans,
1668
- style: row.style,
1669
- });
1670
- return {
1671
- widths,
1672
- head: laidOut(header),
1673
- laid: rows.map(laidOut),
1674
- totalRows: totals.map(laidOut),
1675
- };
1676
- };
1677
-
1678
1470
  // Lay out one table from the events it arrived as: measure every cell,
1679
1471
  // allocate the columns, then emit the header, the rows and the total,
1680
1472
  // breaking pages as needed.
@@ -1943,7 +1735,7 @@ let flow = (canvas) => {
1943
1735
  // The nesting the router is at. While the buffer holds, routing runs ahead of
1944
1736
  // placement: nothing has been placed, so `state.open` is frozen at the depth
1945
1737
  // the region opened at and only the buffer knows where the walk really is.
1946
- // Once `commit` nulls `held`, the replay *is* the placement timeline and the
1738
+ // Once `decide` nulls `held`, the replay *is* the placement timeline and the
1947
1739
  // open stack is exact again.
1948
1740
  let nesting = () =>
1949
1741
  state.region && state.region.held ? state.region.nesting : state.open.length - 1;
@@ -1993,14 +1785,14 @@ let flow = (canvas) => {
1993
1785
  if (buffer(state, event, payload, hollows.has(event))) replay();
1994
1786
  };
1995
1787
 
1996
- // Lay the buffer out: `commit` decides the strip height and hands the held
1788
+ // Lay the buffer out: `decide` settles the strip height and hands the held
1997
1789
  // entries back in order, and this walks them through `route`. The walk lives
1998
1790
  // here rather than under the region section marker for the reason everything
1999
1791
  // there is module-level — that section is reachable without the router, and
2000
1792
  // a walk through `route` is not.
2001
1793
  let replay = () => {
2002
1794
  let region = /** @type {Region} */ (state.region);
2003
- for (let entry of commit(state)) {
1795
+ for (let entry of decide(state)) {
2004
1796
  if (breaksFor(state, region, entry.span)) advance(state);
2005
1797
  route(entry.event, entry.payload);
2006
1798
  }
package/lib/measure.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The measured table: everything that turns a buffered table into cells with
3
+ * heights, and nothing that puts them on a page.
4
+ *
5
+ * The split is not where a reader expects it. The table engine's emitting
6
+ * half — slicing a row across a break, carrying the header over, deciding
7
+ * whether to break at all — is bound to the band flow's cursor, and
8
+ * `quario-70jg.15` measured that it can never leave: **emitting a table is
9
+ * participating in the page break, and the page break owns the block layer.**
10
+ * So what closes is the measurement, and only that.
11
+ *
12
+ * One door in. `measureTable` is the single entry `quario-70jg.14` collapsed
13
+ * the two into, which is what makes this a module with a name rather than a
14
+ * region with two.
15
+ *
16
+ * Nothing here reads the cursor, so nothing here can page — which is what
17
+ * buys a suite that can say something about a column width without a `Flow`,
18
+ * a recording canvas, or ten imports.
19
+ */
20
+
21
+ /** @import { Canvas } from './canvas.js' */
22
+
23
+ import { CELL_PAD, insetOf, unbox } from "./box.js";
24
+ import { col, merge, rowHeight, sizeOf, vshift } from "./style.js";
25
+ import { atoms, heightOf, wrap } from "./text.js";
26
+
27
+ /** @typedef {{ cells: any[], spans: number[] | null }} Voting */
28
+ /** The column geometry a row is drawn against.
29
+ * @typedef {{ xOffsets: number[], widths: number[] }} Columns */
30
+ /** One measured row: its cells with their lines, and the height they came to.
31
+ * @typedef {{ cells: any[], h: number, style?: any, spans?: number[] | null }} TableRow */
32
+
33
+ /** The offsets a spanning row's merged columns start at. */
34
+ /** @type {(xOffsets: number[], spans: number[] | null) => number[]} */
35
+ let spanOffsets = (xOffsets, spans) => (spans ? overSpans(spans, (at) => xOffsets[at]) : xOffsets);
36
+
37
+ // Pre-measure one cell: wrapped lines per column width come later; natural
38
+ // width first (no wrapping except hard breaks). `mt`/`mb` go unread: SCHEMA.md
39
+ // gives flow spacing no meaning inside a table row, in either target.
40
+ /** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
41
+ let cellOf = (canvas, cell, rowStyle) => {
42
+ // A row's block reaches its cells with no box in it: the engine resolved
43
+ // that half onto the cells themselves before the event was emitted
44
+ // (SCHEMA.md, "Style declarations"), which is why the inset and the ink
45
+ // both read the cell and a row is never asked for a box it cannot have.
46
+ let style = merge(rowStyle, unbox(cell.style));
47
+ let inset = insetOf(cell.style, CELL_PAD);
48
+ let list = atoms(canvas.settings, cell.tokens, style);
49
+ let natural = wrap(list, Infinity, sizeOf(style, canvas.settings.base)).reduce(
50
+ (widest, line) => Math.max(widest, line.w),
51
+ 0,
52
+ );
53
+ return {
54
+ list,
55
+ natural: natural + inset.l + inset.r,
56
+ align: style.align,
57
+ valign: style.valign,
58
+ bg: col(style.background),
59
+ inset,
60
+ style: cell.style,
61
+ path: cell.path,
62
+ };
63
+ };
64
+
65
+ /** @type {(canvas: Canvas, cells: any[], widths: number[]) => { cells: any[], h: number }} */
66
+ let rowOf = (canvas, cells, widths) => {
67
+ let h = rowHeight(canvas);
68
+ /** @type {number[]} */
69
+ let owns = [];
70
+ let out = cells.map((cell, i) => {
71
+ let inner = Math.max(widths[i] - cell.inset.l - cell.inset.r, 1);
72
+ // A glyph or a hard break occupies; spaces alone are an empty cell.
73
+ let lines = cell.list.some(
74
+ (/** @type {{ hard: boolean, space: boolean }} */ atom) => atom.hard || !atom.space,
75
+ )
76
+ ? wrap(cell.list, inner, cell.list[0].size)
77
+ : [];
78
+ let own = heightOf(lines) + cell.inset.t + cell.inset.b;
79
+ if (own > h) h = own;
80
+ owns.push(own);
81
+ return { ...cell, lines };
82
+ });
83
+ // The row's height is known only now, so the slack each cell's `valign`
84
+ // reads is measured here, as `splitBlock` measures a slot's.
85
+ for (let [i, cell] of out.entries()) cell.drop = vshift(cell.valign, h - owns[i]);
86
+ return { cells: out, h };
87
+ };
88
+
89
+ // The column geometry one row sees. A cell covering several columns starts
90
+ // where the first of them starts and is as wide as all of them together; a row
91
+ // that spans nothing sees the columns themselves, and pays nothing for the
92
+ // feature. `null` spans is that row -- every data row, and every total row
93
+ // whose cells each cover one column.
94
+ /** @type {(cells: any[]) => number[] | null} */
95
+ let spansOf = (cells) =>
96
+ cells.some((cell) => cell.span > 1) ? cells.map((cell) => cell.span || 1) : null;
97
+
98
+ // One value per cell, walking the columns each of them covers. Reached only
99
+ // for a row that spans, so the closure it takes costs nothing per data row.
100
+ /** @type {(spans: number[], pick: (at: number, span: number) => number) => number[]} */
101
+ let overSpans = (spans, pick) => {
102
+ /** @type {number[]} */
103
+ let out = [];
104
+ let at = 0;
105
+ for (let span of spans) {
106
+ out.push(pick(at, span));
107
+ at += span;
108
+ }
109
+ return out;
110
+ };
111
+
112
+ /** @type {(widths: number[], spans: number[] | null) => number[]} */
113
+ let spanWidths = (widths, spans) =>
114
+ spans ? overSpans(spans, (at, span) => sum(widths.slice(at, at + span))) : widths;
115
+
116
+ // The widest of each column's header, rows and totals, padding included.
117
+ //
118
+ // A cell covering more than one column has no say in their widths (SCHEMA.md):
119
+ // what a span states is which columns a cell reaches across, never how wide
120
+ // they are. So a column can end up with no voter at all -- every cell above it
121
+ // spans over it -- which only an empty table reaches, since a data row never
122
+ // spans. It opens at the padding floor rather than at nothing, so an empty
123
+ // table still shows the geometry it promises.
124
+ /**
125
+ */
126
+ /** @type {(cols: any[], header: Voting, rows: Voting[], totals: Voting[]) => number[]} */
127
+ let naturalWidths = (cols, header, rows, totals) => {
128
+ /** @type {(number | null)[]} */
129
+ let widest = cols.map(() => null);
130
+ /** @type {(at: number, natural: number) => void} */
131
+ let widen = (at, natural) => {
132
+ let held = widest[at];
133
+ if (held === null || natural > held) widest[at] = natural;
134
+ };
135
+ /** @type {(row: Voting) => void} */
136
+ let vote = (row) => {
137
+ let at = 0;
138
+ for (let [i, cell] of row.cells.entries()) {
139
+ let span = row.spans ? row.spans[i] : 1;
140
+ if (span === 1) widen(at, cell.natural);
141
+ at += span;
142
+ }
143
+ };
144
+ vote(header);
145
+ for (let row of rows) vote(row);
146
+ for (let row of totals) vote(row);
147
+ return widest.map((width) => (width === null ? CELL_PAD.l + CELL_PAD.r : width));
148
+ };
149
+
150
+ /** @type {(values: number[]) => number} */
151
+ let sum = (values) => values.reduce((total, value) => total + value, 0);
152
+
153
+ // An authored `width` percentage fixes its column; the rest share what is left
154
+ // in proportion to their natural widths. Three cases, one return each — the
155
+ // fall-through is all-authored within budget, honoured exactly.
156
+ //
157
+ // There is no over-commitment case: the engine rejects a document whose fixed
158
+ // shares leave the width-less columns nothing, so `left` is positive whenever
159
+ // an auto column exists and the fixed shares never exceed the content width.
160
+ // That invariant is asserted rather than trusted — falling through with auto
161
+ // columns unplaced would draw them at zero width, an invisible failure — the
162
+ // same fail-loud posture as the measuring canvas's `newPage`.
163
+ /** @type {(cols: any[], natural: number[], avail: number) => number[]} */
164
+ let columnWidths = (cols, natural, avail) => {
165
+ let fixed = cols.map((column) => Number.isFinite(column.width));
166
+ if (!fixed.some(Boolean)) {
167
+ // Natural widths always carry the cell padding, so the sum is never zero.
168
+ let wanted = sum(natural);
169
+ return natural.map((width) => (width * avail) / wanted);
170
+ }
171
+ let widths = cols.map((col, i) => (fixed[i] ? (col.width / 100) * avail : 0));
172
+ let auto = natural.map((width, i) => (fixed[i] ? 0 : width));
173
+ let left = avail - sum(widths);
174
+ let wanted = sum(auto);
175
+ // Room for the auto columns: they share what the fixed ones left.
176
+ if (wanted > 0) {
177
+ if (left <= 0) throw new Error("over-committed column widths reached the layout");
178
+ return widths.map((width, i) => (fixed[i] ? width : (auto[i] * left) / wanted));
179
+ }
180
+ return widths;
181
+ };
182
+
183
+ // A buffered table, in the one shape `table` lays out: its columns come from
184
+ // the opening event, its rows are the row events themselves, and its total is
185
+ // the total row's cells. Both readers build it here — the replay, collecting
186
+ // events as they arrive, and the region buffer, reading them back off what it
187
+ // held — so a new table event kind cannot reach one and miss the other.
188
+ /** @type {(events: any[]) => any} */
189
+ let tableOf = (events) => {
190
+ let opening = events[0];
191
+ return {
192
+ columns: opening.columns.map((/** @type {any} */ col) => ({ ...col })),
193
+ // The header row arrives whole (docs/adr/0053), so both readers -- the
194
+ // replay and the region buffer -- take the same list rather than each
195
+ // rebuilding it from the columns.
196
+ header: opening.header.cells,
197
+ headerStyle: opening.header.style,
198
+ rows: events.filter((event) => event.type === "row"),
199
+ // The events themselves, as `rows` are: a total row is a row, and the
200
+ // buffer files each corrected height under the event it was measured from.
201
+ totals: events.filter((event) => event.type === "total-row"),
202
+ };
203
+ };
204
+
205
+ /**
206
+ * @typedef {{ widths: number[], head: any, laid: any[], totalRows: any[] }} Measured
207
+ */
208
+ // Measure a buffered table at a given width: the column widths every cell has
209
+ // a say in, then each row laid out against them. Everything here is
210
+ // arithmetic over the cells, so the region's buffer can ask for it before
211
+ // anything is drawn — which is the only way a row's real height is known
212
+ // early enough to balance on (bead `quario-cgk`).
213
+ /** @type {(canvas: Canvas, buffered: any, avail: number) => Measured} */
214
+ let measureTable = (canvas, buffered, avail) => {
215
+ let cols = /** @type {any[]} */ (buffered.columns);
216
+ /** @type {(cells: any[], style: any, spans: number[] | null) => Voting & { style: any }} */
217
+ let measured = (cells, style, spans) => ({
218
+ cells: cells.map((/** @type {any} */ cell) => cellOf(canvas, cell, style)),
219
+ spans,
220
+ style,
221
+ });
222
+ let header = measured(buffered.header, buffered.headerStyle, spansOf(buffered.header));
223
+ // A data row's cells are the columns' own, so nothing in one can span
224
+ // (SCHEMA.md, "Span"). Asking each row anyway would scan every cell of the
225
+ // table to rediscover what the schema already guarantees.
226
+ let rows = buffered.rows.map((/** @type {any} */ row) => measured(row.cells, row.style, null));
227
+ let totals = buffered.totals.map((/** @type {any} */ row) =>
228
+ measured(row.cells, row.style, spansOf(row.cells)),
229
+ );
230
+ let widths = columnWidths(cols, naturalWidths(cols, header, rows, totals), avail);
231
+ // Each row wraps against the geometry it sees, which is the columns' own
232
+ // unless one of its cells spans.
233
+ /** @type {(row: Voting & { style: any }) => any} */
234
+ let laidOut = (row) => ({
235
+ ...rowOf(canvas, row.cells, spanWidths(widths, row.spans)),
236
+ spans: row.spans,
237
+ style: row.style,
238
+ });
239
+ return {
240
+ widths,
241
+ head: laidOut(header),
242
+ laid: rows.map(laidOut),
243
+ totalRows: totals.map(laidOut),
244
+ };
245
+ };
246
+
247
+ // What this row draws against: the grid's own columns, or the merged geometry
248
+ // a spanning row sees. Read at draw time rather than kept on the row, because
249
+ // `rebase` moves the offsets under it every time the table crosses a strip.
250
+ //
251
+ // Typed on the geometry rather than on the flow's `Grid`, which is the seam
252
+ // this module sits on: a grid carries a cursor and this never reads one, so
253
+ // asking for the whole record would claim a coupling that is not here.
254
+ /** @type {<T extends Columns>(grid: T, row: TableRow) => Columns} */
255
+ let gridOf = (grid, row) =>
256
+ row.spans
257
+ ? {
258
+ xOffsets: spanOffsets(grid.xOffsets, row.spans),
259
+ widths: spanWidths(grid.widths, row.spans),
260
+ }
261
+ : grid;
262
+
263
+ export { gridOf, measureTable, sum, tableOf };
package/lib/style.js CHANGED
@@ -75,17 +75,20 @@ let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : o
75
75
 
76
76
  // Band-role omakase defaults — the third of those four layers, over the report
77
77
  // default and under the author's own, which therefore always wins. Only the
78
- // headline roles carry one; the XLSX target carries the same two,
79
- // byte-identical, in `packages/xlsx/lib/index.js`, and they move together
80
- // (SCHEMA.md states the pair for both). A target can only import public engine helpers, so there is
81
- // nowhere to share this from and the two copies are synced by hand.
78
+ // headline roles carry one, and SCHEMA.md states the pair. A target can only
79
+ // import public engine helpers, so there is nowhere to share this from and the
80
+ // copies are separate on purpose.
82
81
  //
83
82
  // `@quario/html` deliberately carries none of this, nor the leading, padding
84
83
  // and band gap below: its consumer has a stylesheet and the `q-*` classes are
85
84
  // the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
86
85
  // a target supplies defaults only where its consumer has no seam to supply
87
- // them. `@quario/html/style.css` is what supplies them there; keep the three in
88
- // agreement.
86
+ // them.
87
+ //
88
+ // `test/omakase-defaults.test.js` is what holds the copies in agreement. This
89
+ // comment names the gate rather than listing the files, because the list is
90
+ // what rotted: it used to name partners that did not exist, and the two
91
+ // comments disagreed on how many there were.
89
92
  /** @type {Record<string, any>} */
90
93
  let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
91
94
 
@@ -97,10 +100,13 @@ let roled = (event) =>
97
100
  ? { ...event, style: merge(ROLES[event.role], event.style) }
98
101
  : event;
99
102
 
100
- // Whether a resolved style asks for either text decoration -- one reading for
101
- // the wrapper that stamps it on a line and the canvas that draws it.
102
- /** @type {(style: any) => boolean} */
103
- let dressed = (style) => !!(style && (style.underline || style.strikethrough));
103
+ // Two resolved colours, or the absence of one, compared by value: `col()`
104
+ // mints a fresh object per read and interns nothing, so identity would answer
105
+ // no for two reads of the same declaration.
106
+ /** @type {(a: Color, b: Color) => boolean} */
107
+ let sameChannels = (a, b) => a.r === b.r && a.g === b.g && a.b === b.b;
108
+ /** @type {(a: Color | null, b: Color | null) => boolean} */
109
+ let sameCol = (a, b) => (a && b ? sameChannels(a, b) : a === b);
104
110
 
105
111
  // Whether a resolved style asks for capitals. This target has no
106
112
  // text-transform to defer to, so the reading is here beside the rest of the
@@ -108,6 +114,15 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
108
114
  /** @type {(style: any) => boolean} */
109
115
  let upper = (style) => !!(style && style.uppercase);
110
116
 
117
+ // A table row's floor height and the gap a group instance opens with. Here
118
+ // rather than in the band flow because both are `k * LEAD * base` and LEAD is
119
+ // here: a constant with two readers and no name drifts between them, and a
120
+ // second copy of the multiplier would drift from the leading itself.
121
+ /** @type {(canvas: any) => number} */
122
+ let rowHeight = (canvas) => LEAD * canvas.settings.base;
123
+ /** @type {(canvas: any) => number} */
124
+ let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
125
+
111
126
  export {
112
127
  BAND,
113
128
  BLACK,
@@ -116,9 +131,11 @@ export {
116
131
  PADX,
117
132
  PADY,
118
133
  col,
119
- dressed,
134
+ instanceGap,
120
135
  merge,
121
136
  roled,
137
+ rowHeight,
138
+ sameCol,
122
139
  shift,
123
140
  sizeOf,
124
141
  upper,
package/lib/text.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * their resolved typography, and a greedy breaker turns those into lines. Pure
4
4
  * measurement against the font registry — nothing here touches the page.
5
5
  */
6
- import { display, format } from "quario";
6
+ import { display, format, styledRuns } from "quario";
7
7
  import { ascOf, face, printable, width } from "./fonts.js";
8
- import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
8
+ import { LEAD, col, merge, sameCol, sizeOf, upper } from "./style.js";
9
9
 
10
10
  // What measuring needs and no more, and it is exactly the render's settings:
11
11
  // the faces to measure against, the base size and family a style falls back to,
@@ -14,14 +14,19 @@ import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
14
14
  // the canvas, so nothing here can touch a page.
15
15
  /** @typedef {import('./settings.js').Settings} Settings */
16
16
 
17
+ // The typography one styled run wears, resolved once and stamped on every atom
18
+ // of it. Decoration and the highlight ride here rather than on the line,
19
+ // because a run is what wears them: underlining a word has to stop where the
20
+ // word does (ADR 0061).
17
21
  /**
18
- * @typedef {{ text: string, font: any, size: number, color: any,
19
- * space: boolean, hard: boolean }} Atom
22
+ * @typedef {{ font: any, size: number, color: any, bg: any,
23
+ * underline: boolean, strikethrough: boolean }} Look
20
24
  */
25
+ /** @typedef {Look & { text: string, space: boolean, hard: boolean }} Atom */
26
+ /** @typedef {Look & { text: string, w: number }} Piece */
21
27
  /**
22
- * @typedef {{ pieces: { text: string, font: any, size: number, color: any,
23
- * w: number }[], w: number, h: number, size: number, asc: number,
24
- * underline?: boolean, strikethrough?: boolean }} Line
28
+ * @typedef {{ pieces: Piece[], w: number, h: number, size: number,
29
+ * asc: number }} Line
25
30
  */
26
31
 
27
32
  /** @typedef {{ cur: Atom[], w: number, lines: Line[], base: number }} Wrap */
@@ -40,40 +45,74 @@ let rawOf = (token, style, settings) => {
40
45
  /** @type {(text: string, style: any) => string} */
41
46
  let cased = (text, style) => (upper(style) ? text.toUpperCase() : text);
42
47
 
43
- /** @type {(out: Atom[], font: any, size: number, color: any, line: string) => void} */
44
- let pushParts = (out, font, size, color, line) => {
45
- for (let part of printable(font, line).split(/( +)/).filter(Boolean))
46
- out.push({ text: part, font, size, color, space: part[0] === " ", hard: false });
48
+ /** @type {(out: Atom[], look: Look, line: string) => void} */
49
+ let pushParts = (out, look, line) => {
50
+ for (let part of printable(look.font, line).split(/( +)/).filter(Boolean))
51
+ out.push({ ...look, text: part, space: part[0] === " ", hard: false });
47
52
  };
48
53
 
49
- /** @type {(out: Atom[], font: any, size: number, color: any, i: number, line: string) => void} */
50
- let pushLine = (out, font, size, color, i, line) => {
51
- if (i) out.push({ text: "", font, size, color, space: false, hard: true });
52
- pushParts(out, font, size, color, line);
54
+ /** @type {(out: Atom[], look: Look, i: number, line: string) => void} */
55
+ let pushLine = (out, look, i, line) => {
56
+ if (i) out.push({ ...look, text: "", space: false, hard: true });
57
+ pushParts(out, look, line);
53
58
  };
54
59
 
55
- /** @type {(settings: Settings, style: any) => { font: any, size: number, color: any }} */
60
+ /** @type {(settings: Settings, style: any) => Look} */
56
61
  let look = (settings, style) => {
57
62
  let resolved = style || {};
58
63
  return {
59
64
  font: face(settings.fonts, resolved, settings.family),
60
65
  size: sizeOf(resolved, settings.base),
61
66
  color: col(resolved.color),
67
+ bg: col(resolved.background),
68
+ underline: resolved.underline === true,
69
+ strikethrough: resolved.strikethrough === true,
62
70
  };
63
71
  };
64
72
 
65
- // Flatten a cell's tokens to word/space atoms carrying the cell's resolved
66
- // typography one face, size and colour for the whole cell. CR, LF, and
67
- // CRLF are one hard break each (SCHEMA.md, Cell values).
73
+ /** @type {(seen: Look, base: any) => Look} */
74
+ let highlighted = (seen, base) => (sameCol(seen.bg, base) ? { ...seen, bg: null } : seen);
75
+
76
+ // What one styled run draws in: the style it presents its values through, and
77
+ // the look its atoms wear. Its own declarations over the cell's where it
78
+ // carries a block, and the cell's answer unchanged where it does not — asked
79
+ // once, so the run's two questions cannot be branched on separately.
80
+ /** @type {(settings: Settings, style: any, styled: any, bare: Look, base: any) =>
81
+ * { worn: any, seen: Look} }*/
82
+ let wornBy = (settings, style, styled, bare, base) => {
83
+ if (!styled.style) return { worn: style, seen: bare };
84
+ let worn = merge(style, styled.style);
85
+ return { worn, seen: highlighted(look(settings, worn), base) };
86
+ };
87
+
88
+ // One styled run's tokens flattened to atoms, all of them wearing its look.
89
+ /** @type {(out: Atom[], settings: Settings, styled: any, dress: { worn: any, seen: Look }) => void} */
90
+ let pushStyledRun = (out, settings, styled, { worn, seen }) => {
91
+ for (let token of styled.tokens)
92
+ for (let [i, line] of cased(rawOf(token, worn, settings), worn)
93
+ .split(/\r\n|\r|\n/)
94
+ .entries())
95
+ pushLine(out, seen, i, line);
96
+ };
97
+
98
+ // Flatten a cell's tokens to word/space atoms carrying their run's resolved
99
+ // typography. A run's style is the engine's fully-resolved answer for that
100
+ // stretch, so it lays over the cell's *effective* style -- the row's, the band
101
+ // role's or the split's, whichever reached this cell -- rather than replacing
102
+ // it: the run says what it says and the layers outside the cell stand where it
103
+ // is silent. A cell with no authored runs is one run wearing the cell's own
104
+ // look, which is what it always was. CR, LF, and CRLF are one hard break each
105
+ // (SCHEMA.md, Cell values).
68
106
  /** @type {(settings: Settings, tokens: any[], style: any) => Atom[]} */
69
107
  let atoms = (settings, tokens, style) => {
70
108
  let out = /** @type {Atom[]} */ ([]);
71
- let { font, size, color } = look(settings, style);
72
- for (let token of tokens)
73
- for (let [i, line] of cased(rawOf(token, style, settings), style)
74
- .split(/\r\n|\r|\n/)
75
- .entries())
76
- pushLine(out, font, size, color, i, line);
109
+ let own = look(settings, style);
110
+ // The cell's own `background` is already painted at cell scope, so a run
111
+ // highlights only where it names a different one -- otherwise every ordinary
112
+ // cell with a background would paint it twice, once per line.
113
+ let bare = { ...own, bg: null };
114
+ for (let styled of styledRuns(tokens))
115
+ pushStyledRun(out, settings, styled, wornBy(settings, style, styled, bare, own.bg));
77
116
  return out;
78
117
  };
79
118
 
@@ -82,10 +121,22 @@ let trimEnd = (cur) => {
82
121
  while (cur.length && cur[cur.length - 1].space) cur.pop();
83
122
  };
84
123
 
85
- /** @type {(pieces: Line['pieces'], atom: Atom, atomWidth: number) => void} */
124
+ // Whether two stretches draw identically, and so may be one piece. Colours
125
+ // compare channel-wise because `col()` mints a fresh object per read and
126
+ // interns nothing, so identity would split every run in two.
127
+ /** @type {(a: Look, b: Look) => boolean} */
128
+ let sameFace = (a, b) => a.font === b.font && a.size === b.size;
129
+ /** @type {(a: Look, b: Look) => boolean} */
130
+ let sameInk = (a, b) => sameCol(a.color, b.color) && sameCol(a.bg, b.bg);
131
+ /** @type {(a: Look, b: Look) => boolean} */
132
+ let sameRule = (a, b) => a.underline === b.underline && a.strikethrough === b.strikethrough;
133
+ /** @type {(a: Look, b: Look) => boolean} */
134
+ let sameLook = (a, b) => sameFace(a, b) && sameInk(a, b) && sameRule(a, b);
135
+
136
+ /** @type {(pieces: Piece[], atom: Atom, atomWidth: number) => void} */
86
137
  let mergeAtom = (pieces, atom, atomWidth) => {
87
138
  let last = pieces[pieces.length - 1];
88
- if (last) {
139
+ if (last && sameLook(last, atom)) {
89
140
  last.text += atom.text;
90
141
  last.w += atomWidth;
91
142
  return;
@@ -95,6 +146,9 @@ let mergeAtom = (pieces, atom, atomWidth) => {
95
146
  font: atom.font,
96
147
  size: atom.size,
97
148
  color: atom.color,
149
+ bg: atom.bg,
150
+ underline: atom.underline,
151
+ strikethrough: atom.strikethrough,
98
152
  w: atomWidth,
99
153
  });
100
154
  };
@@ -219,9 +273,9 @@ let placeAtom = (state, atom, avail) => {
219
273
  };
220
274
 
221
275
  // Greedy wrap against `avail`: spaces never start a line, an over-wide word
222
- // breaks by character, hard breaks always break. A cell's atoms share one
223
- // typography, so a line's atoms merge into a single draw piece. `size` is the
224
- // empty-run height — a blank item, a hard-break hole.
276
+ // breaks by character, hard breaks always break. Atoms that draw alike merge
277
+ // into one piece, so a cell with no styled runs is still one piece per line.
278
+ // `size` is the empty-run height — a blank item, a hard-break hole.
225
279
  /** @type {(list: Atom[], avail: number, size: number) => Line[]} */
226
280
  let wrap = (list, avail, size) => {
227
281
  /** @type {Wrap} */
@@ -231,22 +285,7 @@ let wrap = (list, avail, size) => {
231
285
  return state.lines;
232
286
  };
233
287
 
234
- // Stamp cell-level decorations onto every wrapped fragment. Measurement does
235
- // not need them; drawing does, and wrapping must not lose them.
236
- /** @type {(line: Line, underline: boolean, strikethrough: boolean) => void} */
237
- let stamp = (line, underline, strikethrough) => {
238
- if (underline) line.underline = true;
239
- if (strikethrough) line.strikethrough = true;
240
- };
241
-
242
- /** @type {(lines: Line[], style: any) => Line[]} */
243
- let dress = (lines, style) => {
244
- if (!dressed(style)) return lines;
245
- for (let line of lines) stamp(line, !!style.underline, !!style.strikethrough);
246
- return lines;
247
- };
248
-
249
288
  /** @type {(lines: Line[]) => number} */
250
289
  let heightOf = (lines) => lines.reduce((total, line) => total + line.h, 0);
251
290
 
252
- export { atoms, dress, heightOf, wrap };
291
+ export { atoms, heightOf, wrap };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/layout",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The paged display list for quario — the layout the PDF target writes and the viewer paints — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -43,13 +43,13 @@
43
43
  "@size-limit/preset-small-lib": "^13.0.3",
44
44
  "@types/fontkit": "^2.0.9",
45
45
  "fontkit": "^2.0.4",
46
- "quario": "^0.6.0",
46
+ "quario": "^0.7.0",
47
47
  "size-limit": "^13.0.3",
48
48
  "typescript": "^7.0.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "fontkit": "^2.0.4",
52
- "quario": "^0.6.0"
52
+ "quario": "^0.7.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "fontkit": {