@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.
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** A counter operation on a `::before` or `::after`; a list item's implicit increment; css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** A list item's implicit increment, so an `<ol>` that runs onto a second page numbers its items from 1 there (measured 2026-11-07, `<ol reversed>` and a `counter-set` of `list-item` likewise); css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -21,7 +21,7 @@ import { STRING_SET_PROP, RUNNING_PROP } from './gcpm-encoding.js';
21
21
  import { format as formatCounter } from './counter-styles.js';
22
22
  import { inRemainders } from './positions.js';
23
23
  /** `counter-reset: a 2 b` → `[['a', 2], ['b', 0]]`. The computed value normalizes name/value pairs, but an absent number still defaults per property, so the default is a parameter. `none` and the empty string yield nothing. */
24
- function pairsOf(value, fallback) {
24
+ export function pairsOf(value, fallback) {
25
25
  const out = [];
26
26
  const tokens = String(value ?? '').trim().split(/\s+/).filter(Boolean);
27
27
  if (!tokens.length || tokens[0] === 'none')
@@ -197,6 +197,11 @@ function opAt(el, pos, depth, { styleOf, textOf, attrOf, marks }) {
197
197
  const mark = marks?.has(el) ? el : null;
198
198
  return op || strings || mark ? { reset: [], increment: [], set: [], ...op, strings, pos, depth, ...(mark ? { mark } : {}) } : null;
199
199
  }
200
+ /** A pseudo-element's counter operations, or `null` where it has none or is not generated: a `::before` or an `::after` whose `content` is `none` or `normal` is no box, and its computed `counter-increment` counts nothing (measured 2026-11-07: Chromium answers `fig 1` for it and the counter stays at 0). */
201
+ function pseudoOpOf(el, pseudo, styleOf) {
202
+ const style = styleOf?.(el, pseudo);
203
+ return style && !/^(?:none|normal)?$/.test(String(style.content ?? '').trim()) ? opOf(style) : null;
204
+ }
200
205
  /** One top-level block's part of the log: its operations in document order, each with its position and depth relative to the block — the words of the block before the element, and its depth below the block (*a resumed run keeps its reads*, 2026-10-22). {@link documentOpsOf} lays the parts end to end, a part's positions offset by its block's base and its depths by the ancestors above the blocks. */
201
206
  export function blockOpsOf(block, index, reads) {
202
207
  const ops = [];
@@ -207,11 +212,22 @@ export function blockOpsOf(block, index, reads) {
207
212
  // A `position: fixed` box has zero footprint at any depth (*a fixed box at any depth*, 2026-09-15), so it has no position in the word space this log is ordered by — `indexBlocks` skips it and so does this. A counter operation or a `string-set` inside such a box is therefore unread, which is exactly what was already true of a top-level fixed box and is recorded on the checklist beside it.
208
213
  if (style.position === 'fixed' || String(customOf(style, RUNNING_PROP)).trim())
209
214
  return;
210
- const op = opAt(el, index.wordsBefore(el), depth, reads);
211
- if (op)
215
+ const pos = index.wordsBefore(el);
216
+ const op = opAt(el, pos, depth, reads);
217
+ // A `::before` is the element's first child and an `::after` its last (CSS 2.1 §12.1), so their counter operations are logged one level down, the one at the element's first word and the other at its end. The element's named strings and its mark are evaluated after its `::before`: the number a heading or a figure prints there is the one its `string-set` and a `target-counter()` to it read.
218
+ const before = pseudoOpOf(el, '::before', reads.pseudoStyleOf);
219
+ if (before) {
220
+ if (op && (op.reset.length || op.increment.length || op.set.length))
221
+ ops.push({ ...op, strings: null, mark: undefined });
222
+ ops.push({ ...before, strings: op?.strings ?? null, pos, depth: depth + 1, ...(op?.mark ? { mark: op.mark } : {}) });
223
+ }
224
+ else if (op)
212
225
  ops.push(op);
213
226
  for (const child of el.children ?? [])
214
227
  walk(child, depth + 1);
228
+ const after = index.wordCountIn ? pseudoOpOf(el, '::after', reads.pseudoStyleOf) : null;
229
+ if (after)
230
+ ops.push({ ...after, strings: null, pos: pos + index.wordCountIn(el), depth: depth + 1 });
215
231
  };
216
232
  walk(block, 0);
217
233
  return ops;
@@ -222,6 +238,7 @@ export function blockOpsOf(block, index, reads) {
222
238
  * @param blocks - the top-level flow blocks, in order — `driver.js`'s `topLevelBlocks`.
223
239
  * @param index - the position index (`positions.js`), which holds every element inside every block.
224
240
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
241
+ * @param opts.pseudoStyleOf - the computed style of an element's `::before` or `::after`, whose counter operations are logged as the element's first and last child's; absent, a pseudo-element's counters are unread. The `::after` needs the index's `wordCountIn`.
225
242
  * @param opts.textOf - an element's text; absent, its `textContent`.
226
243
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
227
244
  * @param opts.ancestors - the elements above the blocks, outermost first (`html`, `body`). They open before the document's first word and are recorded at position −1.
@@ -229,8 +246,8 @@ export function blockOpsOf(block, index, reads) {
229
246
  * @param opts.running - the document's running elements in document order (`driver.js`' `runningBoxes`), each with the flat position the flow stood at where it was written (`driver.js`' `runningPosition`): each is logged as an assignment of its name there, carrying its place in this list as `running.at` (*a running element is a named node*, 2026-10-21). A running element is out of the flow at any depth, so the walk does not enter one, and its own counters and strings are unread, as a fixed box's are.
230
247
  * @param opts.keep - the memo's door (`forcing-memo.js`): each block's {@link blockOpsOf} part is asked of it, and a part kept from an earlier run is laid in place of a walk (*a resumed run keeps its reads*, 2026-10-22). A part read under marks is kept apart from one read without, and answers only while the marks that fall in its block are the ones it was read under (the position index says which block a mark is in). Absent, every block is walked.
231
248
  */
232
- export function documentOpsOf(blocks, index, { styleOf, textOf = (el) => el.textContent, attrOf = (el, name) => el.getAttribute(name), ancestors = [], marks = null, running = [], keep = null }) {
233
- const reads = { styleOf, textOf, attrOf, marks };
249
+ export function documentOpsOf(blocks, index, { styleOf, pseudoStyleOf = null, textOf = (el) => el.textContent, attrOf = (el, name) => el.getAttribute(name), ancestors = [], marks = null, running = [], keep = null }) {
250
+ const reads = { styleOf, textOf, attrOf, marks, pseudoStyleOf };
234
251
  const ops = [];
235
252
  ancestors.forEach((el, i) => {
236
253
  const op = opAt(el, -1, i, reads);
@@ -256,7 +273,7 @@ export function documentOpsOf(blocks, index, { styleOf, textOf = (el) => el.text
256
273
  const mine = marksIn?.get(b) ?? new Set();
257
274
  const read = () => ({ ops: blockOpsOf(blocks[b], index, reads), marks: mine });
258
275
  const part = keep
259
- ? keep(marks ? 'ops:marked' : 'ops', blocks[b], read, marks ? (p) => p.marks.size === mine.size && [...mine].every((m) => p.marks.has(m)) : undefined)
276
+ ? keep(`${marks ? 'ops:marked' : 'ops'}${pseudoStyleOf ? ':pseudo' : ''}`, blocks[b], read, marks ? (p) => p.marks.size === mine.size && [...mine].every((m) => p.marks.has(m)) : undefined)
260
277
  : read();
261
278
  for (const op of part.ops)
262
279
  ops.push({ ...op, pos: start + op.pos, depth: base + op.depth });
@@ -289,6 +306,75 @@ const runningOp = (r, at) => ({ reset: [], increment: [], set: [], strings: null
289
306
  export function counterStateAt(ops, pos, away = []) {
290
307
  return replay(ops, { until: pos, away });
291
308
  }
309
+ /** No page-context rule touches the page counter. */
310
+ export const NO_PAGE_COUNTER_OPS = Object.freeze({ reset: null, increment: null, set: null });
311
+ /**
312
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, with an element's reset of the page counter counted on from the page it begins on, and the page context's own `counter-reset`, `counter-increment` and `counter-set` applied as Chromium's print applies them.
313
+ *
314
+ * @remarks
315
+ * css-page-3 names a reset of the page counter only in the page context, but an author writes `counter-reset: page 1` on a chapter or on each letter of a print run to number its pages from one, and once the engine applies it the pages after must count on from there rather than stay at the value (Codeberg issue 1). The page counter is the page's own, reset on the host as `page N` (`chase.js`'s `setCounters`), so the reset is folded into N here and never delivered as an author counter: delivered, it reset the flow and the margin cells to the same value on every later page in the reset's scope. `target-counter()` to a page reads the same N (`reads.js`'s `pageValues`), so a reference answers the value the counter has at its target (Codeberg issue 2).
316
+ *
317
+ * The last `counter-reset` or `counter-set` of `page` at or before `pos` is the one in force: **at**, not strictly before, because the element that begins on the page is the one that numbers it, where an author counter's reset waits for the next page (css-page-3 §6.1 is about author counters). Scope is not replayed, since the page counter has one instance: a reset stands until the next one, whatever the depth of either. A reset on an element that begins mid-page numbers the next page.
318
+ *
319
+ * **A blank page the engine makes to bring the element to its side begins where the element does and stands before it** (Codeberg issue 13): the reset is not in force on it, so it continues the count it stands in, and it is not counted toward the pages after it, so the element's page bears the value. A blank page inside the scope, after the element began, counts as every page does.
320
+ *
321
+ * **The page context's rules are the page box's, and each page box is its own scope.** Measured 2026-11-07 on Chromium's print (`test/browser/page-context-counters.browser.ts`): every page adds its `counter-increment` of `page`, or 1 where none is declared, to the count the pages carry; a `counter-set` sets that count, and the pages after count on from it (`@page :first { counter-set: page 7 }` prints 7, 8, 9); a `counter-reset` opens a counter of the page box's own, so the page prints the reset value plus that page's own declared increment and the pages after continue the count as though it had not been there (`@page :first { counter-reset: page 10 }` prints 10, 1, 2, and with `@page { counter-increment: page 2 }` beside it 12, 2, 4). An element's reset is applied to the count before the page's own rules, and the page it begins on takes no implicit increment. Until 2026-11-07 these declarations reached the page host as author style, where `counter-increment: page 2` added to the host's `page N` and the host's inline reset hid every `@page` reset: 3, 4, 5 for Chromium's 7, 2, 4.
322
+ *
323
+ * @param pages - each page before `pageIndex`, by index: its first word position, whether it is blank, and its page context's rules.
324
+ * @param blank - whether page `pageIndex` is one the engine made blank.
325
+ * @param counter - page `pageIndex`'s page context's rules.
326
+ * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
327
+ */
328
+ export function pageNumberAt(ops, pageIndex, pos, { pages, blank = false, counter = NO_PAGE_COUNTER_OPS, away = [] }) {
329
+ const inForce = (at, start, isBlank) => at < start || (at === start && !isBlank);
330
+ const resets = [];
331
+ for (const op of ops) {
332
+ if (op.pos > pos)
333
+ break;
334
+ for (const [name, value] of [...op.reset, ...op.set])
335
+ if (name === 'page')
336
+ resets.push({ pos: op.pos, value });
337
+ }
338
+ /** The last element reset in force on a page; the current page alone knows the remainders it carries in. */
339
+ const resetOn = (start, isBlank, current) => {
340
+ let found = null;
341
+ for (const r of resets)
342
+ if (inForce(r.pos, start, isBlank) && !(current && away.length && inRemainders(away, r.pos)))
343
+ found = r;
344
+ return found;
345
+ };
346
+ let count = 0;
347
+ let shown = 0;
348
+ let before = null;
349
+ for (let i = 0; i <= pageIndex; i++) {
350
+ const page = i === pageIndex ? { start: pos, blank, counter } : pages(i);
351
+ const own = page.counter ?? NO_PAGE_COUNTER_OPS;
352
+ const reset = resets.length ? resetOn(page.start, page.blank, i === pageIndex) : null;
353
+ const elementReset = reset !== null && reset !== before;
354
+ before = reset;
355
+ if (elementReset)
356
+ count = reset.value;
357
+ if (own.reset !== null) {
358
+ // The page box's own counter: it shows here and the count goes on beneath it.
359
+ shown = own.set ?? own.reset + (own.increment ?? 0);
360
+ continue;
361
+ }
362
+ count += own.increment ?? (elementReset ? 0 : 1);
363
+ if (own.set !== null)
364
+ count = own.set;
365
+ shown = count;
366
+ }
367
+ return shown;
368
+ }
369
+ /** The author's counters alone: the page's own two, `page` and `pages`, are the host's (`pageNumberAt`), and delivered as an author's they would reset the flow and the cells under the host's. */
370
+ export function withoutPageCounters(state) {
371
+ if (!('page' in state) && !('pages' in state))
372
+ return state;
373
+ const out = { ...state };
374
+ delete out.page;
375
+ delete out.pages;
376
+ return out;
377
+ }
292
378
  /** One replay, two readers. `counterStateAt` stops at a position and returns the state there; `stringAssignmentsOf` runs the log out and is handed the state at each element that assigns a named string. Written once because the scope arithmetic is the delicate part and two copies of it would agree only until one of them was fixed. */
293
379
  function replay(ops, { until = Infinity, onOp = null, away = [] } = {}) {
294
380
  /**
@@ -25,7 +25,11 @@ export type Dressing = {
25
25
  /** whether the value words' properties are registered, which is done once a stylesheet the document wears names one. */
26
26
  registered: boolean;
27
27
  };
28
- /** Make sure the document has the words sheet's rules, once; later calls answer the same record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears. */
28
+ /**
29
+ * Make sure the document has the words sheet's rules, and answer its record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears.
30
+ *
31
+ * **The document is asked, not remembered** ([*the engine asks the document for its sheets*](../../../.claude/decisions/cicero/the-engine-asks-the-document-for-its-sheets.md)), as `ensureContinuationDressing` does: a host that re-seats a kept `Document` empties its adopted sheets, so a later call adopts the words sheet again, and names the layer again, where the document no longer carries them.
32
+ */
29
33
  export declare function ensureDressing(doc: Document): Dressing;
30
34
  /**
31
35
  * Make sure the document wears the continuation dressing, and answer its sheet: a piece that resumes a box shows none of the box's beginnings, `::before`, `::marker`, `::first-letter` and `::first-line`, and a piece the cut passes through shows no `::after`. The sheet is made once per document and adopted after whatever the document wears; a later call adopts it again only where the document no longer wears it.
@@ -36,25 +36,31 @@ function providesRules(doc) {
36
36
  const provision = doc.head.querySelector('style[data-beltype~="cicero-provision"]');
37
37
  return !!provision && (provision.textContent ?? '').includes(WORDS_RULES);
38
38
  }
39
- /** Make sure the document has the words sheet's rules, once; later calls answer the same record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears. */
39
+ /**
40
+ * Make sure the document has the words sheet's rules, and answer its record. A document the provision reached already has them and nothing is seated; any other gets the layer named first in the head and the rules adopted after whatever it wears.
41
+ *
42
+ * **The document is asked, not remembered** ([*the engine asks the document for its sheets*](../../../.claude/decisions/cicero/the-engine-asks-the-document-for-its-sheets.md)), as `ensureContinuationDressing` does: a host that re-seats a kept `Document` empties its adopted sheets, so a later call adopts the words sheet again, and names the layer again, where the document no longer carries them.
43
+ */
40
44
  export function ensureDressing(doc) {
41
- const known = DRESSINGS.get(doc);
42
- if (known)
43
- return known;
44
- const dressing = { words: null, values: null, delivered: new Set(), registered: false };
45
- DRESSINGS.set(doc, dressing);
46
- if (providesRules(doc))
45
+ let dressing = DRESSINGS.get(doc);
46
+ if (!dressing) {
47
+ dressing = { words: null, values: null, delivered: new Set(), registered: false };
48
+ DRESSINGS.set(doc, dressing);
49
+ if (providesRules(doc))
50
+ return dressing;
51
+ dressing.words = new (doc.defaultView.CSSStyleSheet)();
52
+ dressing.words.replaceSync(WORDS_RULES);
53
+ }
54
+ if (!dressing.words)
47
55
  return dressing;
48
- const Sheet = doc.defaultView.CSSStyleSheet;
49
56
  if (!doc.getElementById(LAYERS_ID)) {
50
57
  const layers = doc.createElement('style');
51
58
  layers.id = LAYERS_ID;
52
59
  layers.textContent = `@layer ${LAYER};`;
53
60
  doc.head.prepend(layers);
54
61
  }
55
- dressing.words = new Sheet();
56
- dressing.words.replaceSync(WORDS_RULES);
57
- doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.words];
62
+ if (!doc.adoptedStyleSheets.includes(dressing.words))
63
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.words];
58
64
  return dressing;
59
65
  }
60
66
  /** The continuation dressing each document wears. */
@@ -134,13 +140,14 @@ export function deliverValues(doc, words) {
134
140
  dressing.delivered.add(word);
135
141
  grew = true;
136
142
  }
137
- if (!grew)
143
+ if (!grew && !dressing.values)
138
144
  return;
139
- if (!dressing.values) {
140
- dressing.values = new (doc.defaultView.CSSStyleSheet)();
145
+ // The document is asked, not remembered: a re-seated document wears no values sheet even when no word is new.
146
+ dressing.values ??= new (doc.defaultView.CSSStyleSheet)();
147
+ if (!doc.adoptedStyleSheets.includes(dressing.values))
141
148
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, dressing.values];
142
- }
143
- dressing.values.replaceSync([...dressing.delivered].map((word) => `${selectorOf(word)} { ${PROPERTY_OF.get(word.slice(0, word.indexOf(':')))}: ${word.slice(word.indexOf(':') + 1)}; }`).join('\n'));
149
+ if (grew)
150
+ dressing.values.replaceSync([...dressing.delivered].map((word) => `${selectorOf(word)} { ${PROPERTY_OF.get(word.slice(0, word.indexOf(':')))}: ${word.slice(word.indexOf(':') + 1)}; }`).join('\n'));
144
151
  }
145
152
  /** Is this adopted sheet one of the engine's three sheets keyed on words — the words sheet a host without the provision gets, the values sheet, and the continuation dressing? A host reading the sheets the document wears (`seam/client.js`' conditions key) leaves them out, as it leaves out the root's own sheets and the reads' delivery sheet. */
146
153
  export function isWordsSheet(doc, sheet) {
@@ -47,6 +47,8 @@ export type ForcingEvent = {
47
47
  sealedBy: number;
48
48
  /** It stands inside a box that lays its children side by side. */
49
49
  rowish: boolean;
50
+ /** For a run of lines after a block child, the event is the run's first word in `el`, which holds it, and this is the block child it follows; the feed is cut after it (*the anonymous box is in the procession*). */
51
+ runAfter?: Element;
50
52
  };
51
53
  /** One block of a page's feed: its clone, the source it was cloned from, and the address it resumes at. */
52
54
  export type FeedItem = {
@@ -111,7 +113,7 @@ export declare function pageNameDownFrom(el: Element, { styleOf, runBefore, bloc
111
113
  blockChildren?: typeof blockChildrenOf;
112
114
  }): string;
113
115
  /**
114
- * Read the document's @page geometry from the CSSOM — every sheet the document wears, adopted ones included (`capture.js`'s `styleSheetsOf`; the capture reads adopted sheets). Returns page and content box sizes in px.
116
+ * 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.
115
117
  *
116
118
  * **Margins only, and that is now a stated limit rather than the whole story** *(a page box has a border and a padding, 2026-09-07)*. The capture models a page's `border` and `padding` and subtracts both from the page area; this reader does not, so a document declaring either gets a content box too large here. Its one caller is `packages/essay`'s benchmark, over a corpus that writes `size` and margins into its page rules and nothing else — so the difference is unreachable there. It is not widened rather than left quietly wrong: this is the legacy single-geometry reader, and a second implementation of the page area is exactly what `readGeometryTable` exists to be the only one of.
117
119
  */
@@ -198,14 +200,18 @@ export declare const combineParity: (earlier: 'left' | 'right' | null, later: 'l
198
200
  *
199
201
  * @param blocks - the top-level blocks, in document order.
200
202
  */
201
- export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf, cutOf, posOf, posOfCut, isMonolith: monolith, blockChildren }: {
203
+ export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf, cutOf, posOf, posOfCut, isMonolith: monolith, blockChildren, extentOf }: {
202
204
  styleOf: (el: Element) => CSSStyleDeclaration;
203
205
  pageNameOf: (el: Element) => string;
204
- cutOf: (el: Element, b: number) => Address;
206
+ cutOf: (el: Element, b: number, word?: number | null) => Address;
205
207
  posOf: (el: Element, b: number) => number;
206
208
  posOfCut?: ((cut: Address) => number) | null;
207
209
  isMonolith?: typeof isMonolith;
208
210
  blockChildren?: typeof blockChildrenOf;
211
+ extentOf?: ((el: Element) => {
212
+ before: number;
213
+ count: number;
214
+ }) | null;
209
215
  }): {
210
216
  size: number;
211
217
  /** The first forcing event strictly after `cursorCut` for a page named `name`, or null. */
@@ -226,6 +232,7 @@ export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf,
226
232
  };
227
233
  parity: "left" | "right" | null;
228
234
  rowish: boolean;
235
+ runAfter: Element | undefined;
229
236
  } | null;
230
237
  /**
231
238
  * 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.
@@ -15,18 +15,18 @@
15
15
  */
16
16
  import { Ledger, createEntry } from './ledger.js';
17
17
  import { assertRenderConditions, awaitCloneImages, awaitFonts, awaitImages, createMeasureContext } from './measure.js';
18
- import { takeReport } from './gauge.js';
18
+ import { holdsAnonymousBlocks, takeReport } from './gauge.js';
19
19
  import { adjudicate, adjudicateFlow, firstBelowTheEdge, isAbsolutelyPositioned, isOutOfFlow, isMonolith, neverCutInside, reachesBelowTheEdge, processionBottoms, fitsPage } from './stonehand.js';
20
20
  import { composeDocument, applyRecordsUnder } from './compose-dom.js';
21
21
  import { CompositionCache } from './composition-key.js';
22
- import { readGeometryTable, effectivePageName, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReach } from './capture.js';
22
+ import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReach, sheetsCountInPseudos } from './capture.js';
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, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
26
+ import { documentOpsOf, counterStateAt, pageNumberAt, withoutPageCounters, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
27
27
  import { RUNNING_PROP } from './gcpm-encoding.js';
28
28
  import { ARTIFACT, hasWord } from './data-beltype.js';
29
- import { blockChildrenOf, footerBeforeRows, isFixedBox, SIDE_BY_SIDE, sourceOf } from './word-space.js';
29
+ import { blockChildrenOf, footerBeforeRows, headerAfterRows, isFixedBox, 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
32
  import { answerHas, boxesWhoseWordsMoved } from './has-words.js';
@@ -37,6 +37,21 @@ function toPx(len) {
37
37
  throw new Error(`unsupported page length: ${len}`);
38
38
  return parseFloat(m[1]) * PX_PER[m[2]];
39
39
  }
40
+ /**
41
+ * The message a table shape refuses with (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the table, by its id or class or its first words, and the shape. Both shapes lost rows with no message, and a refusal by name is the detector until the address can say where §17.2 lays the group out.
42
+ *
43
+ * @remarks The two shapes, both groups whose words and box disagree in order. A header group written after the rows is laid out above them (CSS 2.1 §17.2) while its words stand after them in the word space: one that repeats is walked first and shown by its copy on every page before its words, and one too tall to repeat, in a divided table, is refused, because which page shows it is the open ruling on §17.2's display order in the word space. A footer group written before the rows is walked where it is laid out, last and whole, and one taller than the page area could be placed nowhere, so the table ended on its first page.
44
+ *
45
+ * @param group - the source `thead` or `tfoot`.
46
+ * @param shape - the shape, in words.
47
+ * @param remedy - what the author can write instead.
48
+ */
49
+ function refusedTable(group, shape, remedy = 'write the groups in HTML\'s order: thead, tbody, tfoot') {
50
+ const table = group.closest('table');
51
+ const words = (table?.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
52
+ const name = table?.id ? `<table id="${table.id}">` : table?.className ? `<table class="${table.className}">` : `the table beginning "${words}"`;
53
+ return `cicero: ${name} has ${shape}, which this engine cannot divide across pages yet; it refuses rather than lose rows. To print it, ${remedy}.`;
54
+ }
40
55
  /** The nearest copy the engine repeats (`artifact`) standing ahead of this element among its siblings — the table's header, in front of its rows — or null. */
41
56
  const artifactBefore = (el) => {
42
57
  for (let s = el?.previousElementSibling; s; s = s.previousElementSibling)
@@ -44,6 +59,13 @@ const artifactBefore = (el) => {
44
59
  return s;
45
60
  return null;
46
61
  };
62
+ /** The header group written after this element among its siblings, which §17.2 lays out ahead of it all the same (`headerAfterRows`), or null. It is a copy on the pages before its words and the original in a feed, which pours it whole, and it stands ahead of the rows either way. */
63
+ const headerCopyAfter = (el) => {
64
+ for (let s = el?.nextElementSibling; s; s = s.nextElementSibling)
65
+ if (headerAfterRows(s))
66
+ return s;
67
+ return null;
68
+ };
47
69
  /** Does a copy the engine repeats (`artifact`) stand ahead of this element among its siblings — the table's header, in front of its rows? */
48
70
  export const standsAfterArtifact = (el) => artifactBefore(el) !== null;
49
71
  /**
@@ -60,9 +82,10 @@ export function gapAbove(box, report, win) {
60
82
  for (let child = box, a = box.parent; a; child = a, a = a.parent) {
61
83
  const cs = win.getComputedStyle(a.el);
62
84
  // A copy the engine repeats ahead of the child (the table's header) is furniture with a footprint, not a spent lead (*the header repeats*): the level owes only what stands above the copy, which under separated borders is the table's `border-spacing` above its first row, and Chromium draws none above a repeated header (*a repeated header joins the rows as Chromium prints them*, 2026-10-22). Under collapsed borders the copy's top is the table's own and nothing is owed.
63
- if (standsAfterArtifact(child.el)) {
64
- // The header's copy, never a footer's: a footer written before its rows stands among the siblings too, and is laid out at the foot (*the footer before the rows*).
65
- let copy = cs.borderCollapse === 'separate' ? artifactBefore(child.el) : null;
85
+ const late = headerCopyAfter(child.el);
86
+ if (late || standsAfterArtifact(child.el)) {
87
+ // The header's copy, never a footer's: a footer written before its rows stands among the siblings too, and is laid out at the foot (*the footer before the rows*). A header written after its rows is laid out ahead of them, and its copy is the one.
88
+ let copy = cs.borderCollapse === 'separate' ? late ?? artifactBefore(child.el) : null;
66
89
  while (copy && !/^THEAD$/i.test(copy.tagName))
67
90
  copy = artifactBefore(copy);
68
91
  const box = copy ? report.boxOf(copy) : null;
@@ -93,28 +116,25 @@ export function pageNameDownFrom(el, { styleOf, runBefore, blockChildren = block
93
116
  return effectivePageName(el);
94
117
  }
95
118
  /**
96
- * Read the document's @page geometry from the CSSOM — every sheet the document wears, adopted ones included (`capture.js`'s `styleSheetsOf`; the capture reads adopted sheets). Returns page and content box sizes in px.
119
+ * 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.
97
120
  *
98
121
  * **Margins only, and that is now a stated limit rather than the whole story** *(a page box has a border and a padding, 2026-09-07)*. The capture models a page's `border` and `padding` and subtracts both from the page area; this reader does not, so a document declaring either gets a content box too large here. Its one caller is `packages/essay`'s benchmark, over a corpus that writes `size` and margins into its page rules and nothing else — so the difference is unreachable there. It is not widened rather than left quietly wrong: this is the legacy single-geometry reader, and a second implementation of the page area is exactly what `readGeometryTable` exists to be the only one of.
99
122
  */
100
123
  export function readPageGeometry(doc) {
101
- for (const sheet of styleSheetsOf(doc)) {
102
- for (const rule of sheet.cssRules) {
103
- if (!(rule instanceof doc.defaultView.CSSPageRule))
104
- continue;
105
- const text = rule.cssText;
106
- const size = /size:\s*([^;}]+)/.exec(text)?.[1].trim().split(/\s+/) ?? [];
107
- const marginRaw = /(?:^|[\s{;])margin:\s*([^;}]+)/.exec(text)?.[1].trim().split(/\s+/) ??
108
- ['top', 'right', 'bottom', 'left'].map((side) => new RegExp(`margin-${side}:\\s*([^;}]+)`).exec(text)?.[1].trim() ?? '0px');
109
- if (size.length === 0)
110
- throw new Error('@page without an explicit size — the corpus always pins geometry (I5)');
111
- const [w, h] = size.length === 1 ? [size[0], size[0]] : size;
112
- const [mt, mr = mt, mb = mt, ml = mr] = marginRaw;
113
- const pageW = toPx(w);
114
- const pageH = toPx(h);
115
- const margins = { top: toPx(mt), right: toPx(mr), bottom: toPx(mb), left: toPx(ml) };
116
- return { pageW, pageH, margins, contentW: pageW - margins.left - margins.right, contentH: pageH - margins.top - margins.bottom };
117
- }
124
+ const { layered, unlayered } = pageRulesOf(doc);
125
+ for (const rule of [...unlayered, ...layered]) {
126
+ const text = rule.cssText;
127
+ const size = /size:\s*([^;}]+)/.exec(text)?.[1].trim().split(/\s+/) ?? [];
128
+ const marginRaw = /(?:^|[\s{;])margin:\s*([^;}]+)/.exec(text)?.[1].trim().split(/\s+/) ??
129
+ ['top', 'right', 'bottom', 'left'].map((side) => new RegExp(`margin-${side}:\\s*([^;}]+)`).exec(text)?.[1].trim() ?? '0px');
130
+ if (size.length === 0)
131
+ throw new Error('@page without an explicit size — the corpus always pins geometry (I5)');
132
+ const [w, h] = size.length === 1 ? [size[0], size[0]] : size;
133
+ const [mt, mr = mt, mb = mt, ml = mr] = marginRaw;
134
+ const pageW = toPx(w);
135
+ const pageH = toPx(h);
136
+ const margins = { top: toPx(mt), right: toPx(mr), bottom: toPx(mb), left: toPx(ml) };
137
+ return { pageW, pageH, margins, contentW: pageW - margins.left - margins.right, contentH: pageH - margins.top - margins.bottom };
118
138
  }
119
139
  throw new Error('no @page rule found');
120
140
  }
@@ -218,7 +238,7 @@ export const combineParity = (earlier, later) => later ?? earlier;
218
238
  *
219
239
  * @param blocks - the top-level blocks, in document order.
220
240
  */
221
- export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posOfCut = null, isMonolith: monolith = isMonolith, blockChildren = blockChildrenOf }) {
241
+ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posOfCut = null, isMonolith: monolith = isMonolith, blockChildren = blockChildrenOf, extentOf = null }) {
222
242
  const events = [];
223
243
  const blockStart = [];
224
244
  /**
@@ -233,9 +253,25 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
233
253
  const k = events.length;
234
254
  events.push(e);
235
255
  const inner = sealedBy >= 0 ? sealedBy : neverCutInside(el, st) ? k : -1;
236
- if (!parallel && !monolith(el, st))
237
- for (const child of blockChildren(el))
238
- visit(child, b, inner, rowish || SIDE_BY_SIDE.has(st.display ?? ''));
256
+ if (!parallel && !monolith(el, st)) {
257
+ const kids = blockChildren(el);
258
+ const runs = !!extentOf && holdsAnonymousBlocks(st.display ?? '');
259
+ const row = rowish || SIDE_BY_SIDE.has(st.display ?? '');
260
+ for (let i = 0; i < kids.length; i++) {
261
+ visit(kids[i], b, inner, row);
262
+ if (!runs)
263
+ continue;
264
+ // 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.
265
+ const ext = extentOf;
266
+ const own = ext(el);
267
+ const done = ext(kids[i]);
268
+ const word = done.before + done.count;
269
+ const next = kids[i + 1];
270
+ if ((next ? ext(next).before : own.before + own.count) <= word)
271
+ continue;
272
+ 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] });
273
+ }
274
+ }
239
275
  e.after = !parallel && isForced(st.breakAfter); // a float's own break-after was never read, and still is not
240
276
  e.afterParity = parityOf(st.breakAfter);
241
277
  e.end = events.length;
@@ -262,7 +298,7 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
262
298
  const e = events[k];
263
299
  const before = !namesOnly && e.before;
264
300
  // §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.
265
- 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 };
301
+ 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 };
266
302
  };
267
303
  /** 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. */
268
304
  const firstAfter = (cursorCut) => {
@@ -318,6 +354,8 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
318
354
  k = events[e.sealedBy].end;
319
355
  else if (!e.inFlow)
320
356
  k = e.end;
357
+ else if (e.runAfter)
358
+ k++; // a run's place is the grab's alone: the Cast-off cuts at a box, and moving forward only grows the feed
321
359
  else
322
360
  return { b: e.b, el: e.el, cut: { ...e.cut, path: [...e.cut.path] } };
323
361
  }
@@ -610,6 +648,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
610
648
  */
611
649
  const documentOps = documentOpsOf(blocks, index, {
612
650
  styleOf: (el) => win.getComputedStyle(el),
651
+ pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
613
652
  ancestors: [doc.documentElement, doc.body].filter(Boolean),
614
653
  running: running.map((r) => ({ name: r.name, pos: runningPosition(r, index) })),
615
654
  keep: memo?.keep
@@ -622,16 +661,18 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
622
661
  /** The grab's events, indexed here, once (*the page asks the index*, 2026-09-25) — beside the document's own counters and for the same reason: the source is never written (*the source document is never written*, C5), so what the grab reads about a box is the same on every page, and the walk that asked it of the tree on every page cost the BGD manual 66 ms a page whatever the cursor. `forcingIndexOf` above carries the question and its rules; the page asks `forcing.find`. */
623
662
  // The memo, if the seam offered one and the sheets leave it standing (*the forced-break index is kept across runs*, 2026-10-09; settled once for the call by `settledMemo`): the reads below are answered from it where an element was read before, and read from the browser and kept where not.
624
663
  memo?.restart();
664
+ const extentOf = (el) => ({ before: index.wordsBefore(el), count: index.wordCountIn(el) });
625
665
  const forcing = memo
626
666
  ? forcingIndexOf(blocks, {
627
667
  styleOf: (el) => memo.facts(el, () => snapshotOfStyle(win.getComputedStyle(el))),
628
668
  pageNameOf: (el) => memo.pageName(el, () => effectivePageName(el)),
629
669
  blockChildren: (el) => memo.blockChildren(el, () => blockChildrenOf(el)),
630
- cutOf: (el, b) => cutTo(el, b, { rawPath: pathOf(el, blocks[b], b, isFixedBox, (e) => memo.indexIn(e, () => inFlowIndex(e))) }),
670
+ cutOf: (el, b, word = null) => cutTo(el, b, { word, rawPath: pathOf(el, blocks[b], b, isFixedBox, (e) => memo.indexIn(e, () => inFlowIndex(e))) }),
631
671
  posOf: (el, b) => index.posOf(b, index.wordsBefore(el)),
632
- posOfCut
672
+ posOfCut,
673
+ extentOf
633
674
  })
634
- : forcingIndexOf(blocks, { styleOf: (el) => win.getComputedStyle(el), pageNameOf: effectivePageName, cutOf: (el, b) => cutTo(el, b), posOf: (el, b) => index.posOf(b, index.wordsBefore(el)), posOfCut });
675
+ : 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 });
635
676
  performance.measure('cicero:forcing-index', { start: t, end: (t = performance.now()), detail: { events: forcing.size, ...(memo ? { memoHits: memo.hits, memoMisses: memo.misses } : {}) } });
636
677
  /**
637
678
  * 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.
@@ -720,10 +761,18 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
720
761
  * @param who - the caller, for the error.
721
762
  */
722
763
  const cutCloneAt = (clone, cloneOfSource, place, who) => {
723
- const target = cloneOfSource.get(place.el);
764
+ const target = cloneOfSource.get(place.runAfter ?? place.el);
724
765
  if (!target || !clone.contains(target))
725
766
  throw new Error(`${who} could not address its own place in block ${place.b} — a defect to file, not a bail to hide`);
726
- truncateFrom(clone, target, { hollow });
767
+ if (!place.runAfter)
768
+ return void truncateFrom(clone, target, { hollow });
769
+ // A run has no element: the feed is cut at the first node after the block child it follows, which stands in the run, so the child stays whole and the run leaves with everything after it.
770
+ let at = target;
771
+ while (!at.nextSibling && at.parentNode && at !== clone)
772
+ at = at.parentNode;
773
+ if (!at.nextSibling)
774
+ throw new Error(`${who} found no run after its block child in block ${place.b}: a defect to file, not a bail to hide`);
775
+ truncateFrom(clone, at.nextSibling, { hollow });
727
776
  };
728
777
  const ledger = new Ledger();
729
778
  // The cursor is the document's own first address — the path to block zero, which divides nothing — plus its projection and whatever flows are still running beside it.
@@ -732,14 +781,25 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
732
781
  let compositionKey = null;
733
782
  // Entry N determines page N+1's conditions (*geometry changes only at page boundaries*, C8): name from the cursor's own address, parity from the page number, :first for page one. A width change re-creates the measure context and recomposes through the cache's width key — *cut-invariance* (C7) suspended locally, exactly as designed; the v3 constraint (widths change only at named-switch boundaries) keeps that suspension off mid-paragraph continuations.
734
783
  const conditionsFor = (pageIndex, { blank = false } = {}) => conditionsAt(pageIndex, cursor.cut, cursor.pos, { blank, carried: cursor.carried });
784
+ /** A kept page's `@page` counter rules, by its conditions: one geometry read per kind of page, not one per page per page. */
785
+ const pageCounters = new Map();
786
+ const pageCounterOf = (i, c) => {
787
+ const key = JSON.stringify([c.name, i === 0, c.parity, c.blank]);
788
+ let ops = pageCounters.get(key);
789
+ if (!ops)
790
+ pageCounters.set(key, ops = table.pageFor({ name: c.name, first: i === 0, parity: c.parity, blank: c.blank }).pageCounter);
791
+ return ops;
792
+ };
735
793
  /** The conditions of page `pageIndex`, which begins at `cut` (projected to `pos`) — {@link conditionsFor} with the cursor made a parameter, so a resumed run can write the conditions of a page it keeps from the address that page began at (*the run resumes from an entry*, build two, 2026-10-08). */
736
794
  const conditionsAt = (pageIndex, cut, pos, { blank = false, carried = null } = {}) => {
737
795
  const name = nameAt(cut);
738
- const pageNumber = pageIndex + 1;
796
+ const away = remaindersOf(carried, index.endOf);
739
797
  const parity = parityAt(pageIndex);
740
798
  const geo = table.pageFor({ name, first: pageIndex === 0, parity, blank });
741
- // 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).
742
- const counters = counterStateAt(documentOps, pos, remaindersOf(carried, index.endOf));
799
+ // 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.
800
+ 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 });
801
+ // 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.
802
+ const counters = withoutPageCounters(counterStateAt(documentOps, pos, away));
743
803
  return Object.freeze({ ...geo, name, pageNumber, parity, blank, counters, dpr: win.devicePixelRatio });
744
804
  };
745
805
  /**
@@ -778,7 +838,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
778
838
  const source = blocks[b];
779
839
  const cut = b === first ? cursor.cut : null;
780
840
  const ends = !!place && b === place.b;
781
- if (ends && place.cut.path.length === 1)
841
+ if (ends && place.cut.path.length === 1 && place.cut.word == null)
782
842
  break; // the place is this block's own start: the whole block leaves, exactly as the item-granular grab always cut
783
843
  const { clone } = cloneFlowPiece(source, { start: divides(cut) ? cut : null, end: ends ? place.cut : null, items: itemsIn(b), hollow, into: cloneOfSource }); // no composition: the tail is cut first, then the hints
784
844
  if (ends)
@@ -1013,6 +1073,8 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1013
1073
  if (!mayRepeat(src))
1014
1074
  continue;
1015
1075
  decided.add(src);
1076
+ if (footerBeforeRows(src) && g.getBoundingClientRect().height > conditions.contentH)
1077
+ throw new Error(refusedTable(src, 'a footer group taller than the page area, written before its rows', 'write the footer after the rows, where it is divided like them, or keep it within the page area'));
1016
1078
  const repeat = g.getBoundingClientRect().height <= conditions.contentH / 4;
1017
1079
  const b = blocks.findIndex((block) => block === src || block.contains(src));
1018
1080
  madeHere.push({ path: pathOf(src, blocks[b], b), repeat });
@@ -1324,7 +1386,8 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1324
1386
  const cutY = v.y;
1325
1387
  const places = v.items.map(({ item, state, verdict }) => ({
1326
1388
  item,
1327
- at: state === 'above' ? { box: item, word: null, px: { remaining: 0, paint: null }, flows: [] } : state === 'below' || state === 'held' ? { box: item, word: null, px: null, flows: [] } : verdict ? verdictPlace(verdict) : placesOf(item, cutY)
1389
+ // An item that is `done` has shown all its content and continues as a shell of what its box has left; the Stonehand said so knowing where a divided cell's content stands (`cellLift`), which the geometry of the feed does not.
1390
+ at: state === 'above' ? { box: item, word: null, px: { remaining: 0, paint: null }, flows: [] } : state === 'done' ? { box: item, word: null, px: { remaining: item.rect.bottom - cutY, paint: null }, flows: [] } : state === 'below' || state === 'held' ? { box: item, word: null, px: null, flows: [] } : verdict ? verdictPlace(verdict) : placesOf(item, cutY)
1328
1391
  }));
1329
1392
  const last = places.pop();
1330
1393
  return { ...last.at, itemBox: last.item, flows: [...places.map(({ item, at }) => ({ ...at, itemBox: item, remainingPx: item.rect.top < cutY && item.rect.bottom > cutY ? item.rect.bottom - cutY : null })), ...last.at.flows] };
@@ -1416,6 +1479,13 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1416
1479
  if (!fits && hasGroups) {
1417
1480
  const ct = container.getBoundingClientRect().top;
1418
1481
  const edge = pageStart + conditions.contentH;
1482
+ // A header written after its rows that does not repeat, in a table the page's edge divides, is refused (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): its words are on the table's last fragment and its box above the first, and which page shows it is the open ruling on §17.2's display order in the word space. Undivided, the one fragment holds both.
1483
+ for (const h of container.querySelectorAll('thead')) {
1484
+ const src = sourceOf(h);
1485
+ const r = h.closest('table')?.getBoundingClientRect();
1486
+ if (src && r && declined.has(src) && headerAfterRows(src) && r.top - ct < edge && r.bottom - ct > edge)
1487
+ throw new Error(refusedTable(src, 'a header group written after its rows, too tall to repeat'));
1488
+ }
1419
1489
  for (const f of container.querySelectorAll('tfoot')) {
1420
1490
  const src = sourceOf(f);
1421
1491
  // A footer written before its rows that does not repeat is still reserved where it is shown: its words stand before the rows, so it is on the table's first fragment and nowhere after (*the footer before the rows*, 2026-10-15; Chromium shows it on the last, which the address cannot say).
@@ -1548,6 +1618,14 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
1548
1618
  visit(f);
1549
1619
  })(rowPlace);
1550
1620
  }
1621
+ else if (v.kind !== 'E') {
1622
+ // A table cell the main path's cut divides gets the same extension: Chromium's print aligns a divided cell's content within its first fragment, which reaches the foot, and the Stonehand judged the cut so (`cellLift`).
1623
+ const foot = pageStart + usableH;
1624
+ for (let p = v.kind === 'A' ? v.box.parent : v.box; p; p = p.parent) {
1625
+ if (win.getComputedStyle(p.el).display === 'table-cell' && p.rect.top < v.y - 0.25 && p.rect.bottom > v.y + 0.25)
1626
+ extents.push({ path: pathOf(sourceOf(p.el), blocks[cutB], cutB), px: foot - p.rect.top });
1627
+ }
1628
+ }
1551
1629
  const tf = performance.now();
1552
1630
  const flat = flattenResume({ pos, box: sourceOf(v.box.el), cut, flows });
1553
1631
  addr.flatten += performance.now() - tf;