@quario/layout 0.2.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/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/page.js CHANGED
@@ -3,8 +3,15 @@
3
3
  * it is written. Owned here because every consumer of the list — the PDF
4
4
  * target, the viewer, the editor — lays out on the same page, and a size that
5
5
  * meant one thing on screen and another on paper would be a silent lie.
6
+ *
7
+ * The derivation belongs with the validation: `frame` below turns a validated
8
+ * page and margin into the geometry everything downstream lays out in, so a
9
+ * page becomes a content box here and `canvas.js` only takes the answer,
10
+ * rather than the drawing surface defining the page it draws on. Deriving a
11
+ * frame is not owning its lifetime, and this module claims only the first:
12
+ * `adopt` (`canvas.js`) narrows `top`/`bottom` once for the page bands, and
13
+ * `reserve` (`layout.js`) mints the frame it narrows from.
6
14
  */
7
- import { frame } from "./canvas.js";
8
15
 
9
16
  // The named sizes. `@quario/pdf` used to own this table, and the two element
10
17
  // packages restated it; the layout package is where all three now read it.
@@ -80,11 +87,42 @@ export let pageBox = (page, at = "page") => {
80
87
  return { width, height, margin: marginOf(page?.margin, width, height, at + ".margin") };
81
88
  };
82
89
 
90
+ // The page box and the content box, and nothing whatever else: geometry, all
91
+ // of it derived below from a page and a margin. Fixed for the document — the
92
+ // one exception is `top`/`bottom`, which the page bands narrow once through
93
+ // `adopt` (`canvas.js`), while the first page is still untouched. Everything
94
+ // else that holds for a whole render is that module's `Settings`; a frame
95
+ // carrying either half of that was a type with two lifetimes, and the copy that
96
+ // kept a probe reading the same locale as the draw had to be written out by
97
+ // hand.
98
+ /**
99
+ * @typedef {{ width: number, height: number, margin: number,
100
+ * content: number, top: number, bottom: number }} Frame
101
+ */
102
+
103
+ // A frame from the page box: how `content`/`top`/`bottom` fall out of a page
104
+ // and a margin is derived here, once, so no caller and no suite has to restate
105
+ // it and drift from what a real render uses. A render reaches a frame through
106
+ // `geometry` below and never through this; the package entry carries neither,
107
+ // so no host reaches one at all. Exported for `layout.js`, which re-derives the
108
+ // page frame a canvas presents, and for the suites that build a fixture frame
109
+ // — the day that re-derivation goes, the export stands for the suites alone,
110
+ // and they should take `geometry` rather than keep it standing.
111
+ /** @type {(width: number, height: number, margin: number) => Frame} */
112
+ export let frame = (width, height, margin) => ({
113
+ width,
114
+ height,
115
+ margin,
116
+ content: width - 2 * margin,
117
+ top: height - margin,
118
+ bottom: margin,
119
+ });
120
+
83
121
  /**
84
122
  * The page box and the content box in one, for a render: the host's size,
85
123
  * and whichever margin the host and the opening event agreed on.
86
124
  *
87
- * @type {(page: any, opening?: any) => import('./canvas.js').Frame}
125
+ * @type {(page: any, opening?: any) => Frame}
88
126
  */
89
127
  export let geometry = (page = {}, opening) => {
90
128
  let { width, height } = pageBox(page, "options.page");
package/lib/paint.js CHANGED
@@ -123,7 +123,9 @@ let loadFaces = async (fonts) => {
123
123
  };
124
124
 
125
125
  // Every bitmap decoded so far, by the bytes it was decoded from: one logo on
126
- // every page is decoded once.
126
+ // every page is decoded once. A rejection is remembered too, so a file the
127
+ // browser refused is refused from memory ever after — a caller repainting the
128
+ // same page never pays for a second decode of bytes that will not decode.
127
129
  /** @type {WeakMap<Uint8Array, Promise<ImageBitmap>>} */
128
130
  let BITMAPS = new WeakMap();
129
131
 
@@ -204,17 +206,30 @@ let PAINTERS = { rect: paintRect, line: paintLine, text: paintText, mark: paintM
204
206
 
205
207
  /** @type {(ctx: CanvasRenderingContext2D, op: Op, bitmaps: Map<Op, ImageBitmap>) => void} */
206
208
  let paintOp = (ctx, op, bitmaps) => {
207
- if (op.kind === "image")
208
- ctx.drawImage(/** @type {ImageBitmap} */ (bitmaps.get(op)), op.x, op.y, op.w, op.h);
209
- else PAINTERS[op.kind](ctx, op);
209
+ // An image with no bitmap did not decode, and is drawn as nothing. The map
210
+ // is the one place that is known: `decode` keeps no entry for a file the
211
+ // browser refused, so there is no second flag to read and no way to ask
212
+ // this question twice.
213
+ if (op.kind === "image") {
214
+ let bitmap = bitmaps.get(op);
215
+ if (bitmap) ctx.drawImage(bitmap, op.x, op.y, op.w, op.h);
216
+ } else PAINTERS[op.kind](ctx, op);
210
217
  };
211
218
 
212
- // Every image on the page, decoded together rather than one after another.
219
+ // Every image on the page, decoded together rather than one after another,
220
+ // and each settling on its own: gathering with `Promise.all` made one file the
221
+ // browser refused the whole page's failure, thrown before the first draw op.
222
+ // `paint`'s own doc says why that is the wrong price. An image that did not
223
+ // decode has no entry here, which is how `paintOp` knows.
213
224
  /** @type {(page: Page) => Promise<Map<Op, ImageBitmap>>} */
214
225
  let decode = async (page) => {
215
226
  let images = page.ops.filter((op) => op.kind === "image");
216
- let bitmaps = await Promise.all(images.map(bitmapOf));
217
- return new Map(images.map((op, i) => [op, bitmaps[i]]));
227
+ let bitmaps = await Promise.all(images.map((op) => bitmapOf(op).catch(() => null)));
228
+ return new Map(
229
+ /** @type {[Op, ImageBitmap][]} */ (
230
+ images.map((op, i) => [op, bitmaps[i]]).filter(([, bitmap]) => bitmap)
231
+ ),
232
+ );
218
233
  };
219
234
 
220
235
  /**
@@ -230,6 +245,14 @@ let decode = async (page) => {
230
245
  * superseded call reach a canvas still on screen — which is what the viewer's
231
246
  * stage retires a page for (docs/adr/0046).
232
247
  *
248
+ * **An image the browser will not decode is drawn as nothing, and the page is
249
+ * drawn around it.** The engine vouched for the magic numbers and the layout
250
+ * read the size out of the header, so a file corrupt past that point is not
251
+ * known to be bad until here; costing the whole page for it — every other op
252
+ * and the marking with it — is a worse answer than costing the image. What is
253
+ * lost is what could not be drawn. A caller wanting the failure instead should
254
+ * decode before it paints.
255
+ *
233
256
  * @param {CanvasRenderingContext2D} ctx The context to paint on.
234
257
  * @param {Page} page One page of a `Layout`.
235
258
  * @param {{ scale?: number, fonts?: any }} [options]
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Everything that holds for a whole render and is not geometry — the render's
3
+ * `Settings`, and the one reading of what a report default amounts to.
4
+ *
5
+ * A `Frame` (canvas.js) is a page's geometry; a `Settings` is the other half:
6
+ * the faces to measure against, the report default narrowed to `base` and
7
+ * `family`, and the three intl facts a formatted value resolves in. A canvas
8
+ * holds one by reference, so a measuring canvas built for a probe reads exactly
9
+ * what the listing canvas reads and a band cannot be reserved against one
10
+ * locale and drawn in another. This module owns what a settings *is* and how a
11
+ * report's opening event becomes one; canvas.js draws with it, text.js measures
12
+ * against it, and neither has to know what `style.size` or `style.family`
13
+ * amount to.
14
+ */
15
+ import { familyName } from "./fonts.js";
16
+ import { sizeOf } from "./style.js";
17
+
18
+ // This layout's baseline type size. Not a host option: a document's type size
19
+ // is the document's own, so it is `style.size` on the report and the number
20
+ // here is only what text renders at when nothing declares one. The XLSX target
21
+ // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
22
+ let BASE = 10;
23
+
24
+ /**
25
+ * Everything that holds for a whole render and is not geometry: the faces to
26
+ * measure against, the report default narrowed to `family` and `size` (landing
27
+ * in `family` and `base` here), and the three intl facts a formatted value
28
+ * resolves in.
29
+ *
30
+ * A canvas holds one of these by reference, never a copy, which is the whole
31
+ * reason it is an object. A measuring canvas built off the same settings reads
32
+ * exactly what the listing canvas reads, so a page band cannot be reserved
33
+ * against one locale and drawn in another — an agreement that used to rest on
34
+ * a hand-written copy staying in step.
35
+ *
36
+ * `family` is null when the report declares none, and each of the intl three is
37
+ * undefined when the engine settled none.
38
+ *
39
+ * @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
40
+ * family: string | null, locale?: string, currency?: string,
41
+ * timeZone?: string }} Settings
42
+ */
43
+
44
+ /**
45
+ * The render's settings, complete by construction from the opening event the
46
+ * target already peeked. The report default is narrowed to `family` and `size`,
47
+ * and each replaces this target's own baseline outright: row heights and band
48
+ * gaps scale with the document's type rather than staying at a size nothing is
49
+ * set in, and text declaring no family is set in the document's. Reading the
50
+ * pair here is the whole of this target's reading of docs/adr/0033 — the default
51
+ * reaches a node as a fallback the settings carry, never as a layer merged into
52
+ * its style, so a document-wide fact costs no allocation however many cells a
53
+ * report has.
54
+ *
55
+ * `sizeOf` and `familyName` are this target's one reading each of what a
56
+ * declared size and family amount to, so they read the default here too — both
57
+ * reach this unchecked from a computed style, and two spellings of that
58
+ * leniency would drift. Each falls back to this target's own baseline, so a
59
+ * default declaring one of the pair leaves the other at the baseline, and one
60
+ * whose value is unusable leaves the baseline standing.
61
+ *
62
+ * Built once and never settled again: these hold for a whole render, so there
63
+ * is no event that could set them a second time — a second settling is not
64
+ * refused, it is unexpressible. `opening` is null when the stream is empty (no
65
+ * report to read a default from), which yields the bare baseline: the target's
66
+ * own size, no family, and the intl three unset. Those three are read straight
67
+ * off the event whether or not it declares them — a formatted value is the only
68
+ * thing that reads them, and an empty stream produces none, so an unset one
69
+ * never reaches `Intl`.
70
+ *
71
+ * @param {import('./fonts.js').Fonts} fonts The loaded faces.
72
+ * @param {any} [opening] The peeked `report-start` event, or null.
73
+ * @returns {Settings}
74
+ */
75
+ let settings = (fonts, opening) => {
76
+ let event = opening || {};
77
+ let style = event.style || {};
78
+ return {
79
+ fonts,
80
+ base: sizeOf(style, BASE),
81
+ family: familyName(style) || null,
82
+ locale: event.locale,
83
+ currency: event.currency,
84
+ timeZone: event.timeZone,
85
+ };
86
+ };
87
+
88
+ export { settings };
package/lib/style.js CHANGED
@@ -69,24 +69,26 @@ let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? s
69
69
  // Style blocks layer outward-in: row under cell, split under slot. Those are
70
70
  // the two innermost of the four layers; the outer two reach a node without
71
71
  // being merged into it -- the band-role default through `roled` below, and the
72
- // report default through the render's settings, as `canvas.js`'s
73
- // `adoptSettings` explains.
72
+ // report default through the render's settings, as `settings.js` explains.
74
73
  /** @type {(under: any, over: any) => any} */
75
74
  let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
76
75
 
77
76
  // Band-role omakase defaults — the third of those four layers, over the report
78
77
  // default and under the author's own, which therefore always wins. Only the
79
- // headline roles carry one; the XLSX target carries the same two,
80
- // byte-identical, in `packages/xlsx/lib/index.js`, and they move together
81
- // (SCHEMA.md states the pair for both). A target can only import public engine helpers, so there is
82
- // 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.
83
81
  //
84
82
  // `@quario/html` deliberately carries none of this, nor the leading, padding
85
83
  // and band gap below: its consumer has a stylesheet and the `q-*` classes are
86
84
  // the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
87
85
  // a target supplies defaults only where its consumer has no seam to supply
88
- // them. `@quario/html/style.css` is what supplies them there; keep the three in
89
- // 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.
90
92
  /** @type {Record<string, any>} */
91
93
  let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
92
94
 
@@ -98,10 +100,13 @@ let roled = (event) =>
98
100
  ? { ...event, style: merge(ROLES[event.role], event.style) }
99
101
  : event;
100
102
 
101
- // Whether a resolved style asks for either text decoration -- one reading for
102
- // the wrapper that stamps it on a line and the canvas that draws it.
103
- /** @type {(style: any) => boolean} */
104
- 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);
105
110
 
106
111
  // Whether a resolved style asks for capitals. This target has no
107
112
  // text-transform to defer to, so the reading is here beside the rest of the
@@ -109,6 +114,15 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
109
114
  /** @type {(style: any) => boolean} */
110
115
  let upper = (style) => !!(style && style.uppercase);
111
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
+
112
126
  export {
113
127
  BAND,
114
128
  BLACK,
@@ -117,9 +131,11 @@ export {
117
131
  PADX,
118
132
  PADY,
119
133
  col,
120
- dressed,
134
+ instanceGap,
121
135
  merge,
122
136
  roled,
137
+ rowHeight,
138
+ sameCol,
123
139
  shift,
124
140
  sizeOf,
125
141
  upper,