@beltype/cicero 0.1.11 → 0.1.13

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.
@@ -23,12 +23,13 @@ import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, regis
23
23
  import { snapshotOfStyle } from './forcing-memo.js';
24
24
  import { cloneFlowPiece, declineRepeats, mayRepeat, mutateToContinuation, truncateFrom } from './fragments.js';
25
25
  import { cutAfter, divides, elAtIn, flattenResume, indexBlocks, inFlowIndex, inRemainders, normalizePath, pathOf, placeAt, remaindersOf } from './positions.js';
26
- import { documentOpsOf, counterStateAt, pageNumberAt, withoutPageCounters, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
26
+ import { documentOpsOf, withRunning, counterStateAt, pageNumberAt, withoutPageCounters, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
27
27
  import { RUNNING_PROP } from './gcpm-encoding.js';
28
28
  import { ARTIFACT, HOST, hasWord } from './data-beltype.js';
29
- import { blockChildrenOf, footerBeforeRows, inDisplayOrder, isFixedBox, SIDE_BY_SIDE, sourceOf } from './word-space.js';
29
+ import { blockChildrenOf, footerBeforeRows, inDisplayOrder, isFixedBox, isInlineLevel, SIDE_BY_SIDE, sourceOf } from './word-space.js';
30
30
  import { changedPairs, settleReads } from './reads.js';
31
31
  import { converge, holderPage, namesNoPlace, priorKeys, restartPage } from './resume.js';
32
+ import { readScope, foldTallColumns } from './scope.js';
32
33
  import { answerHas, boxesWhoseWordsMoved } from './has-words.js';
33
34
  const PX_PER = { px: 1, pt: 96 / 72, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 };
34
35
  function toPx(len) {
@@ -93,10 +94,46 @@ export function gapAbove(box, report, win) {
93
94
  }
94
95
  return gap;
95
96
  }
97
+ /** The displays besides a block container's whose children stand at class A break points: a table's row groups and a row group's rows (css-break-3 §4.1). */
98
+ const ROWS_HOLDER = /^(inline-)?table$|^table-(row-group|header-group|footer-group)$/;
99
+ /** Does a box of this display lay its children out at class A break points, a block container's in-flow block-level children or a table's rows (css-break-3 §4.1)? A flex or grid container does not: css-page-3 §8.1 gives `page` to no flex or grid item. */
100
+ const laysClassA = (display) => holdsAnonymousBlocks(display) || ROWS_HOLDER.test(display);
96
101
  /**
97
- * The effective page name of the deepest in-flow block that begins where a box does (css-page-3 §8.1): down the box's first in-flow block child while no run of words stands before that child, then `effectivePageName` from the box found. A first in-flow block child begins exactly where its parent does (§3.1.1's own coincidence), so the deepest box that starts the page is the end of the first-child chain.
102
+ * Does the `page` property apply to a box (css-page-3 §8.1, *"boxes that create class A break points"*)? An in-flow block-level box whose parent lays its children at class A break points (css-break-3 §4.1), and a table's row group or row.
98
103
  *
99
- * @remarks The driver's `nameAt` asks this of a cut that resumes at a box whole, with the position index's word counts for `runBefore`; a host with no index passes its own reading of the same question (*the engine knows no view*). Two reads are the host's: how a box's style is asked, and whether a run stands before a child.
104
+ * @remarks
105
+ * css-break-3 §4.1 counts *"a float and an immediately-adjacent in-flow or floated box"* among class A points too; the WPT tests `css-page/page-name-float-001` and `-002` have a name on a float force nothing, as Chromium prints them, and the engine follows the tests. A flex item, a grid item, a table cell and an inline-level box are not boxes it applies to (`page-name-flex-*`, `page-name-inline-block-003`).
106
+ *
107
+ * @param parent - the parent box's style, or null where the box stands in the body and nothing reads it.
108
+ */
109
+ export function pageApplies(st, parent) {
110
+ if (isOutOfFlow(st))
111
+ return false;
112
+ const display = st.display ?? '';
113
+ if (isInlineLevel(display) || /^table-(cell|column|column-group|caption)$/.test(display))
114
+ return false;
115
+ return laysClassA(parent?.display ?? 'block');
116
+ }
117
+ /**
118
+ * The used page name of the box at `el` (css-page-3 §8.1): `effectivePageName` where the property applies to it, else its parent's. A box it does not apply to takes no name of its own; an inline box standing among blocks is in an anonymous block box, which the property applies to, named as its parent is.
119
+ */
120
+ export function usedPageName(el, styleOf) {
121
+ const parent = el.parentElement;
122
+ return !parent || pageApplies(styleOf(el), styleOf(parent)) ? effectivePageName(el) : effectivePageName(parent);
123
+ }
124
+ /**
125
+ * The depth in the counter log's terms of the place a cut names: the element its path reaches, below the ancestors, or one level further in where the cut falls inside it, among its children (a `word` or a `px`). What `counterStateAt` takes as its `depth`.
126
+ *
127
+ * @remarks
128
+ * A place inside an element is taken at its children's depth, so an instance a grandchild opened before the cut is ended there even where the cut falls inside the child that holds it: a counter reset on an inline element nested in another inside a paragraph a page divides. No case claims it. Exported for a host that reads the same log over its own addresses (*the engine knows no view*).
129
+ *
130
+ * @param ancestors - How many elements the log records above the top-level blocks (`documentOpsOf`'s `ancestors`), two for the driver: `html` and `body`.
131
+ */
132
+ export const depthOfCut = (cut, ancestors) => ancestors + cut.path.length - 1 + (cut.word != null || cut.px != null ? 1 : 0);
133
+ /**
134
+ * The start page value of a box (css-page-3 §8.1): *"the value (if any) propagated from its first … child box, else the used value on the box itself"*, where *"a child propagates its own start … page value if and only if the page property applies to it"*. So down the first in-flow child while it is a box the property applies to ({@link pageApplies}) and no run of words stands before it, then {@link usedPageName} from the box found. The driver's `nameAt` names a page by it, and the forcing index compares it against the end page value before it.
135
+ *
136
+ * @remarks The driver's `nameAt` asks this of a cut that resumes at a box whole, with the position index's word counts for `runBefore`; a host with no index passes its own reading of the same question (*the engine knows no view*). Two reads are the host's: how a box's style is asked, and whether a run stands before a child. A run before the child is the box's first child box, an anonymous block box, which carries the box's own used value.
100
137
  *
101
138
  * @param el - The box the page begins in.
102
139
  * @param reads.runBefore - Whether a line of words stands before `child` in `parent`, which is the page's first content and the container's.
@@ -104,12 +141,15 @@ export function gapAbove(box, report, win) {
104
141
  */
105
142
  export function pageNameDownFrom(el, { styleOf, runBefore, blockChildren = blockChildrenOf }) {
106
143
  for (;;) {
144
+ const st = styleOf(el);
145
+ if (isOutOfFlow(st) || !laysClassA(st.display ?? ''))
146
+ break; // a page a float begins is named by the float's place in the flow, never by its interior
107
147
  const next = blockChildren(el).find((c) => !isOutOfFlow(styleOf(c)));
108
- if (!next || runBefore(el, next))
148
+ if (!next || runBefore(el, next) || !pageApplies(styleOf(next), st))
109
149
  break;
110
150
  el = next;
111
151
  }
112
- return effectivePageName(el);
152
+ return usedPageName(el, styleOf);
113
153
  }
114
154
  /**
115
155
  * Read the document's @page geometry from the CSSOM — every sheet the document wears, adopted ones included, and the rules inside its layers and conditions (`capture.ts`'s `pageRulesOf`). Returns page and content box sizes in px.
@@ -216,7 +256,7 @@ export const combineParity = (earlier, later) => later ?? earlier;
216
256
  *
217
257
  * This used to be a loop over consecutive feed items, and a feed item is a top-level block — so a command declared on a descendant was invisible, which is every heading in every BelType document since the belt began wrapping them in sections. The law (*no forced break lies inside an interval*, C1) always required this reading; the mechanism was narrower than the law. Descending closes it at word granularity, and §3.1.1's child→parent propagation falls out as a corollary rather than as walks: a `break-before` on a first in-flow child sits at the same word position as its container's start edge, and a `break-after` on a last in-flow child at the same position as its container's end, so the descent finds both where a promotion would have put them. `forcedBreakAfter` — the third of the rule's four walks, and the only forced one ever built — is deleted for exactly that reason.
218
258
  *
219
- * What the walk refuses to enter, each for its own citation: an absolutely positioned box (§3.1's must not — it neither carries forcing nor receives it, and §4.3's name switch is forcing under another name), a float's interior (§2.1 — a forced break inside a parallel flow does not affect the content outside it), and a monolith's interior (*the line box is the atom*, I3, and §4.1 in as many words: a forced break inside monolithic content must be ignored). A float's and a monolith's own commands are still read, which keeps case 14's declared §3.1 divergence exactly where the sixth round left it — except `break-after` on an out-of-flow box, which `forcedBreakAfter` never read either and which this walk goes on not reading rather than changing two things at once.
259
+ * What the walk refuses to enter, each for its own citation: an absolutely positioned box (§3.1's must not — it neither carries forcing nor receives it, and §4.3's name switch is forcing under another name), a float's interior (§2.1 — a forced break inside a parallel flow does not affect the content outside it), and a monolith's interior (*the line box is the atom*, I3, and §4.1 in as many words: a forced break inside monolithic content must be ignored). A float's and a monolith's own commands are still read: §3.1 says *"User agents should also apply these properties to floated boxes whose containing block is in the normal flow of the root fragmented element"*, so a float's forced `break-before` falls before its place in the flow and its forced `break-after` is pending into the box or run after it, never inside the float. A float the walk reaches has a containing block in that flow, since it never enters a float's or an absolutely positioned box's interior.
220
260
  *
221
261
  * `namesOnly` answers the composition region's version of the question. A name switch is the only forcing that changes the measure, so it is the only forcing a region must stop at: lines past a forced break are composed at the right width and merely unused.
222
262
  *
@@ -240,41 +280,84 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
240
280
  /**
241
281
  * @param [rowish]
242
282
  */
243
- const visit = (el, b, sealedBy, rowish = false) => {
283
+ /**
284
+ * css-page-3 §8.1's two values ride the walk: `visit` answers a box's start and end page values where it stands among its siblings at class A break points — a box the property applies to, or an inline box standing among blocks, which is an anonymous block box named as its parent is — and null for any other. Each sibling whose start value differs from the end value before it is a name break.
285
+ *
286
+ * @param [rowish]
287
+ * @param parentStyle - the parent's style, null in the body where nothing reads it.
288
+ * @param parentName - the parent's used page name, which an anonymous block box in it takes.
289
+ */
290
+ const visit = (el, b, sealedBy, rowish, parentStyle, parentName) => {
244
291
  const st = styleOf(el);
245
292
  if (isAbsolutelyPositioned(st))
246
- return; // §3.1's must not: it neither carries forcing nor receives it, so it is not an event at all
293
+ return null; // §3.1's must not: it neither carries forcing nor receives it, so it is not an event at all
247
294
  const parallel = isOutOfFlow(st);
248
- const e = { b, el, cut: cutOf(el, b), name: pageNameOf(el), before: isForced(st.breakBefore), beforeParity: parityOf(st.breakBefore), after: false, afterParity: null, end: 0, pos: posOf(el, b), inFlow: !parallel, sealedBy, rowish };
295
+ const applies = pageApplies(st, parentStyle);
296
+ const anonymous = !applies && !parallel && isInlineLevel(st.display ?? '') && laysClassA(parentStyle?.display ?? 'block');
297
+ const used = applies ? pageNameOf(el) : parentName;
298
+ const e = { b, el, cut: cutOf(el, b), nameBreak: false, before: isForced(st.breakBefore), beforeParity: parityOf(st.breakBefore), after: false, afterParity: null, end: 0, pos: posOf(el, b), inFlow: !parallel, sealedBy, rowish };
249
299
  const k = events.length;
250
300
  events.push(e);
251
301
  const inner = sealedBy >= 0 ? sealedBy : neverCutInside(el, st) ? k : -1;
302
+ let first = null;
303
+ let last = null;
252
304
  if (!parallel && !monolith(el, st)) {
253
305
  const kids = blockChildren(el);
254
- const runs = !!extentOf && holdsAnonymousBlocks(st.display ?? '');
306
+ // A run of lines among block children is an anonymous block box, or in a flex or grid container an anonymous item (CSS Flexbox §4, CSS Grid §6), which a forced break before it reaches the same way.
307
+ const runs = !!extentOf && (holdsAnonymousBlocks(st.display ?? '') || /^(inline-)?(flex|grid)$/.test(st.display ?? ''));
255
308
  const row = rowish || SIDE_BY_SIDE.has(st.display ?? '');
309
+ const flow = laysClassA(st.display ?? '');
310
+ const own = pageNameOf(el);
311
+ // The siblings' sequence: each box's start against the end before it, a run of lines being an anonymous block box named as `el` is.
312
+ const meet = (at, start, end) => {
313
+ if (!flow)
314
+ return;
315
+ if (last !== null && last !== start)
316
+ events[at].nameBreak = true;
317
+ first ??= start;
318
+ last = end;
319
+ };
320
+ if (runs && kids.length > 0 && extentOf(kids[0]).before > extentOf(el).before)
321
+ first = last = own;
256
322
  for (let i = 0; i < kids.length; i++) {
257
- visit(kids[i], b, inner, row);
323
+ const at = events.length;
324
+ const met = visit(kids[i], b, inner, row, st, own);
325
+ if (met)
326
+ meet(at, met.start, met.end);
258
327
  if (!runs)
259
328
  continue;
260
329
  // A run of lines after this child is an anonymous block box, and the box a forced `break-after` closing here is pending into: an event at its first word, in the word space of the box that holds it (*the anonymous box is in the procession*). Words standing between the child's end and the next child's start, or this box's end, are the run; none is no box.
261
- const ext = extentOf;
262
- const own = ext(el);
263
- const done = ext(kids[i]);
330
+ const extent = extentOf;
331
+ const ext = extent(el);
332
+ const done = extent(kids[i]);
264
333
  const word = done.before + done.count;
265
334
  const next = kids[i + 1];
266
- if ((next ? ext(next).before : own.before + own.count) <= word)
335
+ if ((next ? extent(next).before : ext.before + ext.count) <= word)
267
336
  continue;
268
- events.push({ b, el, cut: cutOf(el, b, word - own.before), name: e.name, before: false, beforeParity: null, after: false, afterParity: null, end: events.length + 1, pos: e.pos + word - own.before, inFlow: true, sealedBy: inner, rowish: row, runAfter: kids[i] });
337
+ events.push({ b, el, cut: cutOf(el, b, word - ext.before), nameBreak: false, before: false, beforeParity: null, after: false, afterParity: null, end: events.length + 1, pos: e.pos + word - ext.before, inFlow: true, sealedBy: inner, rowish: row, runAfter: kids[i] });
338
+ meet(events.length - 1, own, own);
269
339
  }
270
340
  }
271
- e.after = !parallel && isForced(st.breakAfter); // a float's own break-after was never read, and still is not
341
+ e.after = isForced(st.breakAfter); // a float's too (§3.1's should): pending into what follows its place in the flow
272
342
  e.afterParity = parityOf(st.breakAfter);
273
343
  e.end = events.length;
344
+ if (!applies && !anonymous)
345
+ return null;
346
+ return { start: applies ? first ?? used : used, end: applies ? last ?? used : used };
274
347
  };
348
+ const root = blocks[0]?.parentElement ?? null;
349
+ const rootStyle = root ? styleOf(root) : null;
350
+ const rootName = root ? pageNameOf(root) : '';
351
+ let before = null;
275
352
  for (let b = 0; b < blocks.length; b++) {
276
353
  blockStart.push(events.length);
277
- visit(blocks[b], b, -1);
354
+ const at = events.length;
355
+ const met = visit(blocks[b], b, -1, false, rootStyle, rootName);
356
+ if (!met)
357
+ continue;
358
+ if (before !== null && before !== met.start)
359
+ events[at].nameBreak = true;
360
+ before = met.end;
278
361
  }
279
362
  const n = events.length;
280
363
  // What the walk carries as `pending` into event k from the boxes that close just in front of it: those whose subtree ends at k, deepest first — a larger index is a deeper box — so the outermost's parity is the one that stands.
@@ -288,13 +371,13 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
288
371
  for (let k = n - 1; k >= 0; k--)
289
372
  nextForced[k] = closing[k] || events[k].before ? k : nextForced[k + 1];
290
373
  const nextName = new Array(n + 1).fill(n);
291
- for (let k = n - 2; k >= 0; k--)
292
- nextName[k] = events[k + 1].name !== events[k].name ? k + 1 : nextName[k + 1];
293
- const hit = (k, pending, name, namesOnly) => {
374
+ for (let k = n - 1; k >= 0; k--)
375
+ nextName[k] = events[k].nameBreak ? k : nextName[k + 1];
376
+ const hit = (k, pending, namesOnly) => {
294
377
  const e = events[k];
295
378
  const before = !namesOnly && e.before;
296
379
  // §4.3's combination at the one break point where two forced values meet: the `break-after` pending into the box, and the box's own `break-before`, the later element in the flow, whose parity wins where it has one. The address is a copy, so no page can reach into the index through it.
297
- return { b: e.b, namedSwitch: e.name !== name, el: e.el, cut: { ...e.cut, path: [...e.cut.path] }, parity: combineParity(pending?.parity ?? null, before ? e.beforeParity : null), rowish: e.rowish, runAfter: e.runAfter };
380
+ return { b: e.b, namedSwitch: e.nameBreak, el: e.el, cut: { ...e.cut, path: [...e.cut.path] }, parity: combineParity(pending?.parity ?? null, before ? e.beforeParity : null), rowish: e.rowish, runAfter: e.runAfter };
298
381
  };
299
382
  /** The first event strictly after the cursor — a binary search, because a bare address never precedes the one before it in walk order — or `n` where there is none. */
300
383
  const firstAfter = (cursorCut) => {
@@ -314,20 +397,24 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
314
397
  };
315
398
  return {
316
399
  size: n,
317
- /** The first forcing event strictly after `cursorCut` for a page named `name`, or null. */
318
- find(cursorCut, name, { namesOnly = false } = {}) {
400
+ /**
401
+ * The first forcing event strictly after `cursorCut`, or null.
402
+ *
403
+ * @param _name - the page's name, which a host still passes; a name break is the index's own, the end page value before a class A break point against the start page value after it (css-page-3 §8.1), and no longer a box's name against the page's.
404
+ */
405
+ find(cursorCut, _name, { namesOnly = false } = {}) {
319
406
  const s = firstAfter(cursorCut);
320
407
  if (s >= n)
321
408
  return null;
322
409
  // Pending into s: the forced break-afters whose subtree closes exactly in front of it — never one that closed earlier, which was spent where it fell (*a box is not its first child*).
323
410
  const pending = namesOnly ? null : closing[s];
324
- if (pending || events[s].name !== name || (!namesOnly && events[s].before))
325
- return hit(s, pending, name, namesOnly);
411
+ if (pending || events[s].nameBreak || (!namesOnly && events[s].before))
412
+ return hit(s, pending, namesOnly);
326
413
  const k = s + 1;
327
414
  if (k >= n)
328
415
  return null;
329
- const f = Math.min(events[k].name !== name ? k : nextName[k], namesOnly ? n : nextForced[k]);
330
- return f < n ? hit(f, namesOnly ? null : closing[f], name, namesOnly) : null;
416
+ const f = Math.min(nextName[k], namesOnly ? n : nextForced[k]);
417
+ return f < n ? hit(f, namesOnly ? null : closing[f], namesOnly) : null;
331
418
  },
332
419
  /**
333
420
  * The Cast-off's place (*the Cast-off bounds the feed*, 2026-09-26): the first block boundary strictly after `cursorCut` whose word position is at least `pos`, where a feed may be cut without moving any box before it — or null, where no such place is left and the feed is the whole remainder.
@@ -531,6 +618,8 @@ export function paginate(doc, opts = {}) {
531
618
  const boxes = opts.originals ?? topLevelBoxes(doc);
532
619
  const hasMarks = answerHas(doc, boxes, keep);
533
620
  performance.measure('cicero:has-words', { start: t0, end: performance.now(), detail: { boxes: boxes.length, marked: hasMarks.size, ...(memo ? { memo: memo.tally.has ?? null } : {}) } });
621
+ // Before the reads' counter walk, whose log the run takes: the reason is paginateOnce's, where the call stays for a caller that runs once.
622
+ registerGcpmProperties(doc.defaultView);
534
623
  return settleReads(doc, { run: (laid) => paginateOnce(doc, opts, laid, { boxes, hasMarks, memo }), blocksOf: () => opts.blocks ?? topLevelBlocks(doc), originals: opts.originals ?? opts.blocks ?? null, warm: opts.prior && opts.dirtyAt ? opts.prior.delivered : null, warmFacts: opts.prior && opts.dirtyAt ? opts.prior.facts ?? null : null, hide: !!opts.pace, keep });
535
624
  }
536
625
  /**
@@ -577,6 +666,8 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
577
666
  await awaitFonts(doc, table.furnitureFaces); // the faces page furniture names, requested now and not when the first margin box is presented (the table is read after the first wait, so its lengths are measured as they always were)
578
667
  const win = doc.defaultView;
579
668
  const blocks = intakeBlocks ?? topLevelBlocks(doc);
669
+ // What is out of scope is refused here, and a multi-column box too tall for a page becomes one column on its copies (`src/scope.ts`).
670
+ foldTallColumns(doc, readScope(win, blocks), table.pageFor({ first: true }));
580
671
  // The running elements and where each was written, handed back with the blocks by a host that took them at intake, for the same reason: by now the body holds the root and nothing else.
581
672
  const running = intakeRunning ?? runningBoxes(doc, memo?.keep);
582
673
  /** Whether a box outside an interval may be hollow (*the clone is hollow outside the interval*, 2026-09-27; *one attribute has words*, 2026-10-10). It may, unless a stylesheet the document wears has a `:has(` that nothing answers for: the host rewrites every `:has()` it can into a union that a `has:` word on the copy satisfies (`has-words.js`), and only an argument reading user state (`:hover`, `:checked`, `:focus-within`, `:target`, …) is left alone, with a console line from the rewrite naming the rule. Then every feed is built deep, as before, and the console says so once per run. `root.js` asks the same question of the same sheets when it presents. */
@@ -643,11 +734,14 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
643
734
  *
644
735
  * The ancestors are `html` and `body` — every top-level block is a body child — and they are recorded before the document's first word, which is what puts a `body { counter-reset }` in force on page one.
645
736
  */
646
- const documentOps = documentOpsOf(blocks, index, {
737
+ const ancestors = [doc.documentElement, doc.body].filter(Boolean);
738
+ const runningAt = running.map((r) => ({ name: r.name, pos: runningPosition(r, index) }));
739
+ // The reads' walk, where they took one, is this walk under their marks: a mark ends what an operation at its depth would end, and adds nothing else (`reads.ts`' `settleReads`).
740
+ const documentOps = laid.ops ? withRunning(laid.ops, runningAt) : documentOpsOf(blocks, index, {
647
741
  styleOf: (el) => win.getComputedStyle(el),
648
742
  pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
649
- ancestors: [doc.documentElement, doc.body].filter(Boolean),
650
- running: running.map((r) => ({ name: r.name, pos: runningPosition(r, index) })),
743
+ ancestors,
744
+ running: runningAt,
651
745
  keep: memo?.keep
652
746
  });
653
747
  // The same walk's second reading (*the host rewrites what the parser drops*, 2026-09-14): every named-string assignment the document makes, with the position it sits at. Evaluated here, where the source is still in its real ancestor chain — `content()` is the element's own text and would come back right from an unassigned slottable, but `counter()` inside a content list would not, and neither would anything inherited.
@@ -672,7 +766,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
672
766
  : forcingIndexOf(blocks, { styleOf: (el) => win.getComputedStyle(el), pageNameOf: effectivePageName, cutOf: (el, b, word = null) => cutTo(el, b, { word }), posOf: (el, b) => index.posOf(b, index.wordsBefore(el)), posOfCut, extentOf });
673
767
  performance.measure('cicero:forcing-index', { start: t, end: (t = performance.now()), detail: { events: forcing.size, ...(memo ? { memoHits: memo.hits, memoMisses: memo.misses } : {}) } });
674
768
  /**
675
- * The effective page name at an address: the deepest in-flow block that begins there, then css-page-3 §8.1's ancestor walk from it. A page may begin inside a block whose interior named a different page, so the cursor's block is not the thing to ask.
769
+ * The page name at an address: the start page value of the box that begins there (css-page-3 §8.1, `pageNameDownFrom`), or, where the page resumes inside a box, that box's used page name. A page may begin inside a block whose interior named a different page, so the cursor's block is not the thing to ask.
676
770
  *
677
771
  * A bare path resumes at a box whole, and a first in-flow block child begins exactly where its parent does (§3.1.1's own coincidence), so the deepest box that starts the page is the end of the first-child chain — which is where the old search for *the box containing word w* arrived, without the ambiguity a wordless first child gave it. A word or a pixel extent names a box the page resumes inside, and that box is itself the deepest; where the old search read the box after an end gap, it was naming the wrong side of a cut that resumes in the gap.
678
772
  */
@@ -681,9 +775,10 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
681
775
  if (!el)
682
776
  return '';
683
777
  // A line before a child is the page's first content, and it is the container's (*the anonymous box is in the procession*).
778
+ const styleOf = (c) => win.getComputedStyle(c);
684
779
  if (cut.word == null && cut.px == null)
685
- return pageNameDownFrom(el, { styleOf: (c) => win.getComputedStyle(c), runBefore: (parent, child) => index.wordsBefore(child) !== index.wordsBefore(parent) });
686
- return effectivePageName(el);
780
+ return pageNameDownFrom(el, { styleOf, runBefore: (parent, child) => index.wordsBefore(child) !== index.wordsBefore(parent) });
781
+ return usedPageName(el, styleOf);
687
782
  };
688
783
  /**
689
784
  * The document's own first page — css-page-3 §3.3, and it is a parity offset rather than a blank entry.
@@ -796,7 +891,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
796
891
  // The run's count, an author's reset of the page counter counted on from the page it begins on, and each page's own `@page` counter rules (`document-state.js`'s `pageNumberAt`). Entry `i` closes page `i`, so page `i` begins where entry `i - 1` ended, under the conditions it closes.
797
892
  const pageNumber = pageNumberAt(documentOps, pageIndex, pos, { pages: (i) => ({ start: i === 0 ? 0 : account[i - 1].pos ?? 0, blank: account[i].blank === true, counter: pageCounterOf(i, account[i].closes) }), blank, counter: geo.pageCounter, away });
798
893
  // The document's counters as they stand at this page's first word — strictly before it, which is §6.1's *spans the page break* in the flat position space (`document-state.js`) — less what the flows carried onto this page have still to show, which stands before that word and is on this page or after it (beltype#43). The page's own two are not among them: they are the host's, and `pageNumber` carries the reset.
799
- const counters = withoutPageCounters(counterStateAt(documentOps, pos, away));
894
+ const counters = withoutPageCounters(counterStateAt(documentOps, pos, away, depthOfCut(cut, ancestors.length)));
800
895
  return Object.freeze({ ...geo, name, pageNumber, parity, blank, counters, dpr: win.devicePixelRatio });
801
896
  };
802
897
  /**
@@ -1139,6 +1234,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1139
1234
  const conditions = conditionsFor(account.length);
1140
1235
  const recreated = ctx === null || conditions.contentW !== ctxWidth;
1141
1236
  if (recreated) {
1237
+ ctx?.dispose(); // each context seats a container of its own (since 2026-11-06), and one left standing is laid out in the document and printed
1142
1238
  ctx = createMeasureContext(doc, conditions.contentW);
1143
1239
  ctxWidth = conditions.contentW;
1144
1240
  }
@@ -1218,10 +1314,15 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1218
1314
  let container;
1219
1315
  let pageStart;
1220
1316
  let spentLead;
1317
+ /** The place the last pour stood at when the check failed: a doubled bound that names it again is the same feed. */
1318
+ let short = null;
1221
1319
  for (;;) {
1222
1320
  pours++;
1223
1321
  // The place first, then the feed cloned to it (*the clone is hollow outside the interval*, 2026-09-27): neither the Cast-off's place nor the forced event needs anything but the cursor and the index.
1224
- const place = castOff.bound(cursor, words);
1322
+ let place = castOff.bound(cursor, words);
1323
+ // A doubled bound whose place is the one the last pour stopped above the edge at, its words landing inside the same long paragraph or the same box the bound never cuts inside, would pour the same feed again: it is doubled on without pouring (critic2-long-paragraph-quadratic, 2026-11-09).
1324
+ while (place && place.el === short)
1325
+ place = castOff.bound(cursor, (words = words * 2));
1225
1326
  const forcingCuts = !!forcedEvent && !rowForced && (!place || !cutAfter(forcedEvent.cut, place.cut, posOfCut));
1226
1327
  bounded = !forcingCuts && !!place;
1227
1328
  ({ items, cloneOfSource } = buildFeed(forcingCuts ? forcedEvent : bounded ? place : null, forcingCuts ? 'the grab' : 'the Cast-off'));
@@ -1240,7 +1341,9 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1240
1341
  pageStart = (truncateLead ? report.contentTop : 0) + spentLead;
1241
1342
  if (rowForced) {
1242
1343
  /** A forced break inside a box that lays its children side by side is not the grab's to cut at (*a parallel flow is a fragmentainer*, 2026-10-08; *a forced break inside a cell*, P37c): the cut falls in the item's flow and only the row's height crosses the wall, so the feed is not cut at the event — a word position cannot say a cell's place, and cutting there put away the neighbour cell with everything after the paragraph in document order. Whether the event is inside a row item is a fact of the layout, asked here of the report: the box stands inside an item of a row that is not itself inside another item's, and after a sibling in the item's procession (a break before an item's first block is the item's own start). Where it is not, the grab cuts as it always did. An event past the feed the Cast-off bounded is not this page's, and a longer pour may reach it. */
1243
- const clone = cloneOfSource.get(forcedEvent.el);
1344
+ // A run has no box: its event is asked through the block child it follows, after which it stands, so it is never its item's start.
1345
+ const ev = forcedEvent;
1346
+ const clone = cloneOfSource.get(ev.runAfter ?? ev.el);
1244
1347
  const at = clone ? report.boxOf(clone) : null;
1245
1348
  rowEventIn = !!clone;
1246
1349
  if (clone) {
@@ -1254,7 +1357,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1254
1357
  item = a;
1255
1358
  }
1256
1359
  const sequence = at ? report.processionOf(at) : [];
1257
- if (!at || !item || nested || sequence.indexOf(at) < 1 || at.rect.top <= item.rect.top + 0.25) {
1360
+ if (!at || !item || nested || (!ev.runAfter && (sequence.indexOf(at) < 1 || at.rect.top <= item.rect.top + 0.25))) {
1258
1361
  rowForced = false; // not a row's: the grab's, as before
1259
1362
  continue;
1260
1363
  }
@@ -1262,6 +1365,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1262
1365
  }
1263
1366
  if (!bounded || reachesBelowTheEdge({ report, sourceOf, pageStart, contentHeight: conditions.contentH }))
1264
1367
  break;
1368
+ short = place.el;
1265
1369
  // @ts-expect-error `words` is a number wherever the feed is bounded, and a feed that is not bounded broke out above
1266
1370
  words *= 2;
1267
1371
  }
@@ -2,7 +2,7 @@
2
2
  * The fragmentation face: the engine's members a view adapter builds on, `@beltype/cicero/fragmentation`.
3
3
  *
4
4
  * @remarks
5
- * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
5
+ * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07; `usedPageName` and `depthOfCut`, by which the run names a page it resumes inside a box and closes its counters, 2026-11-13); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
@@ -10,7 +10,7 @@ export { effectivePageName, readGeometryTable, registerGcpmProperties, sheetReac
10
10
  export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.ts';
11
11
  export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.ts';
12
12
  export { ensureContinuationDressing, ensureDressing } from './dressing.ts';
13
- export { forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks } from './driver.ts';
13
+ export { depthOfCut, forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks, usedPageName } from './driver.ts';
14
14
  export { ForcingMemo } from './forcing-memo.ts';
15
15
  export type { Keep } from './forcing-memo.ts';
16
16
  export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack, truncateLeadingDecoration } from './fragments.ts';
@@ -2,7 +2,7 @@
2
2
  * The fragmentation face: the engine's members a view adapter builds on, `@beltype/cicero/fragmentation`.
3
3
  *
4
4
  * @remarks
5
- * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
5
+ * Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07; `usedPageName` and `depthOfCut`, by which the run names a page it resumes inside a box and closes its counters, 2026-11-13); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
@@ -10,7 +10,7 @@ export { effectivePageName, readGeometryTable, registerGcpmProperties, sheetReac
10
10
  export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.js';
11
11
  export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.js';
12
12
  export { ensureContinuationDressing, ensureDressing } from './dressing.js';
13
- export { forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks } from './driver.js';
13
+ export { depthOfCut, forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks, usedPageName } from './driver.js';
14
14
  export { ForcingMemo } from './forcing-memo.js';
15
15
  export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack, truncateLeadingDecoration } from './fragments.js';
16
16
  export { takeReport } from './gauge.js';
@@ -762,6 +762,10 @@ export function mutateToContinuation(clone, { cut = null, remainderPx = null, so
762
762
  const mine = itemsOf(flows ?? flowsOf(items), path, source, cloneOf);
763
763
  // The items of a divided row stand before the place this cut resumes at, and are not spent: each has a place of its own, applied below.
764
764
  const keep = new Set(mine.map((m) => m.itemClone));
765
+ // A row a spanning cell's group reaches stands before the place too (the Gauge's rows: a `rowspan` makes the cells of every row it spans one row), and every cell in it is an item with a place of its own, so the row stays and its cells are cut below.
766
+ for (const m of mine)
767
+ if (m.itemClone.parentElement && m.itemClone.parentElement !== clone && /^TR$/i.test(m.itemClone.parentElement.tagName))
768
+ keep.add(m.itemClone.parentElement);
765
769
  if (divides(cut)) {
766
770
  const target = targetOf(cut, source, cloneOf);
767
771
  if (!target)
@@ -914,6 +918,9 @@ function shellAt(clone, target, px, keep = new Set(), hollow = false) {
914
918
  el.style.boxSizing = 'border-box';
915
919
  el.style.height = `${Math.max(0, px.remaining)}px`;
916
920
  el.style.minHeight = '0';
921
+ // Its block-end padding is spent with the rest (`block-end-padding-taller-than-page`, assessment 2026-11-07): the shell has no content, so its padding paints exactly as the content box it gives way to, and the border box is the remainder alone. A padding taller than the page area otherwise held every shell at its own height, the remainder never shrank, and the walk refused. A box that clones its decoration wears its padding on every fragment, so it keeps it.
922
+ if (!clonesDecoration(el))
923
+ el.style.setProperty('padding-bottom', '0', 'important');
917
924
  return clone;
918
925
  }
919
926
  // The painted shell: a slice wrapper takes the box's place in flow at exactly the leftover height and clips there; the box keeps its natural size — an image must never be resized into the remainder — and a negative top margin pulls the already-shown part above the clip.
package/dist/src/gauge.js CHANGED
@@ -193,6 +193,28 @@ export function takeReport(container, { contentHeight }) {
193
193
  for (const item of row)
194
194
  rowOfBox.set(item, row);
195
195
  }
196
+ /**
197
+ * A cell spanning rows (`rowspan`) stands beside the cells of every row it spans, so the cells of those rows are one row of parallel flows, in document order: a cut through the second spanned row divides the spanning cell and that row's cells, each at its own verdict, and leaves the rows above it spent and the rows below it whole, as Chromium prints it (CSS 2.1 §17.5.3; css-break-3 §2.1; the essay's case 92, *the cell that spans the cut*). A span stays inside its row group, as §17.2 says.
198
+ */
199
+ for (const box of boxes) {
200
+ const trs = box.blockChildren.filter((c) => !c.anonymous && win.getComputedStyle(c.el).display === 'table-row');
201
+ for (let i = 0; i < trs.length; i++) {
202
+ let end = i;
203
+ for (let j = i; j <= end; j++) {
204
+ for (const cell of trs[j].blockChildren) {
205
+ const span = cell.el.rowSpan ?? 1;
206
+ end = Math.min(trs.length - 1, Math.max(end, span === 0 ? trs.length - 1 : j + span - 1));
207
+ }
208
+ }
209
+ if (end === i)
210
+ continue;
211
+ const row = trs.slice(i, end + 1).flatMap((tr) => tr.blockChildren.filter((c) => !['absolute', 'fixed'].includes(win.getComputedStyle(c.el).position)));
212
+ if (row.length > 1)
213
+ for (const item of row)
214
+ rowOfBox.set(item, row);
215
+ i = end;
216
+ }
217
+ }
196
218
  const topLevel = boxes.filter((b) => !b.parent);
197
219
  const contentTop = topLevel.length ? topLevel[0].rect.top : 0;
198
220
  let flowBottom = contentTop;
@@ -14,7 +14,9 @@ export type MeasureContext = ReturnType<typeof createMeasureContext>;
14
14
  *
15
15
  * A vertical or sideways root is refused by name too (Codeberg issue 12): every read in this engine is horizontal, the lines by vertical overlap, the cut by a y, the page area by its height, so under `writing-mode: sideways-lr` on the root the text of a positioned paragraph fell below the clip and was never resumed, and the printer's tiling invariant refused what the engine should have. Vertical writing is outside the beta, and a refusal that says so at intake is cheaper than one three tools later.
16
16
  *
17
- * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), or a document root whose writing mode is not `horizontal-tb`.
17
+ * A right-to-left root or body (`direction: rtl`) is refused by name as well, as out of scope (SPEC II.1, item 6); a vertical box below the root is refused by `readScope` (`src/scope.ts`).
18
+ *
19
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), a document root whose writing mode is not `horizontal-tb`, or a root or body that computes `direction: rtl`.
18
20
  */
19
21
  export declare function assertRenderConditions(doc: Document, { relaxDpr }?: {
20
22
  relaxDpr?: boolean;
@@ -17,7 +17,9 @@ const NEUTRALIZE_ID = '__beltype-neutralize';
17
17
  *
18
18
  * A vertical or sideways root is refused by name too (Codeberg issue 12): every read in this engine is horizontal, the lines by vertical overlap, the cut by a y, the page area by its height, so under `writing-mode: sideways-lr` on the root the text of a positioned paragraph fell below the clip and was never resumed, and the printer's tiling invariant refused what the engine should have. Vertical writing is outside the beta, and a refusal that says so at intake is cheaper than one three tools later.
19
19
  *
20
- * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), or a document root whose writing mode is not `horizontal-tb`.
20
+ * A right-to-left root or body (`direction: rtl`) is refused by name as well, as out of scope (SPEC II.1, item 6); a vertical box below the root is refused by `readScope` (`src/scope.ts`).
21
+ *
22
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), a document root whose writing mode is not `horizontal-tb`, or a root or body that computes `direction: rtl`.
21
23
  */
22
24
  export function assertRenderConditions(doc, { relaxDpr = false } = {}) {
23
25
  const win = doc.defaultView;
@@ -29,6 +31,10 @@ export function assertRenderConditions(doc, { relaxDpr = false } = {}) {
29
31
  if (!/^-?[\d.]+px$/.test(lh)) {
30
32
  throw new Error(`I5 (DPR ruling): explicit line-height required, document root computes '${lh}'`);
31
33
  }
34
+ const rtl = [root, doc.body ? win.getComputedStyle(doc.body) : null].find((st) => st?.direction === 'rtl');
35
+ if (rtl) {
36
+ throw new Error(`right-to-left text is outside this engine's scope: the document computes direction 'rtl' on its ${rtl === root ? 'root' : 'body'}, and every read here is left to right (the line's start edge, the hanging of a margin, the page's left and right). Write the document ltr, or print it with another engine.`);
37
+ }
32
38
  if (root.writingMode && root.writingMode !== 'horizontal-tb') {
33
39
  throw new Error(`a vertical or sideways root is outside this engine's beta: the document root computes writing-mode '${root.writingMode}', and every read here is horizontal (the line by vertical overlap, the cut by a y). Write the document horizontal-tb, or print it with another engine.`);
34
40
  }
@@ -114,12 +120,32 @@ export async function awaitCloneImages(container) {
114
120
  unsettled = true;
115
121
  break;
116
122
  }
117
- if (!unsettled)
123
+ const objects = objectsFetching(container);
124
+ if (!unsettled && !objects.length)
118
125
  return false;
119
- await awaitImages(container);
126
+ await Promise.all([unsettled ? awaitImages(container) : null, ...objects]);
120
127
  void container.offsetHeight;
121
128
  return true;
122
129
  }
130
+ /**
131
+ * The clones' `<object>` elements that fetch their `data`, each as a promise of its `load` or `error`. A copy of an `<object>` fetches its data again and is 0 by 0 until the fetch answers, and only then does it show its resource or its fallback content (measured 2026-11-09: a copy of an object whose data fails measured 0 px in the turn it was mounted, 150 px at its `error` and 700 px, its fallback's height, one task later). Measured early, an object whose fallback is taller than the page was cut as nothing and presented clipped on one page (`cicero-object-fallback-throws`, assessment 2026-11-07). An object has no `complete`, so every fetching copy is awaited, and one that never answers is given up after two seconds rather than holding the run.
132
+ */
133
+ function objectsFetching(container) {
134
+ const win = container.ownerDocument.defaultView;
135
+ const out = [];
136
+ for (const o of container.querySelectorAll('object[data]')) {
137
+ if (o.getAttribute('is') || win.getComputedStyle(o).display === 'none')
138
+ continue; // a presenter's element and a hidden one are measured by nobody's fetch
139
+ out.push(new Promise((resolve) => {
140
+ // The fallback is laid out a task after `error` fires (measured: 150 px, the default object size, at the event; 700 px a `setTimeout(0)` later), so the promise settles one task after the event.
141
+ const later = () => setTimeout(resolve, 0);
142
+ o.addEventListener('load', later, { once: true });
143
+ o.addEventListener('error', later, { once: true });
144
+ setTimeout(resolve, 2000); // ponytail: a fixed ceiling for a fetch that never answers; the rim hears nothing of an object, so a later answer is not re-cut
145
+ }));
146
+ }
147
+ return out;
148
+ }
123
149
  /** The declarations {@link publishOn} last wrote on a container, so the next call takes them off. */
124
150
  const PUBLISHED = new WeakMap();
125
151
  /** Write a page's published values on the measure container, and take the previous page's off. */