@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/seam/index.js +3 -1
- package/dist/src/capture.d.ts +22 -2
- package/dist/src/capture.js +164 -70
- package/dist/src/chase.js +2 -0
- package/dist/src/data-beltype.d.ts +2 -2
- package/dist/src/data-beltype.js +4 -4
- package/dist/src/document-state.d.ts +49 -2
- package/dist/src/document-state.js +93 -7
- package/dist/src/dressing.d.ts +5 -1
- package/dist/src/dressing.js +23 -16
- package/dist/src/driver.d.ts +10 -3
- package/dist/src/driver.js +118 -40
- package/dist/src/fragmentation.d.ts +1 -1
- package/dist/src/fragmentation.js +1 -1
- package/dist/src/fragments.d.ts +2 -2
- package/dist/src/fragments.js +49 -10
- package/dist/src/gauge.d.ts +4 -0
- package/dist/src/gauge.js +14 -2
- package/dist/src/hyphen-patterns/en-us.d.ts +1 -0
- package/dist/src/hyphen-patterns/en-us.js +2 -0
- package/dist/src/hyphen-patterns/it.d.ts +21 -0
- package/dist/src/hyphen-patterns/it.js +52 -0
- package/dist/src/hyphenate.d.ts +5 -2
- package/dist/src/hyphenate.js +14 -6
- package/dist/src/measure.d.ts +5 -3
- package/dist/src/measure.js +15 -10
- package/dist/src/reads.d.ts +3 -2
- package/dist/src/reads.js +27 -5
- package/dist/src/stonehand.js +41 -14
- package/dist/src/word-space.d.ts +8 -0
- package/dist/src/word-space.js +15 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
211
|
-
|
|
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'
|
|
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
|
/**
|
package/dist/src/dressing.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/src/dressing.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/src/driver.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
package/dist/src/driver.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
742
|
-
const
|
|
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
|
-
|
|
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;
|