@beltype/cicero 0.1.6 → 0.1.8

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/dist/src/reads.js CHANGED
@@ -7,12 +7,12 @@
7
7
  *
8
8
  * **A missing target shows nothing**: no declaration is delivered for it, the fallback `""` stands, the pseudo-element still has its box (the leader the author drew still draws), and the console names the link once per paginate.
9
9
  *
10
- * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is `doc.getElementById`'s answer, the first element in tree order, which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
10
+ * **The loop is a consumer's too, over a chain of its own** (*the engine knows no view*, `.claude/decisions/project/the-engine-knows-no-view.md`, ruling 3). `settleReads`'s `run` and `blocksOf` are the door: a view that holds its own pages settles through this loop, with a `run` that refills the dirty entries of its chain, and never through a copy of it. What the loop assumes of its host, read in its body and its helpers: the sites are found in `doc`'s sheets and matched among `originals` (the whole document where none are given), and a site or a target counts only where it stands inside `blocksOf()`'s blocks, since the position index places it; so `originals` are the boxes that hold the blocks, never a presented copy of them. A target is the first element in tree order carrying the id among `originals` (`doc.getElementById`'s answer where none are given), which holds where the source boxes precede every copy that keeps the id, as `<beltype-root>` keeps its originals before its copies, or where the copies carry no id; a host's own element that carries an author's id is outside the originals and is never a target, and a `target-text()` target outside `blocksOf()`'s blocks shows nothing, as a counter or page target there does. The blocks are any elements in document order: nothing asks that they share a parent or be `body`'s children, since the position index, the counter walk and `pathOf` each walk one block on its own. The counter walk's ancestors are `html` and `body`, the two the driver's own walk reads, so another ancestor of the blocks must carry no counter operation. A changed site is measured on a clone of its block in the measure container, at its page's content width, the ruler the run measures with. The run's result carries an `account` whose entries have the driver's shape (`pos`, `cut`, `carried`, `blank`, `trace.forced`, and `closes` with `pageNumber`, `contentW`, `contentH` and `strings`), every address in `blocksOf()`'s numbering, and the `index` it was handed; and, where its pages publish values of their own, `laidFacts`, the page values each page was laid out under (a flow site reads them), which the loop compares the truth with in place of the table it handed the run (a run that returns none was laid out under the table it was handed, as the driver's is; see CHANGELOG 2026-10-28); a layout sent round again is handed `resume.at`, the addresses of the moved boxes, which such a `run` maps to the entries they dirty. The values reach every element in `doc`'s own tree through one adopted sheet, and the page facts are the page hosts' (`chase.js`). One assumption was the root's alone and is a parameter: the sheet that hides a reservation scopes its rules to `beltype-root`, and `hide` names another scope.
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
14
14
  import { READ_PREFIX, STRING_KEYWORDS, STRING_PROP_PREFIX, decodeRead, encodeStringProp, splitTopLevel } from './gcpm-encoding.js';
15
- import { styleSheetsOf } from './capture.js';
15
+ import { sheetsCountInPseudos, styleSheetsOf } from './capture.js';
16
16
  import { format as formatCounter } from './counter-styles.js';
17
17
  import { collapse, counterStatesAfter, counterValue, counterValues, documentOpsOf } from './document-state.js';
18
18
  import { indexBlocks, pageContaining, pageIntervals, pathOf } from './positions.js';
@@ -26,7 +26,7 @@ export const MAX_RUNS = 3;
26
26
  /** The reservation a forward layout read stands at during the first run, Egildo's ruling of 2026-10-03: wrong for nearly every reference, so nothing rests on it, and never printed, because the loop keeps the last true values. */
27
27
  export const RESERVATION = '0';
28
28
  /** A CSS string literal for any text: a quote, a backslash and a line break escaped, nothing else. */
29
- export const cssString = (text) => `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\a ')}"`;
29
+ export const cssString = (text) => `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\u0000-\u001f\u007f]/g, (ch) => `\\${ch.charCodeAt(0).toString(16)} `)}"`;
30
30
  /** The selector a delivery is keyed by: the attribute the function reads, at the value this element carries. */
31
31
  export const attrSelector = (attr, value) => `[${attr}=${cssString(value)}]`;
32
32
  /**
@@ -285,7 +285,7 @@ export function findSites(doc, originals = null, keep = null) {
285
285
  read,
286
286
  link,
287
287
  key: read.attr != null ? (link == null ? null : attrSelector(read.attr, link)) : '',
288
- target: fragment == null ? null : doc.getElementById(fragment),
288
+ target: fragment == null ? null : targetOf(doc, fragment, originals),
289
289
  pseudos
290
290
  });
291
291
  }
@@ -293,6 +293,19 @@ export function findSites(doc, originals = null, keep = null) {
293
293
  }
294
294
  return sites.length ? sites : null;
295
295
  }
296
+ /** The element a fragment names: `doc.getElementById`'s answer, or, where the host hands its originals, the first element carrying the id among them, so a host's own element that shares an author's id is never a target. */
297
+ function targetOf(doc, fragment, originals) {
298
+ const first = doc.getElementById(fragment);
299
+ if (!originals || !first || originals.some((box) => box.contains(first)))
300
+ return first;
301
+ const selector = `[id="${fragment.replace(/["\\]/g, '\\$&').replace(/\n/g, '\\a ')}"]`;
302
+ for (const box of originals) {
303
+ const el = box.id === fragment ? box : box.querySelector(selector);
304
+ if (el)
305
+ return el;
306
+ }
307
+ return null;
308
+ }
296
309
  /** The engine's one delivery sheet per document, replaced at every delivery and withdrawn when a paginate finds nothing to deliver. */
297
310
  const SHEETS = new WeakMap();
298
311
  /** Deliver a table of values: one constructed sheet, adopted once, its text replaced. */
@@ -377,6 +390,7 @@ export function staticValues(sites, { doc, blocks, index = null, keep = null })
377
390
  const win = doc.defaultView;
378
391
  const ops = documentOpsOf(blocks, index ?? indexBlocks(blocks), {
379
392
  styleOf: (el) => win.getComputedStyle(el),
393
+ pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
380
394
  ancestors: [doc.documentElement, doc.body].filter(Boolean),
381
395
  marks: new Set(counted.map((s) => s.target)),
382
396
  keep
@@ -514,7 +528,7 @@ function boxAddress(site, index, blocks) {
514
528
  *
515
529
  * **A layout sent round again resumes from the one before it** (*delivery resumes from the moved page*, 2026-10-21). A delivered value that moves a line is an edit at its site's page, so `run` is handed `resume`, the layout just checked and the addresses of the boxes whose lines moved, and the driver restarts one page before the first of them, re-cuts, and stops where it converges past the last, exactly as it resumes from a host's edit. The result's `resumed` and `why` stay the call's: two resumes compose into one from the prior the host handed (`resume.js`' `composeResumed`), a whole layout anywhere in the loop makes `resumed` null, and each resumed layout leaves a `cicero:reads-run` measure naming the pages it cut again.
516
530
  *
517
- * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22).
531
+ * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
518
532
  */
519
533
  export async function settleReads(doc, { run, blocksOf, originals = null, warm = null, warmFacts = null, hide = false, keep = null }) {
520
534
  let t = performance.now(); // the site search is the reads' own cost and is inside their measure (*a resumed run keeps its reads*, 2026-10-22)
@@ -529,6 +543,10 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
529
543
  const name = (s) => ('read' in s ? `<${s.el.tagName.toLowerCase()}${s.link == null ? '' : ` ${s.read.attr ?? 'url'}="${s.link}"`}>` : `<${s.el.tagName.toLowerCase()}${s.el.id ? ` id="${s.el.id}"` : ''}>`);
530
544
  const named = (list) => [...new Set(list.map(name))].join(', ');
531
545
  const blocks = blocksOf();
546
+ // A text target counts only inside the blocks, as every other target does through the position index and the counter walk: an element outside them (a host's own) is no target, and the reference shows nothing.
547
+ for (const s of sites)
548
+ if (s.target && s.read.fn === 'text' && !blocks.some((b) => b.contains(s.target)))
549
+ s.target = null;
532
550
  // One walk of the words, shared with the run (*the run resumes from an entry*, build two: 60 ms of every manual run was this walk, done twice). The counter log is not shared, and the reason is worth a line: it is read here before the run registers the GCPM properties, and an unregistered `string-set` inherits, so a log taken now would assign every heading's string to its descendants too.
533
551
  const index = indexBlocks(blocks, undefined, undefined, keep);
534
552
  const fixed = staticValues(sites, { doc, blocks, index, keep });
@@ -573,6 +591,10 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
573
591
  }
574
592
  runs++;
575
593
  t = performance.now();
594
+ if (result.complete === false) { // a partial chain is not the document: its pages place only the targets cut so far, so the reservations stay hidden and the table it was laid out under stays the warm one
595
+ facts = laidFacts;
596
+ break;
597
+ }
576
598
  showReservations(doc); // the run is complete: what was reserved is delivered true on the next lines, or was true already
577
599
  const truth = pageValues(paged, result);
578
600
  unplaced = truth.unplaced;
@@ -12,6 +12,7 @@
12
12
  * @packageDocumentation
13
13
  */
14
14
  import { isAtom, READS_ITS_CHILDREN } from './word-space.js';
15
+ import { ARTIFACT, hasWord } from './data-beltype.js';
15
16
  /**
16
17
  * THE MAKE-UP ORDER (*breaks are classified*, I3, amended in the fifth round) — which constraint yields first, and the standing fact that a constraint which has yielded still pulls.
17
18
  *
@@ -158,7 +159,7 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
158
159
  const monolithic = (box) => {
159
160
  if (box.anonymous)
160
161
  return false; // a run of lines, never one unit
161
- if (box.laidOutLast)
162
+ if (box.laidOutLast || box.laidOutFirst)
162
163
  return true; // a footer written before its rows: its words stand before the rows in the word space, so no place inside it could be addressed (*the footer before the rows*)
163
164
  const live = sourceOf(box.el) ?? box.el;
164
165
  const held = isMonolith(box.el, styleOfBox(box), live);
@@ -197,9 +198,11 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
197
198
  /** A row's items are parallel flows too (*every branch gets a place*, 2026-09-21; §2.1's own list — the cells of a row, grid items, flex items). `report.rowOf` is the Gauge's answer: the side-by-side siblings a box stands among, or null. An item's interior offers nothing, exactly as a float's does, and the boundary between two items of one row is not a place in any procession — they stand beside each other, so nothing lies between them on the block axis. A row therefore offers two readings and no more: the boundary before it, on its first item, and the page edge through it. */
198
199
  const rowOf = (box) => (box === root ? null : report.rowOf?.(box) ?? null);
199
200
  const insideRowItem = (box) => climb(box, (p) => !!rowOf(p), false);
200
- /** Does the walk read this box at all? Not an inline box, nothing inside a monolith, a parallel flow or a row item — and a row answers once, at its first item (see `rowOf`). */
201
+ /** A copy the engine repeats (`artifact`), the table's header on a continuation: furniture with a footprint, no content of the flow, so no break point stands in it (*an artifact is never read*). */
202
+ const copy = (box) => !box.anonymous && hasWord(box.el, ARTIFACT);
203
+ /** Does the walk read this box at all? Not an inline box, nothing inside a monolith, a parallel flow, a row item or a repeated copy — and a row answers once, at its first item (see `rowOf`). */
201
204
  const walks = (box) => {
202
- if (box.inline || insideMonolith(box) || insideParallel(box) || insideRowItem(box))
205
+ if (box.inline || copy(box) || insideMonolith(box) || insideParallel(box) || insideRowItem(box) || climb(box, copy, false))
203
206
  return false;
204
207
  const row = rowOf(box);
205
208
  return !row || row[0] === box;
@@ -210,7 +213,7 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
210
213
  * A float, an absolutely positioned box and a row are not where it stops: a float's top is where the float was pushed, not the procession's, and a row's first item is not the row's top. The premise is stated rather than proved — that in-flow tops do not go backward in document order — and a negative margin taller than the box above it, or a relative offset, would break it. A table's footer written before its rows broke it too, and is walked where it is laid out instead (`gauge.js`'s `laidOutLast`, case 110).
211
214
  */
212
215
  const standsBelow = (box, limit, eps) => !rowOf(box) && !parallelBox(box) && box.rect.top > limit + eps;
213
- return { report, sourceOf, boxes, win, root, styleCache, forcing, styleOfBox, monolithic, insideMonolith, parallelBox, insideParallel, rowOf, insideRowItem, walks, standsBelow };
216
+ return { report, sourceOf, boxes, win, root, styleCache, forcing, styleOfBox, monolithic, insideMonolith, parallelBox, insideParallel, rowOf, insideRowItem, copy, walks, standsBelow };
214
217
  }
215
218
  /** Does this feed hold the box the retreat walk stops at — the first in-flow box whose top lies below the page's limit? The check a bounded feed must pass before anything is decided on it (*the Cast-off bounds the feed*, 2026-09-26): a feed that holds that box gives the walk everything it reads, and one that does not stopped above the edge and is poured again. */
216
219
  export function reachesBelowTheEdge({ report, sourceOf, pageStart, contentHeight, eps = 0.25 }) {
@@ -261,7 +264,7 @@ export function firstBelowTheEdge({ report, sourceOf, limit, eps = 0.25, sourced
261
264
  * @param a.stats - when given, counts what the collection costs: the boxes it visited, the line stacks it measured and the ones it skipped, and the milliseconds the lines and the end gaps took (`adjudicate` publishes it as the measure's detail).
262
265
  */
263
266
  function collect(w, { boxes, limit, pageStart, eps, stats }) {
264
- const { report, win, root, styleOfBox, monolithic, parallelBox, rowOf, walks, standsBelow } = w;
267
+ const { report, win, root, styleOfBox, monolithic, parallelBox, rowOf, copy, walks, standsBelow } = w;
265
268
  /**
266
269
  * §5.3's *leaving room*, priced (*the fragment that wraps itself*, 2026-09-15). *"When a box breaks, its content box extends to fill any remaining fragmentainer extent (leaving room for any margins/borders/padding applied by `box-decoration-break: clone`)."* So a cut inside a box that clones its decoration costs the page that box's block-end border and padding, on top of the content above it — and every ancestor the same cut breaks costs its own.
267
270
  *
@@ -280,6 +283,13 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
280
283
  }
281
284
  return px;
282
285
  };
286
+ /** The rise a cut grants inside a table cell it divides: the cell's own nearest, the box included and the root not (a row item's rise is its row's, `rowPlan`). */
287
+ const liftIn = (box) => {
288
+ for (let p = box; p && p !== root; p = p.parent)
289
+ if (styleOfBox(p).display === 'table-cell')
290
+ return cellLift(w, p);
291
+ return 0;
292
+ };
283
293
  /** A box's bottom as a place in the procession: a row ends where its tallest item does, not where the item that happens to stand last in the source does. */
284
294
  const bottomOf = (box) => {
285
295
  const row = rowOf(box);
@@ -310,17 +320,19 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
310
320
  else if (item.rect.top >= h - eps)
311
321
  plan.push({ item, state: 'below', verdict: null });
312
322
  else {
313
- const [begins, reachesBelow] = contentSpanOf(w, item, h, eps);
323
+ // A cell the height divides sets its content from its own top on this page (`cellLift`), so its content is judged that much lower.
324
+ const lift = cellLift(w, item);
325
+ const [begins, reachesBelow] = contentSpanOf(w, item, h + lift, eps);
314
326
  if (begins === null)
315
327
  plan.push({ item, state: 'divided', verdict: null }); // holds nothing to show: a stretched box, divided by the geometry alone
316
- else if (begins >= h - eps)
328
+ else if (begins >= h + lift - eps)
317
329
  plan.push({ item, state: 'below', verdict: null }); // its content begins at or below the height: the break falls in the gap above it (§4.1's Class C, whatever made the gap — padding, an alignment), and the item resumes whole
318
330
  else if (!reachesBelow)
319
331
  plan.push({ item, state: 'done', verdict: null }); // all it has to show stands above the height; only its box reaches below, stretched by the row
320
332
  else if (monolithic(item))
321
333
  plan.push({ item, state: 'held', verdict: null }); // a monolith the height crosses resumes whole: the stated limit, as a float's (the item is the graphic, and nothing inside it is a place)
322
334
  else {
323
- const v = flowVerdict(w, item, h, eps);
335
+ const v = flowVerdict(w, item, h + lift, eps);
324
336
  if (!v)
325
337
  return null; // content crosses the height and the item offers no place above it: it shows nothing here
326
338
  plan.push({ item, state: 'divided', verdict: v });
@@ -344,15 +356,17 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
344
356
  const siblings = box === root ? [] : report.processionOf(box).filter((b) => !isAbsolutelyPositioned(styleOfBox(b)));
345
357
  const si = siblings.indexOf(box);
346
358
  // A footer written before its rows offers no boundary before it (*the footer before the rows*, 2026-10-15): the place is after the last row in the layout and before the first in the document, and an address can only say the second.
347
- if (si > 0 && !box.laidOutLast) {
359
+ // Nor does the box after a header written after its rows (the mirror): the place is after the header in the layout and before every row in the document, so the page would hold the header's copy alone.
360
+ if (si > 0 && !box.laidOutLast && !siblings[si - 1].laidOutFirst) {
348
361
  // Two siblings answer here, and they are not always the same one. **The keeps** are the immediately-adjacent box's, because §4.1 names *"a float and an immediately-adjacent in-flow or floated box"* as a Class A pair in as many words — a float's own `break-after` binds the boundary after it, which is §3.1's *should* on floats that this engine honours and Chromium declines. **The position** is the previous in-flow sibling's bottom border edge, because a parallel flow's extent is not a place in the procession the cuts divide (§2.1): the float is laid out beside the content, so the page closes where the previous in-flow box ended and the float's own bottom says nothing about it. Pricing this at the float's bottom is how a 96mm rail put every slab standing beside it off the page — measured, `cases/21-parallel-flows`, where the boundary before the first slab was priced at y 400 in a 264px page area.
349
362
  const pair = siblings[si - 1];
350
363
  const anchor = last(siblings.slice(0, si).filter((b) => !parallelBox(b))) ?? null;
351
- if (anchor) {
364
+ // Before the rows of a continuation only the repeated header stands, and a copy is furniture (*an artifact is never read*): a boundary there takes nothing of the box, and would send the page back to where the box began.
365
+ if (anchor && !siblings.slice(0, si).every((b) => parallelBox(b) || copy(b))) {
352
366
  candidates.push({
353
367
  kind: 'A',
354
368
  cls: 'A',
355
- y: bottomOf(anchor),
369
+ y: bottomOf(anchor) - liftIn(box.parent),
356
370
  docIndex,
357
371
  box,
358
372
  cost: clonedBlockEnd(box.parent), // the box itself goes whole to the next page; what this boundary breaks is its container and up
@@ -425,7 +439,7 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
425
439
  stats.gapMs += performance.now() - tg;
426
440
  if (gap) {
427
441
  const y = Math.min(gap.bottom, limit);
428
- if (y - gap.base > eps) {
442
+ if (y - (gap.base - liftIn(box)) > eps) {
429
443
  candidates.push({
430
444
  kind: 'C',
431
445
  cls: 'C',
@@ -444,7 +458,7 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
444
458
  // The place it names is before the box's first in-flow child, past the gap (*the cut divides its ancestors*, 2026-09-29): the page before carries the box's border, its padding and whatever stands in the gap — an empty float, the clearance under it — and the page after resumes the box divided, its first child whole. So the position is the child's outer top, which is the content edge plus the gap; it used to be the box's top plus its border, which put the padding on the next page and offered a break point §4.1 does not provide (`packages/essay/cases/71-the-box-that-begins-a-page`).
445
459
  const startGap = box.blockChildren.length ? report.gapAt({ box, edge: 'start' }) : 0;
446
460
  const firstInFlow = startGap > eps ? box.blockChildren.find((c) => !parallelBox(c)) : undefined;
447
- if (firstInFlow) {
461
+ if (firstInFlow && !copy(firstInFlow)) {
448
462
  const prevSibling = si > 0 ? siblings[si - 1] : null;
449
463
  const bs = win.getComputedStyle(box.el);
450
464
  candidates.push({
@@ -480,11 +494,12 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
480
494
  const orphans = parseInt(st.orphans, 10) || 1;
481
495
  const widows = parseInt(st.widows, 10) || 1;
482
496
  const window_ = classBWindow(T, orphans, widows);
497
+ const lift = liftIn(box);
483
498
  for (let k = 0; k < T - 1; k++) {
484
499
  out.push({
485
500
  kind: 'B',
486
501
  cls: 'B',
487
- y: lines[k].bottom,
502
+ y: lines[k].bottom - lift,
488
503
  docIndex,
489
504
  box,
490
505
  cost: clonedBlockEnd(box),
@@ -545,6 +560,18 @@ function subtreeOf(boxes, root) {
545
560
  }
546
561
  return out;
547
562
  }
563
+ /**
564
+ * How far a table cell's content rises when a cut divides the cell: the distance from its content box's top to its first line or monolith. A cell's `vertical-align` places its content in the cell's whole height; Chromium's print aligns a divided cell's first fragment within that fragment instead (`FinalizeTableCellLayout`: *the fragment will start at the block-start edge* on a resumption, and the free space is the fragment's own), so content the alignment set below the cut stands above it once the cell is divided. The presentation matches it by pinning the divided cell's height to the page's foot. Zero for anything else, and for a cell aligned at its top.
565
+ *
566
+ * @remarks Under collapsed borders the content box is read with the cell's whole border width where half of it is used, so the rise is under by half a border, never over.
567
+ */
568
+ function cellLift(w, cell) {
569
+ const st = w.styleOfBox(cell);
570
+ if (st.display !== 'table-cell')
571
+ return 0;
572
+ const [begins] = contentSpanOf(w, cell, -Infinity, 0);
573
+ return begins === null ? 0 : Math.max(0, begins - (cell.rect.top + (parseFloat(st.borderTopWidth) || 0) + (parseFloat(st.paddingTop) || 0)));
574
+ }
548
575
  /** Where a flow's content stands against a height: where it begins (the top of its first line or monolith, null where it holds none) and whether any of it stands below the height — asked of the leaves that reach below it alone, so a flow of a thousand paragraphs above the foot costs the leaves it needs and not its line stacks (a leaf whose box ends above the height has every line above it too). */
549
576
  function contentSpanOf(w, root, h, eps) {
550
577
  let begins = null;
@@ -216,6 +216,14 @@ export declare function blockChildrenOf(el: Element, isInline?: (el: Element) =>
216
216
  * @param el - a clone or a source element.
217
217
  */
218
218
  export declare function footerBeforeRows(el: Element): boolean;
219
+ /**
220
+ * Is this a table's header group written after a row or row group of its table, which CSS 2.1 §17.2 lays out before them all the same (*"always displayed before all other rows and row groups"*)?
221
+ *
222
+ * @remarks The mirror of {@link footerBeforeRows} (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the Gauge walks such a header first, the Stonehand offers no place inside it or right after it, and its words stand after the rows in the word space, so a page before its words shows it by its repeated copy alone. One that does not repeat, in a divided table, is refused by the driver. Read by computed display, so a clone answers for its source.
223
+ *
224
+ * @param el - a clone or a source element.
225
+ */
226
+ export declare function headerAfterRows(el: Element): boolean;
219
227
  /**
220
228
  * The computed style of the box an author declared — the clone's registered source, or the element itself where it is live and connected. `null` for everything else, which is the honest answer rather than a default: a detached clone with no registration is a `<br>` or a filler span the Compositor inserted, and an element nobody declared has no declaration to read.
221
229
  *
@@ -495,6 +495,21 @@ export function footerBeforeRows(el) {
495
495
  return true;
496
496
  return false;
497
497
  }
498
+ /**
499
+ * Is this a table's header group written after a row or row group of its table, which CSS 2.1 §17.2 lays out before them all the same (*"always displayed before all other rows and row groups"*)?
500
+ *
501
+ * @remarks The mirror of {@link footerBeforeRows} (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the Gauge walks such a header first, the Stonehand offers no place inside it or right after it, and its words stand after the rows in the word space, so a page before its words shows it by its repeated copy alone. One that does not repeat, in a divided table, is refused by the driver. Read by computed display, so a clone answers for its source.
502
+ *
503
+ * @param el - a clone or a source element.
504
+ */
505
+ export function headerAfterRows(el) {
506
+ if (sourceStyle(el)?.display !== 'table-header-group')
507
+ return false;
508
+ for (let s = el.previousElementSibling; s; s = s.previousElementSibling)
509
+ if (/^table-row(-group)?$/.test(sourceStyle(s)?.display ?? ''))
510
+ return true;
511
+ return false;
512
+ }
498
513
  /**
499
514
  * The computed style of the box an author declared — the clone's registered source, or the element itself where it is live and connected. `null` for everything else, which is the honest answer rather than a default: a detached clone with no registration is a `<br>` or a filler span the Compositor inserted, and an element nobody declared has no declaration to read.
500
515
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cicero",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Cicero, a paged-media engine for the live browser: it cuts a document into pages under CSS Paged Media and presents them as real frames. Beta.",
5
5
  "type": "module",
6
6
  "license": "MIT",