@beltype/cicero 0.1.10 → 0.1.12

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.
@@ -35,7 +35,11 @@ declare function patchBox(index: number, path: number[], el: Element): Address |
35
35
  export type ChromePatch = {
36
36
  chrome: true;
37
37
  };
38
- /** Resolves when the rim has nothing pending and no run is under way (a run stopped at a page is at rest). The door a venue test and a host that wants to know it is looking at the settled document call. */
38
+ /**
39
+ * Resolves when the document will not be cut again unless something is done to it: no call of `run()` is unanswered, no run is under way (a run stopped at a page is at rest), the rim has nothing it is answering or judging, `document.fonts` has nothing loading and every picture of the document's boxes has arrived (CHANGELOG, Unreleased). The door a venue test, a host that wants to know it is looking at the settled document and a printer that reads the presented pages call.
40
+ *
41
+ * @remarks `run()` resolving is not this. A face a page's margin boxes ask for is requested when the presentation is laid out, which is after the last run began, so it arrives after the run's answer, is heard as a late fact (`loadingdone`) and cuts the document again under whoever read the pages in between: measured 2026-11-08 on the essay's `margin-boxes/content-001`, a data-URL face first requested by the presented margin boxes, heard 33 ms into the first run and cutting again 3 ms later, and Caster refusing 'the pages were replaced while they were being printed' one print in three. So the wait is a loop: lay out, so every face the pages ask for is asked for; await the faces and the pictures; await a frame, so what they caused is heard; and look again, leaving only when a whole pass found nothing under way. A released client is settled.
42
+ */
39
43
  declare function settled(): Promise<void>;
40
44
  /** One run, from the call that started it to the moment it is complete or let go. */
41
45
  export type Session = {
@@ -24,7 +24,7 @@ import { dress, isDressingSheet } from '../src/root.js';
24
24
  import { isDeliverySheet } from '../src/reads.js';
25
25
  import { ForcingMemo } from '../src/forcing-memo.js';
26
26
  import { isInlineBox, isFixedBox, holdsFixedBox } from '../src/word-space.js';
27
- import { FRAGMENT, selectorOf } from '../src/data-beltype.js';
27
+ import { FRAGMENT, decodeFragment, selectorOf } from '../src/data-beltype.js';
28
28
  import { present, presenterCount } from '../src/presenters.js';
29
29
  import { rewriteDocumentSheets } from './rewrite-document.js';
30
30
  import { isLayerName } from './rewrite.js';
@@ -273,10 +273,28 @@ async function quiet() {
273
273
  while (working || calls.size || (live && !live.gate))
274
274
  await Promise.race([working, ...calls, live?.finished].filter(Boolean)).catch(() => { });
275
275
  }
276
- /** Resolves when the rim has nothing pending and no run is under way (a run stopped at a page is at rest). The door a venue test and a host that wants to know it is looking at the settled document call. */
276
+ /**
277
+ * Resolves when the document will not be cut again unless something is done to it: no call of `run()` is unanswered, no run is under way (a run stopped at a page is at rest), the rim has nothing it is answering or judging, `document.fonts` has nothing loading and every picture of the document's boxes has arrived (CHANGELOG, Unreleased). The door a venue test, a host that wants to know it is looking at the settled document and a printer that reads the presented pages call.
278
+ *
279
+ * @remarks `run()` resolving is not this. A face a page's margin boxes ask for is requested when the presentation is laid out, which is after the last run began, so it arrives after the run's answer, is heard as a late fact (`loadingdone`) and cuts the document again under whoever read the pages in between: measured 2026-11-08 on the essay's `margin-boxes/content-001`, a data-URL face first requested by the presented margin boxes, heard 33 ms into the first run and cutting again 3 ms later, and Caster refusing 'the pages were replaced while they were being printed' one print in three. So the wait is a loop: lay out, so every face the pages ask for is asked for; await the faces and the pictures; await a frame, so what they caused is heard; and look again, leaving only when a whole pass found nothing under way. A released client is settled.
280
+ */
277
281
  async function settled() {
278
- while (working || (live && !live.gate))
279
- await (working ?? live.finished);
282
+ for (;;) {
283
+ await quiet();
284
+ if (released)
285
+ return;
286
+ void document.documentElement.offsetHeight; // the layout that requests the faces the presented pages ask for
287
+ await document.fonts.ready;
288
+ await picturesArrived();
289
+ await frame();
290
+ if (released || !(working || calls.size || (live && !live.gate) || rim?.judging() || document.fonts.status === 'loading'))
291
+ return;
292
+ }
293
+ }
294
+ /** Resolves when every `<img>` of the document's top-level boxes that was still loading has loaded or failed. */
295
+ async function picturesArrived() {
296
+ const pending = (presented?.boxes ?? []).flatMap((box) => [...box.querySelectorAll('img')]).filter((img) => !img.complete);
297
+ await Promise.all(pending.map((img) => new Promise((resolve) => { img.addEventListener('load', () => resolve(), { once: true }); img.addEventListener('error', () => resolve(), { once: true }); })));
280
298
  }
281
299
  /** The run that stopped at a page, if there is one (*the pages are cut as far as they are asked*, 2026-10-12): the driver is waiting inside its page loop with its place, its measure context and its Ledger kept, and the pages it has cut are on screen. `run()` with no `resumeAt` on the same mode goes on from here; anything else lets it go. */
282
300
  let live = null;
@@ -428,7 +446,7 @@ function show(session, { account, index, total, from, to, resume }) {
428
446
  seatDoors(blocks);
429
447
  if (!session.hashed) {
430
448
  // An address that names a place in the document (`…#chapter`) is a place on a page that may not be built; the browser's own scroll to the fragment ran before there were pages. A place not cut to yet waits for the cut that reaches it.
431
- const named = window.location.hash ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null;
449
+ const named = window.location.hash ? document.getElementById(decodeFragment(window.location.hash)) : null;
432
450
  if (!named || !root.contains(named))
433
451
  session.hashed = true;
434
452
  else if (root.pageOf(named) !== 'not-yet') {
@@ -65,7 +65,7 @@ export const ciceroEngine = {
65
65
  return ` if (window.__beltypeCicero) {
66
66
  try {
67
67
  const cicero = window.__beltypeCicero;
68
- const named = location.hash.length > 1 ? document.getElementById(decodeURIComponent(location.hash.slice(1))) : null;
68
+ const named = location.hash.length > 1 ? document.getElementById((() => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } })()) : null;
69
69
  let answer = await cicero.run({ until: named || 2 });
70
70
  if (!answer.complete) {
71
71
  const sheet = document.querySelector('beltype-root')?.shadowRoot?.querySelector('beltype-page');
@@ -26,7 +26,7 @@
26
26
  * @packageDocumentation
27
27
  */
28
28
  import { parse, walk } from './csstree.js';
29
- import { STRING_SET_PROP, RUNNING_PROP, encodeString, encodeElement, encodePageName, BLANK_SUFFIX, encodeRead, parseReadFunction, READ_PREFIX, encodeStringProp } from '../src/gcpm-encoding.js';
29
+ import { STRING_SET_PROP, RUNNING_PROP, HYPHENATE_LIMIT_LINES_PROP, encodeString, encodeElement, encodePageName, BLANK_SUFFIX, encodeRead, parseReadFunction, READ_PREFIX, encodeStringProp } from '../src/gcpm-encoding.js';
30
30
  import { MARGIN_BOXES } from '../src/margin-boxes.js';
31
31
  import { FRAGMENT_GUARD, HAS_PROPERTY_PREFIX, HAS_REGISTRY_SELECTOR, encodeHasArgument, encodeHasUnion } from '../src/data-beltype.js';
32
32
  /** Splice a list of non-overlapping `{ start, end, text }` edits into a source string, right to left so earlier offsets stay valid. */
@@ -465,6 +465,12 @@ function rewriteText(original, pages = true) {
465
465
  }
466
466
  return;
467
467
  }
468
+ if (property === 'hyphenate-limit-lines') { // Chromium drops it at parse, and the Compositor reads the author's limit from the custom property instead
469
+ const colon = source.indexOf(':', node.loc.start.offset);
470
+ if (colon > node.loc.start.offset)
471
+ edits.push({ start: node.loc.start.offset, end: colon, text: HYPHENATE_LIMIT_LINES_PROP });
472
+ return;
473
+ }
468
474
  if (property === 'position') {
469
475
  const m = /^running\(\s*([^\s)]+)\s*\)$/i.exec(textOf(source, node.value));
470
476
  if (m) {
@@ -41,7 +41,7 @@ export declare function bringsNewFace(event: Event): boolean;
41
41
  * @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.
42
42
  * @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.
43
43
  * @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.
44
- * @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.
44
+ * @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; `judging` says whether a size or a picture was heard and not yet judged; `stop` releases everything.
45
45
  */
46
46
  export declare function watchLateTruth({ document: doc, fonts, originals, onSignal, presented, pageHolding, measured, idle, busy, unbuilt }: {
47
47
  document: Document;
@@ -60,6 +60,7 @@ export declare function watchLateTruth({ document: doc, fonts, originals, onSign
60
60
  unbuilt?: () => boolean;
61
61
  }): {
62
62
  stop: () => void;
63
+ judging: () => boolean;
63
64
  add: (box: Element, replaced?: Element) => void;
64
65
  pieces: (built: Array<[Element, Element]>, removed: Iterable<Element>) => void;
65
66
  };
package/dist/seam/rim.js CHANGED
@@ -75,7 +75,7 @@ export function bringsNewFace(event) {
75
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
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.
77
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.
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.
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; `judging` says whether a size or a picture was heard and not yet judged; `stop` releases everything.
79
79
  */
80
80
  export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, onSignal, presented = () => false, pageHolding = () => null, measured = () => undefined, idle = async () => { }, busy = () => false, unbuilt = () => false }) {
81
81
  const win = doc.defaultView;
@@ -269,6 +269,8 @@ export function watchLateTruth({ document: doc, fonts = doc.fonts, originals, on
269
269
  releases.push(() => doc.removeEventListener('beltype:change', foreign));
270
270
  return {
271
271
  add,
272
+ /** Is a size or a picture heard and not yet judged? The client's `settled` waits for it. */
273
+ judging: () => judging,
272
274
  pieces(built, removed) {
273
275
  for (const el of removed) {
274
276
  if (!pieces.delete(el))
@@ -932,29 +932,16 @@ function compareSpecificity(a, b) {
932
932
  return a[i] - b[i];
933
933
  return 0;
934
934
  }
935
- /** Margin-box declarations out of a CSSPageRule — via child CSSMarginRule objects where the engine exposes them, else a cssText walk. Returns { 'bottom-center': rawContentValue, ... }. */
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, ... }. */
936
936
  function marginBoxesOf(rule) {
937
937
  const out = {};
938
- if (rule.cssRules) {
939
- for (const child of rule.cssRules) {
940
- const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
941
- if (MARGIN_BOXES.includes(name)) {
942
- const content = child.style?.getPropertyValue('content');
943
- if (content)
944
- out[name] = content.trim();
945
- }
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();
946
944
  }
947
- if (Object.keys(out).length)
948
- return out;
949
- }
950
- const re = /@([a-z-]+)\s*\{([^}]*)\}/g;
951
- let m;
952
- while ((m = re.exec(rule.cssText))) {
953
- if (!MARGIN_BOXES.includes(m[1]))
954
- continue;
955
- const c = /content\s*:\s*([^;}]+)/.exec(m[2]);
956
- if (c)
957
- out[m[1]] = c[1].trim();
958
945
  }
959
946
  return out;
960
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;
@@ -10,11 +10,12 @@
10
10
  * @packageDocumentation
11
11
  */
12
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';
16
17
  import { hyphenationOffsets, languageFor, resolveLimits } from './hyphenate.js';
17
- import { BREAKING_SPACE, measuringCopyOf, HTML_NS, isAtom, isFixedBox, isInlineBox, isInlineLevel, noteEngineText, rangeOf, sourceOf, splitTextAt, UNSPACED_SCRIPT } from './word-space.js';
18
+ import { BREAKING_SPACE, measuringCopyOf, HTML_NS, isAtom, isFixedBox, isInlineBox, isInlineLevel, isOutsideMarker, noteEngineText, rangeOf, sourceOf, splitTextAt, UNSPACED_SCRIPT } from './word-space.js';
18
19
  import { attributesOf, blockKey, CompositionCache, lookup, paragraphKey, remember, shapeOf } from './composition-key.js';
19
20
  /** The computed displays of a block container, one of which a paragraph's own box must have (*the paragraph is what the browser breaks*, clause one). */
20
21
  const PARAGRAPH_DISPLAY = new Set(['block', 'flow-root', 'list-item', 'table-cell', 'table-caption']);
@@ -41,16 +42,30 @@ export function isComposable(el) {
41
42
  for (const child of el.children) {
42
43
  if (child.tagName === 'BR')
43
44
  continue;
44
- if (!isInlineBox(child))
45
+ if (!isInlineBox(child) && !isOutsideMarker(child))
45
46
  return false;
46
47
  }
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
  *
@@ -104,6 +119,9 @@ export function collectWords(el) {
104
119
  i = j;
105
120
  }
106
121
  }
122
+ else if (child.nodeType === 1 && isOutsideMarker(child)) {
123
+ // a hanging number: out of the text being set
124
+ }
107
125
  else if (child.nodeType === 1 && child.tagName === 'BR') {
108
126
  current = null;
109
127
  if (words.length && runStarts[runStarts.length - 1] !== words.length)
@@ -176,6 +194,16 @@ export function hyphenCharOf(style) {
176
194
  return raw;
177
195
  return raw.slice(1, -1).replace(/\\(.)/g, '$1');
178
196
  }
197
+ /**
198
+ * 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}.
199
+ */
200
+ export function limitLinesOf(style) {
201
+ const raw = style.getPropertyValue(HYPHENATE_LIMIT_LINES_PROP).trim();
202
+ if (raw === 'no-limit')
203
+ return null;
204
+ const n = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
205
+ return Number.isFinite(n) && n >= 1 ? n : HYPHENATE_LIMIT_LINES;
206
+ }
179
207
  /**
180
208
  * css-text-4 §6.3.4's `hyphenate-limit-chars` as three values, `null` for each `auto`.
181
209
  *
@@ -367,16 +395,27 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
367
395
  }
368
396
  // 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
397
  const intrudedByFloat = (clone, rect) => floats.some((f) => !f.el.contains(clone) && overlaps(rect, f.rect));
398
+ /** 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. */
399
+ const contentBoxOf = (cs, rect) => {
400
+ const left = rect.left + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft);
401
+ const top = rect.top + parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop);
402
+ return { left, top, right: rect.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight), bottom: rect.bottom - parseFloat(cs.borderBottomWidth) - parseFloat(cs.paddingBottom) };
403
+ };
370
404
  const refusedBesideFloat = [];
371
405
  /** 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
406
  const refusedUnspaced = [];
407
+ /** A third refusal: a block in a multi-column container (`inColumns`). */
408
+ const refusedInColumns = [];
373
409
  const jobs = [];
374
410
  for (const [clone, source] of cloneToSource) {
375
- if (!isComposable(clone))
411
+ if (!isComposable(clone)) {
412
+ if (inColumns(clone) && PARAGRAPH_DISPLAY.has(win.getComputedStyle(clone).display))
413
+ refusedInColumns.push(source);
376
414
  continue;
415
+ }
377
416
  const cs = win.getComputedStyle(clone);
378
417
  const rect = clone.getBoundingClientRect();
379
- if (floats.length && intrudedByFloat(clone, rect)) {
418
+ if (floats.length && intrudedByFloat(clone, contentBoxOf(cs, rect))) {
380
419
  refusedBesideFloat.push(source);
381
420
  continue;
382
421
  }
@@ -393,12 +432,13 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
393
432
  const indent = parseFloat(sourceStyle.textIndent) || 0;
394
433
  const hyphenChar = hyphenCharOf(sourceStyle);
395
434
  const limits = limitCharsOf(sourceStyle);
435
+ const limitLines = limitLinesOf(sourceStyle);
396
436
  const breakAnywhere = ['anywhere', 'break-word'].includes(sourceStyle.overflowWrap);
397
437
  const paragraph = paragraphKey({
398
438
  path: pathToSource.get(source),
399
439
  markup: shapeOf(source.outerHTML),
400
440
  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)]
441
+ 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
442
  });
403
443
  const hit = lookup(cache, paragraph);
404
444
  if (hit) {
@@ -420,6 +460,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
420
460
  // 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
461
  hyphenChar,
422
462
  limits,
463
+ limitLines,
423
464
  breakAnywhere
424
465
  });
425
466
  }
@@ -432,8 +473,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
432
473
  c.style.textIndent = '0';
433
474
  return c;
434
475
  });
476
+ // 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.
477
+ const measureRoots = jobs.map((j, i) => {
478
+ let top = nowrapClones[i];
479
+ for (let cur = j.source; !tops.has(cur) && cur.parentElement; cur = cur.parentElement) {
480
+ const wrap = cur.parentElement.cloneNode(false);
481
+ wrap.appendChild(top);
482
+ top = wrap;
483
+ }
484
+ return top;
485
+ });
435
486
  const probes = jobs.map((job, i) => seatHyphenProbe(nowrapClones[i], job.hyphenChar));
436
- ctx.measure(nowrapClones);
487
+ ctx.measure(measureRoots);
437
488
  // 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
489
  const hyphenWidths = probes.map(probeWidth);
439
490
  for (const probe of probes)
@@ -463,7 +514,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
463
514
  indent: r === 0 ? job.indent : 0,
464
515
  align: job.align,
465
516
  breakAnywhere: job.breakAnywhere,
466
- limitLines: HYPHENATE_LIMIT_LINES
517
+ limitLines: job.limitLines
467
518
  });
468
519
  record.runs.push({ start: runs[r].start, ...result });
469
520
  record.lineCount += result.lineCount;
@@ -472,7 +523,7 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
472
523
  resolved.set(job.source, record);
473
524
  });
474
525
  }
475
- return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced };
526
+ return { floaty: floats.length > 0, refusedBesideFloat, refusedUnspaced, refusedInColumns };
476
527
  };
477
528
  let todo = blocks.map((_, i) => i).filter((i) => !(trustMemo && memos?.[i]));
478
529
  let laid = lay(todo);
@@ -510,13 +561,18 @@ export function composeDocument(doc, blocks, ctx, cache = new CompositionCache()
510
561
  tally.blocks.set(memoKeys[i], { records, floaty: laid.floaty, refused: laid.refusedUnspaced.filter((src) => el.contains(src)).length });
511
562
  }
512
563
  }
513
- const { refusedBesideFloat, refusedUnspaced } = laid;
564
+ const { refusedBesideFloat, refusedUnspaced, refusedInColumns } = laid;
514
565
  // 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.
515
566
  if (refusedBesideFloat.length) {
516
567
  win.console?.warn(`BelType/Cicero: ${refusedBesideFloat.length} block(s) sit beside a float and are NOT composed by this engine — ` +
517
568
  'their lines are the browser\'s, because a float shortens line boxes and the Compositor has one measure per block, not one per line. ' +
518
569
  'The pages are honest; the line breaking in those blocks is not this engine\'s.');
519
570
  }
571
+ if (refusedInColumns.length) {
572
+ win.console?.warn(`BelType/Cicero: ${refusedInColumns.length} block(s) sit in a multi-column container and are NOT composed by this engine — ` +
573
+ 'their lines are the browser\'s, because the Compositor has one measure per block, not one per column. ' +
574
+ 'The pages are honest; the line breaking in those blocks is not this engine\'s.');
575
+ }
520
576
  const refusedCount = refusedUnspaced.length + refusedFromMemo;
521
577
  if (refusedCount) {
522
578
  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; ` +
@@ -60,6 +60,7 @@ export declare const FILL = "fill";
60
60
  export declare const WRAPPED = "wrapped";
61
61
  export declare const ADJOIN = "adjoin";
62
62
  export declare const AWAY = "away";
63
+ export declare const ONE_COLUMN = "one-column";
63
64
  /** PDF's own name for pagination furniture: a copy the engine repeats that a reader does not read (the registry entry says which). */
64
65
  export declare const ARTIFACT = "artifact";
65
66
  /** The words that mark an element as the engine's own — what the "class starts with `__beltype-`" test used to say, as a named set: a divided box (it carried `__beltype-cont` or `-cut`, and a first fragment `-first`), a fixed box's copy, and the three elements the engine creates. `fragment` is not here: a composed block's clone carried no class and was never an engine mark. */
@@ -160,6 +161,13 @@ export declare const CUT_OFF: string;
160
161
  * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
161
162
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
162
163
  * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
164
+ * - `one-column`: a multi-column box too tall for any page is laid out as one column, since the engine does not divide columns yet (`src/scope.ts`).
163
165
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
164
166
  */
165
167
  export declare const WORDS_RULES: string;
168
+ /**
169
+ * The id a URL fragment names, percent-decoded; the raw text when it is not valid percent-encoding (`#%E0%A4%A`), where `decodeURIComponent` throws.
170
+ *
171
+ * @param hash - A `location.hash`, with or without its `#`.
172
+ */
173
+ export declare function decodeFragment(hash: string): string;
@@ -39,6 +39,7 @@ export const REGISTRY = Object.freeze([
39
39
  { name: 'adjoin', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'engine', meaning: 'the first-child chain after a cut BETWEEN boxes: its block-start margin adjoins the cut and is truncated, and the box is not divided' },
40
40
  { name: 'away', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'engine', meaning: 'a box put away on this page (`display: none`): kept as an element so structural selectors read as in the source' },
41
41
  { name: 'host', kind: 'flag', status: 'in use', writer: 'host', from: 'host', facing: 'engine', meaning: 'the host\'s word on an element of its own machinery that stands among the document\'s boxes (a hidden `<iframe>`, a probe); the intake passes over it, so it gets no address and moves none (ruled 2026-11-08)' },
42
+ { name: 'one-column', kind: 'flag', status: 'in use', writer: 'engine', from: 'original', facing: 'engine', meaning: 'a multi-column box too tall for any page, laid out as one column on its copies because the engine does not yet divide columns (the console names the element)' },
42
43
  { name: 'artifact', kind: 'flag', status: 'in use', writer: 'engine', from: 'cut', facing: 'host', meaning: 'a copy the engine repeats that is not the one a reader reads: a running element\'s copy in a margin box, a fixed box\'s copy on every page after the first, and a table\'s repeated header or footer group; PDF\'s own word for pagination furniture, and nothing else. Every artifact is also aria-hidden' }
43
44
  ].map((e) => Object.freeze(e)));
44
45
  /** The flags in use. */
@@ -63,6 +64,7 @@ export const FILL = 'fill';
63
64
  export const WRAPPED = 'wrapped';
64
65
  export const ADJOIN = 'adjoin';
65
66
  export const AWAY = 'away';
67
+ export const ONE_COLUMN = 'one-column';
66
68
  /** PDF's own name for pagination furniture: a copy the engine repeats that a reader does not read (the registry entry says which). */
67
69
  export const ARTIFACT = 'artifact';
68
70
  /** The words that mark an element as the engine's own — what the "class starts with `__beltype-`" test used to say, as a named set: a divided box (it carried `__beltype-cont` or `-cut`, and a first fragment `-first`), a fixed box's copy, and the three elements the engine creates. `fragment` is not here: a composed block's clone carried no class and was never an engine mark. */
@@ -300,6 +302,7 @@ const RESUMED_BOX = `${RESUMED}:not(${w(SHELL)})`;
300
302
  * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
301
303
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
302
304
  * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
305
+ * - `one-column`: a multi-column box too tall for any page is laid out as one column, since the engine does not divide columns yet (`src/scope.ts`).
303
306
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
304
307
  */
305
308
  export const WORDS_RULES = `
@@ -310,5 +313,20 @@ export const WORDS_RULES = `
310
313
  ${CUT_OFF} { margin-bottom: 0 !important; }
311
314
  ${CUT_OFF}:not(${w(WRAPPED)}) { border-bottom-width: 0 !important; padding-bottom: 0 !important; border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; }
312
315
  ${w(ADJOIN)} { margin-top: 0 !important; }
316
+ ${w(ONE_COLUMN)} { column-count: auto !important; column-width: auto !important; }
313
317
  }
314
318
  `;
319
+ /**
320
+ * The id a URL fragment names, percent-decoded; the raw text when it is not valid percent-encoding (`#%E0%A4%A`), where `decodeURIComponent` throws.
321
+ *
322
+ * @param hash - A `location.hash`, with or without its `#`.
323
+ */
324
+ export function decodeFragment(hash) {
325
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
326
+ try {
327
+ return decodeURIComponent(raw);
328
+ }
329
+ catch {
330
+ return raw;
331
+ }
332
+ }
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), a top-level `position: fixed` box, which is not among the flow blocks the position index is built over; and a scope that ended before the position asked about with no operation logged between its end and the position, since the replay closes a scope only when an operation at or above its depth arrives: a `counter(list-item)` read on a page past a list whose last item holds a list reads the inner list's count (3 where Chromium prints 2, measured 2026-11-07), and an author's nested reset is read the same way. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). A scope that ended before the page with no operation logged between its end and the page's first word is closed since 2026-11-09, by the depth of the page's opening place (`counterStateAt`'s `depth`): until then a `counter(list-item)` read on a page past a list whose last item holds a list read the inner list's count, 3 where Chromium prints 2, and an author's nested reset the same. **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -126,6 +126,16 @@ export declare function documentOpsOf(blocks: ArrayLike<Element>, index: {
126
126
  }>;
127
127
  keep?: Keep | null;
128
128
  }): Op[];
129
+ /**
130
+ * A log with the document's running elements laid in, each at the position the flow stood at where it was written: {@link documentOpsOf}'s `running`, for a log walked without them.
131
+ *
132
+ * @remarks
133
+ * A running element has no word of its own, so it joins the log before every operation past its position: the log stays in position order, which the replay's early stop needs. Its depth is infinite, so it ends no counter scope.
134
+ */
135
+ export declare function withRunning(ops: Op[], running: ReadonlyArray<{
136
+ name: string;
137
+ pos: number;
138
+ }>): Op[];
129
139
  /**
130
140
  * The document's counters at a position — every operation strictly before it, replayed under CSS 2.1's *Nested counters and scope*.
131
141
  *
@@ -133,12 +143,13 @@ export declare function documentOpsOf(blocks: ArrayLike<Element>, index: {
133
143
  *
134
144
  * @param pos - the flat word position asked about — for a page, its first word.
135
145
  * @param away - the remainders the page's opening cut carries in (`positions.js`' `remaindersOf`): an operation before `pos` inside one of them is on this page or a later one, not on a page before it, and is not yet in force (beltype#43). The replay still runs in document order.
146
+ * @param depth - the depth in the log's terms of the place `pos` names (`driver.ts`' `depthOfCut`): every instance created deeper is ended there, as an operation at that depth would end it, so a scope that ended before the page with nothing logged between is closed (cicero-counter-scope-stays-open-at-page, 2026-11-09). Absent, the replay keeps what the last operation before `pos` left open.
136
147
  * @returns per counter name, the values of every instance in scope, outermost first. Plain data, so it crosses a `page.evaluate` boundary in the account's `closes` like every other condition.
137
148
  */
138
149
  export declare function counterStateAt(ops: ReturnType<typeof documentOpsOf>, pos: number, away?: ReadonlyArray<{
139
150
  from: number;
140
151
  to: number;
141
- }>): Record<string, number[]>;
152
+ }>, depth?: number): Record<string, number[]>;
142
153
  /** What the page context's own rules do to the page counter on one page: the `page` value of the winning `counter-reset`, `counter-increment` and `counter-set` of the `@page` rules that apply to it, `null` where the winning declaration names no `page` (`capture.ts`'s `pageFor`). */
143
154
  export type PageCounterOps = {
144
155
  reset: number | null;
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), a top-level `position: fixed` box, which is not among the flow blocks the position index is built over; and a scope that ended before the position asked about with no operation logged between its end and the position, since the replay closes a scope only when an operation at or above its depth arrives: a `counter(list-item)` read on a page past a list whose last item holds a list reads the inner list's count (3 where Chromium prints 2, measured 2026-11-07), and an author's nested reset is read the same way. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** css-lists-3's `reversed()` counters (an author's `counter-reset: reversed(name)`), and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A list's and a list item's implicit `list-item` operations are read since 2026-11-07 (`implicitListOps`), so a `counter(list-item)` read on a page a list continued onto or past it counts the items before it. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). A scope that ended before the page with no operation logged between its end and the page's first word is closed since 2026-11-09, by the depth of the page's opening place (`counterStateAt`'s `depth`): until then a `counter(list-item)` read on a page past a list whose last item holds a list read the inner list's count, 3 where Chromium prints 2, and an author's nested reset the same. **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -354,9 +354,17 @@ export function documentOpsOf(blocks, index, { styleOf, pseudoStyleOf = null, te
354
354
  for (const op of part.ops)
355
355
  ops.push({ ...op, pos: start + op.pos, depth: base + op.depth });
356
356
  }
357
+ return withRunning(ops, running);
358
+ }
359
+ /**
360
+ * A log with the document's running elements laid in, each at the position the flow stood at where it was written: {@link documentOpsOf}'s `running`, for a log walked without them.
361
+ *
362
+ * @remarks
363
+ * A running element has no word of its own, so it joins the log before every operation past its position: the log stays in position order, which the replay's early stop needs. Its depth is infinite, so it ends no counter scope.
364
+ */
365
+ export function withRunning(ops, running) {
357
366
  if (!running.length)
358
367
  return ops;
359
- // A running element has no word of its own, so it joins the log at the position the flow stood at, before every operation past it: the log stays in position order, which the replay's early stop needs. Its depth is infinite, so it ends no counter scope.
360
368
  const merged = [];
361
369
  let r = 0;
362
370
  for (const op of ops) {
@@ -377,10 +385,11 @@ const runningOp = (r, at) => ({ reset: [], increment: [], set: [], strings: null
377
385
  *
378
386
  * @param pos - the flat word position asked about — for a page, its first word.
379
387
  * @param away - the remainders the page's opening cut carries in (`positions.js`' `remaindersOf`): an operation before `pos` inside one of them is on this page or a later one, not on a page before it, and is not yet in force (beltype#43). The replay still runs in document order.
388
+ * @param depth - the depth in the log's terms of the place `pos` names (`driver.ts`' `depthOfCut`): every instance created deeper is ended there, as an operation at that depth would end it, so a scope that ended before the page with nothing logged between is closed (cicero-counter-scope-stays-open-at-page, 2026-11-09). Absent, the replay keeps what the last operation before `pos` left open.
380
389
  * @returns per counter name, the values of every instance in scope, outermost first. Plain data, so it crosses a `page.evaluate` boundary in the account's `closes` like every other condition.
381
390
  */
382
- export function counterStateAt(ops, pos, away = []) {
383
- return replay(ops, { until: pos, away });
391
+ export function counterStateAt(ops, pos, away = [], depth = Infinity) {
392
+ return replay(ops, { until: pos, away, depth });
384
393
  }
385
394
  /** No page-context rule touches the page counter. */
386
395
  export const NO_PAGE_COUNTER_OPS = Object.freeze({ reset: null, increment: null, set: null });
@@ -452,7 +461,7 @@ export function withoutPageCounters(state) {
452
461
  return out;
453
462
  }
454
463
  /** One replay, two readers. `counterStateAt` stops at a position and returns the state there; `stringAssignmentsOf` runs the log out and is handed the state at each element that assigns a named string. Written once because the scope arithmetic is the delicate part and two copies of it would agree only until one of them was fixed. */
455
- function replay(ops, { until = Infinity, onOp = null, away = [] } = {}) {
464
+ function replay(ops, { until = Infinity, onOp = null, away = [], depth = Infinity } = {}) {
456
465
  /**
457
466
  * @type name → [{ depth, value }], innermost last.
458
467
  */
@@ -496,6 +505,10 @@ function replay(ops, { until = Infinity, onOp = null, away = [] } = {}) {
496
505
  if (onOp && (op.strings || op.mark))
497
506
  onOp(op, snapshot());
498
507
  }
508
+ // The place asked about is entered as an element at `depth` would be: what was created deeper has had its parent left behind.
509
+ for (const stack of scopes.values())
510
+ while (stack.length && stack[stack.length - 1].depth > depth)
511
+ stack.pop();
499
512
  return snapshot();
500
513
  }
501
514
  /** One `<content-list>` term against the element's own facts and the counter state in force at it. */