@beltype/cicero 0.1.9 → 0.1.11
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/README.md +7 -6
- package/dist/seam/client.d.ts +12 -4
- package/dist/seam/client.js +57 -21
- package/dist/seam/facsimile.d.ts +13 -5
- package/dist/seam/facsimile.js +28 -12
- package/dist/seam/index.js +1 -1
- package/dist/seam/rewrite.js +7 -1
- package/dist/seam/rim.d.ts +6 -2
- package/dist/seam/rim.js +54 -11
- package/dist/src/capture.d.ts +28 -0
- package/dist/src/capture.js +121 -45
- package/dist/src/compose-dom.d.ts +8 -0
- package/dist/src/compose-dom.js +62 -8
- package/dist/src/compositor.d.ts +11 -2
- package/dist/src/compositor.js +47 -15
- package/dist/src/data-beltype.d.ts +11 -1
- package/dist/src/data-beltype.js +20 -1
- package/dist/src/document-state.d.ts +6 -2
- package/dist/src/document-state.js +87 -11
- package/dist/src/driver.d.ts +6 -0
- package/dist/src/driver.js +36 -46
- package/dist/src/forcing-memo.d.ts +10 -0
- package/dist/src/forcing-memo.js +22 -0
- package/dist/src/fragmentation.d.ts +3 -3
- package/dist/src/fragmentation.js +3 -3
- package/dist/src/fragments.d.ts +1 -1
- package/dist/src/fragments.js +47 -16
- package/dist/src/gauge.d.ts +0 -4
- package/dist/src/gauge.js +16 -30
- package/dist/src/gcpm-encoding.d.ts +2 -0
- package/dist/src/gcpm-encoding.js +2 -0
- package/dist/src/measure.d.ts +17 -12
- package/dist/src/measure.js +30 -16
- package/dist/src/positions.js +21 -9
- package/dist/src/reads.d.ts +4 -2
- package/dist/src/reads.js +10 -4
- package/dist/src/root.d.ts +18 -1
- package/dist/src/root.js +29 -3
- package/dist/src/stonehand.js +2 -6
- package/dist/src/word-space.d.ts +10 -8
- package/dist/src/word-space.js +44 -16
- package/package.json +5 -1
package/dist/src/driver.js
CHANGED
|
@@ -14,19 +14,19 @@
|
|
|
14
14
|
* @packageDocumentation
|
|
15
15
|
*/
|
|
16
16
|
import { Ledger, createEntry } from './ledger.js';
|
|
17
|
-
import { assertRenderConditions, awaitCloneImages, awaitFonts,
|
|
17
|
+
import { assertRenderConditions, awaitCloneImages, awaitFonts, eagerImages, createMeasureContext } from './measure.js';
|
|
18
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, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas,
|
|
22
|
+
import { readGeometryTable, effectivePageName, pageRulesOf, styleSheetsOf, registerGcpmProperties, sheetsUseHas, sheetReachOf, 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
26
|
import { documentOpsOf, counterStateAt, pageNumberAt, withoutPageCounters, stringAssignmentsOf, runningAssignmentsOf, namedStringsForPage } from './document-state.js';
|
|
27
27
|
import { RUNNING_PROP } from './gcpm-encoding.js';
|
|
28
|
-
import { ARTIFACT, hasWord } from './data-beltype.js';
|
|
29
|
-
import { blockChildrenOf, footerBeforeRows,
|
|
28
|
+
import { ARTIFACT, HOST, hasWord } from './data-beltype.js';
|
|
29
|
+
import { blockChildrenOf, footerBeforeRows, inDisplayOrder, 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';
|
|
@@ -38,31 +38,28 @@ function toPx(len) {
|
|
|
38
38
|
return parseFloat(m[1]) * PX_PER[m[2]];
|
|
39
39
|
}
|
|
40
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.
|
|
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. The one shape left is a footer group written before its rows and taller than the page area, refused by name as a box taller than its page is rather than lose rows with no message.
|
|
42
42
|
*
|
|
43
|
-
* @
|
|
44
|
-
*
|
|
45
|
-
* @param group - the source `thead` or `tfoot`.
|
|
43
|
+
* @param group - the source `tfoot`.
|
|
46
44
|
* @param shape - the shape, in words.
|
|
47
45
|
* @param remedy - what the author can write instead.
|
|
48
46
|
*/
|
|
49
|
-
function refusedTable(group, shape, remedy
|
|
47
|
+
function refusedTable(group, shape, remedy) {
|
|
50
48
|
const table = group.closest('table');
|
|
51
49
|
const words = (table?.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
52
50
|
const name = table?.id ? `<table id="${table.id}">` : table?.className ? `<table class="${table.className}">` : `the table beginning "${words}"`;
|
|
53
51
|
return `cicero: ${name} has ${shape}, which this engine cannot divide across pages yet; it refuses rather than lose rows. To print it, ${remedy}.`;
|
|
54
52
|
}
|
|
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. */
|
|
53
|
+
/** The nearest copy the engine repeats (`artifact`) standing ahead of this element among its siblings in display order (`inDisplayOrder`) — the table's header, in front of its rows, wherever it is written — or null. */
|
|
56
54
|
const artifactBefore = (el) => {
|
|
55
|
+
const isCopy = (s) => typeof s.getAttribute === 'function' && hasWord(s, ARTIFACT);
|
|
56
|
+
const parent = el?.parentElement;
|
|
57
|
+
const written = parent ? [...parent.children] : [];
|
|
58
|
+
const shown = parent ? inDisplayOrder(parent, written) : written;
|
|
59
|
+
if (shown !== written)
|
|
60
|
+
return shown.slice(0, shown.indexOf(el)).reverse().find(isCopy) ?? null;
|
|
57
61
|
for (let s = el?.previousElementSibling; s; s = s.previousElementSibling)
|
|
58
|
-
if (
|
|
59
|
-
return s;
|
|
60
|
-
return null;
|
|
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))
|
|
62
|
+
if (isCopy(s))
|
|
66
63
|
return s;
|
|
67
64
|
return null;
|
|
68
65
|
};
|
|
@@ -82,10 +79,9 @@ export function gapAbove(box, report, win) {
|
|
|
82
79
|
for (let child = box, a = box.parent; a; child = a, a = a.parent) {
|
|
83
80
|
const cs = win.getComputedStyle(a.el);
|
|
84
81
|
// 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.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let copy = cs.borderCollapse === 'separate' ? late ?? artifactBefore(child.el) : null;
|
|
82
|
+
if (standsAfterArtifact(child.el)) {
|
|
83
|
+
// The header's copy, never a footer's: the siblings are in display order, where a footer stands after the rows wherever it is written (*the-word-space-follows-the-table-display-order*).
|
|
84
|
+
let copy = cs.borderCollapse === 'separate' ? artifactBefore(child.el) : null;
|
|
89
85
|
while (copy && !/^THEAD$/i.test(copy.tagName))
|
|
90
86
|
copy = artifactBefore(copy);
|
|
91
87
|
const box = copy ? report.boxOf(copy) : null;
|
|
@@ -363,13 +359,15 @@ export function forcingIndexOf(blocks, { styleOf, pageNameOf, cutOf, posOf, posO
|
|
|
363
359
|
}
|
|
364
360
|
};
|
|
365
361
|
}
|
|
362
|
+
/** The tags that never render, so the intake passes over them wherever a host leaves one among the document's boxes: `TEMPLATE`, whose content is inert; `LINK`, `META`, `BASE`, `TITLE` and `HEAD`, metadata the HTML parser may leave in the body and the UA sheet hides; and `NOSCRIPT`, which renders only when scripts are off, and the engine is a script. A closed list, beside `STYLE` and `SCRIPT`, which were passed over before it (ruled 2026-11-08). */
|
|
363
|
+
const NEVER_RENDERED = new Set(['STYLE', 'SCRIPT', 'TEMPLATE', 'LINK', 'META', 'BASE', 'TITLE', 'HEAD', 'NOSCRIPT']);
|
|
366
364
|
/**
|
|
367
|
-
* The element children of `root` (the body, unless a host names another) minus engine scaffolding
|
|
365
|
+
* The element children of `root` (the body, unless a host names another) minus what is not the document's: engine scaffolding (an `id` starting `__beltype`), the tags that never render ({@link NEVER_RENDERED}), and any element carrying the host word ({@link HOST}), a host's own machinery standing among the boxes (ruled 2026-11-08). Every top-level box the document declares, in or out of the flow. The two functions below divide it. **This is the one filter**: the client's intake (`seam/client.js`) calls it too, so a box this passes over has no address anywhere. A host that holds the document in an element of its own still names it with `root` first.
|
|
368
366
|
*
|
|
369
367
|
* @param root - the element the boxes stand in.
|
|
370
368
|
*/
|
|
371
|
-
function topLevelBoxes(doc, root = doc.body) {
|
|
372
|
-
return [...root.children].filter((el) => !el.id.startsWith('__beltype') && !
|
|
369
|
+
export function topLevelBoxes(doc, root = doc.body) {
|
|
370
|
+
return [...root.children].filter((el) => !el.id.startsWith('__beltype') && !NEVER_RENDERED.has(el.tagName) && !hasWord(el, HOST));
|
|
373
371
|
}
|
|
374
372
|
/**
|
|
375
373
|
* The document's top-level blocks — the flow, which is what the cuts divide. One definition, shared by the build loop and the differential's judge measure.
|
|
@@ -538,7 +536,7 @@ export function paginate(doc, opts = {}) {
|
|
|
538
536
|
/**
|
|
539
537
|
* The memo this call reads through, settled once before any walk, or `null` (*the forced-break index is kept across runs*, 2026-10-09; *a resumed run keeps its reads*, 2026-10-22).
|
|
540
538
|
*
|
|
541
|
-
* The boxes a resumed call names as edited — `dirtyAt` and `edited`, resolved against the blocks as they stand now — are reported through the memo's one door, {@link ForcingMemo.replaced}, beside the boxes `patchBox` reported there: a host that edits a box in place, as the venue's pins do, replaces no element and would leave the walks' parts of its box standing. Then the memo is settled against the sheets' reach; a sheet by which a box could change an ancestor's style (`sheetReach` 2) empties it for this call, and the console says
|
|
539
|
+
* The boxes a resumed call names as edited — `dirtyAt` and `edited`, resolved against the blocks as they stand now — are reported through the memo's one door, {@link ForcingMemo.replaced}, beside the boxes `patchBox` reported there: a host that edits a box in place, as the venue's pins do, replaces no element and would leave the walks' parts of its box standing. Then the memo is settled against the sheets' reach; a sheet by which a box could change an ancestor's style (`sheetReach` 2) empties it for this call and a sheet by which it could change a sibling's (1) narrows it, and the console says each once, naming the rule ({@link ForcingMemo.notice}).
|
|
542
540
|
*/
|
|
543
541
|
function settledMemo(doc, { forcingMemo = null, prior, dirtyAt, edited = [], blocks }) {
|
|
544
542
|
if (!forcingMemo)
|
|
@@ -551,13 +549,12 @@ function settledMemo(doc, { forcingMemo = null, prior, dirtyAt, edited = [], blo
|
|
|
551
549
|
forcingMemo.replaced(elAtIn(block, at.path) ?? block);
|
|
552
550
|
}
|
|
553
551
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
doc.defaultView?.console?.info(
|
|
559
|
-
|
|
560
|
-
return null;
|
|
552
|
+
const read = sheetReachOf(doc);
|
|
553
|
+
const usable = forcingMemo.settle(read.reach);
|
|
554
|
+
const line = forcingMemo.notice(read);
|
|
555
|
+
if (line)
|
|
556
|
+
doc.defaultView?.console?.info(line);
|
|
557
|
+
return usable ? forcingMemo : null;
|
|
561
558
|
}
|
|
562
559
|
/**
|
|
563
560
|
* One run: the document laid out once, from its first word to its last, or from the page an edit dirtied to the page where the run converges. A layout the reads send round again resumes from the layout before it, at the boxes whose lines the delivered values moved (*delivery resumes from the moved page*, 2026-10-21): `laid.resume` names them and that layout, and they stand in for `prior`, `dirtyAt` and `edited`.
|
|
@@ -569,13 +566,13 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
|
|
|
569
566
|
assertRenderConditions(doc, { relaxDpr });
|
|
570
567
|
// The two rewritten GCPM declarations, registered before anything reads a computed style: unregistered, a custom property inherits, and `string-set` on a heading would reach every descendant of it (*the host rewrites what the parser drops*, 2026-09-14).
|
|
571
568
|
registerGcpmProperties(doc.defaultView);
|
|
572
|
-
// Once each, before the first measure — late-arriving truth never enters adjudication. one call site covers every host, because the seam's bootstrap, the editing tools' `beltype:ready` call and the venue's own client all enter through here. The images are
|
|
569
|
+
// Once each, before the first measure — late-arriving truth never enters adjudication. one call site covers every host, because the seam's bootstrap, the editing tools' `beltype:ready` call and the venue's own client all enter through here. The images are not awaited (ruled 2026-11-08): they are made eager and the run goes on, and an arrival that changes a box is the rim's (`measure.js`'s `eagerImages` carries the reason).
|
|
573
570
|
// The run keeps time in the User Timing API (*the run keeps time*, 2026-09-25): one running timestamp, one `performance.measure` per phase, `detail` carrying the count a reader normalizes by. A measure is read by nothing here and decides nothing.
|
|
574
571
|
let t = performance.now();
|
|
575
572
|
await awaitFonts(doc);
|
|
576
573
|
performance.measure('cicero:fonts', { start: t, end: (t = performance.now()) });
|
|
577
|
-
const images =
|
|
578
|
-
performance.measure('cicero:images', { start: t, end: (t = performance.now()), detail: { images } });
|
|
574
|
+
const images = eagerImages(originals ?? intakeBlocks ?? doc);
|
|
575
|
+
performance.measure('cicero:images', { start: t, end: (t = performance.now()), detail: { images, awaited: false } }); // the eager pass alone: nothing is waited for since 2026-11-08
|
|
579
576
|
const table = intakeTable ?? readGeometryTable(doc);
|
|
580
577
|
await awaitFonts(doc, table.furnitureFaces); // the faces page furniture names, requested now and not when the first margin box is presented (the table is read after the first wait, so its lengths are measured as they always were)
|
|
581
578
|
const win = doc.defaultView;
|
|
@@ -1231,7 +1228,7 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
|
|
|
1231
1228
|
performance.measure('cicero:feed', { start: t, end: (t = performance.now()), detail: { page: account.length, blocks: items.length, carried: (cursor.carried ?? []).length, bound: bounded ? words : null, reach: (bounded ? posOfCut(place.cut) : forcingCuts ? posOfCut(forcedEvent.cut) : index.total) - cursor.pos, pours } });
|
|
1232
1229
|
container = ctx.measure(items.map((it) => it.clone), laid.facts?.[account.length] ?? null); // a page fact a flow site reads stands as the run before decided it, or at its reservation (*the page carries its conditions*)
|
|
1233
1230
|
performance.measure('cicero:layout', { start: t, end: (t = performance.now()), detail: { page: account.length, blocks: items.length } });
|
|
1234
|
-
// The clones' own images, settled before the report is taken: a clone of a loaded image is not always available in the turn it is mounted (`measure.js`'s `awaitCloneImages`), and a figure measured at no height moves every cut after it.
|
|
1231
|
+
// The clones' own images, settled before the report is taken: a clone of a loaded image is not always available in the turn it is mounted (`measure.js`'s `awaitCloneImages`), and a figure measured at no height moves every cut after it. Kept when the first run's awaits went (2026-11-08), until the Service Worker case is measured without it.
|
|
1235
1232
|
const waited = await awaitCloneImages(container);
|
|
1236
1233
|
performance.measure('cicero:clone-images', { start: t, end: (t = performance.now()), detail: { page: account.length, waited } });
|
|
1237
1234
|
report = takeReport(container, { contentHeight: conditions.contentH });
|
|
@@ -1479,17 +1476,10 @@ async function paginateOnce(doc, { composed = false, compositionCache = new Comp
|
|
|
1479
1476
|
if (!fits && hasGroups) {
|
|
1480
1477
|
const ct = container.getBoundingClientRect().top;
|
|
1481
1478
|
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
|
-
}
|
|
1489
1479
|
for (const f of container.querySelectorAll('tfoot')) {
|
|
1490
1480
|
const src = sourceOf(f);
|
|
1491
|
-
// A footer
|
|
1492
|
-
if (!f.getClientRects().length || !src ||
|
|
1481
|
+
// A footer that does not repeat is not reserved: its words follow the rows wherever it is written (*the-word-space-follows-the-table-display-order*), so it stands on the table's last fragment alone, where Chromium prints it.
|
|
1482
|
+
if (!f.getClientRects().length || !src || declined.has(src) || !mayRepeat(src))
|
|
1493
1483
|
continue;
|
|
1494
1484
|
const table = f.closest('table');
|
|
1495
1485
|
if (!table)
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* **Who must keep the promise.** The host: a box replaced any other way than `patchBox` (which calls {@link ForcingMemo.replaced}) must be reported the same way or the run must ask for a fresh memo. Under the top-level unit, a top-level box whose name or attributes change is a new element, and the box it replaced is reported too. The seam fills the memo only from runs on a presented frame, never from the first run after load, whose realm (the sources are children of `body`, not of `<beltype-root>`) can answer a `body > …` or `:first-child` selector otherwise than later runs do.
|
|
15
15
|
*
|
|
16
|
+
* **A rule that only paints reaches nothing** (ruled 2026-11-08). Both readings pass over a style rule whose declarations all set a property on `capture.js`' `PAINT_ONLY` list, whatever its selector, and an `@container` or `@scope` rule every rule under which paints. Where a reach of 2 puts the memo out of use or a reach of 1 narrows it, {@link ForcingMemo.notice} gives the console one line per memo life at each level, naming the first rule that set the level and its sheet.
|
|
17
|
+
*
|
|
16
18
|
* @see .claude/decisions/cicero/the-forced-break-index-is-kept-across-runs.md
|
|
17
19
|
* @packageDocumentation
|
|
18
20
|
*/
|
|
21
|
+
import type { Reach } from './capture.ts';
|
|
19
22
|
/** The computed style properties `forcingIndexOf` and the predicates it calls (`isAbsolutelyPositioned`, `isOutOfFlow`, `isMonolith`, `neverCutInside`, `notYetDivided`) read of a box, and so the whole of a snapshot. `test/forcing-memo.test.js` records the reads of a real walk against this list. */
|
|
20
23
|
export declare const FORCING_STYLE_READS: readonly string[];
|
|
21
24
|
/** The door a run-level walk asks a per-box part through (*a resumed run keeps its reads*, 2026-10-22): `kind` names the walk, `box` is the top-level box the part is of, `read` takes the part, and `still`, where given, says whether a kept part still answers this run's question. {@link ForcingMemo.keep} is the one implementation; a walk handed none reads every box. */
|
|
@@ -29,6 +32,8 @@ export declare class ForcingMemo {
|
|
|
29
32
|
misses: number;
|
|
30
33
|
/** Whether the console has said, for this memo's life, that a sheet's reach put it out of use. */
|
|
31
34
|
said: boolean;
|
|
35
|
+
/** Whether the console has said, for this memo's life, that a sheet's reach of 1 narrowed what it keeps. */
|
|
36
|
+
saidNarrowed: boolean;
|
|
32
37
|
/** Per walk, the boxes whose part was kept and the boxes read, since the last {@link ForcingMemo.settle}. */
|
|
33
38
|
tally: Record<string, {
|
|
34
39
|
kept: number;
|
|
@@ -36,6 +41,11 @@ export declare class ForcingMemo {
|
|
|
36
41
|
}>;
|
|
37
42
|
/** The top-level boxes the memo was last settled with under the top-level unit, in order, which `capture.js`' `topLevelReach` takes as `before`; empty where it keeps nothing. */
|
|
38
43
|
get boxes(): Element[];
|
|
44
|
+
/**
|
|
45
|
+
* The console line for a reach that put this memo out of use (2) or narrowed it (1), once per memo life at each level, or `null` (ruled 2026-11-08): it names the first rule that set the level, its sheet, and how many more rules reached as far, so an author can find the rule that costs the edit.
|
|
46
|
+
* @param read - `capture.js`' `sheetReachOf` or `topLevelReachOf`.
|
|
47
|
+
*/
|
|
48
|
+
notice(read: Reach): string | null;
|
|
39
49
|
/** Forget everything: the conditions moved, the run was not a resume, or it threw. */
|
|
40
50
|
clear(): void;
|
|
41
51
|
/** Begin a run: the tally starts again. */
|
package/dist/src/forcing-memo.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* **Who must keep the promise.** The host: a box replaced any other way than `patchBox` (which calls {@link ForcingMemo.replaced}) must be reported the same way or the run must ask for a fresh memo. Under the top-level unit, a top-level box whose name or attributes change is a new element, and the box it replaced is reported too. The seam fills the memo only from runs on a presented frame, never from the first run after load, whose realm (the sources are children of `body`, not of `<beltype-root>`) can answer a `body > …` or `:first-child` selector otherwise than later runs do.
|
|
16
16
|
*
|
|
17
|
+
* **A rule that only paints reaches nothing** (ruled 2026-11-08). Both readings pass over a style rule whose declarations all set a property on `capture.js`' `PAINT_ONLY` list, whatever its selector, and an `@container` or `@scope` rule every rule under which paints. Where a reach of 2 puts the memo out of use or a reach of 1 narrows it, {@link ForcingMemo.notice} gives the console one line per memo life at each level, naming the first rule that set the level and its sheet.
|
|
18
|
+
*
|
|
17
19
|
* @see .claude/decisions/cicero/the-forced-break-index-is-kept-across-runs.md
|
|
18
20
|
* @packageDocumentation
|
|
19
21
|
*/
|
|
@@ -32,6 +34,8 @@ export class ForcingMemo {
|
|
|
32
34
|
misses = 0;
|
|
33
35
|
/** Whether the console has said, for this memo's life, that a sheet's reach put it out of use. */
|
|
34
36
|
said = false;
|
|
37
|
+
/** Whether the console has said, for this memo's life, that a sheet's reach of 1 narrowed what it keeps. */
|
|
38
|
+
saidNarrowed = false;
|
|
35
39
|
#facts = new WeakMap();
|
|
36
40
|
#pageName = new WeakMap();
|
|
37
41
|
#children = new WeakMap();
|
|
@@ -47,6 +51,24 @@ export class ForcingMemo {
|
|
|
47
51
|
get boxes() {
|
|
48
52
|
return this.#boxes;
|
|
49
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The console line for a reach that put this memo out of use (2) or narrowed it (1), once per memo life at each level, or `null` (ruled 2026-11-08): it names the first rule that set the level, its sheet, and how many more rules reached as far, so an author can find the rule that costs the edit.
|
|
56
|
+
* @param read - `capture.js`' `sheetReachOf` or `topLevelReachOf`.
|
|
57
|
+
*/
|
|
58
|
+
notice(read) {
|
|
59
|
+
const { reach, culprit, count } = read;
|
|
60
|
+
if (reach === 0 || !culprit || (reach === 2 ? this.said : this.saidNarrowed))
|
|
61
|
+
return null;
|
|
62
|
+
if (reach === 2)
|
|
63
|
+
this.said = true;
|
|
64
|
+
else
|
|
65
|
+
this.saidNarrowed = true;
|
|
66
|
+
const what = culprit.kind === 'style' ? `the rule "${culprit.selector}" in ${culprit.sheet}` : culprit.kind === 'unreadable' ? `the sheet ${culprit.sheet}, which the engine cannot read,` : `an @${culprit.kind} rule in ${culprit.sheet}`;
|
|
67
|
+
const more = count ? `${culprit.kind === 'unreadable' ? '' : ','} and ${count} more ${count === 1 ? 'rule' : 'rules'},` : '';
|
|
68
|
+
return reach === 2
|
|
69
|
+
? `BelType/Cicero: ${what}${more} can change the style of a box an edit did not touch, so the forced-break index and the run-level walks are read afresh on every run rather than kept; the pages are the same and an edit costs more.`
|
|
70
|
+
: `BelType/Cicero: ${what}${more} can make a box change the style of the boxes beside it, so the forced-break index and the run-level walks read the boxes beside an edited one again rather than keep them; the pages are the same and an edit costs more.`;
|
|
71
|
+
}
|
|
50
72
|
/** Forget everything: the conditions moved, the run was not a resume, or it threw. */
|
|
51
73
|
clear() {
|
|
52
74
|
this.#facts = new WeakMap();
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* The fragmentation face: the engine's members a view adapter builds on, `@beltype/cicero/fragmentation`.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
* Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/
|
|
5
|
+
* Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
-
export { effectivePageName, readGeometryTable, registerGcpmProperties, topLevelReach, transliterateContent } from './capture.ts';
|
|
9
|
+
export { effectivePageName, readGeometryTable, registerGcpmProperties, sheetReach, topLevelReach, transliterateContent } from './capture.ts';
|
|
10
10
|
export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.ts';
|
|
11
11
|
export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.ts';
|
|
12
12
|
export { ensureContinuationDressing, ensureDressing } from './dressing.ts';
|
|
@@ -19,6 +19,6 @@ export { STRING_KEYWORDS, encodeStringProp } from './gcpm-encoding.ts';
|
|
|
19
19
|
export { assertRenderConditions, awaitCloneImages, awaitFonts, createMeasureContext } from './measure.ts';
|
|
20
20
|
export { divides, elAt, extensionPx, indexBlocks, normalizePath, pageContaining, pageIntervals, pathOf, placeAt } from './positions.ts';
|
|
21
21
|
export { present, presentation } from './presenters.ts';
|
|
22
|
-
export { cssString, findPageSites, settleReads } from './reads.ts';
|
|
22
|
+
export { cssString, findPageSites, findSites, settleReads } from './reads.ts';
|
|
23
23
|
export { adjudicate, firstBelowTheEdge, fitsPage, isOutOfFlow, neverCutInside, processionBottoms, reachesBelowTheEdge } from './stonehand.ts';
|
|
24
24
|
export { BREAKING_SPACE, clonesDecoration, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.ts';
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* The fragmentation face: the engine's members a view adapter builds on, `@beltype/cicero/fragmentation`.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
* Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/
|
|
5
|
+
* Exactly the names the first view adapter imported by deep path on 2026-10-29 (68 of them, from 16 modules), re-exported unchanged, and the names a view has asked for since (the leading-decoration door, `WRAPPED`, `clonesDecoration` and `truncateLeadingDecoration`, 2026-11-07); nothing is un-exported. Which of them stay public, merge or leave is the shape round's question. The face loads in node: `chase.ts`, which defines the `<beltype-page>` element and so needs a DOM at load, is not here; a view imports it for that effect as `@beltype/cicero/page`.
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
-
export { effectivePageName, readGeometryTable, registerGcpmProperties, topLevelReach, transliterateContent } from './capture.js';
|
|
9
|
+
export { effectivePageName, readGeometryTable, registerGcpmProperties, sheetReach, topLevelReach, transliterateContent } from './capture.js';
|
|
10
10
|
export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.js';
|
|
11
11
|
export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.js';
|
|
12
12
|
export { ensureContinuationDressing, ensureDressing } from './dressing.js';
|
|
@@ -18,6 +18,6 @@ export { STRING_KEYWORDS, encodeStringProp } from './gcpm-encoding.js';
|
|
|
18
18
|
export { assertRenderConditions, awaitCloneImages, awaitFonts, createMeasureContext } from './measure.js';
|
|
19
19
|
export { divides, elAt, extensionPx, indexBlocks, normalizePath, pageContaining, pageIntervals, pathOf, placeAt } from './positions.js';
|
|
20
20
|
export { present, presentation } from './presenters.js';
|
|
21
|
-
export { cssString, findPageSites, settleReads } from './reads.js';
|
|
21
|
+
export { cssString, findPageSites, findSites, settleReads } from './reads.js';
|
|
22
22
|
export { adjudicate, firstBelowTheEdge, fitsPage, isOutOfFlow, neverCutInside, processionBottoms, reachesBelowTheEdge } from './stonehand.js';
|
|
23
23
|
export { BREAKING_SPACE, clonesDecoration, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.js';
|
package/dist/src/fragments.d.ts
CHANGED
|
@@ -144,7 +144,7 @@ export declare function mutateToContinuation(clone: Element, { cut, remainderPx,
|
|
|
144
144
|
/**
|
|
145
145
|
* The ordinal value of each item an `<ol>` owns, as HTML numbers them (§4.4.8) and css-lists-3 states it as the `list-item` counter: the list resets the counter one step before its `start`, which under `reversed` defaults to the number of items; each item adds its step, 1 or −1 under `reversed` unless a `counter-increment` names `list-item`, and a `counter-set` of `list-item`, or else a `value` attribute, sets it.
|
|
146
146
|
*
|
|
147
|
-
* @remarks A `counter-reset` of `list-item` on an item
|
|
147
|
+
* @remarks A `counter-reset` of `list-item` on an item is left out on purpose: Chromium's print numbers the markers on through it, its ordinals HTML's and not the reset counter's (measured 2026-11-07, `test/browser/list-continuation.browser.ts`), so the `start` this ordinal becomes is the browser's own number for the item.
|
|
148
148
|
*/
|
|
149
149
|
export declare function listOrdinals(ol: Element): Map<Element, number>;
|
|
150
150
|
/**
|
package/dist/src/fragments.js
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* @packageDocumentation
|
|
19
19
|
*/
|
|
20
20
|
import { applyRecordsUnder, hyphenCharOf } from './compose-dom.js';
|
|
21
|
-
import { addWord, ADJOIN, ARTIFACT, AWAY, END, ENGINE_MARKS, hasAnyWord, hasWord, removeWord, SHELL, SPLIT, START, WRAPPED } from './data-beltype.js';
|
|
21
|
+
import { addWord, ADJOIN, ARTIFACT, AWAY, BREAK, END, ENGINE_MARKS, hasAnyWord, hasWord, removeWord, SHELL, SPLIT, START, WRAPPED } from './data-beltype.js';
|
|
22
22
|
import { pairsOf } from './document-state.js';
|
|
23
23
|
import { divides, elAtIn, flowsOf } from './positions.js';
|
|
24
|
-
import { BREAKING_SPACE, clonesDecoration, cutRanges, emptyText, leaveOut, replaceChars, isEngineText, isFixedBox, isInlineBox, isLineBreak, copyOf, shellOf, noteText, READS_ITS_CHILDREN, sourceOf, sourceStyle } from './word-space.js';
|
|
24
|
+
import { BREAKING_SPACE, clonesDecoration, cutRanges, emptyText, inDisplayOrder, leaveOut, replaceChars, isEngineText, mayBeAutoHyphenated, noteEngineText, splitTextAt, isFixedBox, isInlineBox, isLineBreak, copyOf, shellOf, noteText, READS_ITS_CHILDREN, sourceOf, sourceStyle } from './word-space.js';
|
|
25
25
|
/** The word space, clone side: words are maximal non-whitespace runs of the subtree's joined text — a run crossing a text-node or inline-element seam is one word, exactly as the Gauge reads, and exactly as the position index counts over sources (src/positions.js — the two tokenizations must never drift; counting per node instead shifts every offset by the seam-spanning words before it, which the test-document's inline markup caught). **A non-inline boundary ends the run** (word-space.js), which is where this stopped agreeing with the page for two months. This walk survives on the clone side only: consumption must find the resume word's actual text nodes in the mutated copy, which no source-side index can answer. */
|
|
26
26
|
export function joinedWords(root, isInline = isInlineBox) {
|
|
27
27
|
const words = [];
|
|
@@ -59,7 +59,7 @@ export function joinedWords(root, isInline = isInlineBox) {
|
|
|
59
59
|
current = null;
|
|
60
60
|
};
|
|
61
61
|
const walk = (el) => {
|
|
62
|
-
for (const node of el.childNodes) {
|
|
62
|
+
for (const node of inDisplayOrder(el, [...el.childNodes])) {
|
|
63
63
|
if (node.nodeType === 3)
|
|
64
64
|
scan(node);
|
|
65
65
|
else if (node.nodeType === 1) {
|
|
@@ -326,11 +326,10 @@ export function truncateFrom(clone, el, { keep = false, hollow = false } = {}) {
|
|
|
326
326
|
return clone;
|
|
327
327
|
const chain = [];
|
|
328
328
|
for (let e = el; e && e !== clone; e = e.parentElement) {
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
// What follows in display order, which inside a table puts a footer written first after the rows and a header written last before them (*the-word-space-follows-the-table-display-order*).
|
|
330
|
+
const siblings = inDisplayOrder(e.parentElement, [...e.parentElement.childNodes]);
|
|
331
|
+
for (const sib of siblings.slice(siblings.indexOf(e) + 1))
|
|
331
332
|
putAway(sib, hollow);
|
|
332
|
-
sib = next;
|
|
333
|
-
}
|
|
334
333
|
if (e !== el || keep)
|
|
335
334
|
chain.push(e);
|
|
336
335
|
}
|
|
@@ -546,6 +545,7 @@ function consumeTrailingWords(clone, n, char = 0, hollow = false) {
|
|
|
546
545
|
for (const [node, ranges] of perNode)
|
|
547
546
|
cutRanges(node, ranges);
|
|
548
547
|
// The hyphen is written at the cut (*the hyphen is written at the cut*, 2026-10-06). A soft hyphen the browser broke the line at is drawn there; one at the end of a fragment's last line is not, because nothing breaks after it. So the last kept character, where it is a soft hyphen, becomes the hyphenation character the browser drew — the box's own `hyphenate-character`, else U+2010 (css-text-4 §6.3.2) — and the empty string, which the property allows, removes it. A replacement: the word keeps its length, and the Compositor's own break, whose hyphen is already written and whose soft hyphen stands after it, cuts before the soft hyphen and never meets this.
|
|
548
|
+
let autoHyphen = null;
|
|
549
549
|
if (char > 0 && words[n].text[char - 1] === '\u00AD') {
|
|
550
550
|
const soft = charRanges(words[n], char - 1, char).find((r) => !r.engine);
|
|
551
551
|
if (soft) {
|
|
@@ -553,6 +553,14 @@ function consumeTrailingWords(clone, n, char = 0, hollow = false) {
|
|
|
553
553
|
replaceChars(soft.node, soft.start, soft.end, hyphen); // one character for one character maps one to one; the empty string, or several, is a removal and the engine's own text (`word-space.js`)
|
|
554
554
|
}
|
|
555
555
|
}
|
|
556
|
+
else if (char > 0) {
|
|
557
|
+
// A cut between two letters under `hyphens: auto` is one the Gauge minted only where it measured the hyphen the browser drew (`gauge.js`, `piecesOf`), so the same predicate on the same characters says the first fragment ends with it: the hyphenation character is added after the last kept letter, as the engine's own text. A Compositor's break already carries its written hyphen, an engine part at the boundary, and gets none.
|
|
558
|
+
const last = charRanges(words[n], char - 1, char);
|
|
559
|
+
const kept = last.find((r) => !r.engine);
|
|
560
|
+
const style = kept && sourceStyle(kept.node.parentElement);
|
|
561
|
+
if (kept && style && !last.some((r) => r.engine) && mayBeAutoHyphenated(words[n].text[char - 1], words[n].text[char], style.hyphens))
|
|
562
|
+
autoHyphen = { node: kept.node, end: kept.end, char: hyphenCharOf(style) };
|
|
563
|
+
}
|
|
556
564
|
leaveOutAfter(clone, keepNode);
|
|
557
565
|
// Structural pass: an element with no remaining words, standing entirely after the boundary, is spent — its edges adjoin the cut (§5.2) and it leaves the fragment.
|
|
558
566
|
// A block standing wholly before the first dropped character is not after the boundary, words or none (*the anonymous box is in the procession*, 2026-09-29): a word cut in a box that holds blocks falls at a run's first word, and a wordless block between the last kept word and the run — a figure after the paragraph before it — is before the cut. Inside a line stack no block stands there, so the leaf's reading is the one it always was.
|
|
@@ -575,6 +583,19 @@ function consumeTrailingWords(clone, n, char = 0, hollow = false) {
|
|
|
575
583
|
})(clone);
|
|
576
584
|
for (const el of spent)
|
|
577
585
|
putAway(el, hollow);
|
|
586
|
+
// Written last, after the leave-out and the structural pass that would take an element and a text with no source for spent: the hyphen the browser drew stands where the Compositor's stands, a text node of its own, the engine's, before a break mark (*the hyphen is written at the cut*; the printer finds the hyphen by that shape and the letters before it in the source's own run).
|
|
587
|
+
if (autoHyphen && autoHyphen.char) {
|
|
588
|
+
const { node, end } = autoHyphen;
|
|
589
|
+
const parent = node.parentNode;
|
|
590
|
+
const doc = node.parentElement.ownerDocument;
|
|
591
|
+
const before = end < node.data.length ? splitTextAt(node, end) : node.nextSibling;
|
|
592
|
+
const written = doc.createTextNode(autoHyphen.char);
|
|
593
|
+
noteEngineText(written);
|
|
594
|
+
const br = doc.createElement('br');
|
|
595
|
+
addWord(br, BREAK);
|
|
596
|
+
parent.insertBefore(written, before);
|
|
597
|
+
parent.insertBefore(br, before);
|
|
598
|
+
}
|
|
578
599
|
for (let el = inner; el && el !== clone; el = el.parentElement)
|
|
579
600
|
truncateTrailingDecoration(el);
|
|
580
601
|
return inner;
|
|
@@ -752,7 +773,7 @@ export function mutateToContinuation(clone, { cut = null, remainderPx = null, so
|
|
|
752
773
|
// Everything standing before the target leaves, at every level. Walked by sibling and never by index: the Compositor inserts `<br>`s and filler spans into a composed leaf, so a hint standing before the resume point is consumed with the rest rather than counted as a box.
|
|
753
774
|
let el = clone;
|
|
754
775
|
for (const step of chain) {
|
|
755
|
-
for (const sib of [...el.childNodes]) {
|
|
776
|
+
for (const sib of inDisplayOrder(el, [...el.childNodes])) {
|
|
756
777
|
if (sib === step)
|
|
757
778
|
break;
|
|
758
779
|
if (keep.has(sib))
|
|
@@ -814,7 +835,7 @@ function ownedItems(ol) {
|
|
|
814
835
|
/**
|
|
815
836
|
* The ordinal value of each item an `<ol>` owns, as HTML numbers them (§4.4.8) and css-lists-3 states it as the `list-item` counter: the list resets the counter one step before its `start`, which under `reversed` defaults to the number of items; each item adds its step, 1 or −1 under `reversed` unless a `counter-increment` names `list-item`, and a `counter-set` of `list-item`, or else a `value` attribute, sets it.
|
|
816
837
|
*
|
|
817
|
-
* @remarks A `counter-reset` of `list-item` on an item
|
|
838
|
+
* @remarks A `counter-reset` of `list-item` on an item is left out on purpose: Chromium's print numbers the markers on through it, its ordinals HTML's and not the reset counter's (measured 2026-11-07, `test/browser/list-continuation.browser.ts`), so the `start` this ordinal becomes is the browser's own number for the item.
|
|
818
839
|
*/
|
|
819
840
|
export function listOrdinals(ol) {
|
|
820
841
|
const items = ownedItems(ol);
|
|
@@ -836,14 +857,14 @@ export function listOrdinals(ol) {
|
|
|
836
857
|
return out;
|
|
837
858
|
}
|
|
838
859
|
/**
|
|
839
|
-
* Every `<ol>` a cut divides is numbered from the first item its piece keeps: the piece's copy carries a `start` attribute of that item's ordinal, so the browser's own marker counts on from the source's numbering. Without it a continued list numbered from 1 again on every page, and the first piece of a `reversed` list counted down from the items it kept (measured 2026-11-07 against Chromium's print, `test/browser/list-continuation.browser.ts`). Read from the source, so it holds whichever cut ran first.
|
|
860
|
+
* Every `<ol>` (or numbered `<ul>` or `<menu>`) a cut divides is numbered from the first item its piece keeps: the piece's copy carries a `start` attribute of that item's ordinal, so the browser's own marker counts on from the source's numbering. Without it a continued list numbered from 1 again on every page, and the first piece of a `reversed` list counted down from the items it kept (measured 2026-11-07 against Chromium's print, `test/browser/list-continuation.browser.ts`). Read from the source, so it holds whichever cut ran first.
|
|
840
861
|
*/
|
|
841
862
|
function numberDividedLists(root, cut, source, cloneOf) {
|
|
842
863
|
if (!divides(cut) || !source || !cloneOf)
|
|
843
864
|
return;
|
|
844
865
|
const target = elAtIn(source, cut.path);
|
|
845
866
|
for (let a = target?.parentElement ?? null; a; a = a === source ? null : a.parentElement) {
|
|
846
|
-
if (!/^OL$/i.test(a.tagName))
|
|
867
|
+
if (!/^(?:OL|UL|MENU)$/i.test(a.tagName))
|
|
847
868
|
continue;
|
|
848
869
|
const copy = cloneOf.get(a);
|
|
849
870
|
if (!copy)
|
|
@@ -852,7 +873,11 @@ function numberDividedLists(root, cut, source, cloneOf) {
|
|
|
852
873
|
const c = cloneOf.get(li);
|
|
853
874
|
if (!c || !root.contains(c) || hasWord(c, AWAY))
|
|
854
875
|
continue;
|
|
855
|
-
|
|
876
|
+
// HTML's `start` belongs to `<ol>` alone, and Chromium's marker ignores a `counter-reset` of `list-item` (both measured 2026-11-08), so a `ul` or `menu` continues by the first kept item's own `value`.
|
|
877
|
+
if (/^OL$/i.test(a.tagName))
|
|
878
|
+
copy.setAttribute('start', String(n));
|
|
879
|
+
else
|
|
880
|
+
c.setAttribute('value', String(n));
|
|
856
881
|
break;
|
|
857
882
|
}
|
|
858
883
|
}
|
|
@@ -870,7 +895,7 @@ function shellAt(clone, target, px, keep = new Set(), hollow = false) {
|
|
|
870
895
|
markShelled(el);
|
|
871
896
|
for (let i = 0; i < chain.length; i++) {
|
|
872
897
|
const step = chain[i];
|
|
873
|
-
for (const sib of [...el.childNodes]) {
|
|
898
|
+
for (const sib of inDisplayOrder(el, [...el.childNodes])) {
|
|
874
899
|
if (sib === step)
|
|
875
900
|
break;
|
|
876
901
|
if (keep.has(sib))
|
|
@@ -956,10 +981,18 @@ export function cloneInterval(source, { start = null, end = null, items = [], ho
|
|
|
956
981
|
const c = register(s);
|
|
957
982
|
const startStep = stepOf(s, onStart, startBox);
|
|
958
983
|
const endStep = stepOf(s, onEnd, endBox);
|
|
984
|
+
// Before, in or after the interval is asked in display order (*the-word-space-follows-the-table-display-order*), and the copy keeps the order written.
|
|
985
|
+
const phases = new Map();
|
|
959
986
|
let phase = startStep ? 'before' : 'in';
|
|
960
|
-
for (const node of s.childNodes) {
|
|
987
|
+
for (const node of inDisplayOrder(s, [...s.childNodes])) {
|
|
961
988
|
if (node === startStep)
|
|
962
989
|
phase = 'in';
|
|
990
|
+
phases.set(node, phase);
|
|
991
|
+
if (node === endStep)
|
|
992
|
+
phase = 'after';
|
|
993
|
+
}
|
|
994
|
+
for (const node of s.childNodes) {
|
|
995
|
+
const phase = phases.get(node);
|
|
963
996
|
let out;
|
|
964
997
|
if (node.nodeType !== 1) {
|
|
965
998
|
out = node.cloneNode(false);
|
|
@@ -973,8 +1006,6 @@ export function cloneInterval(source, { start = null, end = null, items = [], ho
|
|
|
973
1006
|
else
|
|
974
1007
|
out = deep(node);
|
|
975
1008
|
c.appendChild(out);
|
|
976
|
-
if (node === endStep)
|
|
977
|
-
phase = 'after';
|
|
978
1009
|
}
|
|
979
1010
|
return c;
|
|
980
1011
|
};
|
package/dist/src/gauge.d.ts
CHANGED
|
@@ -43,10 +43,6 @@ export type Box = {
|
|
|
43
43
|
blockChildren: Box[];
|
|
44
44
|
/** An anonymous block box (CSS 2.1 §9.2.1.1): no element of its own, no decoration, and every break property `auto`. */
|
|
45
45
|
anonymous?: boolean;
|
|
46
|
-
/** A table's footer group written before its rows (`footerBeforeRows`), moved to the end of the table's procession, where §17.2 lays it out. */
|
|
47
|
-
laidOutLast?: boolean;
|
|
48
|
-
/** A table's header group written after its rows (`headerAfterRows`), moved to the front of the table's procession, where §17.2 lays it out. */
|
|
49
|
-
laidOutFirst?: boolean;
|
|
50
46
|
};
|
|
51
47
|
/** One line box, recovered arithmetically from the rects clustered onto it and the line-heights they are set on (`computeLines`). */
|
|
52
48
|
export type LineBox = {
|
package/dist/src/gauge.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @packageDocumentation
|
|
16
16
|
*/
|
|
17
17
|
import { BREAK, selectorOf } from './data-beltype.js';
|
|
18
|
-
import { blockChildrenOf,
|
|
18
|
+
import { blockChildrenOf, inDisplayOrder, isAtom, isEngineText, isInlineBox, isLineBreak, mayBeAutoHyphenated, rangeOf, READS_ITS_CHILDREN, rowsAmong, SIDE_BY_SIDE, wordRuns } from './word-space.js';
|
|
19
19
|
/** The Compositor's own `<br>`, found by the browser and never by splitting the attribute here (the walkers below run per box). */
|
|
20
20
|
const BREAK_SELECTOR = `br${selectorOf(BREAK)}`;
|
|
21
21
|
/**
|
|
@@ -71,37 +71,17 @@ export function takeReport(container, { contentHeight }) {
|
|
|
71
71
|
if (!box.inline)
|
|
72
72
|
box.blockChildren = blockChildrenOf(box.el).map((el) => byEl.get(el)).filter(Boolean);
|
|
73
73
|
const flowProcession = blockChildrenOf(container).map((el) => byEl.get(el)).filter(Boolean);
|
|
74
|
-
/**
|
|
74
|
+
/** A table's procession is in display order (*the-word-space-follows-the-table-display-order*, 2026-11-07; `word-space.js`' `inDisplayOrder`): CSS 2.1 §17.2 lays a header group out before every row and row group and a footer group after them, wherever each is written, and the procession and the box list are the order the Stonehand walks and stops at the edge in. The word space and the paths follow the same order, so a place among a table's groups is addressed as it is laid out. Walked as written, a footer written first stood first and far below the edge, and the walk stopped at it with the page holding the header alone (measured, `packages/essay/cases/110-the-footer-before-the-rows`). */
|
|
75
75
|
let moved = false;
|
|
76
76
|
for (const box of boxes) {
|
|
77
|
-
if (box.inline || box.blockChildren.length < 2
|
|
77
|
+
if (box.inline || box.blockChildren.length < 2)
|
|
78
78
|
continue;
|
|
79
79
|
for (const list of [box.blockChildren, box.children]) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
80
|
+
const ordered = inDisplayOrder(box.el, list, (c) => c.el);
|
|
81
|
+
if (ordered === list)
|
|
82
82
|
continue;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (/^table-row(-group)?$/.test(win.getComputedStyle(c.el).display))
|
|
86
|
-
last = i;
|
|
87
|
-
});
|
|
88
|
-
if (last < f)
|
|
89
|
-
continue;
|
|
90
|
-
const [footer] = list.splice(f, 1);
|
|
91
|
-
list.splice(last, 0, footer); // `last` moved down by one with the removal, so this is the place after it
|
|
92
|
-
footer.laidOutLast = moved = true;
|
|
93
|
-
}
|
|
94
|
-
// The header stands first, the mirror (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): §17.2 lays a `table-header-group` out *"before all other rows and row groups"*, so one written after them is moved before the first. Its words stay after the rows in the word space, which is why the Stonehand offers no place inside it or right after it, and why the driver refuses one that does not repeat: only a copy can show it on a page before its words.
|
|
95
|
-
for (const list of [box.blockChildren, box.children]) {
|
|
96
|
-
const h = list.findIndex((c) => headerAfterRows(c.el));
|
|
97
|
-
if (h < 0)
|
|
98
|
-
continue;
|
|
99
|
-
const first = list.findIndex((c) => /^table-row(-group)?$/.test(win.getComputedStyle(c.el).display));
|
|
100
|
-
if (first < 0 || first > h)
|
|
101
|
-
continue;
|
|
102
|
-
const [header] = list.splice(h, 1);
|
|
103
|
-
list.splice(first, 0, header);
|
|
104
|
-
header.laidOutFirst = moved = true;
|
|
83
|
+
list.splice(0, list.length, ...ordered);
|
|
84
|
+
moved = true;
|
|
105
85
|
}
|
|
106
86
|
}
|
|
107
87
|
/**
|
|
@@ -185,7 +165,7 @@ export function takeReport(container, { contentHeight }) {
|
|
|
185
165
|
box.blockChildren = procession;
|
|
186
166
|
}
|
|
187
167
|
if (runSlot.size || moved) {
|
|
188
|
-
// Into the box list at their places in document order, the order the Stonehand walks and stops at the edge in: before the block child each precedes, and a trailing one after its container's whole subtree. A
|
|
168
|
+
// Into the box list at their places in document order, the order the Stonehand walks and stops at the edge in: before the block child each precedes, and a trailing one after its container's whole subtree. A table's groups are visited in display order, where its children now put them.
|
|
189
169
|
const ordered = [];
|
|
190
170
|
const visit = (box) => {
|
|
191
171
|
ordered.push(box);
|
|
@@ -278,7 +258,7 @@ export function takeReport(container, { contentHeight }) {
|
|
|
278
258
|
* One rect per word, largest top (the soft-wrap rule); duplicates from nested inlines collapse by construction — and one piece per line for a word the browser set on several lines at silent breaks or at a soft hyphen (*a line inside a word is a break point*, 2026-09-23; *the hyphen is written at the cut*, 2026-10-06).
|
|
279
259
|
*
|
|
280
260
|
* @remarks
|
|
281
|
-
* A word is a run of non-whitespace and a line box is not: CJK text, `word-break: break-all` and `overflow-wrap`'s emergency break set one word on many lines, and a word homed on its last line left every line above it out of the stack — no Class B point between them, and a page edge that divided a line through its glyphs (`packages/essay/cases/63-the-line-inside-a-word`). So where a word's inked rects (width over half a pixel: the soft-wrap rule's zero-width leading rect is not ink) cluster onto more than one line, the character each later line begins at is found by a binary search over single-character ranges, and each silent break opens a piece. A break after a soft hyphen opens a piece at the character after it, and the fragment writes the hyphen the browser drew there (*the hyphen is written at the cut*, 2026-10-06); a break
|
|
261
|
+
* A word is a run of non-whitespace and a line box is not: CJK text, `word-break: break-all` and `overflow-wrap`'s emergency break set one word on many lines, and a word homed on its last line left every line above it out of the stack — no Class B point between them, and a page edge that divided a line through its glyphs (`packages/essay/cases/63-the-line-inside-a-word`). So where a word's inked rects (width over half a pixel: the soft-wrap rule's zero-width leading rect is not ink) cluster onto more than one line, the character each later line begins at is found by a binary search over single-character ranges, and each silent break opens a piece. A break after a soft hyphen opens a piece at the character after it, and the fragment writes the hyphen the browser drew there (*the hyphen is written at the cut*, 2026-10-06); a break between two letters under `hyphens: auto` (`word-space.js`' `mayBeAutoHyphenated`) opens a piece where the browser drew a hyphen there, read in the range rects (`drewHyphen`), and the fragment writes it; one that drew none opens none, and that line joins the next, exactly as the largest-top rule always homed it. **A box carrying the Compositor's own breaks is split at them and nowhere else**: its lines are set by hints, so a word's later lines begin at the source characters its `<br>`s stand before, counted without the hyphen the Compositor wrote.
|
|
282
262
|
*/
|
|
283
263
|
function measureWords(box) {
|
|
284
264
|
if (wordCache.has(box))
|
|
@@ -301,6 +281,12 @@ export function takeReport(container, { contentHeight }) {
|
|
|
301
281
|
ink = r;
|
|
302
282
|
return ink;
|
|
303
283
|
};
|
|
284
|
+
/** Did the browser draw a hyphen at the line break before character `i`? Chromium leaves the glyph it drew in that character's range: an inked rect on the line the break ends, beside the character's own on the next (measured 2026-11-07 on Chromium 153, as *the hyphen is written at the cut* measured 2026-10-06; an `overflow-wrap` emergency break leaves none). */
|
|
285
|
+
const drewHyphen = (i, line) => {
|
|
286
|
+
range.setStart(...pointFor(nodes, i));
|
|
287
|
+
range.setEnd(...pointFor(nodes, i + 1, true));
|
|
288
|
+
return [...range.getClientRects()].some((r) => r.width > 0.5 && sharesLine(r.top, r.bottom, line.top, line.bottom));
|
|
289
|
+
};
|
|
304
290
|
/** The pieces of the word at `[at, at + w.length)` whose inked rects are `rects`, or null for a word on one line. */
|
|
305
291
|
const piecesOf = (at, w, rects) => {
|
|
306
292
|
// The word's own lines, by the one rule `computeLines` reads a line by: sorted by top, a rect joins the line above it where it shares it.
|
|
@@ -339,7 +325,7 @@ export function takeReport(container, { contentHeight }) {
|
|
|
339
325
|
break;
|
|
340
326
|
const last = pieces[pieces.length - 1];
|
|
341
327
|
const line = { top: tops[j].top, height: tops[j].bottom - tops[j].top };
|
|
342
|
-
if (mayBeAutoHyphenated(w[lo - 1], w[lo], hyphens))
|
|
328
|
+
if (mayBeAutoHyphenated(w[lo - 1], w[lo], hyphens) && !drewHyphen(at + lo, tops[j - 1]))
|
|
343
329
|
Object.assign(last, line);
|
|
344
330
|
else
|
|
345
331
|
pieces.push({ char: lo, ...line });
|