@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/layout.js CHANGED
@@ -21,39 +21,64 @@
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
+ /** `Measured` is the measured table's own type, and the flow passes one
27
+ * through: the region buffer measures early and the replay hands it back.
28
+ * @import { Measured, TableRow } from './measure.js' */
29
+
30
+ /** One table being emitted: what `carryOver`, `sliced` and `put` all need. It
31
+ * holds the flow, so it is the band flow's own — the measured half never sees
32
+ * a cursor.
33
+ * @typedef {{ state: Flow, head: TableRow,
34
+ * xOffsets: number[], widths: number[], x: number, avail: number }} Grid */
35
+ import { gridOf, measureTable, sum, tableOf } from "./measure.js";
36
+ import { adopt, measuring } from "./canvas.js";
37
+ import { frame } from "./page.js";
27
38
  import { intrinsic } from "./image.js";
28
39
  import { CELL_PAD, NO_PAD, WHOLE, insetOf, isWhole, paintBox, sliceInset, unbox } from "./box.js";
29
- import { BAND, col, GUTTER, LEAD, merge, roled, shift, sizeOf, vshift } from "./style.js";
30
- import { atoms, dress, heightOf, wrap } from "./text.js";
40
+ import {
41
+ BAND,
42
+ col,
43
+ GUTTER,
44
+ instanceGap,
45
+ merge,
46
+ roled,
47
+ rowHeight,
48
+ shift,
49
+ sizeOf,
50
+ vshift,
51
+ } from "./style.js";
52
+ import { atoms, heightOf, wrap } from "./text.js";
31
53
 
32
54
  /** @typedef {import('./balance.js').Unit} Unit */
33
55
  /** @typedef {import('./canvas.js').Canvas} Canvas */
34
- /** @typedef {import('./canvas.js').Frame} Frame */
35
- /** @typedef {import('./canvas.js').Settings} Settings */
56
+ /** @typedef {import('./page.js').Frame} Frame */
57
+ /** @typedef {import('./settings.js').Settings} Settings */
36
58
  /** @typedef {import('./text.js').Line} Line */
37
59
 
38
60
  // `open` is a stack, one entry per group instance still open: the header items
39
61
  // 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).
62
+ // a page turn replays them from. Each entry also carries the `gap` and `skip`
63
+ // that stood before it opened, which is what a hollow instance puts back.
64
+ // `gap` is the space owed before the next group instance, `marks` the outline
65
+ // entries, which `anchor` positions from the cursor, and `starts` the page
66
+ // indexes where a `reset: "page"` sequence begins (the document itself is the
67
+ // sequence that starts at page 0).
44
68
  /**
45
- * @typedef {{ held: any[], blocks: Block[] }} Group
69
+ * @typedef {{ pending: any[], blocks: Block[], gap: number, skip: boolean, titled: boolean }} Group
46
70
  */
47
71
  /**
48
72
  * @typedef {{ canvas: Canvas, open: Group[], gap: number, marks: any[],
49
73
  * 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
74
+ * pin: number | null, skipGap: boolean, inHeader: boolean }} Flow
52
75
  */
53
76
  /**
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.
77
+ * Where the cursor is and whether a strip is open — the whole of what
78
+ * page-vs-strip geometry depends on, and less than the whole flow. The five
79
+ * accessors under "the cursor" below take this, and so do the flow-spacing
80
+ * helpers beside them, which is how page furniture drives spacing off a canvas
81
+ * and no region at all.
57
82
  * @typedef {{ canvas: Canvas, region: Region | null }} Cursor
58
83
  */
59
84
 
@@ -87,15 +112,36 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
87
112
  * @typedef {{ block: Block, w: number }} SlotPart
88
113
  */
89
114
 
115
+ // A block no page break may pass through, and the negation of `slice`. The
116
+ // region balancer answers the same question its own way, keyed on the event
117
+ // rather than the block (`UNITS`), so a new kind of one belongs there too.
118
+ /** @type {(block: Block) => boolean} */
119
+ let unsliceable = (block) => Boolean(block.picture || block.parts);
120
+
90
121
  // How `fit` sizes a picture against the width it has: `natural` is the
91
122
  // image's own size in points, never wider than the content box; `width`
92
123
  // scales to the content box either way. The ratio is kept in both, so a
93
124
  // height is never anything but the width's consequence.
94
125
  /** @type {(event: any, avail: number) => Picture} */
95
126
  let pictureOf = (event, avail) => {
96
- let { w, h } = intrinsic(event.bytes, event.format);
127
+ // Located here rather than in `image.js`, which is handed bytes and a
128
+ // format and has never heard of a schema: this is the innermost place that
129
+ // knows both what went wrong and which item asked for it. SCHEMA.md calls
130
+ // this failure a render error, and a render error names its node.
131
+ let sized;
132
+ try {
133
+ sized = intrinsic(event.bytes, event.format);
134
+ } catch (cause) {
135
+ throw imageError(event.path, /** @type {Error} */ (cause).message, cause);
136
+ }
137
+ let { w, h } = sized;
97
138
  let width = event.fit === "width" ? avail : Math.min(w, avail);
98
- return { bytes: event.bytes, format: event.format, w: width, h: (h * width) / w };
139
+ return {
140
+ bytes: event.bytes,
141
+ format: event.format,
142
+ w: width,
143
+ h: (h * width) / w,
144
+ };
99
145
  };
100
146
 
101
147
  // `under` is the enclosing style a slot's own layers over — a split's, when
@@ -124,7 +170,7 @@ let blockOf = (canvas, event, avail, under = null) => {
124
170
  };
125
171
  }
126
172
  let size = sizeOf(text, canvas.settings.base);
127
- let lines = dress(wrap(atoms(canvas.settings, event.tokens, text), inner, size), text);
173
+ let lines = wrap(atoms(canvas.settings, event.tokens, text), inner, size);
128
174
  return {
129
175
  lines,
130
176
  bg,
@@ -161,7 +207,15 @@ let drawPicture = (canvas, block, x, avail) => {
161
207
  let at = x + shift(block.align, avail - outer);
162
208
  paintBox(canvas, at, canvas.y, outer, block.h, block.style, block.bg);
163
209
  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);
210
+ canvas.picture(
211
+ block.path,
212
+ bytes,
213
+ format,
214
+ at + inset.l,
215
+ canvas.y - inset.t - block.drop - h,
216
+ w,
217
+ h,
218
+ );
165
219
  canvas.y -= block.h;
166
220
  canvas.fresh = false;
167
221
  };
@@ -330,14 +384,24 @@ let drawBlock = (canvas, block, x, avail) =>
330
384
  * @typedef {{ count: number, owner: number }} Pending
331
385
  */
332
386
 
387
+ // --- the cursor -------------------------------------------------------------
388
+
389
+ // The five below are the whole of how anything reaches page-vs-strip geometry,
390
+ // and they take a `Cursor` — a canvas and the region open on it, if one is —
391
+ // because the region is the only thing that changes any of their answers. The
392
+ // drawing primitives take a bare `Canvas` and read it flat, and that is not an
393
+ // exception to this: a primitive draws where it is told and does not choose a
394
+ // column, so it has nothing to ask. Page furniture reads flat for the same
395
+ // reason — `band` places at the page's own margin and never inside a region.
396
+ // The flow-spacing helpers below take a `Cursor` too: `keepLead` asks
397
+ // `freshOf`, so the spacing and the geometry are one seam, not two.
398
+
333
399
  // 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} */
400
+ // region, and the page's content box outside one.
401
+ /** @type {(state: Cursor) => number} */
338
402
  let originOf = (state) =>
339
403
  state.canvas.margin + (state.region ? state.region.index * (state.region.width + GUTTER) : 0);
340
- /** @type {(state: Flow) => number} */
404
+ /** @type {(state: Cursor) => number} */
341
405
  let widthOf = (state) => (state.region ? state.region.width : state.canvas.content);
342
406
  // The bottom the cursor fills to. A balanced strip's floor sits above the page
343
407
  // bottom and is a target rather than a bound: a fresh strip still takes at
@@ -345,7 +409,7 @@ let widthOf = (state) => (state.region ? state.region.width : state.canvas.conte
345
409
  // overruns. The last strip answers to the page instead — a balanced share is
346
410
  // rounded, so the strips before it may each stop a little short, and the
347
411
  // remainder has to land somewhere.
348
- /** @type {(state: Flow) => number} */
412
+ /** @type {(state: Cursor) => number} */
349
413
  let floorOf = (state) => {
350
414
  let region = state.region;
351
415
  if (!region || region.height == null || region.index === region.count - 1)
@@ -353,11 +417,11 @@ let floorOf = (state) => {
353
417
  return region.top - region.height;
354
418
  };
355
419
  // The y a fresh column starts from: a strip's shared top inside a region.
356
- /** @type {(state: Flow) => number} */
420
+ /** @type {(state: Cursor) => number} */
357
421
  let ceilOf = (state) => (state.region ? state.region.top : state.canvas.top);
358
422
  // Has this column taken anything yet? Answered positionally, because the only
359
423
  // 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`.
424
+ // drawing primitives that move it take a `Canvas` rather than a `Cursor`.
361
425
  // `canvas.fresh` stays the *page*'s own answer — it is what keeps `turn` from
362
426
  // leaving an empty page behind — so a strip claims no second meaning for it.
363
427
  // A block of no height would read fresh here and used there; none is reachable
@@ -438,7 +502,7 @@ let skipFlow = (state, event, name) => {
438
502
  /** @type {(state: Cursor, event: any) => number} */
439
503
  let flowPad = (state, event) => leadOf(state, event) + spaceOf(event.style, "spaceAfter");
440
504
 
441
- // The unsplittable blocks -- a picture and a split -- move whole to a fresh
505
+ // The unsliceable blocks -- a picture and a split -- move whole to a fresh
442
506
  // column rather than breaking, so a balanced floor never cuts one: only the
443
507
  // page's own floor bears on it. A picture that no column can hold is scaled
444
508
  // down to the one it lands on, since obeying the guarantee literally would
@@ -538,12 +602,12 @@ let item = (state, event, measured = null) => {
538
602
  skipFlow(state, event, "spaceAfter");
539
603
  };
540
604
 
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.
605
+ // What a block does when it does not fit where it stands. An unsliceable block
606
+ // moves whole to a fresh column; anything else flows down it, breaking where
607
+ // `fitLines` says.
544
608
  /** @type {(state: Flow, block: Block) => void} */
545
609
  let overflow = (state, block) =>
546
- block.picture || block.parts ? placeWhole(state, block) : slice(state, block);
610
+ unsliceable(block) ? placeWhole(state, block) : slice(state, block);
547
611
 
548
612
  // The height a page turn carries over: the drawn headers of the groups still
549
613
  // open, which every page their instances continue onto replays. Computed rather
@@ -643,7 +707,7 @@ let stripWidth = (state, count) => (state.canvas.content - GUTTER * (count - 1))
643
707
  //
644
708
  // It opens buffering: what the strips do with the content depends on how much
645
709
  // of it there is, and that is not known until the region ends or outgrows a
646
- // page. `held` is that buffer; `null` means the region has committed.
710
+ // page. `held` is that buffer; `null` means the region has decided.
647
711
  /** @type {(state: Flow) => void} */
648
712
  let openRegion = (state) => {
649
713
  let owed = state.pending;
@@ -666,18 +730,14 @@ let openRegion = (state) => {
666
730
  };
667
731
  };
668
732
 
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);
733
+ // Land the region: the cursor drops to whichever strip reached lowest, and
734
+ // full-width content resumes under it on the same page (SCHEMA.md). The last
735
+ // reader of `ends`, which `restrip` empties and `advance` fills — closing is
736
+ // the flow's, because a region still holding has to be laid out first, but
737
+ // where the region lands is geometry and belongs here.
738
+ /** @type {(state: Flow, region: Region) => void} */
739
+ let landRegion = (state, region) => {
740
+ state.canvas.y = Math.min(state.canvas.y, ...region.ends);
681
741
  state.region = null;
682
742
  };
683
743
 
@@ -739,11 +799,14 @@ let UNITS = {
739
799
  },
740
800
  row: restated,
741
801
  "total-row": restated,
802
+ // The kinds `unsliceable` names, restated because a strip is costed from the
803
+ // event rather than the block -- this table's `whole` is the balancer's
804
+ // model, not the pagination truth. A third such kind belongs in both places.
742
805
  image: unbroken,
743
806
  split: unbroken,
744
807
  };
745
808
 
746
- // An unsplittable block owes a strip nothing but its own height.
809
+ // An entry no strip may cut owes it nothing but its own height.
747
810
  /** @type {(entry: any) => Unit} */
748
811
  function unbroken(entry) {
749
812
  return unsplit(entry.h, entry.h);
@@ -766,20 +829,21 @@ let stripHeight = (state, region, own) =>
766
829
  ? null
767
830
  : balance(unitsOf(state, own), region.count, roomOf(state, region), rowHeight(state.canvas));
768
831
 
769
- /** @type {(state: Flow) => void} */
770
- let commit = (state) => {
832
+ // Decide the region: settle the strip height, stop holding, and hand the held
833
+ // entries back in the order they arrived, for `flow`'s `replay` to walk.
834
+ // Not `commit`: the editor's session already spends that word on a gesture
835
+ // ending, and git spends it on a commit.
836
+ //
837
+ // Marking the spans is also what picks out the region's own children, and the
838
+ // height is decided from those — so it runs before the replay, not with it.
839
+ /** @type {(state: Flow) => any[]} */
840
+ let decide = (state) => {
771
841
  let region = /** @type {Region} */ (state.region);
772
842
  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
843
  region.height = stripHeight(state, region, spans(held));
777
844
  region.held = null;
778
845
  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
- }
846
+ return held;
783
847
  };
784
848
 
785
849
  // A group instance is the atomic unit inside a region: one that would cross a
@@ -864,10 +928,11 @@ let NESTING = { "group-start": 1, "group-end": -1 };
864
928
  // of what it holds. A measured block rides along so the replay never wraps the
865
929
  // same text twice; a held header counts towards the total like anything else,
866
930
  // 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) => {
931
+ // kept. Answers whether that event filled it: a buffer that outgrows what the
932
+ // strips could hold has answered the balancing question early, and everything
933
+ // after it streams.
934
+ /** @type {(state: Flow, event: any, payload: Block | Measured | null, hollow: boolean) => boolean} */
935
+ let buffer = (state, event, payload, hollow) => {
871
936
  let region = /** @type {Region} */ (state.region);
872
937
  // The one door into the held list, so it is where the buffer's own walk is
873
938
  // kept. A bracket buffered here is a bracket the open stack will not see
@@ -877,7 +942,33 @@ let buffer = (state, event, payload) => {
877
942
  /** @type {any[]} */ (region.held).push(entry);
878
943
  region.measured += entry.h;
879
944
  if (event.type === "table-end") region.measured += remeasure(state, region, entry);
880
- if (outgrown(state, region)) commit(state);
945
+ if (hollow) region.measured -= refund(/** @type {any[]} */ (region.held));
946
+ return outgrown(state, region);
947
+ };
948
+
949
+ // Give back what `WORTH` charged the opening of an instance that turned out
950
+ // hollow, and zero the entry so the replay's `spans` walk agrees. Taken as the
951
+ // close lands rather than at commit because `outgrown` is tested on every
952
+ // buffered event: a region carrying a phantom height can give up on balancing
953
+ // before it ever reaches the commit that would have corrected it.
954
+ //
955
+ // The walk back is over brackets only, which is safe precisely because the
956
+ // caller has already established the instance is hollow — there is nothing
957
+ // else between the two ends to walk over. Nested hollow instances refunded
958
+ // themselves on the way in, so each charge is given back exactly once.
959
+ /** @type {(held: any[]) => number} */
960
+ let refund = (held) => {
961
+ let depth = 0;
962
+ for (let i = held.length - 2; i >= 0; i--) {
963
+ if (held[i].event.type === "group-end") depth++;
964
+ else if (depth) depth--;
965
+ else {
966
+ let charged = held[i].h;
967
+ held[i].h = 0;
968
+ return charged;
969
+ }
970
+ }
971
+ return 0;
881
972
  };
882
973
 
883
974
  // A table's events reach the buffer one at a time, and each is worth a bare
@@ -963,7 +1054,7 @@ let anchor = (state) => {
963
1054
  };
964
1055
 
965
1056
  /** @type {(state: Flow) => Group[]} */
966
- let pending = (state) => state.open.filter((group) => group.held.length);
1057
+ let pending = (state) => state.open.filter((group) => group.pending.length);
967
1058
 
968
1059
  /** @type {(state: Flow, extra: number, runs: Block[][]) => number} */
969
1060
  let needed = (state, extra, runs) =>
@@ -974,12 +1065,12 @@ let needed = (state, extra, runs) =>
974
1065
  // for good: every other path drew its headers inside the room a page still
975
1066
  // had, so a run always leaves space for the content it introduces.
976
1067
  /** @type {(state: Flow, holding: Group[]) => void} */
977
- let dropHeld = (state, holding) => {
978
- let held = holding.flatMap((group) => group.held);
979
- for (let group of holding) group.held = [];
1068
+ let dropPending = (state, holding) => {
1069
+ let waiting = holding.flatMap((group) => group.pending);
1070
+ for (let group of holding) group.pending = [];
980
1071
  state.gap = 0;
981
1072
  anchor(state);
982
- for (let entry of held) item(state, entry.event, entry.block);
1073
+ for (let entry of waiting) item(state, entry.event, entry.block);
983
1074
  };
984
1075
 
985
1076
  /** @type {(state: Flow, need: number) => void} */
@@ -997,12 +1088,12 @@ let settle = (state, need) => {
997
1088
  // — the turn above already did — so an instance taking its blocks the moment
998
1089
  // they are drawn can never have them replayed on top of themselves.
999
1090
  /** @type {(state: Flow, holding: Group[], runs: Block[][], avail: number) => void} */
1000
- let drawHeld = (state, holding, runs, avail) => {
1091
+ let drawPending = (state, holding, runs, avail) => {
1001
1092
  let x = originOf(state);
1002
1093
  for (let [i, group] of holding.entries()) {
1003
1094
  for (let block of runs[i]) drawBlock(state.canvas, block, x, avail);
1004
1095
  group.blocks.push(...runs[i]);
1005
- group.held = [];
1096
+ group.pending = [];
1006
1097
  }
1007
1098
  };
1008
1099
 
@@ -1018,27 +1109,25 @@ let flush = (state, extra) => {
1018
1109
  // at this same width, so a replayed run reuses those rather than wrapping
1019
1110
  // the same text a second time (ADR 0027).
1020
1111
  let runs = runsFor.map((group) =>
1021
- group.held.map((entry) => entry.block || blockOf(canvas, entry.event, avail)),
1112
+ group.pending.map((entry) => entry.block || blockOf(canvas, entry.event, avail)),
1022
1113
  );
1023
1114
  let need = needed(state, extra, runs);
1024
1115
  // Against what a fresh column has left once the page has replayed what it
1025
1116
  // carries. Inside a region that is a strip, and the replay sits above it.
1026
- if (need > ceilOf(state) - carried(state) - floorOf(state)) return dropHeld(state, runsFor);
1117
+ if (need > ceilOf(state) - carried(state) - floorOf(state)) return dropPending(state, runsFor);
1027
1118
  settle(state, need);
1028
- drawHeld(state, runsFor, runs, avail);
1119
+ drawPending(state, runsFor, runs, avail);
1029
1120
  };
1030
1121
 
1031
1122
  // 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.
1123
+ // An unsliceable block is kept whole -- the same blocks `overflow` moves
1124
+ // rather than slices. There is no first part of one to keep company with: a
1125
+ // split's own `lines` are empty, so clipping to `KEEP_LINES` would reserve
1126
+ // nothing at all and strand the run it was called to protect.
1036
1127
  let KEEP_LINES = 2;
1037
1128
  /** @type {(block: Block) => number} */
1038
1129
  let keepWith = (block) =>
1039
- block.picture || block.parts
1040
- ? block.h
1041
- : Math.min(block.h, heightOf(block.lines.slice(0, KEEP_LINES)));
1130
+ unsliceable(block) ? block.h : Math.min(block.h, heightOf(block.lines.slice(0, KEEP_LINES)));
1042
1131
 
1043
1132
  // Flush any pending header with this item's opening, then place the item. The
1044
1133
  // block is measured once here and handed on rather than wrapped twice.
@@ -1060,7 +1149,7 @@ let keepBlock = (state, event, width) =>
1060
1149
  // Is anything waiting on this item's arrival — a header run, or the gap an
1061
1150
  // instance opens with?
1062
1151
  /** @type {(state: Flow) => boolean} */
1063
- let holding = (state) => state.open.some((group) => group.held.length) || state.gap > 0;
1152
+ let holding = (state) => state.open.some((group) => group.pending.length) || state.gap > 0;
1064
1153
 
1065
1154
  // A half-line gap before each instance (dropped at a page top by `flush`)
1066
1155
  // keeps groups reading as blocks. Every instance becomes an outline entry.
@@ -1070,16 +1159,52 @@ let holding = (state) => state.open.some((group) => group.held.length) || state.
1070
1159
  // starts a new `page.number` / `page.total` sequence on the page it opens.
1071
1160
  // A duplicate start on a page that already began a sequence is a zero-length
1072
1161
  // range; `numbered` skips it.
1162
+ // Run `seen` on every event before the handler that acts on it. The stream is
1163
+ // the only place hollowness can be read in order: past this point a region may
1164
+ // hold an event back for a commit that lands after its own instance has closed.
1165
+ /** @type {(seen: (event: any) => void, handlers: Record<string, (event: any) => void>) => Record<string, (event: any) => void>} */
1166
+ let tracked = (seen, handlers) =>
1167
+ Object.fromEntries(
1168
+ Object.entries(handlers).map(([type, handle]) => [
1169
+ type,
1170
+ (/** @type {any} */ event) => {
1171
+ seen(event);
1172
+ handle(event);
1173
+ },
1174
+ ]),
1175
+ );
1176
+
1073
1177
  /** @type {(state: Flow, event: any) => void} */
1074
1178
  let openGroup = (state, event) => {
1075
1179
  breakFor(state, event);
1076
1180
  if (event.columns) state.pending = { count: event.columns, owner: event.depth };
1077
- state.gap = state.skipGap ? 0 : Math.max(state.gap, instanceGap(state.canvas));
1181
+ let gap = state.gap;
1182
+ let skip = state.skipGap;
1183
+ state.gap = skip ? 0 : Math.max(state.gap, instanceGap(state.canvas));
1078
1184
  state.skipGap = false;
1079
- state.open.push({ held: [], blocks: [] });
1185
+ state.open.push({ pending: [], blocks: [], gap, skip, titled: false });
1080
1186
  state.marks.push(markFor(event));
1081
1187
  };
1082
1188
 
1189
+ // Put back everything opening this instance displaced, so a hollow one leaves
1190
+ // the flow exactly as it found it. Restoring rather than zeroing is the whole
1191
+ // point: `Math.max` folded any ancestor's gap into this one, and a close that
1192
+ // zeroed would rob the parent of a gap its own opening earned. The pinned
1193
+ // header's `skip` travels the same way — a hollow instance is not the band the
1194
+ // pin was suppressing the gap for, so the suppression passes through it to
1195
+ // whatever lands next. The mark goes too: the outline is what a reader opens,
1196
+ // and a bookmark onto content this instance did not draw is a worse answer
1197
+ // than no bookmark. It is this instance's mark for certain — a non-hollow
1198
+ // child would have made this instance non-hollow, and a hollow one already
1199
+ // took its own back.
1200
+ /** @type {(state: Flow) => void} */
1201
+ let unopen = (state) => {
1202
+ let frame = state.open[state.open.length - 1];
1203
+ state.gap = frame.gap;
1204
+ state.skipGap = frame.skip;
1205
+ state.marks.pop();
1206
+ };
1207
+
1083
1208
  // A real page, inside a region as much as outside one: ADR 0013 declined a
1084
1209
  // `break: "column"`, so this never means the next strip. `turn` restrips.
1085
1210
  /** @type {(state: Flow, event: any) => void} */
@@ -1093,7 +1218,6 @@ let markFor = (event) => ({
1093
1218
  // display(), not String(): a Date group key must title its outline bookmark
1094
1219
  // with the same ISO 8601 UTC text its cells render, on every machine.
1095
1220
  title: event.name + ": " + display(event.key),
1096
- titled: false,
1097
1221
  depth: event.depth,
1098
1222
  page: -1,
1099
1223
  y: 0,
@@ -1103,24 +1227,24 @@ let markFor = (event) => ({
1103
1227
  // page bottom. The instance's first header titles its outline entry.
1104
1228
  /** @type {(state: Flow, event: any, title: string | null, block?: Block | null) => void} */
1105
1229
  let holdHeader = (state, event, title, block = null) => {
1230
+ let group = state.open[state.open.length - 1];
1106
1231
  let mark = state.marks[state.marks.length - 1];
1232
+ // Whether a header has claimed this entry is the open instance's own fact,
1233
+ // not the outline entry's: a Mark is what a consumer receives, and it
1234
+ // carries only what `index.d.ts` declares. The two records correspond here
1235
+ // because `openGroup` pushes them together and this only ever runs inside
1236
+ // that instance's own header band, before any nested group opens -- groups
1237
+ // pop from `open` while their marks stay, so nowhere else may assume it.
1238
+ //
1107
1239
  // A picture has no words to title an outline entry with, so it holds its
1108
1240
  // place in the run and leaves the title to the next header that has some.
1109
- if (mark && !mark.titled && title != null) {
1110
- mark.titled = true;
1241
+ if (mark && !group.titled && title != null) {
1242
+ group.titled = true;
1111
1243
  mark.title = title;
1112
1244
  }
1113
- state.open[state.open.length - 1].held.push({ event, block });
1245
+ group.pending.push({ event, block });
1114
1246
  };
1115
1247
 
1116
- // A table row's floor height and the gap a group instance opens with: named
1117
- // because the region's estimator reads both, and a constant with two readers
1118
- // and no name drifts between them.
1119
- /** @type {(canvas: Canvas) => number} */
1120
- let rowHeight = (canvas) => LEAD * canvas.settings.base;
1121
- /** @type {(canvas: Canvas) => number} */
1122
- let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
1123
-
1124
1248
  // What a fresh strip has to hold to take an instance: the span less the
1125
1249
  // opening gap, which `settle` drops at a strip head. This is the one fact the
1126
1250
  // balance model and the placement rule have to agree on, so both read it here
@@ -1130,60 +1254,6 @@ let headOf = (canvas, span) => span - instanceGap(canvas);
1130
1254
 
1131
1255
  // --- tables -----------------------------------------------------------------
1132
1256
 
1133
- // Pre-measure one cell: wrapped lines per column width come later; natural
1134
- // width first (no wrapping except hard breaks). `mt`/`mb` go unread: SCHEMA.md
1135
- // gives flow spacing no meaning inside a table row, in either target.
1136
- /** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
1137
- let cellOf = (canvas, cell, rowStyle) => {
1138
- // A row's block reaches its cells with no box in it: the engine resolved
1139
- // that half onto the cells themselves before the event was emitted
1140
- // (SCHEMA.md, "Style declarations"), which is why the inset and the ink
1141
- // both read the cell and a row is never asked for a box it cannot have.
1142
- let style = merge(rowStyle, unbox(cell.style));
1143
- let inset = insetOf(cell.style, CELL_PAD);
1144
- let list = atoms(canvas.settings, cell.tokens, style);
1145
- let natural = wrap(list, Infinity, sizeOf(style, canvas.settings.base)).reduce(
1146
- (widest, line) => Math.max(widest, line.w),
1147
- 0,
1148
- );
1149
- return {
1150
- list,
1151
- natural: natural + inset.l + inset.r,
1152
- align: style.align,
1153
- valign: style.valign,
1154
- bg: col(style.background),
1155
- underline: !!style.underline,
1156
- strikethrough: !!style.strikethrough,
1157
- inset,
1158
- style: cell.style,
1159
- path: cell.path,
1160
- };
1161
- };
1162
-
1163
- /** @type {(canvas: Canvas, cells: any[], widths: number[]) => { cells: any[], h: number }} */
1164
- let rowOf = (canvas, cells, widths) => {
1165
- let h = rowHeight(canvas);
1166
- /** @type {number[]} */
1167
- let owns = [];
1168
- let out = cells.map((cell, i) => {
1169
- let inner = Math.max(widths[i] - cell.inset.l - cell.inset.r, 1);
1170
- // A glyph or a hard break occupies; spaces alone are an empty cell.
1171
- let lines = cell.list.some(
1172
- (/** @type {{ hard: boolean, space: boolean }} */ atom) => atom.hard || !atom.space,
1173
- )
1174
- ? dress(wrap(cell.list, inner, cell.list[0].size), cell)
1175
- : [];
1176
- let own = heightOf(lines) + cell.inset.t + cell.inset.b;
1177
- if (own > h) h = own;
1178
- owns.push(own);
1179
- return { ...cell, lines };
1180
- });
1181
- // The row's height is known only now, so the slack each cell's `valign`
1182
- // reads is measured here, as `splitBlock` measures a slot's.
1183
- for (let [i, cell] of out.entries()) cell.drop = vshift(cell.valign, h - owns[i]);
1184
- return { cells: out, h };
1185
- };
1186
-
1187
1257
  /** @type {(canvas: Canvas, cell: any, bg: any, x: number, y: number, w: number, h: number) => void} */
1188
1258
  let fillCell = (canvas, cell, bg, x, y, w, h) => {
1189
1259
  if (cell.bg && cell.bg !== bg) canvas.rect(cell.bg, x, y, w, h);
@@ -1243,107 +1313,7 @@ let drawRow = (canvas, row, xOffsets, widths, bg) => {
1243
1313
  );
1244
1314
  };
1245
1315
 
1246
- // The column geometry one row sees. A cell covering several columns starts
1247
- // where the first of them starts and is as wide as all of them together; a row
1248
- // that spans nothing sees the columns themselves, and pays nothing for the
1249
- // feature. `null` spans is that row -- every data row, and every total row
1250
- // whose cells each cover one column.
1251
- /** @type {(cells: any[]) => number[] | null} */
1252
- let spansOf = (cells) =>
1253
- cells.some((cell) => cell.span > 1) ? cells.map((cell) => cell.span || 1) : null;
1254
- // One value per cell, walking the columns each of them covers. Reached only
1255
- // for a row that spans, so the closure it takes costs nothing per data row.
1256
- /** @type {(spans: number[], pick: (at: number, span: number) => number) => number[]} */
1257
- let overSpans = (spans, pick) => {
1258
- /** @type {number[]} */
1259
- let out = [];
1260
- let at = 0;
1261
- for (let span of spans) {
1262
- out.push(pick(at, span));
1263
- at += span;
1264
- }
1265
- return out;
1266
- };
1267
- /** @type {(widths: number[], spans: number[] | null) => number[]} */
1268
- let spanWidths = (widths, spans) =>
1269
- spans ? overSpans(spans, (at, span) => sum(widths.slice(at, at + span))) : widths;
1270
1316
  /** @type {(xOffsets: number[], spans: number[] | null) => number[]} */
1271
- let spanOffsets = (xOffsets, spans) => (spans ? overSpans(spans, (at) => xOffsets[at]) : xOffsets);
1272
-
1273
- // The widest of each column's header, rows and totals, padding included.
1274
- //
1275
- // A cell covering more than one column has no say in their widths (SCHEMA.md):
1276
- // what a span states is which columns a cell reaches across, never how wide
1277
- // they are. So a column can end up with no voter at all -- every cell above it
1278
- // spans over it -- which only an empty table reaches, since a data row never
1279
- // spans. It opens at the padding floor rather than at nothing, so an empty
1280
- // table still shows the geometry it promises.
1281
- /**
1282
- * @typedef {{ cells: any[], spans: number[] | null }} Voting
1283
- */
1284
- /** @type {(cols: any[], header: Voting, rows: Voting[], totals: Voting[]) => number[]} */
1285
- let naturalWidths = (cols, header, rows, totals) => {
1286
- /** @type {(number | null)[]} */
1287
- let widest = cols.map(() => null);
1288
- /** @type {(at: number, natural: number) => void} */
1289
- let widen = (at, natural) => {
1290
- let held = widest[at];
1291
- if (held === null || natural > held) widest[at] = natural;
1292
- };
1293
- /** @type {(row: Voting) => void} */
1294
- let vote = (row) => {
1295
- let at = 0;
1296
- for (let [i, cell] of row.cells.entries()) {
1297
- let span = row.spans ? row.spans[i] : 1;
1298
- if (span === 1) widen(at, cell.natural);
1299
- at += span;
1300
- }
1301
- };
1302
- vote(header);
1303
- for (let row of rows) vote(row);
1304
- for (let row of totals) vote(row);
1305
- return widest.map((width) => (width === null ? CELL_PAD.l + CELL_PAD.r : width));
1306
- };
1307
-
1308
- /** @type {(values: number[]) => number} */
1309
- let sum = (values) => values.reduce((total, value) => total + value, 0);
1310
-
1311
- // An authored `width` percentage fixes its column; the rest share what is left
1312
- // in proportion to their natural widths. Three cases, one return each — the
1313
- // fall-through is all-authored within budget, honoured exactly.
1314
- //
1315
- // There is no over-commitment case: the engine rejects a document whose fixed
1316
- // shares leave the width-less columns nothing, so `left` is positive whenever
1317
- // an auto column exists and the fixed shares never exceed the content width.
1318
- // That invariant is asserted rather than trusted — falling through with auto
1319
- // columns unplaced would draw them at zero width, an invisible failure — the
1320
- // same fail-loud posture as the measuring canvas's `newPage`.
1321
- /** @type {(cols: any[], natural: number[], avail: number) => number[]} */
1322
- let columnWidths = (cols, natural, avail) => {
1323
- let fixed = cols.map((column) => Number.isFinite(column.width));
1324
- if (!fixed.some(Boolean)) {
1325
- // Natural widths always carry the cell padding, so the sum is never zero.
1326
- let wanted = sum(natural);
1327
- return natural.map((width) => (width * avail) / wanted);
1328
- }
1329
- let widths = cols.map((col, i) => (fixed[i] ? (col.width / 100) * avail : 0));
1330
- let auto = natural.map((width, i) => (fixed[i] ? 0 : width));
1331
- let left = avail - sum(widths);
1332
- let wanted = sum(auto);
1333
- // Room for the auto columns: they share what the fixed ones left.
1334
- if (wanted > 0) {
1335
- if (left <= 0) throw new Error("over-committed column widths reached the layout");
1336
- return widths.map((width, i) => (fixed[i] ? width : (auto[i] * left) / wanted));
1337
- }
1338
- return widths;
1339
- };
1340
-
1341
- // One table being emitted: what `carryOver`, `sliced` and `put` all need.
1342
- /**
1343
- * @typedef {{ cells: any[], h: number, style?: any, spans?: number[] | null }} TableRow
1344
- * @typedef {{ state: Flow, head: TableRow,
1345
- * xOffsets: number[], widths: number[], x: number, avail: number }} Grid
1346
- */
1347
1317
 
1348
1318
  // Continue in a fresh column: a table that spans columns or pages restates its
1349
1319
  // headings, under whatever headers the open groups replay above them. Both
@@ -1356,18 +1326,6 @@ let columnWidths = (cols, natural, avail) => {
1356
1326
  /** @type {(row: { style?: any }) => any} */
1357
1327
  let rowFill = (row) => col((row.style || {}).background);
1358
1328
 
1359
- // What this row draws against: the grid's own columns, or the merged geometry
1360
- // a spanning row sees. Read at draw time rather than kept on the row, because
1361
- // `rebase` moves the offsets under it every time the table crosses a strip.
1362
- /** @type {(grid: Grid, row: TableRow) => { xOffsets: number[], widths: number[] }} */
1363
- let gridOf = (grid, row) =>
1364
- row.spans
1365
- ? {
1366
- xOffsets: spanOffsets(grid.xOffsets, row.spans),
1367
- widths: spanWidths(grid.widths, row.spans),
1368
- }
1369
- : grid;
1370
-
1371
1329
  /** @type {(grid: Grid) => void} */
1372
1330
  let carryOver = (grid) => {
1373
1331
  advance(grid.state);
@@ -1509,74 +1467,6 @@ let emitRows = (grid, laid, totalRows) => {
1509
1467
  for (let row of totalRows) put(grid, row, rowFill(row), 0);
1510
1468
  };
1511
1469
 
1512
- // A buffered table, in the one shape `table` lays out: its columns come from
1513
- // the opening event, its rows are the row events themselves, and its total is
1514
- // the total row's cells. Both readers build it here — the replay, collecting
1515
- // events as they arrive, and the region buffer, reading them back off what it
1516
- // held — so a new table event kind cannot reach one and miss the other.
1517
- /** @type {(events: any[]) => any} */
1518
- let tableOf = (events) => {
1519
- let opening = events[0];
1520
- return {
1521
- 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,
1531
- rows: events.filter((event) => event.type === "row"),
1532
- // The events themselves, as `rows` are: a total row is a row, and the
1533
- // buffer files each corrected height under the event it was measured from.
1534
- totals: events.filter((event) => event.type === "total-row"),
1535
- };
1536
- };
1537
-
1538
- /**
1539
- * @typedef {{ widths: number[], head: any, laid: any[], totalRows: any[] }} Measured
1540
- */
1541
- // Measure a buffered table at a given width: the column widths every cell has
1542
- // a say in, then each row laid out against them. Everything here is
1543
- // arithmetic over the cells, so the region's buffer can ask for it before
1544
- // anything is drawn — which is the only way a row's real height is known
1545
- // early enough to balance on (bead `quario-cgk`).
1546
- /** @type {(canvas: Canvas, buffered: any, avail: number) => Measured} */
1547
- let measureTable = (canvas, buffered, avail) => {
1548
- let cols = /** @type {any[]} */ (buffered.columns);
1549
- /** @type {(cells: any[], style: any, spans: number[] | null) => Voting & { style: any }} */
1550
- let measured = (cells, style, spans) => ({
1551
- cells: cells.map((/** @type {any} */ cell) => cellOf(canvas, cell, style)),
1552
- spans,
1553
- style,
1554
- });
1555
- let header = measured(buffered.header, buffered.headerStyle, spansOf(buffered.header));
1556
- // A data row's cells are the columns' own, so nothing in one can span
1557
- // (SCHEMA.md, "Span"). Asking each row anyway would scan every cell of the
1558
- // table to rediscover what the schema already guarantees.
1559
- let rows = buffered.rows.map((/** @type {any} */ row) => measured(row.cells, row.style, null));
1560
- let totals = buffered.totals.map((/** @type {any} */ row) =>
1561
- measured(row.cells, row.style, spansOf(row.cells)),
1562
- );
1563
- let widths = columnWidths(cols, naturalWidths(cols, header, rows, totals), avail);
1564
- // Each row wraps against the geometry it sees, which is the columns' own
1565
- // unless one of its cells spans.
1566
- /** @type {(row: Voting & { style: any }) => any} */
1567
- let laidOut = (row) => ({
1568
- ...rowOf(canvas, row.cells, spanWidths(widths, row.spans)),
1569
- spans: row.spans,
1570
- style: row.style,
1571
- });
1572
- return {
1573
- widths,
1574
- head: laidOut(header),
1575
- laid: rows.map(laidOut),
1576
- totalRows: totals.map(laidOut),
1577
- };
1578
- };
1579
-
1580
1470
  // Lay out one table from the events it arrived as: measure every cell,
1581
1471
  // allocate the columns, then emit the header, the rows and the total,
1582
1472
  // breaking pages as needed.
@@ -1678,14 +1568,15 @@ let reserve = (geo, settings, bands) => {
1678
1568
  };
1679
1569
  };
1680
1570
 
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.
1571
+ // The page frame a canvas presents, recovered from the canvas after `adopt`
1572
+ // narrowed it. Only `top`/`bottom` were overwritten there; `width`, `height`
1573
+ // and `margin` are still the page's own, so this re-derives the two that went
1574
+ // through the same `frame` every other consumer reads a derivation from live
1575
+ // fields, never a copy that could drift from one. Both halves of page furniture
1576
+ // want it: reservation to measure against, the draw pass to hang the header
1577
+ // from.
1687
1578
  /** @type {(canvas: Canvas) => Frame} */
1688
- let pageFrame = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1579
+ let unnarrowed = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1689
1580
 
1690
1581
  // One finished page's furniture, drawn in the strips `reserve` left for it:
1691
1582
  // the header hanging from the top margin, the footer resting on the bottom
@@ -1695,7 +1586,7 @@ let pageFrame = (canvas) => frame(canvas.width, canvas.height, canvas.margin);
1695
1586
  // that made room for them.
1696
1587
  /** @type {(canvas: Canvas, bands: any, anchor: any) => void} */
1697
1588
  let furniture = (canvas, bands, anchor) => {
1698
- if (bands.header) band(canvas, bands.header(anchor), pageFrame(canvas).top);
1589
+ if (bands.header) band(canvas, bands.header(anchor), unnarrowed(canvas).top);
1699
1590
  if (bands.footer) band(canvas, bands.footer(anchor), canvas.bottom - BAND);
1700
1591
  };
1701
1592
 
@@ -1728,12 +1619,12 @@ let numbered = (count, starts) => {
1728
1619
  * `finish` flushes whatever the last event left pending and returns what the
1729
1620
  * passes over the finished pages still need: the outline marks — the only way
1730
1621
  * 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.
1622
+ * not a mistake this seam leaves open — and the per-page `{ number, total }`
1623
+ * anchors the furniture pass draws with. The opening event itself the entry
1624
+ * peeked for both halves it is built from, so this seam keeps none of it.
1734
1625
  *
1735
1626
  * @type {(canvas: Canvas) => { handlers: Record<string, (event: any) => void>,
1736
- * finish: () => { marks: any[], opening: any,
1627
+ * finish: () => { marks: any[],
1737
1628
  * pages: { number: number, total: number }[] } }}
1738
1629
  */
1739
1630
  let flow = (canvas) => {
@@ -1749,23 +1640,35 @@ let flow = (canvas) => {
1749
1640
  pin: null,
1750
1641
  skipGap: false,
1751
1642
  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
1643
  };
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
1644
  // A table is buffered whole before it is laid out: column widths come from
1762
1645
  // every cell in it, so the last row has to be in hand first.
1763
1646
  /** @type {any[]} */
1764
1647
  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;
1648
+ // Hollowness is a property of the stream, not of what gets drawn: an
1649
+ // instance is hollow when nothing but hollow brackets arrived between its
1650
+ // own ends. Tracked at the handler layer, which every event reaches exactly
1651
+ // once `route` is re-entered by a region's replay, and a buffered instance
1652
+ // has to be judged before it is buffered, not after. `filling` is one flag
1653
+ // per open bracket; the verdict rides the closing event itself, so the
1654
+ // buffer and the replay both read the answer the stream gave.
1655
+ /** @type {boolean[]} */
1656
+ let filling = [];
1657
+ /** @type {WeakSet<any>} */
1658
+ let hollows = new WeakSet();
1659
+ // Whatever instance is open drew something. Called for an ordinary event and
1660
+ // for a child that turned out not to be hollow, because those are the same
1661
+ // claim: an instance draws through its content and through its children.
1662
+ let fill = () => {
1663
+ if (filling.length) filling[filling.length - 1] = true;
1664
+ };
1665
+ /** @type {(event: any) => void} */
1666
+ let track = (event) => {
1667
+ if (event.type === "group-start") filling.push(false);
1668
+ else if (event.type !== "group-end") fill();
1669
+ else if (filling.pop()) fill();
1670
+ else hollows.add(event);
1671
+ };
1769
1672
  // The body opens its own first page. Not through `turn`: a canvas that has
1770
1673
  // drawn nothing is fresh already, and would be left with no page at all.
1771
1674
  canvas.newPage();
@@ -1803,8 +1706,9 @@ let flow = (canvas) => {
1803
1706
  // default of its own -- its slots carry theirs.
1804
1707
  split: heldOrPlaced,
1805
1708
  "group-start": (event) => openGroup(state, event),
1806
- "group-end": () => {
1807
- flush(state, 0);
1709
+ "group-end": (event) => {
1710
+ if (hollows.has(event)) unopen(state);
1711
+ else flush(state, 0);
1808
1712
  state.open.pop();
1809
1713
  },
1810
1714
  // A table is collected as the events it arrived as, and shaped by `tableOf`
@@ -1831,7 +1735,7 @@ let flow = (canvas) => {
1831
1735
  // The nesting the router is at. While the buffer holds, routing runs ahead of
1832
1736
  // placement: nothing has been placed, so `state.open` is frozen at the depth
1833
1737
  // the region opened at and only the buffer knows where the walk really is.
1834
- // Once `commit` nulls `held`, the replay *is* the placement timeline and the
1738
+ // Once `decide` nulls `held`, the replay *is* the placement timeline and the
1835
1739
  // open stack is exact again.
1836
1740
  let nesting = () =>
1837
1741
  state.region && state.region.held ? state.region.nesting : state.open.length - 1;
@@ -1864,13 +1768,53 @@ let flow = (canvas) => {
1864
1768
  let route = (event, payload = null) => {
1865
1769
  snapPin(event);
1866
1770
  if (fullBand(event)) fullWidth(event, payload);
1867
- else if (deciding()) buffer(state, event, payload);
1771
+ else if (deciding()) hold(event, payload);
1868
1772
  else if (opens(event)) begin(event);
1869
1773
  else place(event, payload);
1870
1774
  };
1871
1775
 
1872
- state.route = route;
1873
- fold = splitFolder(route);
1776
+ // The body walk's own folder, emitting each finished event onward to `route`.
1777
+ let fold = splitFolder(route);
1778
+
1779
+ // Hold one event back in the region's buffer, and lay that buffer out if this
1780
+ // is the event that filled it. Not `holdHeader`: that holds a group's header
1781
+ // for the content it introduces, this holds anything at all for a region
1782
+ // still deciding.
1783
+ /** @type {(event: any, payload: Block | Measured | null) => void} */
1784
+ let hold = (event, payload) => {
1785
+ if (buffer(state, event, payload, hollows.has(event))) replay();
1786
+ };
1787
+
1788
+ // Lay the buffer out: `decide` settles the strip height and hands the held
1789
+ // entries back in order, and this walks them through `route`. The walk lives
1790
+ // here rather than under the region section marker for the reason everything
1791
+ // there is module-level — that section is reachable without the router, and
1792
+ // a walk through `route` is not.
1793
+ let replay = () => {
1794
+ let region = /** @type {Region} */ (state.region);
1795
+ for (let entry of decide(state)) {
1796
+ if (breaksFor(state, region, entry.span)) advance(state);
1797
+ route(entry.event, entry.payload);
1798
+ }
1799
+ };
1800
+
1801
+ // Close the region: anything still held is laid out first, and the region
1802
+ // then lands where `landRegion` puts it.
1803
+ //
1804
+ // Nothing the replay routes can arrive back here, which is why the local
1805
+ // `region` survives the call. `route` asks `fullBand` before `deciding()`, so
1806
+ // a full band is never buffered; and a replayed event, asked `fullBand` a
1807
+ // second time, answers as it did the first, because `nesting()`'s two
1808
+ // branches walk the same brackets — `buffer` carries `region.nesting` through
1809
+ // them while nothing is placed, `state.open` through them once the replay is
1810
+ // placing. The other closer, `endOwned`, is wired above `route` at the
1811
+ // handler layer, where a replay does not reach.
1812
+ let closeRegion = () => {
1813
+ let region = state.region;
1814
+ if (!region) return;
1815
+ if (deciding()) replay();
1816
+ landRegion(state, region);
1817
+ };
1874
1818
 
1875
1819
  // Everything drawn across the page rather than down a strip: the declaring
1876
1820
  // node's own bands, its footer among them.
@@ -1890,7 +1834,7 @@ let flow = (canvas) => {
1890
1834
  /** @type {(depth: number) => void} */
1891
1835
  let endOwned = (depth) => {
1892
1836
  if (owner() !== depth) return;
1893
- if (state.region) closeRegion(state);
1837
+ if (state.region) closeRegion();
1894
1838
  else state.pending = null;
1895
1839
  };
1896
1840
 
@@ -1905,7 +1849,7 @@ let flow = (canvas) => {
1905
1849
  let fullWidth = (event, payload) => {
1906
1850
  // Asked before the region closes, because that is what it reads.
1907
1851
  let own = ownFooter(event);
1908
- closeRegion(state);
1852
+ closeRegion();
1909
1853
  if (own) state.pending = null;
1910
1854
  place(event, payload);
1911
1855
  };
@@ -1921,7 +1865,7 @@ let flow = (canvas) => {
1921
1865
  let block = keepBlock(state, event, stripWidth(state, owed.count));
1922
1866
  flush(state, block ? keepWith(block) : 0);
1923
1867
  openRegion(state);
1924
- buffer(state, event, block);
1868
+ hold(event, block);
1925
1869
  };
1926
1870
 
1927
1871
  /** @type {(event: any) => void} */
@@ -1935,12 +1879,17 @@ let flow = (canvas) => {
1935
1879
  };
1936
1880
 
1937
1881
  return {
1938
- handlers: {
1882
+ // Every handler is wrapped so `track` sees the stream once, in order, ahead
1883
+ // of the dispatch that may buffer the event for later. One wrap rather than
1884
+ // a call in each handler: a new event type must count towards the instance
1885
+ // it arrives in without anyone remembering to say so.
1886
+ handlers: tracked(track, {
1939
1887
  "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);
1888
+ // The document-wide settings were built complete from this same event
1889
+ // before the walk began (index.js peeks it), so this handler reads them
1890
+ // through `canvas.settings` and settles nothing only the page bands,
1891
+ // whose height is not known until here, are taken off it below. The
1892
+ // entry peeked the event too, so nothing is kept for it here.
1944
1893
  if (event.columns) state.pending = { count: event.columns, owner: -1 };
1945
1894
  if (!event.page) {
1946
1895
  pinHeader(event);
@@ -1952,7 +1901,7 @@ let flow = (canvas) => {
1952
1901
  // one thing a measurement must not be able to do. What a band needs is
1953
1902
  // a property of the page box in any case, not of a content box
1954
1903
  // something may already have narrowed.
1955
- adopt(canvas, reserve(pageFrame(canvas), canvas.settings, event.page));
1904
+ adopt(canvas, reserve(unnarrowed(canvas), canvas.settings, event.page));
1956
1905
  pinHeader(event);
1957
1906
  },
1958
1907
  // Everything else routes: `placed` above says what each event does, and
@@ -1975,14 +1924,17 @@ let flow = (canvas) => {
1975
1924
  endOwned(event.depth);
1976
1925
  route(event);
1977
1926
  },
1978
- },
1927
+ }),
1979
1928
  finish: () => {
1980
1929
  snapPin({ type: "report-end" });
1981
1930
  // A root region reaches here undrained when a report declares `columns`
1982
1931
  // and no full-width footer followed its body.
1983
- closeRegion(state);
1932
+ closeRegion();
1984
1933
  flush(state, 0);
1985
- return { marks: state.marks, opening, pages: numbered(canvas.count, state.starts) };
1934
+ return {
1935
+ marks: state.marks,
1936
+ pages: numbered(canvas.count, state.starts),
1937
+ };
1986
1938
  },
1987
1939
  };
1988
1940
  };