@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/seam/rim.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE RIM — the system's edge, where a fact the engine measured before it was true announces that it is (*late truth is an edit*, 2026-10-13; `.claude/decisions/cicero/late-truth-is-an-edit.md`, Decisions three and four; *a box announces its own size*, 2026-10-21; `.claude/decisions/cicero/a-box-announces-its-own-size.md`).
|
|
3
3
|
*
|
|
4
|
-
* It listens on the top-level boxes the intake took, and on nothing else: an `<img>` inside one (`load` and `error`, caught in the capture phase because they do not bubble
|
|
4
|
+
* It listens on the top-level boxes the intake took, and on nothing else: an `<img>` inside one (`load` and `error`, caught in the capture phase because they do not bubble: a picture still pending at intake, which the first run no longer waits for, and a picture whose `src` changes later are the same event), a custom element whose name is not yet defined (`customElements.whenDefined`, then one animation frame for its `connectedCallback` to have run), and `document.fonts` (`loadingdone`, a fact about the conditions and so about no box).
|
|
5
5
|
*
|
|
6
6
|
* **And the one cause that needs no name: a box's own size.** One `ResizeObserver` over the same boxes reports each one's border box; the report is read only while the box is drawn as itself on a page (the root's `isPresented`, and a reading above zero, since a parked box and a box on a page the browser skips read zero), and it is news only where it stands more than a quarter pixel from the height the last run measured for that box (the driver's table, which the client hands in as `measured`). The ear never measures: the observation is a trigger, and the resume measures. It is never paused; what it hears while a run is called or under way is judged when the runs have ended, against the table they leave, which is what keeps the engine's own presentations silent with no flag on its writes.
|
|
7
7
|
*
|
|
8
8
|
* **And the pieces a built page draws a block through** (*a fragment announces its own size*, 2026-10-22; `.claude/decisions/cicero/a-fragment-announces-its-own-size.md`). A divided block's original is parked, and so is a composed block's and one holding a nested fixed box, so the root names to the rim, through the client, every piece it slots into a built page's flow and every node a presentation takes out (`pieces`), and the same observer watches each from the frame it is built in. **A piece's expected height is its first reading above zero**, the presentation's own; a later reading more than the quarter pixel from it, judged under the same gate as an original's, is a `size` signal naming the piece's source box, the page the piece stands on beside it. The engine's own rebuild of a page makes new pieces with new first readings, so its writes pass with no flag. **Its limits**: a change of the original's tree after the copy was taken reaches no piece and is heard by nothing; a box on a page far from the reader is heard when the page comes near (the hearing probe, P40e), and a piece on a page never yet near has read nothing, so a change made before then, like one landing in the presentation's own frame, is its first reading and passes; and a reflow that keeps a box's height and moves its cut passes unheard, since a cut is a word address and the gate a height.
|
|
9
9
|
*
|
|
10
|
+
* **A picture is news only where its arrival changed its box** (amended 2026-11-08, on Egildo's ruling of that day). A `load` or an `error` is judged when the runs have ended, as a size is: where the box is drawn as itself and the observer's last reading of it stands within the quarter pixel of the height the last run measured, the picture moved nothing and is no signal, which is what an image that declares its `width` and `height` gives; where the observer has read the box as nothing (a page the browser skips), the box's own `offsetHeight` is read instead. A box not drawn as itself while some page is not built is judged again when the observer next reports, since it may stand on that page. Where the reading differs, or the box is not drawn as itself once every page is built (parked, drawn through pieces) or was never measured whole, the rim cannot compare, and the picture is an `image` signal as before. A reading taken before the arrival was laid out passes, and the observer's own `size` hears the box a frame later.
|
|
11
|
+
*
|
|
10
12
|
* **Every signal is one cancelable `beltype:change` on the document**, `detail: { kind, boxes }` with `kind` one of `image`, `element`, `font`, `size` and `boxes` the top-level boxes in document order (empty for a font); a `size` adds `pages`, beside each box the page it was heard on through a piece, or `null` where it was heard as itself. The same name is the one the live-values store dispatches for its writes (`kind: 'store'`, stage 2): the engine learns one name for every late truth and the host cancels one event to own the pace. A host that calls `preventDefault()` has taken it; otherwise `onSignal` runs with the same detail, and the client answers with the resumable run. **The default action is the engine's for an event the rim did not dispatch as well** (the amendment of 2026-10-13): the store's write (`kind: 'store'`) is dispatched by the host's module, which writes its fields synchronously when the dispatch returns un-cancelled, so the rim waits one animation frame and then, if nobody took the pace, hands `{ kind, boxes }` to the same `onSignal`.
|
|
11
13
|
*
|
|
12
14
|
* This module knows no run: it hears, tells, and hands the default action back. The `originals` array is the client's own and is read live — `patch` replaces an entry in it — so a box that left it is never named.
|
|
@@ -71,10 +73,11 @@ export function bringsNewFace(event) {
|
|
|
71
73
|
* @param p.pageHolding - the page a piece is slotted into (`root.js`' `pageHolding`), asked when a piece is judged changed.
|
|
72
74
|
* @param p.measured - the box's border-box height as the last run measured it, or undefined where it did not.
|
|
73
75
|
* @param p.idle - resolves when no run is under way and nothing the rim told is still being answered: a size is judged then, against the table the run leaves.
|
|
76
|
+
* @param p.unbuilt - whether some page of the presentation is not built yet: a picture's box that is not drawn as itself may stand on it, and is judged again when the observer next reports. Absent, every page is built.
|
|
74
77
|
* @param p.busy - whether a run is under way or a signal is being answered now: asked again just before a size is judged, since a named signal may have come while the ear waited for the frame.
|
|
75
78
|
* @returns `add` subscribes to a box, releasing what the box it replaced (a patched edit) and the box itself already held, so one load is one signal; `pieces` observes the pieces a page was just built with, each beside its source box, and lets go of the ones a presentation took out; `stop` releases everything.
|
|
76
79
|
*/
|
|
77
|
-
export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, onSignal, presented = () => false, pageHolding = () => null, measured = () => undefined, idle = async () => { }, busy = () => false }) {
|
|
80
|
+
export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, onSignal, presented = () => false, pageHolding = () => null, measured = () => undefined, idle = async () => { }, busy = () => false, unbuilt = () => false }) {
|
|
78
81
|
const win = doc.defaultView;
|
|
79
82
|
let stopped = false;
|
|
80
83
|
/** What each box's listeners hand back. */
|
|
@@ -86,6 +89,26 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
|
|
|
86
89
|
const heard = new Map();
|
|
87
90
|
/** The pieces a built page draws its blocks through (*a fragment announces its own size*, 2026-10-22): each one's source box and its expected height, which is the first reading above zero the observer reports for it — the presentation's own, since the feed has no number for a constructed first fragment or a two-cut fragment and a second arithmetic for the others is the fork the account ruling forbids. Zero until that reading; an entry leaves with its piece. */
|
|
88
91
|
const pieces = new Map();
|
|
92
|
+
/** Each original's last border-box height as the observer reported it, zero included: what a picture's arrival is judged by. */
|
|
93
|
+
const latest = new WeakMap();
|
|
94
|
+
/** The boxes a picture loaded or failed in since the last judgment. */
|
|
95
|
+
const loaded = new Set();
|
|
96
|
+
/**
|
|
97
|
+
* What a picture's arrival in a box is: `same` where the box is drawn as itself at the height the last run measured for it, `later` where it is not drawn as itself while some page is not built (it may stand on that page, and is judged again when the observer next reports), and `moved` otherwise, which is also every box the rim cannot compare.
|
|
98
|
+
*
|
|
99
|
+
* @remarks The height is the observer's last reading, within {@link TOLERANCE}. Where the observer has read nothing above zero (a box on a page the browser skips reads zero, and a box parked when it was observed is never reported until it is laid out), the box is asked its `offsetHeight`, which lays out that one box and rounds to the whole pixel, so the gate there is half a pixel wider.
|
|
100
|
+
*/
|
|
101
|
+
const verdict = (box) => {
|
|
102
|
+
if (!presented(box))
|
|
103
|
+
return unbuilt() ? 'later' : 'moved';
|
|
104
|
+
const was = measured(box);
|
|
105
|
+
if (was === undefined)
|
|
106
|
+
return 'moved';
|
|
107
|
+
const now = latest.get(box);
|
|
108
|
+
if (now)
|
|
109
|
+
return Math.abs(now - was) <= TOLERANCE ? 'same' : 'moved';
|
|
110
|
+
return box instanceof win.HTMLElement && Math.abs(box.offsetHeight - was) <= 0.5 + TOLERANCE ? 'same' : 'moved';
|
|
111
|
+
};
|
|
89
112
|
let judging = false;
|
|
90
113
|
const observer = new win.ResizeObserver((entries) => {
|
|
91
114
|
const t = performance.now();
|
|
@@ -102,6 +125,7 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
|
|
|
102
125
|
piece.expected = size;
|
|
103
126
|
continue;
|
|
104
127
|
}
|
|
128
|
+
latest.set(e.target, size);
|
|
105
129
|
// Zero is what a box no page renders reads: a parked one, and one slotted into a page whose content the browser skips (`content-visibility: auto` on a page not yet near the reader — measured 2026-10-21: a page shown by a run stopped at it reads zero for one frame). It is not news; a box that comes into view reads again.
|
|
106
130
|
if (size > 0 && presented(e.target))
|
|
107
131
|
heard.set(e.target, size);
|
|
@@ -110,7 +134,11 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
|
|
|
110
134
|
}
|
|
111
135
|
// The run keeps time (*the run keeps time*, 2026-09-25), and so does the ear: inert unless a host reads the measures.
|
|
112
136
|
performance.measure('cicero:size-heard', { start: t, end: performance.now(), detail: { entries: entries.length, kept: heard.size } });
|
|
113
|
-
|
|
137
|
+
judge();
|
|
138
|
+
});
|
|
139
|
+
/** Judge what was heard, sizes and pictures, once the runs have ended. */
|
|
140
|
+
function judge() {
|
|
141
|
+
if (judging || (heard.size === 0 && loaded.size === 0))
|
|
114
142
|
return;
|
|
115
143
|
judging = true;
|
|
116
144
|
// A frame to gather the batch; the runs' end; then the frame after it and the task after that frame, so the observer has reported what the last presentation left before anything is judged — and again while a run or an answer began in the meantime (the battery's parallel suite once heard a size beside an element and a face that the named signals were about to answer, 2026-10-22).
|
|
@@ -145,11 +173,24 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
|
|
|
145
173
|
moved.set(el, null);
|
|
146
174
|
}
|
|
147
175
|
heard.clear();
|
|
148
|
-
|
|
176
|
+
const pictures = loaded.size;
|
|
177
|
+
const late = [];
|
|
178
|
+
for (const box of loaded) {
|
|
179
|
+
const v = verdict(box);
|
|
180
|
+
if (v !== 'later')
|
|
181
|
+
loaded.delete(box);
|
|
182
|
+
if (v === 'moved')
|
|
183
|
+
late.push(box);
|
|
184
|
+
}
|
|
185
|
+
performance.measure('cicero:size-judged', { start: t, end: performance.now(), detail: { heard: n, moved: moved.size, pictures, late: late.length, later: loaded.size } });
|
|
186
|
+
for (const box of late)
|
|
187
|
+
moved.delete(box);
|
|
188
|
+
if (late.length)
|
|
189
|
+
signal('image', late);
|
|
149
190
|
if (moved.size)
|
|
150
191
|
signal('size', moved.keys(), moved);
|
|
151
192
|
});
|
|
152
|
-
}
|
|
193
|
+
}
|
|
153
194
|
/**
|
|
154
195
|
* @param [pageOf] - a size's: the page each box was heard on through a piece, or null where it was heard as itself.
|
|
155
196
|
*/
|
|
@@ -176,12 +217,14 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
|
|
|
176
217
|
held.delete(gone);
|
|
177
218
|
}
|
|
178
219
|
observer.observe(box, { box: 'border-box' });
|
|
179
|
-
// `load` and `error` do not bubble, so one capturing listener per box hears every picture inside it
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
// `load` and `error` do not bubble, so one capturing listener per box hears every picture inside it: the ones pending now, the intake waiting for none since 2026-11-08, and the ones whose `src` changes later, which a listener on the pending alone would miss. Heard, and judged with the sizes: only an arrival that changed the box is a signal.
|
|
221
|
+
const arrived = (e) => { if (!stopped && e.target.localName === 'img') {
|
|
222
|
+
loaded.add(box);
|
|
223
|
+
judge();
|
|
224
|
+
} };
|
|
225
|
+
box.addEventListener('load', arrived, true);
|
|
226
|
+
box.addEventListener('error', arrived, true);
|
|
227
|
+
held.set(box, () => { box.removeEventListener('load', arrived, true); box.removeEventListener('error', arrived, true); observer.unobserve(box); loaded.delete(box); });
|
|
185
228
|
for (const el of [box, ...box.querySelectorAll('*')]) {
|
|
186
229
|
const name = el.localName;
|
|
187
230
|
if (!name.includes('-') || win.customElements.get(name))
|
package/dist/src/capture.d.ts
CHANGED
|
@@ -108,12 +108,36 @@ export declare function sheetsUseHas(doc: Document): boolean;
|
|
|
108
108
|
export declare function sheetsCountInPseudos(doc: Document): boolean;
|
|
109
109
|
/** The arguments the host rewrote, read back from the registry rules (`:not(*) { --beltype-has-<h>: "<A>" }`, one per rewritten sheet): hash → the argument's canonical text. The capture reads the CSSOM and nothing else, so the arguments come from the rule the rewriter appended and no selector is ever parsed back. */
|
|
110
110
|
export declare function hasArgumentsOf(doc: Document): Map<string, string>;
|
|
111
|
+
/**
|
|
112
|
+
* The properties that paint and move no box, the one list the sheets' reach reads a rule's declarations against (ruled 2026-11-08): a style rule whose every declaration sets one of these is passed over by {@link sheetReach} and {@link topLevelReach} whatever its selector, because the reach asks whether an edit can move another box's facts, and a colour, a shadow or an opacity moves none.
|
|
113
|
+
*
|
|
114
|
+
* @remarks
|
|
115
|
+
* Closed and short on purpose. The CSSOM lists a rule's declarations as longhands, so `background` arrives as its `background-*` longhands and `border-color` as the four sides', and both families are taken by their prefix and suffix besides the names here. Anything else counts as layout, custom properties (`--x`) included, since one may feed a layout value through `var()`. Four were weighed and left out: `visibility`, whose `collapse` removes a table row or column from the layout; and `filter`, `backdrop-filter` and `will-change`, which make a containing block for a fixed or absolutely positioned descendant and so move it.
|
|
116
|
+
*/
|
|
117
|
+
export declare const PAINT_ONLY: ReadonlySet<string>;
|
|
118
|
+
/** The rule that set a reach and the sheet it stands in, which the memo's console notice names (ruled 2026-11-08): `selector` is a style rule's `selectorText`, `null` for an `@container` or `@scope` rule or a sheet the engine cannot read; `sheet` the sheet's `href`, or `<style> n` with its place among the document's sheets, counted from 1. */
|
|
119
|
+
export type ReachCulprit = {
|
|
120
|
+
selector: string | null;
|
|
121
|
+
sheet: string;
|
|
122
|
+
kind: 'style' | 'container' | 'scope' | 'unreadable';
|
|
123
|
+
};
|
|
124
|
+
/** A reach with the first rule that set it and how many more rules reached as far, {@link sheetReachOf}'s and {@link topLevelReachOf}'s answer. */
|
|
125
|
+
export type Reach = {
|
|
126
|
+
reach: 0 | 1 | 2;
|
|
127
|
+
culprit: ReachCulprit | null;
|
|
128
|
+
count: number;
|
|
129
|
+
};
|
|
111
130
|
/**
|
|
112
131
|
* How far a box's attributes can reach through the sheets the document wears, read off the selectors (*the forced-break index is kept across runs*, 2026-10-09) — what the forcing index's memo needs to know to say which of its answers an edit made stale, when the unit of replacement is `patchBox`'s, a box at any depth ({@link topLevelReach} reads the same sheets for a whole top-level box).
|
|
113
132
|
*
|
|
114
133
|
* 0: nothing but the box and what is below it; a descendant combinator or a child combinator reads a box's ancestors, which an edit does not change. 1: a sibling — `+`, `~` or `:nth-child(… of …)`, by which a box's attributes decide the style of the boxes after it (or beside it) and what they hold. 2: an ancestor or anything at all — `:has(`, an `@container` rule (a size the edit moves), or a sheet the engine cannot read. Blink's invalidation sets draw the same line and take the same fallback: where a selector defeats the reasoning, widen. The reading is textual and errs only toward more reach — a `+` in a selector's attribute value is read as a combinator — which costs speed and never a stale answer.
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, and an `@container` rule every rule under which paints answers nothing of itself; its nested rules are read as rules (ruled 2026-11-08).
|
|
115
137
|
*/
|
|
116
138
|
export declare function sheetReach(doc: Document): 0 | 1 | 2;
|
|
139
|
+
/** {@link sheetReach} with the first rule that set the reach and how many more reached as far, for the memo's console notice. */
|
|
140
|
+
export declare function sheetReachOf(doc: Document): Reach;
|
|
117
141
|
/**
|
|
118
142
|
* How far an edit's effect can travel through the sheets when the unit of replacement is a whole top-level box (*the forced-break index is kept across runs*, amended 2026-10-25): what {@link ForcingMemo.settle}, told these boxes, needs to say which top-level boxes' kept parts an edit made stale. Named for its unit, as `sheetReach` is the reading for `patchBox`'s, a box replaced at any depth.
|
|
119
143
|
*
|
|
@@ -124,12 +148,16 @@ export declare function sheetReach(doc: Document): 0 | 1 | 2;
|
|
|
124
148
|
*
|
|
125
149
|
* A nested rule is read as it matches: its `&` is its parent's selector in `:is()`, as CSS Nesting defines it. Conservative by construction, and these are the cases it does not read, each answered 2: a `:is()`, `:where()`, `:not()` or other wrapper whose argument holds a combinator and also a sibling combinator, a `:has(` or a structural pseudo-class, which takes in a nested rule whose parent's selector holds them (a wrapper with a bare `*` among its arguments constrains nothing and is passed over, which is how the engine's own `:has()` union reads); a `&` in a rule nested in no style rule; a selector whose quotes or brackets do not close; a relative selector at the top of a rule. The pseudo-classes `sheetReach` passes over (`:target`, `:checked`, `:dir()`) are passed over here too.
|
|
126
150
|
*
|
|
151
|
+
* A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, so a paint-only `p + p` or `body:has(.x) p` reaches nothing, and an `@container` or `@scope` rule every rule under which paints answers nothing of itself; the walk reads its nested rules as rules (ruled 2026-11-08).
|
|
152
|
+
*
|
|
127
153
|
* The unit's promise, which the host keeps: a top-level box whose name or attributes change is a new element, and the element it replaced is among `before`.
|
|
128
154
|
*
|
|
129
155
|
* @param boxes - the top-level boxes as they stand: the element children of their one parent, in order.
|
|
130
156
|
* @param before - the top-level boxes the memo was last settled with (`ForcingMemo`'s `boxes`), empty where it keeps nothing.
|
|
131
157
|
*/
|
|
132
158
|
export declare function topLevelReach(doc: Document, boxes: ArrayLike<Element>, before?: ArrayLike<Element>): 0 | 1 | 2;
|
|
159
|
+
/** {@link topLevelReach} with the first rule that set the reach, its sheet, and how many more rules reached as far, for the memo's console notice (ruled 2026-11-08). The number alone stays `topLevelReach`'s answer, which a view adapter imports. */
|
|
160
|
+
export declare function topLevelReachOf(doc: Document, boxes: ArrayLike<Element>, before?: ArrayLike<Element>): Reach;
|
|
133
161
|
/** What {@link topLevelReach} asks of the document while it reads a selector. */
|
|
134
162
|
export type ReachContext = {
|
|
135
163
|
/** Whether the selector up to a compound can match a top-level box, or the compound alone a box an edit took out. */
|
package/dist/src/capture.js
CHANGED
|
@@ -80,25 +80,106 @@ function someRule(doc, test) {
|
|
|
80
80
|
};
|
|
81
81
|
return styleSheetsOf(doc).some(inSheet);
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The properties that paint and move no box, the one list the sheets' reach reads a rule's declarations against (ruled 2026-11-08): a style rule whose every declaration sets one of these is passed over by {@link sheetReach} and {@link topLevelReach} whatever its selector, because the reach asks whether an edit can move another box's facts, and a colour, a shadow or an opacity moves none.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Closed and short on purpose. The CSSOM lists a rule's declarations as longhands, so `background` arrives as its `background-*` longhands and `border-color` as the four sides', and both families are taken by their prefix and suffix besides the names here. Anything else counts as layout, custom properties (`--x`) included, since one may feed a layout value through `var()`. Four were weighed and left out: `visibility`, whose `collapse` removes a table row or column from the layout; and `filter`, `backdrop-filter` and `will-change`, which make a containing block for a fixed or absolutely positioned descendant and so move it.
|
|
88
|
+
*/
|
|
89
|
+
export const PAINT_ONLY = new Set(['color', 'background', 'border-color', 'outline-color', 'text-decoration-color', 'text-emphasis-color', 'caret-color', 'accent-color', 'column-rule-color', 'opacity', 'mix-blend-mode', 'box-shadow', 'text-shadow', 'cursor', 'pointer-events', 'user-select', 'forced-color-adjust', 'color-scheme', 'print-color-adjust']);
|
|
90
|
+
const paints = (name) => PAINT_ONLY.has(name) || name.startsWith('background-') || (name.startsWith('border-') && name.endsWith('-color'));
|
|
91
|
+
/** Whether a rule's own declarations, read through `rule.style`, all paint: a rule with none does too. A rule with no `style` is not a style rule and answers no. */
|
|
92
|
+
function paintOnly(rule) {
|
|
93
|
+
const style = rule.style;
|
|
94
|
+
if (!style || typeof style.length !== 'number')
|
|
95
|
+
return false;
|
|
96
|
+
for (let i = 0; i < style.length; i++)
|
|
97
|
+
if (!paints(style.item(i)))
|
|
98
|
+
return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/** Whether every rule under a grouping rule paints, nested rules and grouping rules within it included; a rule that is neither a style rule nor a grouping rule answers no. */
|
|
102
|
+
function allPaint(rules) {
|
|
103
|
+
for (const rule of Array.from((rules ?? []))) {
|
|
104
|
+
if (rule.style ? !paintOnly(rule) : !rule.cssRules)
|
|
105
|
+
return false;
|
|
106
|
+
if (rule.cssRules && !allPaint(rule.cssRules))
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/** Every rule of every sheet the document wears, with its sheet's name, walked as {@link someRule} walks them but to the end; a sheet the engine cannot read is handed to `unread`. */
|
|
112
|
+
function eachRule(doc, visit, unread) {
|
|
113
|
+
const inRules = (rules, sheet) => {
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
visit(rule, sheet);
|
|
116
|
+
if (rule.cssRules)
|
|
117
|
+
inRules(rule.cssRules, sheet);
|
|
118
|
+
if (rule.styleSheet)
|
|
119
|
+
inSheet(rule.styleSheet, sheet);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const inSheet = (sheet, name) => {
|
|
123
|
+
const own = sheet.href ?? name;
|
|
124
|
+
let rules;
|
|
125
|
+
try {
|
|
126
|
+
rules = sheet.cssRules;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
unread(own);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
inRules(rules, own);
|
|
133
|
+
};
|
|
134
|
+
styleSheetsOf(doc).forEach((sheet, i) => inSheet(sheet, `<style> ${i + 1}`));
|
|
135
|
+
}
|
|
136
|
+
/** A tally of what reached how far: the highest level, the first rule at it, and the rules after it at the same level. */
|
|
137
|
+
function reachTally() {
|
|
138
|
+
const found = { reach: 0, culprit: null, count: 0 };
|
|
139
|
+
const add = (reach, culprit) => {
|
|
140
|
+
if (reach > found.reach)
|
|
141
|
+
Object.assign(found, { reach, culprit, count: 0 });
|
|
142
|
+
else if (reach > 0 && reach === found.reach)
|
|
143
|
+
found.count++;
|
|
144
|
+
};
|
|
145
|
+
return { found, add };
|
|
146
|
+
}
|
|
147
|
+
/** A grouping rule's kind where it is one the reach answers 2 for unless every rule under it paints, else `null`. */
|
|
148
|
+
const queried = (rule) => {
|
|
149
|
+
const kind = rule.constructor?.name;
|
|
150
|
+
return kind === 'CSSContainerRule' ? 'container' : kind === 'CSSScopeRule' ? 'scope' : null;
|
|
151
|
+
};
|
|
83
152
|
/**
|
|
84
153
|
* How far a box's attributes can reach through the sheets the document wears, read off the selectors (*the forced-break index is kept across runs*, 2026-10-09) — what the forcing index's memo needs to know to say which of its answers an edit made stale, when the unit of replacement is `patchBox`'s, a box at any depth ({@link topLevelReach} reads the same sheets for a whole top-level box).
|
|
85
154
|
*
|
|
86
155
|
* 0: nothing but the box and what is below it; a descendant combinator or a child combinator reads a box's ancestors, which an edit does not change. 1: a sibling — `+`, `~` or `:nth-child(… of …)`, by which a box's attributes decide the style of the boxes after it (or beside it) and what they hold. 2: an ancestor or anything at all — `:has(`, an `@container` rule (a size the edit moves), or a sheet the engine cannot read. Blink's invalidation sets draw the same line and take the same fallback: where a selector defeats the reasoning, widen. The reading is textual and errs only toward more reach — a `+` in a selector's attribute value is read as a combinator — which costs speed and never a stale answer.
|
|
156
|
+
*
|
|
157
|
+
* @remarks
|
|
158
|
+
* A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, and an `@container` rule every rule under which paints answers nothing of itself; its nested rules are read as rules (ruled 2026-11-08).
|
|
87
159
|
*/
|
|
88
160
|
export function sheetReach(doc) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
161
|
+
return sheetReachOf(doc).reach;
|
|
162
|
+
}
|
|
163
|
+
/** {@link sheetReach} with the first rule that set the reach and how many more reached as far, for the memo's console notice. */
|
|
164
|
+
export function sheetReachOf(doc) {
|
|
165
|
+
const { found, add } = reachTally();
|
|
166
|
+
eachRule(doc, (rule, sheet) => {
|
|
167
|
+
if (rule.constructor?.name === 'CSSContainerRule') {
|
|
168
|
+
if (!allPaint(rule.cssRules))
|
|
169
|
+
add(2, { selector: null, sheet, kind: 'container' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
93
172
|
const text = rule.selectorText;
|
|
94
|
-
if (!text)
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
173
|
+
if (!text || paintOnly(rule))
|
|
174
|
+
return;
|
|
175
|
+
const culprit = { selector: text, sheet, kind: 'style' };
|
|
176
|
+
if (text.includes(':has('))
|
|
177
|
+
return add(2, culprit);
|
|
178
|
+
// Parenthesised arguments and attribute selectors out of the way first: `:nth-child(2n+1)` and `[lang~=en]` are not combinators, and `:nth-child(2 of .x)` is the one `nth` form that looks sideways.
|
|
179
|
+
if (/:nth-[\w-]+\([^)]*\sof\s/.test(text) || /[+~]/.test(text.replace(/\[[^\]]*\]/g, '').replace(/:nth-[\w-]+\([^)]*\)/g, '')))
|
|
180
|
+
add(1, culprit);
|
|
181
|
+
}, (sheet) => add(2, { selector: null, sheet, kind: 'unreadable' }));
|
|
182
|
+
return found;
|
|
102
183
|
}
|
|
103
184
|
/**
|
|
104
185
|
* How far an edit's effect can travel through the sheets when the unit of replacement is a whole top-level box (*the forced-break index is kept across runs*, amended 2026-10-25): what {@link ForcingMemo.settle}, told these boxes, needs to say which top-level boxes' kept parts an edit made stale. Named for its unit, as `sheetReach` is the reading for `patchBox`'s, a box replaced at any depth.
|
|
@@ -110,12 +191,18 @@ export function sheetReach(doc) {
|
|
|
110
191
|
*
|
|
111
192
|
* A nested rule is read as it matches: its `&` is its parent's selector in `:is()`, as CSS Nesting defines it. Conservative by construction, and these are the cases it does not read, each answered 2: a `:is()`, `:where()`, `:not()` or other wrapper whose argument holds a combinator and also a sibling combinator, a `:has(` or a structural pseudo-class, which takes in a nested rule whose parent's selector holds them (a wrapper with a bare `*` among its arguments constrains nothing and is passed over, which is how the engine's own `:has()` union reads); a `&` in a rule nested in no style rule; a selector whose quotes or brackets do not close; a relative selector at the top of a rule. The pseudo-classes `sheetReach` passes over (`:target`, `:checked`, `:dir()`) are passed over here too.
|
|
112
193
|
*
|
|
194
|
+
* A rule whose declarations all paint ({@link PAINT_ONLY}) is passed over before its selector is read, so a paint-only `p + p` or `body:has(.x) p` reaches nothing, and an `@container` or `@scope` rule every rule under which paints answers nothing of itself; the walk reads its nested rules as rules (ruled 2026-11-08).
|
|
195
|
+
*
|
|
113
196
|
* The unit's promise, which the host keeps: a top-level box whose name or attributes change is a new element, and the element it replaced is among `before`.
|
|
114
197
|
*
|
|
115
198
|
* @param boxes - the top-level boxes as they stand: the element children of their one parent, in order.
|
|
116
199
|
* @param before - the top-level boxes the memo was last settled with (`ForcingMemo`'s `boxes`), empty where it keeps nothing.
|
|
117
200
|
*/
|
|
118
201
|
export function topLevelReach(doc, boxes, before = []) {
|
|
202
|
+
return topLevelReachOf(doc, boxes, before).reach;
|
|
203
|
+
}
|
|
204
|
+
/** {@link topLevelReach} with the first rule that set the reach, its sheet, and how many more rules reached as far, for the memo's console notice (ruled 2026-11-08). The number alone stays `topLevelReach`'s answer, which a view adapter imports. */
|
|
205
|
+
export function topLevelReachOf(doc, boxes, before = []) {
|
|
119
206
|
const now = Array.from(boxes);
|
|
120
207
|
const was = Array.from(before);
|
|
121
208
|
const here = new Set(now);
|
|
@@ -143,22 +230,24 @@ export function topLevelReach(doc, boxes, before = []) {
|
|
|
143
230
|
shaped,
|
|
144
231
|
changed
|
|
145
232
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const kind = rule
|
|
149
|
-
if (kind
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
233
|
+
const { found, add } = reachTally();
|
|
234
|
+
eachRule(doc, (rule, sheet) => {
|
|
235
|
+
const kind = queried(rule);
|
|
236
|
+
if (kind) {
|
|
237
|
+
if (!allPaint(rule.cssRules))
|
|
238
|
+
add(2, { selector: null, sheet, kind });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!rule.selectorText || rule.constructor?.name === 'CSSPageRule' || paintOnly(rule))
|
|
242
|
+
return;
|
|
153
243
|
const text = resolvedSelector(rule);
|
|
154
244
|
if (text != null && !CROSSING.test(text))
|
|
155
|
-
return
|
|
245
|
+
return;
|
|
156
246
|
const r = text == null ? 2 : selectorListReach(text, ctx);
|
|
157
|
-
if (r
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return unread ? 2 : reach;
|
|
247
|
+
if (r)
|
|
248
|
+
add(r, { selector: rule.selectorText, sheet, kind: 'style' });
|
|
249
|
+
}, (sheet) => add(2, { selector: null, sheet, kind: 'unreadable' }));
|
|
250
|
+
return found;
|
|
162
251
|
}
|
|
163
252
|
/** A style rule's selector as it matches: a rule nested in a style rule (through any grouping rules between them) has each `&` read as the parent's selector in `:is()`, and an item with no `&` the parent's as its ancestor, which is CSS Nesting's own definition, resolved up the chain; `null` for a `&` in a rule nested in no style rule, or text that does not close. */
|
|
164
253
|
function resolvedSelector(rule) {
|
|
@@ -843,29 +932,16 @@ function compareSpecificity(a, b) {
|
|
|
843
932
|
return a[i] - b[i];
|
|
844
933
|
return 0;
|
|
845
934
|
}
|
|
846
|
-
/** Margin-box declarations out of a CSSPageRule — via child CSSMarginRule objects
|
|
935
|
+
/** Margin-box declarations out of a CSSPageRule — via the child CSSMarginRule objects (the pinned Chromium has them; `margin-box-padding.browser.ts` asserts it). Returns { 'bottom-center': rawContentValue, ... }. */
|
|
847
936
|
function marginBoxesOf(rule) {
|
|
848
937
|
const out = {};
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
out[name] = content.trim();
|
|
856
|
-
}
|
|
938
|
+
for (const child of (rule.cssRules ?? [])) {
|
|
939
|
+
const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
|
|
940
|
+
if (MARGIN_BOXES.includes(name)) {
|
|
941
|
+
const content = child.style?.getPropertyValue('content');
|
|
942
|
+
if (content)
|
|
943
|
+
out[name] = content.trim();
|
|
857
944
|
}
|
|
858
|
-
if (Object.keys(out).length)
|
|
859
|
-
return out;
|
|
860
|
-
}
|
|
861
|
-
const re = /@([a-z-]+)\s*\{([^}]*)\}/g;
|
|
862
|
-
let m;
|
|
863
|
-
while ((m = re.exec(rule.cssText))) {
|
|
864
|
-
if (!MARGIN_BOXES.includes(m[1]))
|
|
865
|
-
continue;
|
|
866
|
-
const c = /content\s*:\s*([^;}]+)/.exec(m[2]);
|
|
867
|
-
if (c)
|
|
868
|
-
out[m[1]] = c[1].trim();
|
|
869
945
|
}
|
|
870
946
|
return out;
|
|
871
947
|
}
|
|
@@ -52,6 +52,10 @@ export type CompositionRecord = {
|
|
|
52
52
|
* It used to ask only the children's display, so a `<pre>`, an SVG's `<text>` and an inline `<code>` were paragraphs. The children's display is asked through `word-space.js`' `isInlineBox`, the procession's one predicate (*the outer display decides*, 2026-09-29): it used to be a second copy of the `inline` prefix test, so a paragraph holding an inline formula, whose `<math>` computes `math`, was refused here while the procession, once fixed, divided it. Every refusal here is silent: a refused block keeps the browser's lines, which are the only lines it has.
|
|
53
53
|
*/
|
|
54
54
|
export declare function isComposable(el: Element): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* A sixth clause: a block in a multi-column container, or itself one, keeps the browser's lines. The Compositor has one measure per block and reads it from the block's own box, not from a column's, so a `columns: 3` box 210px wide was set at the container's measure, the longest line 356px against a column of 70. Refused like a block beside a float, with a console line (`refusedInColumns`); the real fix is a per-column measure and the fragmentation of a multi-column box, which is not this engine's yet.
|
|
57
|
+
*/
|
|
58
|
+
export declare function inColumns(el: Element): boolean;
|
|
55
59
|
/**
|
|
56
60
|
* Words and author-break runs of an element, with DOM points. A word is a maximal non-whitespace run of text not crossing a <br>; nested inlines merge (parts). Returns { words: [{ text, parts: [{node, start, end}] }], runs: [{ start, end }] } where runs index into words, end exclusive.
|
|
57
61
|
*
|
|
@@ -74,6 +78,10 @@ export declare function collectWords(el: Element): {
|
|
|
74
78
|
export declare function hyphenCharOf(style: {
|
|
75
79
|
hyphenateCharacter?: string;
|
|
76
80
|
}): string;
|
|
81
|
+
/**
|
|
82
|
+
* css-text-4 §6.3.5's `hyphenate-limit-lines`, read from the custom property the host rewrote it into (Chromium drops the property itself): `no-limit` is `null`, an integer is the limit, and an absent or unreadable value is the engine's own {@link HYPHENATE_LIMIT_LINES}.
|
|
83
|
+
*/
|
|
84
|
+
export declare function limitLinesOf(style: CSSStyleDeclaration): number | null;
|
|
77
85
|
/** Two border-box rects overlapping in both axes, sub-pixel noise excluded. The geometry half of the beside-a-float refusal below, exported so it is checkable without a browser; what it cannot say is which boxes were handed to it, which is the case runner's job. */
|
|
78
86
|
export declare function overlaps(a: {
|
|
79
87
|
top: number;
|
package/dist/src/compose-dom.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
import { compose } from './compositor.js';
|
|
12
|
+
import { compose, HYPHENATE_LIMIT_LINES } from './compositor.js';
|
|
13
|
+
import { HYPHENATE_LIMIT_LINES_PROP } from './gcpm-encoding.js';
|
|
13
14
|
import { addWord, AWAY, BREAK, FILL, hasWord } from './data-beltype.js';
|
|
14
15
|
import { createMeasureContext } from './measure.js';
|
|
15
16
|
import { indexBlocks, pageIntervals, presentationWidths } from './positions.js';
|
|
@@ -47,10 +48,24 @@ export function isComposable(el) {
|
|
|
47
48
|
for (let a = el.parentElement; a; a = a.parentElement)
|
|
48
49
|
if (isAtom(a))
|
|
49
50
|
return false;
|
|
51
|
+
if (inColumns(el))
|
|
52
|
+
return false;
|
|
50
53
|
if (holdsGeneratedText(el, win))
|
|
51
54
|
return false;
|
|
52
55
|
return collectWords(el).words.some((w) => !w.atom);
|
|
53
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A sixth clause: a block in a multi-column container, or itself one, keeps the browser's lines. The Compositor has one measure per block and reads it from the block's own box, not from a column's, so a `columns: 3` box 210px wide was set at the container's measure, the longest line 356px against a column of 70. Refused like a block beside a float, with a console line (`refusedInColumns`); the real fix is a per-column measure and the fragmentation of a multi-column box, which is not this engine's yet.
|
|
59
|
+
*/
|
|
60
|
+
export function inColumns(el) {
|
|
61
|
+
const win = el.ownerDocument.defaultView;
|
|
62
|
+
for (let a = el; a; a = a.parentElement) {
|
|
63
|
+
const cs = win.getComputedStyle(a);
|
|
64
|
+
if (cs.columnCount !== 'auto' || cs.columnWidth !== 'auto')
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
54
69
|
/**
|
|
55
70
|
* Does this pseudo-element put a box in its element's lines? Its `content` must generate one — `none` and `normal` do not — and its outer display must be inline: a `display: block` `::after` is a block of its own after the lines, not text in them.
|
|
56
71
|
*
|
|
@@ -176,6 +191,16 @@ export function hyphenCharOf(style) {
|
|
|
176
191
|
return raw;
|
|
177
192
|
return raw.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
178
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* css-text-4 §6.3.5's `hyphenate-limit-lines`, read from the custom property the host rewrote it into (Chromium drops the property itself): `no-limit` is `null`, an integer is the limit, and an absent or unreadable value is the engine's own {@link HYPHENATE_LIMIT_LINES}.
|
|
196
|
+
*/
|
|
197
|
+
export function limitLinesOf(style) {
|
|
198
|
+
const raw = style.getPropertyValue(HYPHENATE_LIMIT_LINES_PROP).trim();
|
|
199
|
+
if (raw === 'no-limit')
|
|
200
|
+
return null;
|
|
201
|
+
const n = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
|
|
202
|
+
return Number.isFinite(n) && n >= 1 ? n : HYPHENATE_LIMIT_LINES;
|
|
203
|
+
}
|
|
179
204
|
/**
|
|
180
205
|
* css-text-4 §6.3.4's `hyphenate-limit-chars` as three values, `null` for each `auto`.
|
|
181
206
|
*
|
|
@@ -367,16 +392,27 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
367
392
|
}
|
|
368
393
|
// A float's own descendants are laid out in the float's own formatting context at the float's own width, which is exactly what their rect reports — nothing is refused there. What is refused is a block the float reaches into: a sibling it stands beside, or an ancestor whose line boxes it is nested in.
|
|
369
394
|
const intrudedByFloat = (clone, rect) => floats.some((f) => !f.el.contains(clone) && overlaps(rect, f.rect));
|
|
395
|
+
/** The block's content box: a float wholly in the padding or the border (a hanging number) shortens none of its line boxes, so it is the content box the float's rect is compared with. */
|
|
396
|
+
const contentBoxOf = (cs, rect) => {
|
|
397
|
+
const left = rect.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft);
|
|
398
|
+
const top = rect.top + parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop);
|
|
399
|
+
return { left, top, right: rect.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight), bottom: rect.bottom - parseFloat(cs.borderBottomWidth) - parseFloat(cs.paddingBottom) };
|
|
400
|
+
};
|
|
370
401
|
const refusedBesideFloat = [];
|
|
371
402
|
/** A second refusal, of the same kind and for the same reason (*a line inside a word is a break point*, 2026-09-23). The Compositor's break opportunities are spaces, hyphenation points and css-text-4 §6.4's emergency break; a block whose text the browser may break anywhere else — `word-break: break-all`, `line-break: anywhere`, or a script written without spaces (`word-space.js`' `UNSPACED_SCRIPT`) — reaches it as words it cannot divide. Measured on `packages/essay/cases/63-the-line-inside-a-word`: a thirty-line `break-all` paragraph was one word to the DP, set on one nowrap line, and twenty-one of its thirty lines were on no page. So such a block keeps the browser's lines, and the console says so; the real fix is the Compositor learning UAX #14's opportunities, which is a later round. */
|
|
372
403
|
const refusedUnspaced = [];
|
|
404
|
+
/** A third refusal: a block in a multi-column container (`inColumns`). */
|
|
405
|
+
const refusedInColumns = [];
|
|
373
406
|
const jobs = [];
|
|
374
407
|
for (const [clone, source] of cloneToSource) {
|
|
375
|
-
if (!isComposable(clone))
|
|
408
|
+
if (!isComposable(clone)) {
|
|
409
|
+
if (inColumns(clone) && PARAGRAPH_DISPLAY.has(win.getComputedStyle(clone).display))
|
|
410
|
+
refusedInColumns.push(source);
|
|
376
411
|
continue;
|
|
412
|
+
}
|
|
377
413
|
const cs = win.getComputedStyle(clone);
|
|
378
414
|
const rect = clone.getBoundingClientRect();
|
|
379
|
-
if (floats.length && intrudedByFloat(clone, rect)) {
|
|
415
|
+
if (floats.length && intrudedByFloat(clone, contentBoxOf(cs, rect))) {
|
|
380
416
|
refusedBesideFloat.push(source);
|
|
381
417
|
continue;
|
|
382
418
|
}
|
|
@@ -393,12 +429,13 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
393
429
|
const indent = parseFloat(sourceStyle.textIndent) || 0;
|
|
394
430
|
const hyphenChar = hyphenCharOf(sourceStyle);
|
|
395
431
|
const limits = limitCharsOf(sourceStyle);
|
|
432
|
+
const limitLines = limitLinesOf(sourceStyle);
|
|
396
433
|
const breakAnywhere = ['anywhere', 'break-word'].includes(sourceStyle.overflowWrap);
|
|
397
434
|
const paragraph = paragraphKey({
|
|
398
435
|
path: pathToSource.get(source),
|
|
399
436
|
markup: shapeOf(source.outerHTML),
|
|
400
437
|
width: key,
|
|
401
|
-
inputs: [align, indent, hyphensMode, langAttr ?? null, hyphenChar, limits.total, limits.before, limits.after, breakAnywhere, sourceStyle.wordBreak, sourceStyle.lineBreak, ...WIDTH_INPUTS.map((name) => sourceStyle[name]), ancestryOf(source)]
|
|
438
|
+
inputs: [align, indent, hyphensMode, langAttr ?? null, hyphenChar, limits.total, limits.before, limits.after, limitLines, breakAnywhere, sourceStyle.wordBreak, sourceStyle.lineBreak, ...WIDTH_INPUTS.map((name) => sourceStyle[name]), ancestryOf(source)]
|
|
402
439
|
});
|
|
403
440
|
const hit = lookup(cache, paragraph);
|
|
404
441
|
if (hit) {
|
|
@@ -420,6 +457,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
420
457
|
// The three css-text-4 properties the line already had and this module was not reading (2026-09-07). All three are inherited, so the block's own computed style is the whole answer.
|
|
421
458
|
hyphenChar,
|
|
422
459
|
limits,
|
|
460
|
+
limitLines,
|
|
423
461
|
breakAnywhere
|
|
424
462
|
});
|
|
425
463
|
}
|
|
@@ -432,8 +470,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
432
470
|
c.style.textIndent = '0';
|
|
433
471
|
return c;
|
|
434
472
|
});
|
|
473
|
+
// A nested paragraph is measured inside shallow copies of its ancestors up to its top-level block, or it loses what it inherits from them and what a descendant selector reads: a pull quote's `blockquote { font: italic 22px serif }` measured at the measure container's 16px set lines 22% too wide for the box (`composed-pull-quote-overrun`). ponytail: shallow copies carry attributes only, so a sibling selector (`p + p`, `:first-child`) still reads the lone copy; clone the earlier siblings too if one is ever measured wrong.
|
|
474
|
+
const measureRoots = jobs.map((j, i) => {
|
|
475
|
+
let top = nowrapClones[i];
|
|
476
|
+
for (let cur = j.source; !tops.has(cur) && cur.parentElement; cur = cur.parentElement) {
|
|
477
|
+
const wrap = cur.parentElement.cloneNode(false);
|
|
478
|
+
wrap.appendChild(top);
|
|
479
|
+
top = wrap;
|
|
480
|
+
}
|
|
481
|
+
return top;
|
|
482
|
+
});
|
|
435
483
|
const probes = jobs.map((job, i) => seatHyphenProbe(nowrapClones[i], job.hyphenChar));
|
|
436
|
-
ctx.measure(
|
|
484
|
+
ctx.measure(measureRoots);
|
|
437
485
|
// One pass reading, one pass removing: the removal is the last write, so the layout it forces is the last one the composition pays for. `collectWords` runs after it, or a probe's character would be a word.
|
|
438
486
|
const hyphenWidths = probes.map(probeWidth);
|
|
439
487
|
for (const probe of probes)
|
|
@@ -462,7 +510,8 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
462
510
|
available: job.avail,
|
|
463
511
|
indent: r === 0 ? job.indent : 0,
|
|
464
512
|
align: job.align,
|
|
465
|
-
breakAnywhere: job.breakAnywhere
|
|
513
|
+
breakAnywhere: job.breakAnywhere,
|
|
514
|
+
limitLines: job.limitLines
|
|
466
515
|
});
|
|
467
516
|
record.runs.push({ start: runs[r].start, ...result });
|
|
468
517
|
record.lineCount += result.lineCount;
|
|
@@ -471,7 +520,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
471
520
|
resolved.set(job.source, record);
|
|
472
521
|
});
|
|
473
522
|
}
|
|
474
|
-
return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced };
|
|
523
|
+
return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced, refusedInColumns };
|
|
475
524
|
};
|
|
476
525
|
let todo = blocks.map((_, i) => i).filter((i) => !(trustMemo && memos?.[i]));
|
|
477
526
|
let laid = lay(todo);
|
|
@@ -509,13 +558,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
509
558
|
tally.blocks.set(memoKeys[i], { records, floaty: laid.floaty, refused: laid.refusedUnspaced.filter((src) => el.contains(src)).length });
|
|
510
559
|
}
|
|
511
560
|
}
|
|
512
|
-
const { refusedBesideFloat, refusedUnspaced } = laid;
|
|
561
|
+
const { refusedBesideFloat, refusedUnspaced, refusedInColumns } = laid;
|
|
513
562
|
// Never in silence — the render-conditions rule, applied to a decision the engine declines to take rather than to one it takes under a relaxation.
|
|
514
563
|
if (refusedBesideFloat.length) {
|
|
515
564
|
win.console?.warn(`BelType/Cicero: ${refusedBesideFloat.length} block(s) sit beside a float and are NOT composed by this engine — ` +
|
|
516
565
|
'their lines are the browser\'s, because a float shortens line boxes and the Compositor has one measure per block, not one per line. ' +
|
|
517
566
|
'The pages are honest; the line breaking in those blocks is not this engine\'s.');
|
|
518
567
|
}
|
|
568
|
+
if (refusedInColumns.length) {
|
|
569
|
+
win.console?.warn(`BelType/Cicero: ${refusedInColumns.length} block(s) sit in a multi-column container and are NOT composed by this engine — ` +
|
|
570
|
+
'their lines are the browser\'s, because the Compositor has one measure per block, not one per column. ' +
|
|
571
|
+
'The pages are honest; the line breaking in those blocks is not this engine\'s.');
|
|
572
|
+
}
|
|
519
573
|
const refusedCount = refusedUnspaced.length + refusedFromMemo;
|
|
520
574
|
if (refusedCount) {
|
|
521
575
|
win.console?.warn(`BelType/Cicero: ${refusedCount} block(s) may break lines inside a word — word-break: break-all, line-break: anywhere, or a script written without spaces — and are NOT composed by this engine; ` +
|