@beltype/cicero 0.1.8 → 0.1.10
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 +55 -19
- package/dist/seam/facsimile.d.ts +13 -5
- package/dist/seam/facsimile.js +28 -12
- 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 +114 -25
- package/dist/src/chase.js +3 -1
- package/dist/src/compose-dom.js +3 -2
- package/dist/src/compositor.d.ts +11 -2
- package/dist/src/compositor.js +47 -15
- package/dist/src/data-beltype.d.ts +5 -1
- package/dist/src/data-beltype.js +6 -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 +4 -4
- package/dist/src/fragmentation.js +4 -4
- package/dist/src/fragments.d.ts +6 -0
- package/dist/src/fragments.js +90 -11
- package/dist/src/gauge.d.ts +0 -4
- package/dist/src/gauge.js +16 -30
- 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.js +3 -3
- package/dist/src/root.d.ts +18 -1
- package/dist/src/root.js +27 -1
- 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
|
@@ -2,17 +2,17 @@
|
|
|
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
|
|
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/internal/chase`.
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
export { effectivePageName, readGeometryTable, registerGcpmProperties, topLevelReach, transliterateContent } from './capture.js';
|
|
10
|
-
export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, addWord, hasWord, removeWord } from './data-beltype.js';
|
|
10
|
+
export { ADJOIN, ARTIFACT, AWAY, END, FRAGMENT, SHELL, SPLIT, START, WRAPPED, addWord, hasWord, removeWord } from './data-beltype.js';
|
|
11
11
|
export { counterStateAt, documentOpsOf, namedStringsForPage, runningAssignmentsOf, stringAssignmentsOf } from './document-state.js';
|
|
12
12
|
export { ensureContinuationDressing, ensureDressing } from './dressing.js';
|
|
13
13
|
export { forcingIndexOf, gapAbove, pageNameDownFrom, runningBoxes, runningPosition, topLevelBlocks } from './driver.js';
|
|
14
14
|
export { ForcingMemo } from './forcing-memo.js';
|
|
15
|
-
export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack } from './fragments.js';
|
|
15
|
+
export { joinedWords, mutateToContinuation, mutateToFirstFragment, pinTrack, truncateLeadingDecoration } from './fragments.js';
|
|
16
16
|
export { takeReport } from './gauge.js';
|
|
17
17
|
export { STRING_KEYWORDS, encodeStringProp } from './gcpm-encoding.js';
|
|
18
18
|
export { assertRenderConditions, awaitCloneImages, awaitFonts, createMeasureContext } from './measure.js';
|
|
@@ -20,4 +20,4 @@ export { divides, elAt, extensionPx, indexBlocks, normalizePath, pageContaining,
|
|
|
20
20
|
export { present, presentation } from './presenters.js';
|
|
21
21
|
export { cssString, findPageSites, settleReads } from './reads.js';
|
|
22
22
|
export { adjudicate, firstBelowTheEdge, fitsPage, isOutOfFlow, neverCutInside, processionBottoms, reachesBelowTheEdge } from './stonehand.js';
|
|
23
|
-
export { BREAKING_SPACE, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.js';
|
|
23
|
+
export { BREAKING_SPACE, clonesDecoration, copyOf, isFixedBox, isInlineBox, isLineBreak, noteSource } from './word-space.js';
|
package/dist/src/fragments.d.ts
CHANGED
|
@@ -141,6 +141,12 @@ export declare function mutateToContinuation(clone: Element, { cut, remainderPx,
|
|
|
141
141
|
hollow?: boolean;
|
|
142
142
|
forced?: boolean;
|
|
143
143
|
}): Element;
|
|
144
|
+
/**
|
|
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
|
+
*
|
|
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
|
+
*/
|
|
149
|
+
export declare function listOrdinals(ol: Element): Map<Element, number>;
|
|
144
150
|
/**
|
|
145
151
|
* A clone of a top-level block for one interval, hollow outside it (*the clone is hollow outside the interval*, 2026-09-27) — the one construction behind the measure feed and every presented fragment, and the one place the clone-to-source map is filled.
|
|
146
152
|
*
|
package/dist/src/fragments.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { applyRecordsUnder, hyphenCharOf } from './compose-dom.js';
|
|
21
21
|
import { addWord, ADJOIN, ARTIFACT, AWAY, END, ENGINE_MARKS, hasAnyWord, hasWord, removeWord, SHELL, SPLIT, START, WRAPPED } from './data-beltype.js';
|
|
22
|
+
import { pairsOf } from './document-state.js';
|
|
22
23
|
import { divides, elAtIn, flowsOf } from './positions.js';
|
|
23
|
-
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, isFixedBox, isInlineBox, isLineBreak, copyOf, shellOf, noteText, READS_ITS_CHILDREN, sourceOf, sourceStyle } from './word-space.js';
|
|
24
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. */
|
|
25
26
|
export function joinedWords(root, isInline = isInlineBox) {
|
|
26
27
|
const words = [];
|
|
@@ -58,7 +59,7 @@ export function joinedWords(root, isInline = isInlineBox) {
|
|
|
58
59
|
current = null;
|
|
59
60
|
};
|
|
60
61
|
const walk = (el) => {
|
|
61
|
-
for (const node of el.childNodes) {
|
|
62
|
+
for (const node of inDisplayOrder(el, [...el.childNodes])) {
|
|
62
63
|
if (node.nodeType === 3)
|
|
63
64
|
scan(node);
|
|
64
65
|
else if (node.nodeType === 1) {
|
|
@@ -325,11 +326,10 @@ export function truncateFrom(clone, el, { keep = false, hollow = false } = {}) {
|
|
|
325
326
|
return clone;
|
|
326
327
|
const chain = [];
|
|
327
328
|
for (let e = el; e && e !== clone; e = e.parentElement) {
|
|
328
|
-
|
|
329
|
-
|
|
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))
|
|
330
332
|
putAway(sib, hollow);
|
|
331
|
-
sib = next;
|
|
332
|
-
}
|
|
333
333
|
if (e !== el || keep)
|
|
334
334
|
chain.push(e);
|
|
335
335
|
}
|
|
@@ -552,6 +552,14 @@ function consumeTrailingWords(clone, n, char = 0, hollow = false) {
|
|
|
552
552
|
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`)
|
|
553
553
|
}
|
|
554
554
|
}
|
|
555
|
+
else if (char > 0) {
|
|
556
|
+
// 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.
|
|
557
|
+
const last = charRanges(words[n], char - 1, char);
|
|
558
|
+
const kept = last.find((r) => !r.engine);
|
|
559
|
+
const style = kept && sourceStyle(kept.node.parentElement);
|
|
560
|
+
if (kept && style && !last.some((r) => r.engine) && mayBeAutoHyphenated(words[n].text[char - 1], words[n].text[char], style.hyphens))
|
|
561
|
+
replaceChars(kept.node, kept.end, kept.end, hyphenCharOf(style));
|
|
562
|
+
}
|
|
555
563
|
leaveOutAfter(clone, keepNode);
|
|
556
564
|
// 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.
|
|
557
565
|
// 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.
|
|
@@ -751,7 +759,7 @@ export function mutateToContinuation(clone, { cut = null, remainderPx = null, so
|
|
|
751
759
|
// 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.
|
|
752
760
|
let el = clone;
|
|
753
761
|
for (const step of chain) {
|
|
754
|
-
for (const sib of [...el.childNodes]) {
|
|
762
|
+
for (const sib of inDisplayOrder(el, [...el.childNodes])) {
|
|
755
763
|
if (sib === step)
|
|
756
764
|
break;
|
|
757
765
|
if (keep.has(sib))
|
|
@@ -790,8 +798,72 @@ export function mutateToContinuation(clone, { cut = null, remainderPx = null, so
|
|
|
790
798
|
applyCarriedRemainder(clone, source, remainderPx);
|
|
791
799
|
for (const m of mine)
|
|
792
800
|
mutateToContinuation(m.itemClone, { cut: m.cut, remainderPx: m.e.remainingPx ?? null, source: m.itemSource, cloneOf, flows: m.nested, rootPath: m.e.item, hollow });
|
|
801
|
+
numberDividedLists(root, cut, source, cloneOf);
|
|
793
802
|
return root;
|
|
794
803
|
}
|
|
804
|
+
/** The list items an `<ol>` owns, in tree order: HTML's list owner is the nearest `ol`, `ul` or `menu` ancestor. An item that is not displayed as a list item has no ordinal, where a style can be read. */
|
|
805
|
+
function ownedItems(ol) {
|
|
806
|
+
const out = [];
|
|
807
|
+
(function walk(el) {
|
|
808
|
+
for (const c of el.children) {
|
|
809
|
+
if (/^(?:OL|UL|MENU)$/i.test(c.tagName))
|
|
810
|
+
continue;
|
|
811
|
+
if (/^LI$/i.test(c.tagName)) {
|
|
812
|
+
const display = sourceStyle(c)?.display;
|
|
813
|
+
if (display == null || display.includes('list-item'))
|
|
814
|
+
out.push(c);
|
|
815
|
+
}
|
|
816
|
+
walk(c);
|
|
817
|
+
}
|
|
818
|
+
})(ol);
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* 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.
|
|
823
|
+
*
|
|
824
|
+
* @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.
|
|
825
|
+
*/
|
|
826
|
+
export function listOrdinals(ol) {
|
|
827
|
+
const items = ownedItems(ol);
|
|
828
|
+
const reversed = ol.hasAttribute('reversed');
|
|
829
|
+
const dflt = reversed ? -1 : 1;
|
|
830
|
+
const start = parseInt(ol.getAttribute('start') ?? '', 10);
|
|
831
|
+
let n = (Number.isFinite(start) ? start : reversed ? items.length : 1) - dflt;
|
|
832
|
+
const out = new Map();
|
|
833
|
+
for (const li of items) {
|
|
834
|
+
const style = sourceStyle(li);
|
|
835
|
+
const named = (value, fallback) => pairsOf(value, fallback).find(([name]) => name === 'list-item')?.[1];
|
|
836
|
+
n += named(style?.counterIncrement, 1) ?? dflt;
|
|
837
|
+
const value = parseInt(li.getAttribute('value') ?? '', 10);
|
|
838
|
+
const set = named(style?.counterSet, 0) ?? (Number.isFinite(value) ? value : null);
|
|
839
|
+
if (set != null)
|
|
840
|
+
n = set;
|
|
841
|
+
out.set(li, n);
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* 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.
|
|
847
|
+
*/
|
|
848
|
+
function numberDividedLists(root, cut, source, cloneOf) {
|
|
849
|
+
if (!divides(cut) || !source || !cloneOf)
|
|
850
|
+
return;
|
|
851
|
+
const target = elAtIn(source, cut.path);
|
|
852
|
+
for (let a = target?.parentElement ?? null; a; a = a === source ? null : a.parentElement) {
|
|
853
|
+
if (!/^OL$/i.test(a.tagName))
|
|
854
|
+
continue;
|
|
855
|
+
const copy = cloneOf.get(a);
|
|
856
|
+
if (!copy)
|
|
857
|
+
continue;
|
|
858
|
+
for (const [li, n] of listOrdinals(a)) {
|
|
859
|
+
const c = cloneOf.get(li);
|
|
860
|
+
if (!c || !root.contains(c) || hasWord(c, AWAY))
|
|
861
|
+
continue;
|
|
862
|
+
copy.setAttribute('start', String(n));
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
795
867
|
/** The shell branch of the start cut: the box the path names continues at exactly the extent it has left, blank or painted. */
|
|
796
868
|
function shellAt(clone, target, px, keep = new Set(), hollow = false) {
|
|
797
869
|
const painted = px.paint != null;
|
|
@@ -805,7 +877,7 @@ function shellAt(clone, target, px, keep = new Set(), hollow = false) {
|
|
|
805
877
|
markShelled(el);
|
|
806
878
|
for (let i = 0; i < chain.length; i++) {
|
|
807
879
|
const step = chain[i];
|
|
808
|
-
for (const sib of [...el.childNodes]) {
|
|
880
|
+
for (const sib of inDisplayOrder(el, [...el.childNodes])) {
|
|
809
881
|
if (sib === step)
|
|
810
882
|
break;
|
|
811
883
|
if (keep.has(sib))
|
|
@@ -891,10 +963,18 @@ export function cloneInterval(source, { start = null, end = null, items = [], ho
|
|
|
891
963
|
const c = register(s);
|
|
892
964
|
const startStep = stepOf(s, onStart, startBox);
|
|
893
965
|
const endStep = stepOf(s, onEnd, endBox);
|
|
966
|
+
// 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.
|
|
967
|
+
const phases = new Map();
|
|
894
968
|
let phase = startStep ? 'before' : 'in';
|
|
895
|
-
for (const node of s.childNodes) {
|
|
969
|
+
for (const node of inDisplayOrder(s, [...s.childNodes])) {
|
|
896
970
|
if (node === startStep)
|
|
897
971
|
phase = 'in';
|
|
972
|
+
phases.set(node, phase);
|
|
973
|
+
if (node === endStep)
|
|
974
|
+
phase = 'after';
|
|
975
|
+
}
|
|
976
|
+
for (const node of s.childNodes) {
|
|
977
|
+
const phase = phases.get(node);
|
|
898
978
|
let out;
|
|
899
979
|
if (node.nodeType !== 1) {
|
|
900
980
|
out = node.cloneNode(false);
|
|
@@ -908,8 +988,6 @@ export function cloneInterval(source, { start = null, end = null, items = [], ho
|
|
|
908
988
|
else
|
|
909
989
|
out = deep(node);
|
|
910
990
|
c.appendChild(out);
|
|
911
|
-
if (node === endStep)
|
|
912
|
-
phase = 'after';
|
|
913
991
|
}
|
|
914
992
|
return c;
|
|
915
993
|
};
|
|
@@ -1004,6 +1082,7 @@ export function mutateToFirstFragment(clone, { cut = null, extPx = 0, source = n
|
|
|
1004
1082
|
/** And the cut line stays justified. css-text-3 §7.2 says a line before a forced or fragmentation break is not the last line of its block, so `text-align: justify` binds it — but in a clone cut AT the break it is the last line and the browser would set it ragged. The alignment is restated rather than inherited, and it is read from the source through the word space's map, because a detached clone has no computed style to ask. The Compositor makes the same move for the same reason (`compose-dom.js`), which is what makes this the engine's one answer written twice for two different line providers rather than two answers. */
|
|
1005
1083
|
if (sourceStyle(inner)?.textAlign === 'justify')
|
|
1006
1084
|
inner.style.textAlignLast = 'justify';
|
|
1085
|
+
numberDividedLists(clone, cut, source, cloneOf);
|
|
1007
1086
|
return clone;
|
|
1008
1087
|
}
|
|
1009
1088
|
/**
|
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 });
|
package/dist/src/measure.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The measure pass (first-coding-round.md, item 2): a hidden measurement container at true content width, off-viewport, one block flow, shared by every page of one geometry. The Chase is not in it and does not measure. Geometry comes from clones laid out here; law (break-* constraints, orphans, widows) is read from the source elements in the live document, so neutralization below never blinds legality reading.
|
|
3
3
|
*
|
|
4
|
-
* *Render-identical conditions* (I5) are pinned here: they are asserted at intake (unit DPR, explicit line-height — the DPR ruling), fonts
|
|
4
|
+
* *Render-identical conditions* (I5) are pinned here: they are asserted at intake (unit DPR, explicit line-height — the DPR ruling), fonts are awaited once before the first measure, and native break properties plus orphans/widows are neutralized in the measure context. They are inert in an unfragmented column anyway — neutralization is stated, not assumed.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
6
|
+
* The first run no longer awaits the document's images (ruled 2026-11-08): it makes them eager ({@link eagerImages}), and the rim answers an arrival that changed a box. A measured slice's own images are still awaited ({@link awaitCloneImages}).
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
@@ -28,26 +28,31 @@ export declare function assertRenderConditions(doc: Document, { relaxDpr }?: {
|
|
|
28
28
|
*/
|
|
29
29
|
export declare function awaitFonts(doc: Document, faces?: string[]): Promise<void>;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Make every `loading="lazy"` image eager, and wait for none: what the first run does with the document's pictures since 2026-11-08.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
33
|
+
* @remarks Egildo ruled on 2026-11-08 that the intake's and the driver's first-run awaits are retired, on the measurement `.claude/plans/quick-rulings-2026-11-08/image-await.md` in the monorepo: on a 59-page document with 60 images served 300 to 800 ms late, the first paint fell from 5.8 s to 0.74 s, the final layout was the same, and with declared sizes nothing moved on arrival. A picture measured before it arrived is late truth, and the rim answers it (`seam/rim.js`, *late truth is an edit*, amended 2026-11-08): one resume where its arrival changed its box, none where it did not. So the fast first paint is the author's to give: an image that declares its `width` and `height` is measured at its size before a byte of it arrives.
|
|
34
34
|
*
|
|
35
|
-
* **A
|
|
35
|
+
* **A lazy image is still made eager** (*late truth is an edit*, 2026-10-13). Measured that day: a lazy image with no box never issues its request, and a parked original has no box, so it was measured at zero and never revisited. Lazy loading optimises for a viewport, and print has no viewport: there is nothing for it to save, and *the source document is never written* (C5) is untouched because the attribute is set on the original's `loading` property, which is a request for the bytes and not a change to what the document says. The clones the measure container receives copy the attribute, so they are eager too.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
37
|
+
* **It reads the originals and never the copies** (2026-10-08). A presented document carries a clone of every box on every page, and `document.images` met each picture once per copy (19 468 images for 157 real ones on a 123-page manual). A host that has presented hands the top-level boxes it took at intake; a first run has no copies and is handed the document.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* @param scope - as {@link awaitImages}'s.
|
|
40
|
+
* @returns how many images were looked at, which the run's timing reads and nothing that decides does.
|
|
41
|
+
*/
|
|
42
|
+
export declare function eagerImages(scope: Document | Element | Element[]): number;
|
|
43
|
+
/**
|
|
44
|
+
* Await every image in a scope that has not settled, a lazy one made eager first as {@link eagerImages} makes it. Since 2026-11-08 its one caller in the engine is {@link awaitCloneImages}: the first run no longer waits for the document's pictures (*images are awaited at intake*, retired that day for the first run).
|
|
40
45
|
*
|
|
41
|
-
*
|
|
46
|
+
* A failed load settles at its error size: the wait resolves on `error` as it resolves on `load`, because what adjudication needs is that the geometry has stopped changing, not that it is the one the author wanted. `allSettled` for the same reason — one picture that never arrives must not hold the others.
|
|
42
47
|
*
|
|
43
|
-
* @param scope - a Document answers `images`; an element is asked for its own; a list of elements
|
|
44
|
-
* @returns how many images were looked at
|
|
48
|
+
* @param scope - a Document answers `images`; an element is asked for its own; a list of elements is asked box by box, each box included.
|
|
49
|
+
* @returns how many images were looked at.
|
|
45
50
|
*/
|
|
46
51
|
export declare function awaitImages(scope: Document | Element | Element[]): Promise<number>;
|
|
47
52
|
/**
|
|
48
|
-
* The clones' images, awaited where they are measured
|
|
53
|
+
* The clones' images, awaited where they are measured (2026-09-21, found by the Chromium 153 census). **It stays when the first run's two awaits went (ruled 2026-11-08) until the Service Worker case below is measured without it**: the measurement behind that ruling ran under no worker, and with this await gone too its first paint fell again, from 0.74 s to 0.39 s, and undeclared sizes refilled the pages as each picture came.
|
|
49
54
|
*
|
|
50
|
-
* `awaitImages(doc)`
|
|
55
|
+
* Until 2026-11-08 `awaitImages(doc)` settled the document's own `<img>` elements first, and the engine measures clones of them, mounted in the measure container. That was sound only on an unstated premise: that a clone of a loaded image is available synchronously — HTML's *list of available images*, which lets a second `<img>` for an already-fetched URL be `complete` the moment it is inserted. Chromium 150 honoured it everywhere. **Chromium 153 does not in a document a Service Worker controls**: measured in the editing tools' pane, every clone answered `complete: false` and a height of 0 in the turn it was mounted, and its real height 300 ms later — so every figure measured nothing, the cuts fell late, and the presentation then clipped what the account believed it had placed (the Caster specimen lost Figure 2 and the drawing after it, on no page at all). The CLI's dev server has no worker and was untouched, which is why the battery did not see it.
|
|
51
56
|
*
|
|
52
57
|
* So the premise is checked instead of assumed: after a mount, if any image in the container has not settled, wait for it and lay out again. It costs nothing where the premise holds, and *a measurement the engine has no way to correct must not be taken early* is already this file's rule.
|
|
53
58
|
*
|
package/dist/src/measure.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The measure pass (first-coding-round.md, item 2): a hidden measurement container at true content width, off-viewport, one block flow, shared by every page of one geometry. The Chase is not in it and does not measure. Geometry comes from clones laid out here; law (break-* constraints, orphans, widows) is read from the source elements in the live document, so neutralization below never blinds legality reading.
|
|
3
3
|
*
|
|
4
|
-
* *Render-identical conditions* (I5) are pinned here: they are asserted at intake (unit DPR, explicit line-height — the DPR ruling), fonts
|
|
4
|
+
* *Render-identical conditions* (I5) are pinned here: they are asserted at intake (unit DPR, explicit line-height — the DPR ruling), fonts are awaited once before the first measure, and native break properties plus orphans/widows are neutralized in the measure context. They are inert in an unfragmented column anyway — neutralization is stated, not assumed.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
6
|
+
* The first run no longer awaits the document's images (ruled 2026-11-08): it makes them eager ({@link eagerImages}), and the rim answers an arrival that changed a box. A measured slice's own images are still awaited ({@link awaitCloneImages}).
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
@@ -45,30 +45,44 @@ export async function awaitFonts(doc, faces = []) {
|
|
|
45
45
|
await Promise.all(faces.map((face) => doc.fonts.load(face).catch(() => [])));
|
|
46
46
|
await doc.fonts.ready;
|
|
47
47
|
}
|
|
48
|
+
/** The `<img>` elements of a scope: a Document answers `images`; an element is asked for its own; a list of elements (the intake's top-level boxes) is asked box by box, each box included. */
|
|
49
|
+
const imagesOf = (scope) => Array.isArray(scope)
|
|
50
|
+
? scope.flatMap((el) => (el.localName === 'img' ? [el] : [...el.querySelectorAll('img')]))
|
|
51
|
+
: scope.images ?? scope.querySelectorAll('img');
|
|
48
52
|
/**
|
|
49
|
-
*
|
|
53
|
+
* Make every `loading="lazy"` image eager, and wait for none: what the first run does with the document's pictures since 2026-11-08.
|
|
50
54
|
*
|
|
51
|
-
*
|
|
55
|
+
* @remarks Egildo ruled on 2026-11-08 that the intake's and the driver's first-run awaits are retired, on the measurement `.claude/plans/quick-rulings-2026-11-08/image-await.md` in the monorepo: on a 59-page document with 60 images served 300 to 800 ms late, the first paint fell from 5.8 s to 0.74 s, the final layout was the same, and with declared sizes nothing moved on arrival. A picture measured before it arrived is late truth, and the rim answers it (`seam/rim.js`, *late truth is an edit*, amended 2026-11-08): one resume where its arrival changed its box, none where it did not. So the fast first paint is the author's to give: an image that declares its `width` and `height` is measured at its size before a byte of it arrives.
|
|
52
56
|
*
|
|
53
|
-
* **A
|
|
57
|
+
* **A lazy image is still made eager** (*late truth is an edit*, 2026-10-13). Measured that day: a lazy image with no box never issues its request, and a parked original has no box, so it was measured at zero and never revisited. Lazy loading optimises for a viewport, and print has no viewport: there is nothing for it to save, and *the source document is never written* (C5) is untouched because the attribute is set on the original's `loading` property, which is a request for the bytes and not a change to what the document says. The clones the measure container receives copy the attribute, so they are eager too.
|
|
54
58
|
*
|
|
55
|
-
*
|
|
59
|
+
* **It reads the originals and never the copies** (2026-10-08). A presented document carries a clone of every box on every page, and `document.images` met each picture once per copy (19 468 images for 157 real ones on a 123-page manual). A host that has presented hands the top-level boxes it took at intake; a first run has no copies and is handed the document.
|
|
56
60
|
*
|
|
57
|
-
*
|
|
61
|
+
* @param scope - as {@link awaitImages}'s.
|
|
62
|
+
* @returns how many images were looked at, which the run's timing reads and nothing that decides does.
|
|
63
|
+
*/
|
|
64
|
+
export function eagerImages(scope) {
|
|
65
|
+
let seen = 0;
|
|
66
|
+
for (const img of imagesOf(scope)) {
|
|
67
|
+
seen++;
|
|
68
|
+
if (!img.complete && img.loading === 'lazy')
|
|
69
|
+
img.loading = 'eager'; // setting it starts the request a lazy image never made
|
|
70
|
+
}
|
|
71
|
+
return seen;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Await every image in a scope that has not settled, a lazy one made eager first as {@link eagerImages} makes it. Since 2026-11-08 its one caller in the engine is {@link awaitCloneImages}: the first run no longer waits for the document's pictures (*images are awaited at intake*, retired that day for the first run).
|
|
58
75
|
*
|
|
59
|
-
*
|
|
76
|
+
* A failed load settles at its error size: the wait resolves on `error` as it resolves on `load`, because what adjudication needs is that the geometry has stopped changing, not that it is the one the author wanted. `allSettled` for the same reason — one picture that never arrives must not hold the others.
|
|
60
77
|
*
|
|
61
|
-
* @param scope - a Document answers `images`; an element is asked for its own; a list of elements
|
|
62
|
-
* @returns how many images were looked at
|
|
78
|
+
* @param scope - a Document answers `images`; an element is asked for its own; a list of elements is asked box by box, each box included.
|
|
79
|
+
* @returns how many images were looked at.
|
|
63
80
|
*/
|
|
64
81
|
export async function awaitImages(scope) {
|
|
65
82
|
const pending = [];
|
|
66
83
|
let seen = 0;
|
|
67
|
-
const images = Array.isArray(scope)
|
|
68
|
-
? scope.flatMap((el) => (el.localName === 'img' ? [el] : [...el.querySelectorAll('img')]))
|
|
69
|
-
: scope.images ?? scope.querySelectorAll('img');
|
|
70
84
|
// A snapshot of a live collection, and no event can fire between the test and the listener: the loop is one synchronous turn.
|
|
71
|
-
for (const img of
|
|
85
|
+
for (const img of imagesOf(scope)) {
|
|
72
86
|
seen++;
|
|
73
87
|
if (img.complete)
|
|
74
88
|
continue;
|
|
@@ -84,9 +98,9 @@ export async function awaitImages(scope) {
|
|
|
84
98
|
return seen;
|
|
85
99
|
}
|
|
86
100
|
/**
|
|
87
|
-
* The clones' images, awaited where they are measured
|
|
101
|
+
* The clones' images, awaited where they are measured (2026-09-21, found by the Chromium 153 census). **It stays when the first run's two awaits went (ruled 2026-11-08) until the Service Worker case below is measured without it**: the measurement behind that ruling ran under no worker, and with this await gone too its first paint fell again, from 0.74 s to 0.39 s, and undeclared sizes refilled the pages as each picture came.
|
|
88
102
|
*
|
|
89
|
-
* `awaitImages(doc)`
|
|
103
|
+
* Until 2026-11-08 `awaitImages(doc)` settled the document's own `<img>` elements first, and the engine measures clones of them, mounted in the measure container. That was sound only on an unstated premise: that a clone of a loaded image is available synchronously — HTML's *list of available images*, which lets a second `<img>` for an already-fetched URL be `complete` the moment it is inserted. Chromium 150 honoured it everywhere. **Chromium 153 does not in a document a Service Worker controls**: measured in the editing tools' pane, every clone answered `complete: false` and a height of 0 in the turn it was mounted, and its real height 300 ms later — so every figure measured nothing, the cuts fell late, and the presentation then clipped what the account believed it had placed (the Caster specimen lost Figure 2 and the drawing after it, on no page at all). The CLI's dev server has no worker and was untouched, which is why the battery did not see it.
|
|
90
104
|
*
|
|
91
105
|
* So the premise is checked instead of assumed: after a mount, if any image in the container has not settled, wait for it and lay out again. It costs nothing where the premise holds, and *a measurement the engine has no way to correct must not be taken early* is already this file's rule.
|
|
92
106
|
*
|
package/dist/src/positions.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* @packageDocumentation
|
|
30
30
|
*/
|
|
31
|
-
import { BREAKING_SPACE, isFixedBox, isInlineBox, isLineBreak } from './word-space.js';
|
|
31
|
+
import { BREAKING_SPACE, inDisplayOrder, isFixedBox, isInlineBox, isLineBreak } from './word-space.js';
|
|
32
32
|
/** `outer` is a proper prefix of `inner`: the row item at `outer` holds the one at `inner`. */
|
|
33
33
|
const holds = (outer, inner) => outer.length < inner.length && outer.every((n, i) => n === inner[i]);
|
|
34
34
|
/**
|
|
@@ -86,11 +86,11 @@ export function flattenResume(tree) {
|
|
|
86
86
|
}
|
|
87
87
|
/** The address, resolved and compared. Six small functions, all of them pure and all of them touching nothing but `.children`, `.parentElement` and plain arrays — so the DOM-free suites feed them object trees, and so a realm that holds only an account can still say which cut comes first. */
|
|
88
88
|
/**
|
|
89
|
-
* A box's in-flow element children — what a path's indices count.
|
|
89
|
+
* A box's in-flow element children — what a path's indices count — in display order, which differs from the order written inside a table alone (`word-space.js`' `inDisplayOrder`; *the-word-space-follows-the-table-display-order*, 2026-11-07): a header group written after its rows is a table's child 0, so paths order a table's boxes as its words are ordered.
|
|
90
90
|
*
|
|
91
91
|
* **A `position: fixed` box is not among them, and that is the only exclusion.** Its footprint is zero at any depth (CSS 2.1 §9.6.1, *a fixed box at any depth*), so it is in no flow, no word space, no feed and no presentation — and the address is the flow's own coordinate, so it is in no path either. That is also the only reading that resolves in every tree a path is resolved in, which is a stronger argument than the principle and was measured rather than assumed: the feed's clones have had fixed boxes stripped, the presentation's clones likewise, and the source is not one tree — `root.append(...blocks, ...fixed)` at intake reparents a nested fixed box out of the block it was declared in, so the tree the driver builds a path against and the tree the presentation resolves it against differ by exactly those boxes. Filtering makes the index the same number in all three; counting them raw made it right in one and wrong in the others, which is what put a shell on the box next door (`packages/essay/cases/55-the-shell-behind-the-fixed-box`).
|
|
92
92
|
*/
|
|
93
|
-
const inFlowChildren = (el, isFixed) => [...(el.children ?? [])].filter((c) => !isFixed(c));
|
|
93
|
+
const inFlowChildren = (el, isFixed) => inDisplayOrder(el, [...(el.children ?? [])].filter((c) => !isFixed(c)));
|
|
94
94
|
/** The box a path names, descending from the top-level block it starts at. `null` where the tree no longer answers the path, which is an edited document rather than a defect. */
|
|
95
95
|
export function elAtIn(blockEl, path, isFixed = isFixedBox) {
|
|
96
96
|
let el = blockEl ?? null;
|
|
@@ -235,7 +235,7 @@ export function indexBlock(block, b, isInline = isInlineBox, isFixed = isFixedBo
|
|
|
235
235
|
const inside = inWord;
|
|
236
236
|
const start = count;
|
|
237
237
|
const before = inside ? count - 1 : count;
|
|
238
|
-
for (const node of el.childNodes) {
|
|
238
|
+
for (const node of inDisplayOrder(el, [...el.childNodes])) {
|
|
239
239
|
if (node.nodeType === 3) {
|
|
240
240
|
const s = node.data;
|
|
241
241
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -304,25 +304,39 @@ export function indexBlocks(blocks, isInline = isInlineBox, isFixed = isFixedBox
|
|
|
304
304
|
export function wordsBeforeChar(el, charOffset, isInline = isInlineBox) {
|
|
305
305
|
if (!(charOffset >= 0))
|
|
306
306
|
return null;
|
|
307
|
+
// The offset counts characters in the order written, and the words are counted in display order (*the-word-space-follows-the-table-display-order*): each text node's first character is found in the first order, and the walk below runs in the second.
|
|
308
|
+
const startOf = new Map();
|
|
307
309
|
let chars = 0;
|
|
310
|
+
(function count(node) {
|
|
311
|
+
for (const child of node.childNodes) {
|
|
312
|
+
if (child.nodeType === 3) {
|
|
313
|
+
startOf.set(child, chars);
|
|
314
|
+
chars += child.data.length;
|
|
315
|
+
}
|
|
316
|
+
else if (child.nodeType === 1)
|
|
317
|
+
count(child);
|
|
318
|
+
}
|
|
319
|
+
})(el);
|
|
320
|
+
if (charOffset > chars)
|
|
321
|
+
return null;
|
|
308
322
|
let words = 0;
|
|
309
323
|
let inWord = false;
|
|
310
324
|
let done = false;
|
|
311
325
|
let inside = false;
|
|
312
326
|
const walk = (node) => {
|
|
313
|
-
for (const child of node.childNodes) {
|
|
327
|
+
for (const child of inDisplayOrder(node, [...node.childNodes])) {
|
|
314
328
|
if (done)
|
|
315
329
|
return;
|
|
316
330
|
if (child.nodeType === 3) {
|
|
317
331
|
const s = child.data;
|
|
332
|
+
const start = startOf.get(child);
|
|
318
333
|
for (let i = 0; i < s.length; i++) {
|
|
319
334
|
// The character at the offset continues a word that began before it exactly when the walk is inside a word and this character is not a breaking space: `inWord` is the tokenization's own state, reset at every non-inline boundary, so a word does not straddle across one.
|
|
320
|
-
if (
|
|
335
|
+
if (start + i === charOffset) {
|
|
321
336
|
inside = inWord && !BREAKING_SPACE.test(s[i]);
|
|
322
337
|
done = true;
|
|
323
338
|
return;
|
|
324
339
|
}
|
|
325
|
-
chars++;
|
|
326
340
|
if (BREAKING_SPACE.test(s[i]))
|
|
327
341
|
inWord = false;
|
|
328
342
|
else if (!inWord) {
|
|
@@ -342,8 +356,6 @@ export function wordsBeforeChar(el, charOffset, isInline = isInlineBox) {
|
|
|
342
356
|
}
|
|
343
357
|
};
|
|
344
358
|
walk(el);
|
|
345
|
-
if (!done && chars !== charOffset)
|
|
346
|
-
return null;
|
|
347
359
|
return inside ? words - 1 : words;
|
|
348
360
|
}
|
|
349
361
|
/**
|