@quario/layout 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - The text join reads a cell's own `currency` code, ahead of the instance's
15
+ default, so every target built on this package presents a per-cell
16
+ denomination.
17
+
18
+ ### Changed
19
+
20
+ - **A group instance that renders nothing no longer takes up space.** A group
21
+ whose header and footer items all resolve `visible: false`, with nothing
22
+ visible under it either, used to open the same half-line gap as any other
23
+ instance and could push the content after it onto a new page. A run of them
24
+ spaced whatever followed by a half-line each, so collapsing a level left the
25
+ rows above it at uneven distances. Such an instance now occupies nothing at
26
+ all and adds no PDF bookmark, which is what makes a group collapsible while
27
+ its rows stay in the aggregates. A group that declares `break: "page"` or
28
+ `reset: "page"` still starts its page either way. Every document with such a
29
+ group renders slightly shorter than it did.
30
+
31
+ - **Numbers presented through `format` now show a fixed two fraction digits,
32
+ matching every other target** — `1,000.00` where `1,000` was rendered,
33
+ `21.00%` where `21%` was, and a currency's own minor units in place of a
34
+ universal two. Because this package presents text and then **measures** it,
35
+ a formatted cell is now up to three characters wider than it was: a line
36
+ that just fitted can wrap, which can move a page break in a PDF, the viewer,
37
+ or an editor preview. Nothing else about wrapping changed.
38
+
39
+ ### Fixed
40
+
41
+ - **An image failure now names the item that asked for the bytes.** A file too
42
+ short to carry a size failed saying only that the size could not be read,
43
+ naming no item, so a report with two pictures gave no way to tell which one
44
+ was bad. The message is now prefixed with the item's `source` path, as every
45
+ other render error is.
46
+
47
+ - **An image the browser cannot decode no longer costs the whole page.** A
48
+ PNG or JPEG whose pixel data is corrupt past the size in its header lays out
49
+ like any other — the size is all that is read of it — and used to throw out
50
+ of `paint()` before its first draw op, leaving the page blank: no white
51
+ fill, none of the other content, and no licence marking. The image is now
52
+ drawn as nothing and the page is drawn around it, so what a bad image costs
53
+ is the image.
54
+
10
55
  ## [0.2.0] - 2026-09-05
11
56
 
12
57
  ### Added
package/lib/canvas.js CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * The drawing surface. Every mark the band flow makes goes through here, so
3
- * nothing above this module knows what a page is made of.
3
+ * nothing above this module knows how a page is drawn on. What a page *is* —
4
+ * its box, and the content box a margin leaves — is `page.js`'s `Frame`, which
5
+ * arrives here already derived: nothing below that module works one out. What
6
+ * this one owns is what becomes of it after — `blank` spreads it flat onto the
7
+ * canvas and `adopt` narrows `top`/`bottom` in place, which is why `layout.js`
8
+ * re-derives the untouched page frame it hangs a header from.
4
9
  *
5
10
  * Two adapters satisfy one interface. `listing` records marks onto the display
6
11
  * list — pages of ops and hit boxes, which the painters consume; `measuring`
@@ -18,72 +23,19 @@
18
23
  * it lands: a list coordinate is measured from the page's top-left corner,
19
24
  * `y` descending, the way a screen reads. Points throughout.
20
25
  */
21
- import { baseSans, familyName } from "./fonts.js";
22
- import { BLACK, dressed, shift, sizeOf } from "./style.js";
26
+ import { baseSans } from "./fonts.js";
27
+ import { BLACK, dressed, shift } from "./style.js";
23
28
 
29
+ /** @typedef {import('./page.js').Frame} Frame */
24
30
  /** @typedef {import('./text.js').Line} Line */
25
31
  /** @typedef {import('./style.js').Color} Color */
32
+ // A canvas holds a render's settings and turns nothing into one: what a
33
+ // settings is, and how an opening event becomes it, is settings.js's.
34
+ /** @typedef {import('./settings.js').Settings} Settings */
26
35
  // The list's own shapes — `Op`, `Box`, `Page` — are described once, in the
27
36
  // hand-written public declarations, and read back here.
28
37
  /** @import { Box, Op, Page } from './index.d.ts' */
29
38
 
30
- // The page box and the content box, and nothing whatever else: geometry, all
31
- // of it derived below from a page and a margin. Fixed for the document — the
32
- // one exception is `top`/`bottom`, which the page bands narrow once through
33
- // `adopt` below, while the first page is still untouched. Everything else that
34
- // holds for a whole render is `Settings`, next door; a frame carrying either
35
- // half of that was a type with two lifetimes, and the copy that kept a probe
36
- // reading the same locale as the draw had to be written out by hand.
37
- /**
38
- * @typedef {{ width: number, height: number, margin: number,
39
- * content: number, top: number, bottom: number }} Frame
40
- */
41
-
42
- // A frame from the page box: how `content`/`top`/`bottom` fall out of a page
43
- // and a margin is derived here, once, so no caller and no suite has to restate
44
- // it and drift from what a real render uses.
45
- /** @type {(width: number, height: number, margin: number) => Frame} */
46
- let frame = (width, height, margin) => ({
47
- width,
48
- height,
49
- margin,
50
- content: width - 2 * margin,
51
- top: height - margin,
52
- bottom: margin,
53
- });
54
-
55
- // This layout's baseline type size. Not a host option: a document's type size
56
- // is the document's own, so it is `style.size` on the report and the number
57
- // here is only what text renders at when nothing declares one. The XLSX target
58
- // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
59
- let BASE = 10;
60
-
61
- /**
62
- * Everything that holds for a whole render and is not geometry: the faces to
63
- * measure against, the report default narrowed to `family` and `size` (landing
64
- * in `family` and `base` here), and the three intl facts a formatted value
65
- * resolves in. Settled once, at
66
- * `report-start`, by `adoptSettings` below, and only read thereafter.
67
- *
68
- * A canvas holds one of these by reference, never a copy, which is the whole
69
- * reason it is an object. A measuring canvas built off the same settings reads
70
- * exactly what the listing canvas reads, so a page band cannot be reserved
71
- * against one locale and drawn in another — an agreement that used to rest on
72
- * a hand-written copy staying in step.
73
- *
74
- * `family` is null when the report declares none, and the intl three are
75
- * absent when the engine settled none.
76
- *
77
- * @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
78
- * family: string | null, locale?: string, currency?: string,
79
- * timeZone?: string }} Settings
80
- */
81
-
82
- // What a render starts from: the loaded faces, and this target's own baseline
83
- // standing in for a default no report has declared yet.
84
- /** @type {(fonts: import('./fonts.js').Fonts) => Settings} */
85
- let settings = (fonts) => ({ fonts, base: BASE, family: null });
86
-
87
39
  // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
88
40
  // it yet, which is what makes a break legal. `count` is how many pages exist.
89
41
  /**
@@ -93,8 +45,8 @@ let settings = (fonts) => ({ fonts, base: BASE, family: null });
93
45
  * rect: (color: Color, x: number, y: number, w: number, h: number) => void,
94
46
  * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
95
47
  * color: Color, dash: number[] | null) => void,
96
- * picture: (bytes: Uint8Array, format: string, x: number, y: number,
97
- * w: number, h: number) => void,
48
+ * picture: (path: string | undefined, bytes: Uint8Array, format: string,
49
+ * x: number, y: number, w: number, h: number) => void,
98
50
  * drawLine: (line: Line, x: number, yTop: number, avail: number,
99
51
  * align: any) => void,
100
52
  * box: (path: string | undefined, x: number, yTop: number, w: number,
@@ -182,9 +134,12 @@ let listing = (box, render) => {
182
134
  dash,
183
135
  });
184
136
 
137
+ // `path` rides along for the one consumer that needs to name the item back
138
+ // to the author: a target whose embedder rejects the bytes has nothing else
139
+ // to say which image it was, the box beside the op being a different record.
185
140
  /** @type {Canvas['picture']} */
186
- let picture = (bytes, format, x, y, w, h) =>
187
- page.ops.push({ kind: "image", bytes, format, x, y: down(y + h), w, h });
141
+ let picture = (path, bytes, format, x, y, w, h) =>
142
+ page.ops.push({ kind: "image", path, bytes, format, x, y: down(y + h), w, h });
188
143
 
189
144
  // `advances` rides only where a painter can use it: a base-14 face's
190
145
  // one-per-code-point shaping, which is what lets a screen stand-in be
@@ -314,42 +269,6 @@ let adopt = (canvas, box) => {
314
269
  canvas.y = canvas.top;
315
270
  };
316
271
 
317
- /**
318
- * The document-wide facts off `report-start`, taken once, before anything is
319
- * measured. The report default is narrowed to `family` and `size`, and each
320
- * replaces this target's own baseline outright: row heights and band gaps scale
321
- * with the document's type rather than staying at a size nothing is set in, and
322
- * text declaring no family is set in the document's. Settling the pair here is
323
- * the whole of this target's reading of docs/adr/0033 — the default reaches a
324
- * node as a fallback the settings carry, never as a layer merged into its
325
- * style, so a document-wide fact costs no allocation however many cells a
326
- * report has.
327
- *
328
- * `sizeOf` and `familyName` are this target's one reading each of what a
329
- * declared size and family amount to, so they read the default here too — both
330
- * reach this unchecked from a computed style, and two spellings of that
331
- * leniency would drift. Each falls back to what the settings already carry, so
332
- * a default declaring one of the pair leaves the other alone, and one whose
333
- * value is unusable leaves this target's own baseline standing.
334
- *
335
- * Settled once, the statement `adopt` makes about geometry: these hold for a
336
- * whole render, so a second event setting them would mean two documents in one.
337
- * The freeze is what enforces it, and the check is what names it.
338
- *
339
- * @param {Settings} render The render's settings.
340
- * @param {any} event The `report-start` event.
341
- */
342
- let adoptSettings = (render, event) => {
343
- if (Object.isFrozen(render)) throw Error("adoptSettings: the render settings are fixed");
344
- let style = event.style || {};
345
- render.base = sizeOf(style, render.base);
346
- render.family = familyName(style) || render.family;
347
- render.locale = event.locale;
348
- render.currency = event.currency;
349
- render.timeZone = event.timeZone;
350
- Object.freeze(render);
351
- };
352
-
353
272
  // The unlicensed-output marking (LICENSE section 6): one translucent line
354
273
  // drawn corner-to-corner across the finished page — over the content, not
355
274
  // under it, so no filled table header or background rectangle can cover it.
@@ -388,4 +307,4 @@ let stamp = (canvas, text) => {
388
307
  };
389
308
  };
390
309
 
391
- export { adopt, adoptSettings, decorateLine, frame, listing, measuring, settings, stamp };
310
+ export { adopt, decorateLine, listing, measuring, stamp };
package/lib/image.js CHANGED
@@ -53,6 +53,6 @@ export let intrinsic = (bytes, format) => {
53
53
  // The engine vouched for the magic numbers, not for the rest of the file:
54
54
  // a truncated header reaches here as a zero, and failing loudly beats
55
55
  // drawing an image with no size (SCHEMA.md, "Image item").
56
- if (!(w > 0 && h > 0)) throw Error("image: could not read the image's size from its bytes");
56
+ if (!(w > 0 && h > 0)) throw Error("could not read the image's size from its bytes");
57
57
  return { w: w * PER_PX, h: h * PER_PX };
58
58
  };
package/lib/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { Target } from "quario";
2
2
 
3
- /** Page geometry, in PostScript points. Host configuration, never schema. */
3
+ /**
4
+ * Page geometry, in PostScript points. Host configuration, except that a
5
+ * document may declare `page.margin` in the host's stead — never both.
6
+ */
4
7
  export interface LayoutPage {
5
8
  /** Named size or `[width, height]` in points. Default `'A4'`. */
6
9
  size?: "A4" | "letter" | [number, number];
@@ -81,9 +84,14 @@ export interface LineOp {
81
84
  dash: number[] | null;
82
85
  }
83
86
 
84
- /** An image, placed. `bytes` is the array the source expression yielded. */
87
+ /**
88
+ * An image, placed. `bytes` is the array the source expression yielded, and
89
+ * `path` names the schema node it came from — the one thing a target whose
90
+ * embedder rejects those bytes has to report the failure with.
91
+ */
85
92
  export interface ImageOp {
86
93
  kind: "image";
94
+ path: string | undefined;
87
95
  bytes: Uint8Array;
88
96
  format: "png" | "jpeg";
89
97
  x: number;
package/lib/index.js CHANGED
@@ -21,10 +21,11 @@
21
21
  * file adds none. The package publishes `lib/` verbatim.
22
22
  */
23
23
  import { breathe, walk } from "quario";
24
- import { listing, settings, stamp } from "./canvas.js";
24
+ import { listing, stamp } from "./canvas.js";
25
25
  import { checkFonts, loadFonts } from "./fonts.js";
26
26
  import { flow, furniture } from "./layout.js";
27
27
  import { geometry } from "./page.js";
28
+ import { settings } from "./settings.js";
28
29
 
29
30
  export { PX_PER_POINT, hit, paint } from "./paint.js";
30
31
  export { pageBox } from "./page.js";
@@ -78,8 +79,13 @@ export function layout(options) {
78
79
  let fonts = await loadFonts(custom);
79
80
  let gen = stream(data);
80
81
  let first = gen.next();
81
- let geo = geometry(options?.page, first.done ? null : first.value);
82
- let canvas = listing(geo, settings(fonts));
82
+ // The opening event carries both halves this render is built from: the page
83
+ // frame and the document-wide settings. Peeked once here — the settings are
84
+ // complete and immutable by construction, so no later event can settle them
85
+ // a second time.
86
+ let opening = first.done ? null : first.value;
87
+ let geo = geometry(options?.page, opening);
88
+ let canvas = listing(geo, settings(fonts, opening));
83
89
  // The band flow owns the placement state and every handler over it, and
84
90
  // opens the first page as it is built; this file only hands it the stream.
85
91
  let { handlers, finish } = flow(canvas);
@@ -88,21 +94,21 @@ export function layout(options) {
88
94
  yield* gen;
89
95
  }
90
96
  await walk(events(), handlers);
91
- // The flow settled the opening event on its way past — it reserves the
92
- // page bands off it and hands it back with the marks. What is left on it
93
- // is what the passes below want: the band closures to render per page, and
94
- // the marking's wording to stamp.
95
- // Null when nothing settled one, which reads as a document owing neither.
96
- let { marks, opening, pages } = finish();
97
- opening = opening || {};
97
+ // The flow reserved the page bands off the opening event on its way past;
98
+ // the passes below want the rest of it the band closures to render per
99
+ // page, and the marking's wording to stamp. It is the same event peeked
100
+ // above, so this file reads it straight rather than through `finish`.
101
+ // Empty when nothing was peeked, which reads as a document owing neither.
102
+ let { marks, pages } = finish();
103
+ let doc = opening || {};
98
104
  // The passes below run over the finished pages, not the stream — no walk
99
105
  // at all — so they open each page themselves and breathe on their own
100
106
  // rather than through the driver.
101
- await furnish(canvas, opening.page, pages);
107
+ await furnish(canvas, doc.page, pages);
102
108
  // The unlicensed marking goes on last, over content and page furniture
103
109
  // alike, once per page (LICENSE section 6). Its wording rode in on
104
110
  // `report-start`; only the placement is this layout's.
105
- await markPages(canvas, opening.marking);
111
+ await markPages(canvas, doc.marking);
106
112
  return /** @type {Layout} */ ({
107
113
  width: geo.width,
108
114
  height: geo.height,
package/lib/layout.js CHANGED
@@ -21,9 +21,10 @@
21
21
  * document's bands claim before anything is placed, and where they are drawn
22
22
  * on each finished page once the count is known.
23
23
  */
24
- import { display, isReportBand, text } from "quario";
24
+ import { display, imageError, isReportBand, text } from "quario";
25
25
  import { balance } from "./balance.js";
26
- import { adopt, adoptSettings, frame, measuring } from "./canvas.js";
26
+ import { adopt, measuring } from "./canvas.js";
27
+ import { frame } from "./page.js";
27
28
  import { intrinsic } from "./image.js";
28
29
  import { CELL_PAD, NO_PAD, WHOLE, insetOf, isWhole, paintBox, sliceInset, unbox } from "./box.js";
29
30
  import { BAND, col, GUTTER, LEAD, merge, roled, shift, sizeOf, vshift } from "./style.js";
@@ -31,29 +32,32 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
31
32
 
32
33
  /** @typedef {import('./balance.js').Unit} Unit */
33
34
  /** @typedef {import('./canvas.js').Canvas} Canvas */
34
- /** @typedef {import('./canvas.js').Frame} Frame */
35
- /** @typedef {import('./canvas.js').Settings} Settings */
35
+ /** @typedef {import('./page.js').Frame} Frame */
36
+ /** @typedef {import('./settings.js').Settings} Settings */
36
37
  /** @typedef {import('./text.js').Line} Line */
37
38
 
38
39
  // `open` is a stack, one entry per group instance still open: the header items
39
40
  // it holds back until content arrives, and — once those are drawn — the blocks
40
- // a page turn replays them from. `gap` is the space owed before the next group
41
- // instance, `marks` the outline entries, which `anchor` positions from the
42
- // cursor, and `starts` the page indexes where a `reset: "page"` sequence
43
- // begins (the document itself is the sequence that starts at page 0).
41
+ // a page turn replays them from. Each entry also carries the `gap` and `skip`
42
+ // that stood before it opened, which is what a hollow instance puts back.
43
+ // `gap` is the space owed before the next group instance, `marks` the outline
44
+ // entries, which `anchor` positions from the cursor, and `starts` the page
45
+ // indexes where a `reset: "page"` sequence begins (the document itself is the
46
+ // sequence that starts at page 0).
44
47
  /**
45
- * @typedef {{ held: any[], blocks: Block[] }} Group
48
+ * @typedef {{ held: any[], blocks: Block[], gap: number, skip: boolean }} Group
46
49
  */
47
50
  /**
48
51
  * @typedef {{ canvas: Canvas, open: Group[], gap: number, marks: any[],
49
52
  * starts: number[], region: Region | null, pending: Pending | null,
50
- * pin: number | null, skipGap: boolean, inHeader: boolean,
51
- * route: (event: any, payload?: Block | Measured | null) => void }} Flow
53
+ * pin: number | null, skipGap: boolean, inHeader: boolean }} Flow
52
54
  */
53
55
  /**
54
- * What flow spacing reads of the page: the cursor and whether a strip is open.
55
- * Page furniture has a canvas and no region, so the skip helpers take this
56
- * rather than the whole flow.
56
+ * Where the cursor is and whether a strip is open — the whole of what
57
+ * page-vs-strip geometry depends on, and less than the whole flow. The five
58
+ * accessors under "the cursor" below take this, and so do the flow-spacing
59
+ * helpers beside them, which is how page furniture drives spacing off a canvas
60
+ * and no region at all.
57
61
  * @typedef {{ canvas: Canvas, region: Region | null }} Cursor
58
62
  */
59
63
 
@@ -87,15 +91,36 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
87
91
  * @typedef {{ block: Block, w: number }} SlotPart
88
92
  */
89
93
 
94
+ // A block no page break may pass through, and the negation of `slice`. The
95
+ // region balancer answers the same question its own way, keyed on the event
96
+ // rather than the block (`UNITS`), so a new kind of one belongs there too.
97
+ /** @type {(block: Block) => boolean} */
98
+ let unsliceable = (block) => Boolean(block.picture || block.parts);
99
+
90
100
  // How `fit` sizes a picture against the width it has: `natural` is the
91
101
  // image's own size in points, never wider than the content box; `width`
92
102
  // scales to the content box either way. The ratio is kept in both, so a
93
103
  // height is never anything but the width's consequence.
94
104
  /** @type {(event: any, avail: number) => Picture} */
95
105
  let pictureOf = (event, avail) => {
96
- let { w, h } = intrinsic(event.bytes, event.format);
106
+ // Located here rather than in `image.js`, which is handed bytes and a
107
+ // format and has never heard of a schema: this is the innermost place that
108
+ // knows both what went wrong and which item asked for it. SCHEMA.md calls
109
+ // this failure a render error, and a render error names its node.
110
+ let sized;
111
+ try {
112
+ sized = intrinsic(event.bytes, event.format);
113
+ } catch (cause) {
114
+ throw imageError(event.path, /** @type {Error} */ (cause).message, cause);
115
+ }
116
+ let { w, h } = sized;
97
117
  let width = event.fit === "width" ? avail : Math.min(w, avail);
98
- return { bytes: event.bytes, format: event.format, w: width, h: (h * width) / w };
118
+ return {
119
+ bytes: event.bytes,
120
+ format: event.format,
121
+ w: width,
122
+ h: (h * width) / w,
123
+ };
99
124
  };
100
125
 
101
126
  // `under` is the enclosing style a slot's own layers over — a split's, when
@@ -161,7 +186,15 @@ let drawPicture = (canvas, block, x, avail) => {
161
186
  let at = x + shift(block.align, avail - outer);
162
187
  paintBox(canvas, at, canvas.y, outer, block.h, block.style, block.bg);
163
188
  canvas.box(block.path, at, canvas.y, outer, block.h);
164
- canvas.picture(bytes, format, at + inset.l, canvas.y - inset.t - block.drop - h, w, h);
189
+ canvas.picture(
190
+ block.path,
191
+ bytes,
192
+ format,
193
+ at + inset.l,
194
+ canvas.y - inset.t - block.drop - h,
195
+ w,
196
+ h,
197
+ );
165
198
  canvas.y -= block.h;
166
199
  canvas.fresh = false;
167
200
  };
@@ -330,14 +363,24 @@ let drawBlock = (canvas, block, x, avail) =>
330
363
  * @typedef {{ count: number, owner: number }} Pending
331
364
  */
332
365
 
366
+ // --- the cursor -------------------------------------------------------------
367
+
368
+ // The five below are the whole of how anything reaches page-vs-strip geometry,
369
+ // and they take a `Cursor` — a canvas and the region open on it, if one is —
370
+ // because the region is the only thing that changes any of their answers. The
371
+ // drawing primitives take a bare `Canvas` and read it flat, and that is not an
372
+ // exception to this: a primitive draws where it is told and does not choose a
373
+ // column, so it has nothing to ask. Page furniture reads flat for the same
374
+ // reason — `band` places at the page's own margin and never inside a region.
375
+ // The flow-spacing helpers below take a `Cursor` too: `keepLead` asks
376
+ // `freshOf`, so the spacing and the geometry are one seam, not two.
377
+
333
378
  // Where the cursor's column starts and how wide it is: the strip's, inside a
334
- // region, and the page's content box outside one. Every drawing call reads
335
- // these rather than `canvas.margin`/`canvas.content` the canvas keeps
336
- // presenting the page, because `pageFrame` re-derives page furniture from it.
337
- /** @type {(state: Flow) => number} */
379
+ // region, and the page's content box outside one.
380
+ /** @type {(state: Cursor) => number} */
338
381
  let originOf = (state) =>
339
382
  state.canvas.margin + (state.region ? state.region.index * (state.region.width + GUTTER) : 0);
340
- /** @type {(state: Flow) => number} */
383
+ /** @type {(state: Cursor) => number} */
341
384
  let widthOf = (state) => (state.region ? state.region.width : state.canvas.content);
342
385
  // The bottom the cursor fills to. A balanced strip's floor sits above the page
343
386
  // bottom and is a target rather than a bound: a fresh strip still takes at
@@ -345,7 +388,7 @@ let widthOf = (state) => (state.region ? state.region.width : state.canvas.conte
345
388
  // overruns. The last strip answers to the page instead — a balanced share is
346
389
  // rounded, so the strips before it may each stop a little short, and the
347
390
  // remainder has to land somewhere.
348
- /** @type {(state: Flow) => number} */
391
+ /** @type {(state: Cursor) => number} */
349
392
  let floorOf = (state) => {
350
393
  let region = state.region;
351
394
  if (!region || region.height == null || region.index === region.count - 1)
@@ -353,11 +396,11 @@ let floorOf = (state) => {
353
396
  return region.top - region.height;
354
397
  };
355
398
  // The y a fresh column starts from: a strip's shared top inside a region.
356
- /** @type {(state: Flow) => number} */
399
+ /** @type {(state: Cursor) => number} */
357
400
  let ceilOf = (state) => (state.region ? state.region.top : state.canvas.top);
358
401
  // Has this column taken anything yet? Answered positionally, because the only
359
402
  // thing that marks a page used is the cursor moving off its top, and the
360
- // drawing primitives that move it take a `Canvas` rather than a `Flow`.
403
+ // drawing primitives that move it take a `Canvas` rather than a `Cursor`.
361
404
  // `canvas.fresh` stays the *page*'s own answer — it is what keeps `turn` from
362
405
  // leaving an empty page behind — so a strip claims no second meaning for it.
363
406
  // A block of no height would read fresh here and used there; none is reachable
@@ -438,7 +481,7 @@ let skipFlow = (state, event, name) => {
438
481
  /** @type {(state: Cursor, event: any) => number} */
439
482
  let flowPad = (state, event) => leadOf(state, event) + spaceOf(event.style, "spaceAfter");
440
483
 
441
- // The unsplittable blocks -- a picture and a split -- move whole to a fresh
484
+ // The unsliceable blocks -- a picture and a split -- move whole to a fresh
442
485
  // column rather than breaking, so a balanced floor never cuts one: only the
443
486
  // page's own floor bears on it. A picture that no column can hold is scaled
444
487
  // down to the one it lands on, since obeying the guarantee literally would
@@ -538,12 +581,12 @@ let item = (state, event, measured = null) => {
538
581
  skipFlow(state, event, "spaceAfter");
539
582
  };
540
583
 
541
- // What a block does when it does not fit where it stands. A picture and a
542
- // split never break -- each moves whole to a fresh column; anything else flows
543
- // down it, breaking where `fitLines` says.
584
+ // What a block does when it does not fit where it stands. An unsliceable block
585
+ // moves whole to a fresh column; anything else flows down it, breaking where
586
+ // `fitLines` says.
544
587
  /** @type {(state: Flow, block: Block) => void} */
545
588
  let overflow = (state, block) =>
546
- block.picture || block.parts ? placeWhole(state, block) : slice(state, block);
589
+ unsliceable(block) ? placeWhole(state, block) : slice(state, block);
547
590
 
548
591
  // The height a page turn carries over: the drawn headers of the groups still
549
592
  // open, which every page their instances continue onto replays. Computed rather
@@ -666,18 +709,14 @@ let openRegion = (state) => {
666
709
  };
667
710
  };
668
711
 
669
- // Close the region: the cursor lands at whichever strip reached lowest, and
670
- // full-width content resumes under it on the same page (SCHEMA.md).
671
- /** @type {(state: Flow) => void} */
672
- let closeRegion = (state) => {
673
- let region = state.region;
674
- if (!region) return;
675
- // Committing replays the buffer, and a replayed footer closes the region on
676
- // its own way through — so re-read rather than trusting the local.
677
- if (region.held) commit(state);
678
- if (!state.region) return;
679
- let canvas = state.canvas;
680
- canvas.y = Math.min(canvas.y, ...region.ends);
712
+ // Land the region: the cursor drops to whichever strip reached lowest, and
713
+ // full-width content resumes under it on the same page (SCHEMA.md). The last
714
+ // reader of `ends`, which `restrip` empties and `advance` fills — closing is
715
+ // the flow's, because a region still holding has to be laid out first, but
716
+ // where the region lands is geometry and belongs here.
717
+ /** @type {(state: Flow, region: Region) => void} */
718
+ let landRegion = (state, region) => {
719
+ state.canvas.y = Math.min(state.canvas.y, ...region.ends);
681
720
  state.region = null;
682
721
  };
683
722
 
@@ -739,11 +778,14 @@ let UNITS = {
739
778
  },
740
779
  row: restated,
741
780
  "total-row": restated,
781
+ // The kinds `unsliceable` names, restated because a strip is costed from the
782
+ // event rather than the block -- this table's `whole` is the balancer's
783
+ // model, not the pagination truth. A third such kind belongs in both places.
742
784
  image: unbroken,
743
785
  split: unbroken,
744
786
  };
745
787
 
746
- // An unsplittable block owes a strip nothing but its own height.
788
+ // An entry no strip may cut owes it nothing but its own height.
747
789
  /** @type {(entry: any) => Unit} */
748
790
  function unbroken(entry) {
749
791
  return unsplit(entry.h, entry.h);
@@ -766,20 +808,19 @@ let stripHeight = (state, region, own) =>
766
808
  ? null
767
809
  : balance(unitsOf(state, own), region.count, roomOf(state, region), rowHeight(state.canvas));
768
810
 
769
- /** @type {(state: Flow) => void} */
811
+ // Commit the region: decide the strip height, stop holding, and hand the held
812
+ // entries back in the order they arrived, for `flow`'s `replay` to walk.
813
+ //
814
+ // Marking the spans is also what picks out the region's own children, and the
815
+ // height is decided from those — so it runs before the replay, not with it.
816
+ /** @type {(state: Flow) => any[]} */
770
817
  let commit = (state) => {
771
818
  let region = /** @type {Region} */ (state.region);
772
819
  let held = /** @type {any[]} */ (region.held);
773
- // Marking the spans is also what picks out the region's own children, and
774
- // the height is decided from those — so it runs before the replay, not with
775
- // it.
776
820
  region.height = stripHeight(state, region, spans(held));
777
821
  region.held = null;
778
822
  restrip(region, region.top);
779
- for (let entry of held) {
780
- if (breaksFor(state, region, entry.span)) advance(state);
781
- state.route(entry.event, entry.payload);
782
- }
823
+ return held;
783
824
  };
784
825
 
785
826
  // A group instance is the atomic unit inside a region: one that would cross a
@@ -864,10 +905,11 @@ let NESTING = { "group-start": 1, "group-end": -1 };
864
905
  // of what it holds. A measured block rides along so the replay never wraps the
865
906
  // same text twice; a held header counts towards the total like anything else,
866
907
  // but the flush that draws it measures from the event, so only its height is
867
- // kept. A buffer that outgrows what the strips could hold has answered the
868
- // question early: it commits to filling, and everything after it streams.
869
- /** @type {(state: Flow, event: any, payload: Block | Measured | null) => void} */
870
- let buffer = (state, event, payload) => {
908
+ // kept. Answers whether that event filled it: a buffer that outgrows what the
909
+ // strips could hold has answered the balancing question early, and everything
910
+ // after it streams.
911
+ /** @type {(state: Flow, event: any, payload: Block | Measured | null, hollow: boolean) => boolean} */
912
+ let buffer = (state, event, payload, hollow) => {
871
913
  let region = /** @type {Region} */ (state.region);
872
914
  // The one door into the held list, so it is where the buffer's own walk is
873
915
  // kept. A bracket buffered here is a bracket the open stack will not see
@@ -877,7 +919,33 @@ let buffer = (state, event, payload) => {
877
919
  /** @type {any[]} */ (region.held).push(entry);
878
920
  region.measured += entry.h;
879
921
  if (event.type === "table-end") region.measured += remeasure(state, region, entry);
880
- if (outgrown(state, region)) commit(state);
922
+ if (hollow) region.measured -= refund(/** @type {any[]} */ (region.held));
923
+ return outgrown(state, region);
924
+ };
925
+
926
+ // Give back what `WORTH` charged the opening of an instance that turned out
927
+ // hollow, and zero the entry so the replay's `spans` walk agrees. Taken as the
928
+ // close lands rather than at commit because `outgrown` is tested on every
929
+ // buffered event: a region carrying a phantom height can give up on balancing
930
+ // before it ever reaches the commit that would have corrected it.
931
+ //
932
+ // The walk back is over brackets only, which is safe precisely because the
933
+ // caller has already established the instance is hollow — there is nothing
934
+ // else between the two ends to walk over. Nested hollow instances refunded
935
+ // themselves on the way in, so each charge is given back exactly once.
936
+ /** @type {(held: any[]) => number} */
937
+ let refund = (held) => {
938
+ let depth = 0;
939
+ for (let i = held.length - 2; i >= 0; i--) {
940
+ if (held[i].event.type === "group-end") depth++;
941
+ else if (depth) depth--;
942
+ else {
943
+ let charged = held[i].h;
944
+ held[i].h = 0;
945
+ return charged;
946
+ }
947
+ }
948
+ return 0;
881
949
  };
882
950
 
883
951
  // A table's events reach the buffer one at a time, and each is worth a bare
@@ -1029,16 +1097,14 @@ let flush = (state, extra) => {
1029
1097
  };
1030
1098
 
1031
1099
  // Enough of an item that its group header is never left introducing nothing.
1032
- // The unsplittable blocks are kept whole -- the same `picture || parts` pair
1033
- // `overflow` moves rather than slices. There is no first part of one to keep
1034
- // company with: a split's own `lines` are empty, so clipping to `KEEP_LINES`
1035
- // would reserve nothing at all and strand the run it was called to protect.
1100
+ // An unsliceable block is kept whole -- the same blocks `overflow` moves
1101
+ // rather than slices. There is no first part of one to keep company with: a
1102
+ // split's own `lines` are empty, so clipping to `KEEP_LINES` would reserve
1103
+ // nothing at all and strand the run it was called to protect.
1036
1104
  let KEEP_LINES = 2;
1037
1105
  /** @type {(block: Block) => number} */
1038
1106
  let keepWith = (block) =>
1039
- block.picture || block.parts
1040
- ? block.h
1041
- : Math.min(block.h, heightOf(block.lines.slice(0, KEEP_LINES)));
1107
+ unsliceable(block) ? block.h : Math.min(block.h, heightOf(block.lines.slice(0, KEEP_LINES)));
1042
1108
 
1043
1109
  // Flush any pending header with this item's opening, then place the item. The
1044
1110
  // block is measured once here and handed on rather than wrapped twice.
@@ -1070,16 +1136,52 @@ let holding = (state) => state.open.some((group) => group.held.length) || state.
1070
1136
  // starts a new `page.number` / `page.total` sequence on the page it opens.
1071
1137
  // A duplicate start on a page that already began a sequence is a zero-length
1072
1138
  // range; `numbered` skips it.
1139
+ // Run `seen` on every event before the handler that acts on it. The stream is
1140
+ // the only place hollowness can be read in order: past this point a region may
1141
+ // hold an event back for a commit that lands after its own instance has closed.
1142
+ /** @type {(seen: (event: any) => void, handlers: Record<string, (event: any) => void>) => Record<string, (event: any) => void>} */
1143
+ let tracked = (seen, handlers) =>
1144
+ Object.fromEntries(
1145
+ Object.entries(handlers).map(([type, handle]) => [
1146
+ type,
1147
+ (/** @type {any} */ event) => {
1148
+ seen(event);
1149
+ handle(event);
1150
+ },
1151
+ ]),
1152
+ );
1153
+
1073
1154
  /** @type {(state: Flow, event: any) => void} */
1074
1155
  let openGroup = (state, event) => {
1075
1156
  breakFor(state, event);
1076
1157
  if (event.columns) state.pending = { count: event.columns, owner: event.depth };
1077
- state.gap = state.skipGap ? 0 : Math.max(state.gap, instanceGap(state.canvas));
1158
+ let gap = state.gap;
1159
+ let skip = state.skipGap;
1160
+ state.gap = skip ? 0 : Math.max(state.gap, instanceGap(state.canvas));
1078
1161
  state.skipGap = false;
1079
- state.open.push({ held: [], blocks: [] });
1162
+ state.open.push({ held: [], blocks: [], gap, skip });
1080
1163
  state.marks.push(markFor(event));
1081
1164
  };
1082
1165
 
1166
+ // Put back everything opening this instance displaced, so a hollow one leaves
1167
+ // the flow exactly as it found it. Restoring rather than zeroing is the whole
1168
+ // point: `Math.max` folded any ancestor's gap into this one, and a close that
1169
+ // zeroed would rob the parent of a gap its own opening earned. The pinned
1170
+ // header's `skip` travels the same way — a hollow instance is not the band the
1171
+ // pin was suppressing the gap for, so the suppression passes through it to
1172
+ // whatever lands next. The mark goes too: the outline is what a reader opens,
1173
+ // and a bookmark onto content this instance did not draw is a worse answer
1174
+ // than no bookmark. It is this instance's mark for certain — a non-hollow
1175
+ // child would have made this instance non-hollow, and a hollow one already
1176
+ // took its own back.
1177
+ /** @type {(state: Flow) => void} */
1178
+ let unopen = (state) => {
1179
+ let frame = state.open[state.open.length - 1];
1180
+ state.gap = frame.gap;
1181
+ state.skipGap = frame.skip;
1182
+ state.marks.pop();
1183
+ };
1184
+
1083
1185
  // A real page, inside a region as much as outside one: ADR 0013 declined a
1084
1186
  // `break: "column"`, so this never means the next strip. `turn` restrips.
1085
1187
  /** @type {(state: Flow, event: any) => void} */
@@ -1519,15 +1621,11 @@ let tableOf = (events) => {
1519
1621
  let opening = events[0];
1520
1622
  return {
1521
1623
  columns: opening.columns.map((/** @type {any} */ col) => ({ ...col })),
1522
- // A header cell arrives without a path of its own; the column's is what
1523
- // its box names, so it rides on the cell from here, where both readers
1524
- // assemble the table. A column a neighbour's span covers declares no
1525
- // header, so this is the header *row* -- as many cells as the columns
1526
- // wrote, which is fewer than the columns whenever one spans.
1527
- header: opening.columns
1528
- .filter((/** @type {any} */ col) => col.header)
1529
- .map((/** @type {any} */ col) => ({ ...col.header, path: col.path })),
1530
- headerStyle: opening.style,
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,
1531
1629
  rows: events.filter((event) => event.type === "row"),
1532
1630
  // The events themselves, as `rows` are: a total row is a row, and the
1533
1631
  // buffer files each corrected height under the event it was measured from.
@@ -1678,14 +1776,15 @@ let reserve = (geo, settings, bands) => {
1678
1776
  };
1679
1777
  };
1680
1778
 
1681
- // The page frame a canvas presents, before any band narrowed it. `frame` owns
1682
- // how `top`/`bottom` fall out of a page and a margin — "so no caller and no
1683
- // suite has to restate it", as it says next door and both halves of page
1684
- // furniture want it: reservation to measure against, the draw pass to hang the
1685
- // header from. Named apart from `page.js`'s exported `pageBox`, which validates
1686
- // a host's `page` option and is a different thing entirely.
1779
+ // The page frame a canvas presents, recovered from the canvas after `adopt`
1780
+ // narrowed it. Only `top`/`bottom` were overwritten there; `width`, `height`
1781
+ // and `margin` are still the page's own, so this re-derives the two that went
1782
+ // through the same `frame` every other consumer reads a derivation from live
1783
+ // fields, never a copy that could drift from one. Both halves of page furniture
1784
+ // want it: reservation to measure against, the draw pass to hang the header
1785
+ // from.
1687
1786
  /** @type {(canvas: Canvas) => Frame} */
1688
- let pageFrame = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1787
+ let unnarrowed = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1689
1788
 
1690
1789
  // One finished page's furniture, drawn in the strips `reserve` left for it:
1691
1790
  // the header hanging from the top margin, the footer resting on the bottom
@@ -1695,7 +1794,7 @@ let pageFrame = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1695
1794
  // that made room for them.
1696
1795
  /** @type {(canvas: Canvas, bands: any, anchor: any) => void} */
1697
1796
  let furniture = (canvas, bands, anchor) => {
1698
- if (bands.header) band(canvas, bands.header(anchor), pageFrame(canvas).top);
1797
+ if (bands.header) band(canvas, bands.header(anchor), unnarrowed(canvas).top);
1699
1798
  if (bands.footer) band(canvas, bands.footer(anchor), canvas.bottom - BAND);
1700
1799
  };
1701
1800
 
@@ -1728,12 +1827,12 @@ let numbered = (count, starts) => {
1728
1827
  * `finish` flushes whatever the last event left pending and returns what the
1729
1828
  * passes over the finished pages still need: the outline marks — the only way
1730
1829
  * to reach them, so an outline read off a band flow that still owes a flush is
1731
- * not a mistake this seam leaves open — the opening event it settled,
1732
- * handed back whole rather than read into, and the per-page `{ number, total }`
1733
- * anchors the furniture pass draws with.
1830
+ * not a mistake this seam leaves open — and the per-page `{ number, total }`
1831
+ * anchors the furniture pass draws with. The opening event itself the entry
1832
+ * peeked for both halves it is built from, so this seam keeps none of it.
1734
1833
  *
1735
1834
  * @type {(canvas: Canvas) => { handlers: Record<string, (event: any) => void>,
1736
- * finish: () => { marks: any[], opening: any,
1835
+ * finish: () => { marks: any[],
1737
1836
  * pages: { number: number, total: number }[] } }}
1738
1837
  */
1739
1838
  let flow = (canvas) => {
@@ -1749,23 +1848,35 @@ let flow = (canvas) => {
1749
1848
  pin: null,
1750
1849
  skipGap: false,
1751
1850
  inHeader: false,
1752
- // Assigned below, once `route` exists: a buffered region replays through
1753
- // it, and one entry point is what makes the replay take the path the
1754
- // content would have taken had it never been held.
1755
- route: () => {},
1756
1851
  };
1757
- // The body walk's own folder, emitting each finished event onward to `route`
1758
- // below. Declared here and bound after `route` exists.
1759
- /** @type {(event: any) => void} */
1760
- let fold;
1761
1852
  // A table is buffered whole before it is laid out: column widths come from
1762
1853
  // every cell in it, so the last row has to be in hand first.
1763
1854
  /** @type {any[]} */
1764
1855
  let collected = [];
1765
- // The opening event, kept for the caller: this band flow reserves the page
1766
- // bands off it, and the passes over the finished pages want the rest.
1767
- /** @type {any} */
1768
- let opening = null;
1856
+ // Hollowness is a property of the stream, not of what gets drawn: an
1857
+ // instance is hollow when nothing but hollow brackets arrived between its
1858
+ // own ends. Tracked at the handler layer, which every event reaches exactly
1859
+ // once `route` is re-entered by a region's replay, and a buffered instance
1860
+ // has to be judged before it is buffered, not after. `filling` is one flag
1861
+ // per open bracket; the verdict rides the closing event itself, so the
1862
+ // buffer and the replay both read the answer the stream gave.
1863
+ /** @type {boolean[]} */
1864
+ let filling = [];
1865
+ /** @type {WeakSet<any>} */
1866
+ let hollows = new WeakSet();
1867
+ // Whatever instance is open drew something. Called for an ordinary event and
1868
+ // for a child that turned out not to be hollow, because those are the same
1869
+ // claim: an instance draws through its content and through its children.
1870
+ let fill = () => {
1871
+ if (filling.length) filling[filling.length - 1] = true;
1872
+ };
1873
+ /** @type {(event: any) => void} */
1874
+ let track = (event) => {
1875
+ if (event.type === "group-start") filling.push(false);
1876
+ else if (event.type !== "group-end") fill();
1877
+ else if (filling.pop()) fill();
1878
+ else hollows.add(event);
1879
+ };
1769
1880
  // The body opens its own first page. Not through `turn`: a canvas that has
1770
1881
  // drawn nothing is fresh already, and would be left with no page at all.
1771
1882
  canvas.newPage();
@@ -1803,8 +1914,9 @@ let flow = (canvas) => {
1803
1914
  // default of its own -- its slots carry theirs.
1804
1915
  split: heldOrPlaced,
1805
1916
  "group-start": (event) => openGroup(state, event),
1806
- "group-end": () => {
1807
- flush(state, 0);
1917
+ "group-end": (event) => {
1918
+ if (hollows.has(event)) unopen(state);
1919
+ else flush(state, 0);
1808
1920
  state.open.pop();
1809
1921
  },
1810
1922
  // A table is collected as the events it arrived as, and shaped by `tableOf`
@@ -1864,13 +1976,53 @@ let flow = (canvas) => {
1864
1976
  let route = (event, payload = null) => {
1865
1977
  snapPin(event);
1866
1978
  if (fullBand(event)) fullWidth(event, payload);
1867
- else if (deciding()) buffer(state, event, payload);
1979
+ else if (deciding()) hold(event, payload);
1868
1980
  else if (opens(event)) begin(event);
1869
1981
  else place(event, payload);
1870
1982
  };
1871
1983
 
1872
- state.route = route;
1873
- fold = splitFolder(route);
1984
+ // The body walk's own folder, emitting each finished event onward to `route`.
1985
+ let fold = splitFolder(route);
1986
+
1987
+ // Hold one event back in the region's buffer, and lay that buffer out if this
1988
+ // is the event that filled it. Not `holdHeader`: that holds a group's header
1989
+ // for the content it introduces, this holds anything at all for a region
1990
+ // still deciding.
1991
+ /** @type {(event: any, payload: Block | Measured | null) => void} */
1992
+ let hold = (event, payload) => {
1993
+ if (buffer(state, event, payload, hollows.has(event))) replay();
1994
+ };
1995
+
1996
+ // Lay the buffer out: `commit` decides the strip height and hands the held
1997
+ // entries back in order, and this walks them through `route`. The walk lives
1998
+ // here rather than under the region section marker for the reason everything
1999
+ // there is module-level — that section is reachable without the router, and
2000
+ // a walk through `route` is not.
2001
+ let replay = () => {
2002
+ let region = /** @type {Region} */ (state.region);
2003
+ for (let entry of commit(state)) {
2004
+ if (breaksFor(state, region, entry.span)) advance(state);
2005
+ route(entry.event, entry.payload);
2006
+ }
2007
+ };
2008
+
2009
+ // Close the region: anything still held is laid out first, and the region
2010
+ // then lands where `landRegion` puts it.
2011
+ //
2012
+ // Nothing the replay routes can arrive back here, which is why the local
2013
+ // `region` survives the call. `route` asks `fullBand` before `deciding()`, so
2014
+ // a full band is never buffered; and a replayed event, asked `fullBand` a
2015
+ // second time, answers as it did the first, because `nesting()`'s two
2016
+ // branches walk the same brackets — `buffer` carries `region.nesting` through
2017
+ // them while nothing is placed, `state.open` through them once the replay is
2018
+ // placing. The other closer, `endOwned`, is wired above `route` at the
2019
+ // handler layer, where a replay does not reach.
2020
+ let closeRegion = () => {
2021
+ let region = state.region;
2022
+ if (!region) return;
2023
+ if (deciding()) replay();
2024
+ landRegion(state, region);
2025
+ };
1874
2026
 
1875
2027
  // Everything drawn across the page rather than down a strip: the declaring
1876
2028
  // node's own bands, its footer among them.
@@ -1890,7 +2042,7 @@ let flow = (canvas) => {
1890
2042
  /** @type {(depth: number) => void} */
1891
2043
  let endOwned = (depth) => {
1892
2044
  if (owner() !== depth) return;
1893
- if (state.region) closeRegion(state);
2045
+ if (state.region) closeRegion();
1894
2046
  else state.pending = null;
1895
2047
  };
1896
2048
 
@@ -1905,7 +2057,7 @@ let flow = (canvas) => {
1905
2057
  let fullWidth = (event, payload) => {
1906
2058
  // Asked before the region closes, because that is what it reads.
1907
2059
  let own = ownFooter(event);
1908
- closeRegion(state);
2060
+ closeRegion();
1909
2061
  if (own) state.pending = null;
1910
2062
  place(event, payload);
1911
2063
  };
@@ -1921,7 +2073,7 @@ let flow = (canvas) => {
1921
2073
  let block = keepBlock(state, event, stripWidth(state, owed.count));
1922
2074
  flush(state, block ? keepWith(block) : 0);
1923
2075
  openRegion(state);
1924
- buffer(state, event, block);
2076
+ hold(event, block);
1925
2077
  };
1926
2078
 
1927
2079
  /** @type {(event: any) => void} */
@@ -1935,12 +2087,17 @@ let flow = (canvas) => {
1935
2087
  };
1936
2088
 
1937
2089
  return {
1938
- handlers: {
2090
+ // Every handler is wrapped so `track` sees the stream once, in order, ahead
2091
+ // of the dispatch that may buffer the event for later. One wrap rather than
2092
+ // a call in each handler: a new event type must count towards the instance
2093
+ // it arrives in without anyone remembering to say so.
2094
+ handlers: tracked(track, {
1939
2095
  "report-start": (event) => {
1940
- opening = event;
1941
- // Every document-wide fact this target reads, settled in one place and
1942
- // once: `canvas.js` owns what they are and what taking them means.
1943
- adoptSettings(canvas.settings, event);
2096
+ // The document-wide settings were built complete from this same event
2097
+ // before the walk began (index.js peeks it), so this handler reads them
2098
+ // through `canvas.settings` and settles nothing only the page bands,
2099
+ // whose height is not known until here, are taken off it below. The
2100
+ // entry peeked the event too, so nothing is kept for it here.
1944
2101
  if (event.columns) state.pending = { count: event.columns, owner: -1 };
1945
2102
  if (!event.page) {
1946
2103
  pinHeader(event);
@@ -1952,7 +2109,7 @@ let flow = (canvas) => {
1952
2109
  // one thing a measurement must not be able to do. What a band needs is
1953
2110
  // a property of the page box in any case, not of a content box
1954
2111
  // something may already have narrowed.
1955
- adopt(canvas, reserve(pageFrame(canvas), canvas.settings, event.page));
2112
+ adopt(canvas, reserve(unnarrowed(canvas), canvas.settings, event.page));
1956
2113
  pinHeader(event);
1957
2114
  },
1958
2115
  // Everything else routes: `placed` above says what each event does, and
@@ -1975,14 +2132,17 @@ let flow = (canvas) => {
1975
2132
  endOwned(event.depth);
1976
2133
  route(event);
1977
2134
  },
1978
- },
2135
+ }),
1979
2136
  finish: () => {
1980
2137
  snapPin({ type: "report-end" });
1981
2138
  // A root region reaches here undrained when a report declares `columns`
1982
2139
  // and no full-width footer followed its body.
1983
- closeRegion(state);
2140
+ closeRegion();
1984
2141
  flush(state, 0);
1985
- return { marks: state.marks, opening, pages: numbered(canvas.count, state.starts) };
2142
+ return {
2143
+ marks: state.marks,
2144
+ pages: numbered(canvas.count, state.starts),
2145
+ };
1986
2146
  },
1987
2147
  };
1988
2148
  };
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,8 +69,7 @@ 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
 
package/lib/text.js CHANGED
@@ -9,10 +9,10 @@ import { LEAD, col, dressed, 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,
12
- // and the intl three a formatted value resolves in. Declared next door, in
13
- // `canvas.js`, where it is built and settled nothing here can touch a page,
14
- // and a caller hands over `canvas.settings` rather than the canvas.
15
- /** @typedef {import('./canvas.js').Settings} Settings */
12
+ // and the intl three a formatted value resolves in. Declared in `settings.js`,
13
+ // the module that owns them; a caller hands over `canvas.settings` rather than
14
+ // the canvas, so nothing here can touch a page.
15
+ /** @typedef {import('./settings.js').Settings} Settings */
16
16
 
17
17
  /**
18
18
  * @typedef {{ text: string, font: any, size: number, color: any,
@@ -29,7 +29,7 @@ import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
29
29
  /** @type {(token: any, style: any, settings: Settings) => string} */
30
30
  let rawOf = (token, style, settings) => {
31
31
  if ("literal" in token) return token.literal;
32
- return format(token.value, style?.format, settings) ?? display(token.value);
32
+ return format(token.value, style, settings) ?? display(token.value);
33
33
  };
34
34
 
35
35
  // This layout has no text-transform to defer to, so `uppercase` is applied to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/layout",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.5.0",
46
+ "quario": "^0.6.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.5.0"
52
+ "quario": "^0.6.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "fontkit": {