@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
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) {
|
package/dist/src/chase.js
CHANGED
|
@@ -312,9 +312,11 @@ export class BeltypePage extends HTMLElement {
|
|
|
312
312
|
const keys = names.map((n, i) => `${this.#dressText} ${n} ${generated[i]} ${(this.#reads[n] ?? []).map((r) => `${r}=${this.#published.get(r) ?? ''}`).join(',')} ${cellKey(cells[i])}`);
|
|
313
313
|
let pairs = keys.map((k) => INTRINSICS.get(k));
|
|
314
314
|
if (pairs.some((p) => !p)) {
|
|
315
|
+
// A rect is drawn under every transform above the page, and a host that zooms with `transform: scale(s)` would hand §5.3.2 widths in its pixels, not the page's, and the cache would keep them for every scale after (assessment 2026-11-07). The band spans exactly `#bandWidth` of the page's own pixels, so its rect says the scale.
|
|
316
|
+
const scale = el.getBoundingClientRect().width / this.#bandWidth || 1;
|
|
315
317
|
const read = (track) => {
|
|
316
318
|
el.style.gridTemplateColumns = `${track} ${track} ${track}`;
|
|
317
|
-
return cells.map((c) => c.getBoundingClientRect().width);
|
|
319
|
+
return cells.map((c) => c.getBoundingClientRect().width / scale);
|
|
318
320
|
};
|
|
319
321
|
const max = read('max-content');
|
|
320
322
|
const min = read('min-content');
|
package/dist/src/compose-dom.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
import { compose } from './compositor.js';
|
|
12
|
+
import { compose, HYPHENATE_LIMIT_LINES } from './compositor.js';
|
|
13
13
|
import { addWord, AWAY, BREAK, FILL, hasWord } from './data-beltype.js';
|
|
14
14
|
import { createMeasureContext } from './measure.js';
|
|
15
15
|
import { indexBlocks, pageIntervals, presentationWidths } from './positions.js';
|
|
@@ -462,7 +462,8 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
|
|
|
462
462
|
available: job.avail,
|
|
463
463
|
indent: r === 0 ? job.indent : 0,
|
|
464
464
|
align: job.align,
|
|
465
|
-
breakAnywhere: job.breakAnywhere
|
|
465
|
+
breakAnywhere: job.breakAnywhere,
|
|
466
|
+
limitLines: HYPHENATE_LIMIT_LINES
|
|
466
467
|
});
|
|
467
468
|
record.runs.push({ start: runs[r].start, ...result });
|
|
468
469
|
record.lineCount += result.lineCount;
|
package/dist/src/compositor.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Compositor — whole-paragraph line breaking (tablet, The Line; architecture, The Compositor; second-coding-round.md item 1). One of the system's two deciders; this module is the decision alone: DOM-free, widths through an injected provider (the whole-line Range policy lives in the provider, built browser-side by compose-dom.js), output is a Hints value the feed writer materializes.
|
|
3
3
|
*
|
|
4
|
-
* Knuth–Plass-shaped total-fit dynamic programming over break candidates: word boundaries plus hyphenation offsets (Liang's, or an author's soft hyphens — the caller supplies them; this module does not know where they came from). Badness is slack against the measure under the alignment's stretch model, and there are two of those (see `STRETCH` below) — aesthetic error only, never geometric: a chosen line fits because its measured width is the oracle's own. Demerits add hyphen, consecutive-hyphen, and last-line-orphan penalties. Ties break toward earlier breaks (candidates priced ascending, replaced only on strictly lower total) — a total order, no randomness, so the determinism that *cut-invariance* (C7) relies on holds by construction.
|
|
4
|
+
* Knuth–Plass-shaped total-fit dynamic programming over break candidates: word boundaries plus hyphenation offsets (Liang's, or an author's soft hyphens — the caller supplies them; this module does not know where they came from). Badness is slack against the measure under the alignment's stretch model, and there are two of those (see `STRETCH` below) — aesthetic error only, never geometric: a chosen line fits because its measured width is the oracle's own. Demerits add hyphen, consecutive-hyphen, and last-line-orphan penalties, and a run of hyphenated line ends longer than `limitLines` is no candidate at all. Ties break toward earlier breaks (candidates priced ascending, replaced only on strictly lower total) — a total order, no randomness, so the determinism that *cut-invariance* (C7) relies on holds by construction.
|
|
5
5
|
*
|
|
6
6
|
* Each node reads only its feasible window (*the composition lays out once*, 2026-09-27): its predecessors are scanned downward and the scan stops at the first overfull span, on the premise that for a fixed end a span starting earlier is never narrower. What was read is then priced in ascending order, so the choice and its tie-breaking are the ones pricing every predecessor would make. On BelType's own manual it took the composition's Range reads from 6.2 to 4.8 million.
|
|
7
7
|
*
|
|
@@ -46,6 +46,13 @@ export type Hints = {
|
|
|
46
46
|
/** The justify filler, serialized as a literal: the last line's slack. */
|
|
47
47
|
fillerPx: number;
|
|
48
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* The most lines in a row the composed mode ends on a hyphen (Codeberg issue 5) — Bringhurst's rule, *avoid more than three consecutive hyphenated lines* (The Elements of Typographic Style, section 2.4), and the value css-text-4 §6.3.5's `hyphenate-limit-lines` would carry, which Chromium does not support and so never reaches computed style.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* A judgement with a lineage, as `RAG_TOLERANCE_EM` is. The consecutive penalty alone did not hold it: measured 2026-11-07 on the delibera specimen, its first paragraph ended seven lines in a row on a hyphen at 3000, six at 10000 (TeX's `\doublehyphendemerits`), three at 30000. Under the limit its longest run is three and its hyphens 10 for 13, the interword space averaging 1.356 of the natural space for 1.333, its spread 0.247 for 0.250, its widest unchanged.
|
|
54
|
+
*/
|
|
55
|
+
export declare const HYPHENATE_LIMIT_LINES = 3;
|
|
49
56
|
/**
|
|
50
57
|
* Compose one run: choose its line breaks by total fit.
|
|
51
58
|
*
|
|
@@ -54,10 +61,11 @@ export type Hints = {
|
|
|
54
61
|
* @param p.measure - the width provider.
|
|
55
62
|
* @param p.available - the line measure in px.
|
|
56
63
|
* @param p.indent - first-line text-indent in px — debits line 1's measure.
|
|
64
|
+
* @param p.limitLines - css-text-4 §6.3.5's `hyphenate-limit-lines`: the most consecutive lines that may end on a hyphen, `null` for `no-limit`. A limit extends the DP's state to (node, the run of hyphenated line ends behind it), so the limit is a constraint and not a price; with none the state is the node alone and the composition is the one it always was. A paragraph the limit leaves no way through but an overfull line is composed without it.
|
|
57
65
|
* @param p.breakAnywhere - css-text-4 §6.4's `overflow-wrap: break-word | anywhere`, which lets *"an otherwise unbreakable sequence of characters be broken at an arbitrary point if there are no otherwise-acceptable break points in the line"*, and says outright that **no hyphenation character is inserted**. Such a break is a node like any other, marked `glyph: false`, so it costs no hyphen advance and carries no hyphen demerit.
|
|
58
66
|
* @throws Error — bare and deliberate (`src/` may not import BelType's error types): ragged alignment with no usable `measure.emWidth`, a refusal rather than a NaN.
|
|
59
67
|
*/
|
|
60
|
-
export declare function compose({ words, hyphenOffsets, measure, available, indent, align, breakAnywhere }: {
|
|
68
|
+
export declare function compose({ words, hyphenOffsets, measure, available, indent, align, breakAnywhere, limitLines }: {
|
|
61
69
|
words: string[];
|
|
62
70
|
hyphenOffsets?: (wordIndex: number) => number[];
|
|
63
71
|
measure: Measure;
|
|
@@ -65,4 +73,5 @@ export declare function compose({ words, hyphenOffsets, measure, available, inde
|
|
|
65
73
|
indent?: number;
|
|
66
74
|
align?: 'left' | 'right' | 'center' | 'justify';
|
|
67
75
|
breakAnywhere?: boolean;
|
|
76
|
+
limitLines?: number | null;
|
|
68
77
|
}): Hints;
|
package/dist/src/compositor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Compositor — whole-paragraph line breaking (tablet, The Line; architecture, The Compositor; second-coding-round.md item 1). One of the system's two deciders; this module is the decision alone: DOM-free, widths through an injected provider (the whole-line Range policy lives in the provider, built browser-side by compose-dom.js), output is a Hints value the feed writer materializes.
|
|
3
3
|
*
|
|
4
|
-
* Knuth–Plass-shaped total-fit dynamic programming over break candidates: word boundaries plus hyphenation offsets (Liang's, or an author's soft hyphens — the caller supplies them; this module does not know where they came from). Badness is slack against the measure under the alignment's stretch model, and there are two of those (see `STRETCH` below) — aesthetic error only, never geometric: a chosen line fits because its measured width is the oracle's own. Demerits add hyphen, consecutive-hyphen, and last-line-orphan penalties. Ties break toward earlier breaks (candidates priced ascending, replaced only on strictly lower total) — a total order, no randomness, so the determinism that *cut-invariance* (C7) relies on holds by construction.
|
|
4
|
+
* Knuth–Plass-shaped total-fit dynamic programming over break candidates: word boundaries plus hyphenation offsets (Liang's, or an author's soft hyphens — the caller supplies them; this module does not know where they came from). Badness is slack against the measure under the alignment's stretch model, and there are two of those (see `STRETCH` below) — aesthetic error only, never geometric: a chosen line fits because its measured width is the oracle's own. Demerits add hyphen, consecutive-hyphen, and last-line-orphan penalties, and a run of hyphenated line ends longer than `limitLines` is no candidate at all. Ties break toward earlier breaks (candidates priced ascending, replaced only on strictly lower total) — a total order, no randomness, so the determinism that *cut-invariance* (C7) relies on holds by construction.
|
|
5
5
|
*
|
|
6
6
|
* Each node reads only its feasible window (*the composition lays out once*, 2026-09-27): its predecessors are scanned downward and the scan stops at the first overfull span, on the premise that for a fixed end a span starting earlier is never narrower. What was read is then priced in ascending order, so the choice and its tie-breaking are the ones pricing every predecessor would make. On BelType's own manual it took the composition's Range reads from 6.2 to 4.8 million.
|
|
7
7
|
*
|
|
@@ -14,6 +14,13 @@ const LINE_PENALTY = 10;
|
|
|
14
14
|
const HYPHEN_DEMERITS = 2500;
|
|
15
15
|
const CONSECUTIVE_HYPHEN_DEMERITS = 3000;
|
|
16
16
|
const LAST_LINE_ORPHAN_DEMERITS = 5000;
|
|
17
|
+
/**
|
|
18
|
+
* The most lines in a row the composed mode ends on a hyphen (Codeberg issue 5) — Bringhurst's rule, *avoid more than three consecutive hyphenated lines* (The Elements of Typographic Style, section 2.4), and the value css-text-4 §6.3.5's `hyphenate-limit-lines` would carry, which Chromium does not support and so never reaches computed style.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* A judgement with a lineage, as `RAG_TOLERANCE_EM` is. The consecutive penalty alone did not hold it: measured 2026-11-07 on the delibera specimen, its first paragraph ended seven lines in a row on a hyphen at 3000, six at 10000 (TeX's `\doublehyphendemerits`), three at 30000. Under the limit its longest run is three and its hyphens 10 for 13, the interword space averaging 1.356 of the natural space for 1.333, its spread 0.247 for 0.250, its widest unchanged.
|
|
22
|
+
*/
|
|
23
|
+
export const HYPHENATE_LIMIT_LINES = 3;
|
|
17
24
|
const OVERFULL_DEMERITS = 1e7;
|
|
18
25
|
/** css-text-4 §6.4's emergency break: dearer than any hyphen and cheaper than an overflow, which is the whole ordering the section asks for once the candidates only exist where they are needed. */
|
|
19
26
|
const EMERGENCY_DEMERITS = 1e5;
|
|
@@ -40,10 +47,11 @@ const STRETCH = { justify: 0.5 };
|
|
|
40
47
|
* @param p.measure - the width provider.
|
|
41
48
|
* @param p.available - the line measure in px.
|
|
42
49
|
* @param p.indent - first-line text-indent in px — debits line 1's measure.
|
|
50
|
+
* @param p.limitLines - css-text-4 §6.3.5's `hyphenate-limit-lines`: the most consecutive lines that may end on a hyphen, `null` for `no-limit`. A limit extends the DP's state to (node, the run of hyphenated line ends behind it), so the limit is a constraint and not a price; with none the state is the node alone and the composition is the one it always was. A paragraph the limit leaves no way through but an overfull line is composed without it.
|
|
43
51
|
* @param p.breakAnywhere - css-text-4 §6.4's `overflow-wrap: break-word | anywhere`, which lets *"an otherwise unbreakable sequence of characters be broken at an arbitrary point if there are no otherwise-acceptable break points in the line"*, and says outright that **no hyphenation character is inserted**. Such a break is a node like any other, marked `glyph: false`, so it costs no hyphen advance and carries no hyphen demerit.
|
|
44
52
|
* @throws Error — bare and deliberate (`src/` may not import BelType's error types): ragged alignment with no usable `measure.emWidth`, a refusal rather than a NaN.
|
|
45
53
|
*/
|
|
46
|
-
export function compose({ words, hyphenOffsets = () => [], measure, available, indent = 0, align = 'left', breakAnywhere = false }) {
|
|
54
|
+
export function compose({ words, hyphenOffsets = () => [], measure, available, indent = 0, align = 'left', breakAnywhere = false, limitLines = null }) {
|
|
47
55
|
const N = words.length;
|
|
48
56
|
if (N === 0)
|
|
49
57
|
return { breaks: [], lines: [], lineCount: 0, fillerPx: 0 };
|
|
@@ -92,14 +100,18 @@ export function compose({ words, hyphenOffsets = () => [], measure, available, i
|
|
|
92
100
|
return Math.max(0, lastWord - firstWhole);
|
|
93
101
|
};
|
|
94
102
|
const lineWidth = (a, b) => measure.width(a, b) + (hyphenated(b) ? measure.hyphenWidth : 0);
|
|
103
|
+
/** The longest run of hyphenated line ends a state may carry: the limit, or 1 with none, where only whether the line before ended on a hyphen is ever read and the state is the node alone. */
|
|
104
|
+
const cap = limitLines != null && limitLines >= 1 ? Math.floor(limitLines) : 1;
|
|
105
|
+
const limited = limitLines != null && limitLines >= 1;
|
|
106
|
+
/** By node, by the run of hyphenated line ends that reaching it closes with (0 for a node that is no hyphen). */
|
|
95
107
|
const best = new Array(nodes.length).fill(null);
|
|
96
|
-
best[0] = { total: 0, prev: -1, width: 0, avail: 0, overfull: false };
|
|
108
|
+
best[0] = [{ total: 0, prev: -1, prevRun: 0, width: 0, avail: 0, overfull: false }];
|
|
97
109
|
/** The widths read for the current node, by predecessor: the feasible window, and `j = 0` where a negative indent makes it the one exception. */
|
|
98
110
|
const feasibleW = new Array(nodes.length);
|
|
99
111
|
const feasibleJ = [];
|
|
100
112
|
for (let k = 1; k < nodes.length; k++) {
|
|
101
113
|
const isLast = k === last;
|
|
102
|
-
|
|
114
|
+
const states = [];
|
|
103
115
|
/** The feasible window. The premise, stated so it can be broken: for a fixed end, a span that starts earlier is never narrower — a Range's rect over one nowrap line is the union of what it holds, and an earlier start holds everything a later one does. So once the span from `j` is overfull, every `j` below it is too against the same measure, and the scan stops there. Every `j` above 0 has the same measure; `j = 0` alone is debited the indent, which a positive indent makes narrower (overfull wherever the `j` above it is, so the stop stands) and a negative one wider — so under a negative indent a scan that stopped above it reads it on its own. */
|
|
104
116
|
feasibleJ.length = 0;
|
|
105
117
|
let stoppedAt = -1;
|
|
@@ -143,35 +155,55 @@ export function compose({ words, hyphenOffsets = () => [], measure, available, i
|
|
|
143
155
|
d += EMERGENCY_DEMERITS;
|
|
144
156
|
if (isLast && k - 1 >= 0 && j !== 0 && gapsIn(nodes[j], nodes[k]) === 0)
|
|
145
157
|
d += LAST_LINE_ORPHAN_DEMERITS;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
best[j].forEach((from, run) => {
|
|
159
|
+
if (!from)
|
|
160
|
+
return;
|
|
161
|
+
const next = hyphenated(nodes[k]) ? Math.min(run + 1, cap) : 0;
|
|
162
|
+
if (limited && hyphenated(nodes[k]) && run + 1 > cap)
|
|
163
|
+
return;
|
|
164
|
+
const total = from.total + d;
|
|
165
|
+
const held = states[next];
|
|
166
|
+
if (!held || total < held.total - 1e-9)
|
|
167
|
+
states[next] = { total, prev: j, prevRun: run, width: W, avail, overfull: false };
|
|
168
|
+
});
|
|
149
169
|
}
|
|
150
|
-
if (!
|
|
170
|
+
if (!states.length && !sawFeasible) {
|
|
151
171
|
// Nothing fits: take the shortest line from the nearest reachable node — overfull, crushing demerits, but the paragraph composes (the spirit of *the build cannot fail*, C2).
|
|
152
172
|
for (let j = k - 1; j >= 0; j--) {
|
|
153
173
|
if (!best[j])
|
|
154
174
|
continue;
|
|
175
|
+
const run = best[j].findIndex(Boolean);
|
|
176
|
+
const from = best[j][run];
|
|
155
177
|
const avail = available - (j === 0 ? indent : 0);
|
|
156
178
|
const W = lineWidth(nodes[j], nodes[k]);
|
|
157
|
-
|
|
179
|
+
states[hyphenated(nodes[k]) ? Math.min(run + 1, cap) : 0] = { total: from.total + OVERFULL_DEMERITS + (W - avail) ** 2, prev: j, prevRun: run, width: W, avail, overfull: true };
|
|
158
180
|
break;
|
|
159
181
|
}
|
|
160
182
|
}
|
|
161
|
-
best[k] =
|
|
183
|
+
best[k] = states.length ? states : null;
|
|
162
184
|
}
|
|
163
185
|
// Read back the chosen chain.
|
|
164
186
|
const chain = [];
|
|
165
|
-
for (let k = last
|
|
166
|
-
|
|
187
|
+
for (let k = last, run = 0; k > 0;) {
|
|
188
|
+
const state = best[k][run];
|
|
189
|
+
chain.push({ k, state });
|
|
190
|
+
k = state.prev;
|
|
191
|
+
run = state.prevRun;
|
|
192
|
+
}
|
|
167
193
|
chain.reverse();
|
|
168
194
|
const lines = [];
|
|
169
195
|
let from = 0;
|
|
170
|
-
for (const k of chain) {
|
|
171
|
-
lines.push({ from: nodes[from], to: nodes[k], width:
|
|
196
|
+
for (const { k, state } of chain) {
|
|
197
|
+
lines.push({ from: nodes[from], to: nodes[k], width: state.width, avail: state.avail, overfull: state.overfull });
|
|
172
198
|
from = k;
|
|
173
199
|
}
|
|
174
|
-
|
|
200
|
+
// A limit that leaves the paragraph no way through but an overfull line is not a reason to set one: the paragraph is set without the limit, where that fits.
|
|
201
|
+
if (limited && lines.some((l) => l.overfull)) {
|
|
202
|
+
const free = compose({ words, hyphenOffsets, measure, available, indent, align, breakAnywhere, limitLines: null });
|
|
203
|
+
if (!free.lines.some((l) => l.overfull))
|
|
204
|
+
return free;
|
|
205
|
+
}
|
|
206
|
+
const breaks = chain.slice(0, -1).map(({ k }) => nodes[k]);
|
|
175
207
|
const lastLine = lines[lines.length - 1];
|
|
176
208
|
const fillerPx = Math.max(0, lastLine.avail - lastLine.width);
|
|
177
209
|
return { breaks, lines, lineCount: lines.length, fillerPx };
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* **No key may end with another key** (`pos:` and `xpos:` cannot both exist), because "every element carrying a key" is selected by substring and a substring cannot anchor to the start of a word. {@link assertNoKeySuffix} checks it once, at load.
|
|
13
13
|
*
|
|
14
|
-
* **One rule, one registry.** The registry is a table of facts, each declared once — its name, its kind, what it is computed from, who writes it and, for a value, the custom property a generated sheet delivers it into — so that a later step adds entries and not a second mechanism. It holds the `has:` family, the line stamp `pos:`, `fragment`, the piece words `split`/`start`/`end`/`slice:N`, `fixed`, the engine-created elements' `shell`/`break`/`fill`, the three words the zeroing needs (`wrapped`, `adjoin`, `away`)
|
|
14
|
+
* **One rule, one registry.** The registry is a table of facts, each declared once — its name, its kind, what it is computed from, who writes it and, for a value, the custom property a generated sheet delivers it into — so that a later step adds entries and not a second mechanism. It holds the `has:` family, the line stamp `pos:`, `fragment`, the piece words `split`/`start`/`end`/`slice:N`, `fixed`, the engine-created elements' `shell`/`break`/`fill`, the three words the zeroing needs (`wrapped`, `adjoin`, `away`), `artifact` (*the piece words*, 2026-10-11; see CHANGELOG 2026-10-11) and the one word a host writes on an element of its own, `host` (ruled 2026-11-08); `hollow` is a later step and is not written here.
|
|
15
15
|
*
|
|
16
16
|
* @packageDocumentation
|
|
17
17
|
*/
|
|
18
18
|
/** The attribute's name. A single place for the literal; the grammar below is the only code that spells the value. */
|
|
19
19
|
export declare const ATTRIBUTE = "data-beltype";
|
|
20
|
+
/** The attribute a printer's preparation sets on every element it makes from generated content, valued by the pseudo it was made from (`before`, `after`, `marker`): its own attribute and not a word, because it is set by the host on the source, before the engine sees it. `content()` and `string-set` leave out what carries it, as css-gcpm-3's `content()` is the element's text without its pseudo-elements, and `content(before)` and `content(after)` read exactly that (ruled 2026-11-07). The name and its values are `GENERATED_ATTRIBUTE` and `GeneratedPseudo` in `@beltype/common/data-beltype`; `src/` imports nothing, so the literal is spelled here once, and is replaced by the import if the engine's layering ever admits common. */
|
|
21
|
+
export declare const GENERATED_ATTRIBUTE = "data-beltype-generated";
|
|
20
22
|
/** One fact the registry declares. */
|
|
21
23
|
export type Entry = {
|
|
22
24
|
/** The flag, or the key of a value word (`has` for `has:<hash>`). */
|
|
@@ -44,6 +46,8 @@ export declare const KEYS: ReadonlySet<string>;
|
|
|
44
46
|
export declare const TRANSFORM = "transform";
|
|
45
47
|
export declare const CICERO_PROVISION = "cicero-provision";
|
|
46
48
|
export declare const CICERO_INTERFACE = "cicero-interface";
|
|
49
|
+
/** The word a host puts on an element of its own machinery that stands among the document's boxes, so the intake passes over it (`topLevelBoxes`, `driver.js`). A host that holds the document in an element of its own names that element with the `root` option first. */
|
|
50
|
+
export declare const HOST = "host";
|
|
47
51
|
/** The words step 2 writes, named once: the engine, the editing tools, Caster and the essay spell them through these and never as a literal of their own. */
|
|
48
52
|
export declare const FRAGMENT = "fragment";
|
|
49
53
|
export declare const SPLIT = "split";
|