@beltype/cicero 0.1.11 → 0.1.13

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.
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
14
+ import { documentOpsOf } from './document-state.ts';
14
15
  import { indexBlocks } from './positions.ts';
15
16
  import type { ReadFn } from './gcpm-encoding.ts';
16
17
  import type { PagePublish } from './measure.ts';
@@ -47,6 +48,8 @@ export type Laid = {
47
48
  sites: Site[] | null;
48
49
  /** the position index the loop already built. */
49
50
  index: ReturnType<typeof indexBlocks> | null;
51
+ /** the counter log the loop already walked, under the marks of its counter references and without running elements, or null where it walked none. */
52
+ ops?: ReturnType<typeof documentOpsOf> | null;
50
53
  /** the references whose page number moved a line of their own in the layout before. */
51
54
  because?: string[];
52
55
  /** on a layout after the first: the layout before it, as the run returned it, and the addresses of the boxes whose lines the delivered values moved, which the run resumes from as from an edit (*delivery resumes from the moved page*, 2026-10-21). */
@@ -132,8 +135,8 @@ export declare function pageFactsOf(account: Array<{
132
135
  * @returns null where the document declares no read, which is every document but a few.
133
136
  */
134
137
  export declare function findSites(doc: Document, originals?: Element[] | null, keep?: Keep | null): Site[] | null;
135
- /** Deliver a table of values: one constructed sheet, adopted once, its text replaced. */
136
- export declare function deliver(doc: Document, values: Values): void;
138
+ /** Deliver a table of values: one constructed sheet per scope, adopted once, its text replaced. The rules still reach every element of `doc` that matches, scope or none: the scope keeps the sheets apart, not the elements. */
139
+ export declare function deliver(doc: Document, values: Values, scope?: object): void;
137
140
  /** Is this adopted sheet one of the engine's own — the delivery sheet (the values a run delivered, rewritten every run) or the sheet that hides a reservation until its true number is delivered? */
138
141
  export declare const isDeliverySheet: (doc: Document, sheet: CSSStyleSheet) => boolean;
139
142
  /**
@@ -149,13 +152,14 @@ export declare function hideSheetText(sites: Pick<Site, 'key' | 'pseudos'>[], sc
149
152
  /**
150
153
  * Paint the reservations invisible: the page numbers the cuts so far do not know yet (*the pages are cut as far as they are asked*, Egildo's ruling 1 of 2026-10-12, on the note's recommendation). A document shown before it is complete must not show `0` where a page number will stand.
151
154
  *
152
- * @param scope - as {@link hideSheetText}.
155
+ * @param selector - as {@link hideSheetText}'s `scope`.
156
+ * @param scope - the host's token for this view, as {@link deliver}'s.
153
157
  */
154
- export declare function hideReservations(doc: Document, sites: Pick<Site, 'key' | 'pseudos'>[], scope?: string | undefined): void;
158
+ export declare function hideReservations(doc: Document, sites: Pick<Site, 'key' | 'pseudos'>[], selector?: string | undefined, scope?: object): void;
155
159
  /** Take the hiding sheet off: the true numbers are delivered, or there is nothing left to hide. */
156
- export declare function showReservations(doc: Document): void;
160
+ export declare function showReservations(doc: Document, scope?: object): void;
157
161
  /** Take the delivery sheet off the document, where one was adopted — a second paginate of a document that no longer declares a read leaves nothing behind. */
158
- export declare function withdraw(doc: Document): void;
162
+ export declare function withdraw(doc: Document, scope?: object): void;
159
163
  /**
160
164
  * The written and cascade facts, before any run: every `target-text()`, and every `target-counter()` and `target-counters()` of a counter other than `page`.
161
165
  *
@@ -170,6 +174,7 @@ export declare function staticValues(sites: Site[], { doc, blocks, index, keep }
170
174
  }): {
171
175
  values: Values;
172
176
  unread: Site[];
177
+ ops: ReturnType<typeof documentOpsOf> | null;
173
178
  };
174
179
  /**
175
180
  * The layout facts, after a run: the page each page read's target lands on, as the ledger assigned it — the page whose interval holds the target's first word, over the intervals `root.pageOf` reads (`positions.js`'s `pageContaining`).
@@ -226,7 +231,7 @@ export declare function linesOfSites<S extends {
226
231
  *
227
232
  * **A layout sent round again resumes from the one before it** (*delivery resumes from the moved page*, 2026-10-21). A delivered value that moves a line is an edit at its site's page, so `run` is handed `resume`, the layout just checked and the addresses of the boxes whose lines moved, and the driver restarts one page before the first of them, re-cuts, and stops where it converges past the last, exactly as it resumes from a host's edit. The result's `resumed` and `why` stay the call's: two resumes compose into one from the prior the host handed (`resume.js`' `composeResumed`), a whole layout anywhere in the loop makes `resumed` null, and each resumed layout leaves a `cicero:reads-run` measure naming the pages it cut again.
228
233
  *
229
- * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
234
+ * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `scope` an object the host holds for this view (the document where absent), which keeps this view's delivery and hiding sheets apart from another view's in the same document; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
230
235
  */
231
236
  export declare function settleReads<R extends {
232
237
  account: any[];
@@ -235,7 +240,7 @@ export declare function settleReads<R extends {
235
240
  why?: string | null;
236
241
  laidFacts?: PagePublish[] | null;
237
242
  complete?: boolean;
238
- }>(doc: Document, { run, blocksOf, originals, warm, warmFacts, hide, keep }: {
243
+ }>(doc: Document, { run, blocksOf, originals, warm, warmFacts, hide, keep, scope }: {
239
244
  run: (laid: Laid) => Promise<R>;
240
245
  blocksOf: () => Element[];
241
246
  originals?: Element[] | null;
@@ -243,6 +248,7 @@ export declare function settleReads<R extends {
243
248
  warmFacts?: PagePublish[] | null;
244
249
  hide?: boolean | string;
245
250
  keep?: Keep | null;
251
+ scope?: object;
246
252
  }): Promise<R & {
247
253
  resumed: Resumed | null;
248
254
  why: string | null;
package/dist/src/reads.js CHANGED
@@ -312,23 +312,32 @@ function targetOf(doc, fragment, originals) {
312
312
  }
313
313
  return null;
314
314
  }
315
- /** The engine's one delivery sheet per document, replaced at every delivery and withdrawn when a paginate finds nothing to deliver. */
315
+ /** The engine's one delivery sheet per scope, replaced at every delivery and withdrawn when a paginate finds nothing to deliver. A scope is any object the host holds for one view of the document (the document itself where none is named), so two hosts in one document keep a sheet each (fix/scope-per-view, 2026-11-08). */
316
316
  const SHEETS = new WeakMap();
317
- /** Deliver a table of values: one constructed sheet, adopted once, its text replaced. */
318
- export function deliver(doc, values) {
319
- let sheet = SHEETS.get(doc);
317
+ /** The text each engine sheet last carried: a sheet whose text is unchanged is not rewritten, since `replaceSync` restyles the document whether or not anything differs (*the reads loop writes only what changed*, CHANGELOG Unreleased). */
318
+ const SHEET_TEXT = new WeakMap();
319
+ const write = (sheet, text) => { if (SHEET_TEXT.get(sheet) !== text) {
320
+ sheet.replaceSync(text);
321
+ SHEET_TEXT.set(sheet, text);
322
+ } };
323
+ /** Every sheet the engine made, whatever its scope: what {@link isDeliverySheet} asks. */
324
+ const OWN = new WeakSet();
325
+ /** Deliver a table of values: one constructed sheet per scope, adopted once, its text replaced. The rules still reach every element of `doc` that matches, scope or none: the scope keeps the sheets apart, not the elements. */
326
+ export function deliver(doc, values, scope = doc) {
327
+ let sheet = SHEETS.get(scope);
320
328
  if (!sheet) {
321
329
  sheet = new (doc.defaultView.CSSStyleSheet)();
322
- SHEETS.set(doc, sheet);
330
+ SHEETS.set(scope, sheet);
331
+ OWN.add(sheet);
323
332
  }
324
- sheet.replaceSync(deliverySheetText(values));
333
+ write(sheet, deliverySheetText(values));
325
334
  if (!doc.adoptedStyleSheets.includes(sheet))
326
335
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
327
336
  }
328
- /** The engine's one hiding sheet per document: the rule that paints a reservation invisible while the pages are still being cut (*the pages are cut as far as they are asked*, 2026-10-12). */
337
+ /** The engine's one hiding sheet per scope: the rule that paints a reservation invisible while the pages are still being cut (*the pages are cut as far as they are asked*, 2026-10-12). */
329
338
  const HIDERS = new WeakMap();
330
339
  /** Is this adopted sheet one of the engine's own — the delivery sheet (the values a run delivered, rewritten every run) or the sheet that hides a reservation until its true number is delivered? */
331
- export const isDeliverySheet = (doc, sheet) => SHEETS.get(doc) === sheet || HIDERS.get(doc) === sheet;
340
+ export const isDeliverySheet = (doc, sheet) => OWN.has(sheet);
332
341
  /**
333
342
  * The hiding sheet's text: one rule per distinct address the reserved page reads are keyed by, naming the pseudo-elements that read them, scoped to the presented document (`beltype-root`, or the scope a host that is not the root names). Pure, so the shape is pinned without a browser.
334
343
  *
@@ -353,33 +362,35 @@ export function hideSheetText(sites, scope = 'beltype-root') {
353
362
  /**
354
363
  * Paint the reservations invisible: the page numbers the cuts so far do not know yet (*the pages are cut as far as they are asked*, Egildo's ruling 1 of 2026-10-12, on the note's recommendation). A document shown before it is complete must not show `0` where a page number will stand.
355
364
  *
356
- * @param scope - as {@link hideSheetText}.
365
+ * @param selector - as {@link hideSheetText}'s `scope`.
366
+ * @param scope - the host's token for this view, as {@link deliver}'s.
357
367
  */
358
- export function hideReservations(doc, sites, scope = undefined) {
359
- const text = hideSheetText(sites, scope);
368
+ export function hideReservations(doc, sites, selector = undefined, scope = doc) {
369
+ const text = hideSheetText(sites, selector);
360
370
  if (!text)
361
371
  return;
362
- let sheet = HIDERS.get(doc);
372
+ let sheet = HIDERS.get(scope);
363
373
  if (!sheet) {
364
374
  sheet = new (doc.defaultView.CSSStyleSheet)();
365
- HIDERS.set(doc, sheet);
375
+ HIDERS.set(scope, sheet);
376
+ OWN.add(sheet);
366
377
  }
367
- sheet.replaceSync(text);
378
+ write(sheet, text);
368
379
  if (!doc.adoptedStyleSheets.includes(sheet))
369
380
  doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
370
381
  }
371
382
  /** Take the hiding sheet off: the true numbers are delivered, or there is nothing left to hide. */
372
- export function showReservations(doc) {
373
- const sheet = HIDERS.get(doc);
383
+ export function showReservations(doc, scope = doc) {
384
+ const sheet = HIDERS.get(scope);
374
385
  if (sheet && doc.adoptedStyleSheets.includes(sheet))
375
386
  doc.adoptedStyleSheets = doc.adoptedStyleSheets.filter((s) => s !== sheet);
376
387
  }
377
388
  /** Take the delivery sheet off the document, where one was adopted — a second paginate of a document that no longer declares a read leaves nothing behind. */
378
- export function withdraw(doc) {
379
- const sheet = SHEETS.get(doc);
389
+ export function withdraw(doc, scope = doc) {
390
+ const sheet = SHEETS.get(scope);
380
391
  if (sheet && doc.adoptedStyleSheets.includes(sheet))
381
392
  doc.adoptedStyleSheets = doc.adoptedStyleSheets.filter((s) => s !== sheet);
382
- showReservations(doc);
393
+ showReservations(doc, scope);
383
394
  }
384
395
  /**
385
396
  * The written and cascade facts, before any run: every `target-text()`, and every `target-counter()` and `target-counters()` of a counter other than `page`.
@@ -392,9 +403,10 @@ export function staticValues(sites, { doc, blocks, index = null, keep = null })
392
403
  const unread = [];
393
404
  const counted = sites.filter((s) => s.target && (s.read.fn === 'counter' || s.read.fn === 'counters') && !isPageRead(s.read));
394
405
  let states = new Map();
406
+ let ops = null;
395
407
  if (counted.length) {
396
408
  const win = doc.defaultView;
397
- const ops = documentOpsOf(blocks, index ?? indexBlocks(blocks), {
409
+ ops = documentOpsOf(blocks, index ?? indexBlocks(blocks), {
398
410
  styleOf: (el) => win.getComputedStyle(el),
399
411
  pseudoStyleOf: sheetsCountInPseudos(doc) ? (el, pseudo) => win.getComputedStyle(el, pseudo) : null,
400
412
  ancestors: [doc.documentElement, doc.body].filter(Boolean),
@@ -422,7 +434,7 @@ export function staticValues(sites, { doc, blocks, index = null, keep = null })
422
434
  }
423
435
  put(values, s.key, s.prop, cssString(formatCounter(counterValue(state, s.read.counter), s.read.style)));
424
436
  }
425
- return { values, unread };
437
+ return { values, unread, ops };
426
438
  }
427
439
  /**
428
440
  * The layout facts, after a run: the page each page read's target lands on, as the ledger assigned it — the page whose interval holds the target's first word, over the intervals `root.pageOf` reads (`positions.js`'s `pageContaining`).
@@ -534,14 +546,14 @@ function boxAddress(site, index, blocks) {
534
546
  *
535
547
  * **A layout sent round again resumes from the one before it** (*delivery resumes from the moved page*, 2026-10-21). A delivered value that moves a line is an edit at its site's page, so `run` is handed `resume`, the layout just checked and the addresses of the boxes whose lines moved, and the driver restarts one page before the first of them, re-cuts, and stops where it converges past the last, exactly as it resumes from a host's edit. The result's `resumed` and `why` stay the call's: two resumes compose into one from the prior the host handed (`resume.js`' `composeResumed`), a whole layout anywhere in the loop makes `resumed` null, and each resumed layout leaves a `cicero:reads-run` measure naming the pages it cut again.
536
548
  *
537
- * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
549
+ * @param how - `run` lays the document out once, whole or resumed (`because`: the references whose page number moved a line of their own in the run before, when this is not the first; `facts`: the page values a flow site reads, per page, as the run before decided them, or null for the reservation; it returns `laidFacts` beside `account` when the page values it was laid out under are not the ones it was handed); `blocksOf` names the top-level blocks; `originals` the intake's top-level boxes, where the document has been presented (see {@link findSites}); `warm` and `warmFacts` the tables the previous run's layout was cut under, for a run that resumes; `hide` that pages are shown before the run is complete (*the pages are cut as far as they are asked*), so the reservations still standing are painted invisible until the true numbers are delivered, under `<beltype-root>` for `true` and under the selector a string names for a host that is not the root; `scope` an object the host holds for this view (the document where absent), which keeps this view's delivery and hiding sheets apart from another view's in the same document; `keep` the memo's door, through which the site search, the position index and the counter walk take the parts of the boxes no edit replaced (*a resumed run keeps its reads*, 2026-10-22). A `run` that answers `complete: false` laid out only part of the document (a view's chain that stopped short): the loop ends there, without checking or delivering page values, so the reservations stay hidden and the values it returns are the ones that layout was cut under; the driver's own run is always complete.
538
550
  */
539
- export async function settleReads(doc, { run, blocksOf, originals = null, warm = null, warmFacts = null, hide = false, keep = null }) {
551
+ export async function settleReads(doc, { run, blocksOf, originals = null, warm = null, warmFacts = null, hide = false, keep = null, scope = doc }) {
540
552
  let t = performance.now(); // the site search is the reads' own cost and is inside their measure (*a resumed run keeps its reads*, 2026-10-22)
541
553
  const found = findSites(doc, originals, keep);
542
554
  const pageSites = findPageSites(doc, originals, keep);
543
555
  if (!found && !pageSites) {
544
- withdraw(doc);
556
+ withdraw(doc, scope);
545
557
  const once = await run({ attempt: 0, delivered: null, facts: null, sites: null, index: null });
546
558
  return { ...once, resumed: once.resumed ?? null, why: once.why ?? null, delivered: null, facts: null, reads: null };
547
559
  }
@@ -553,17 +565,17 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
553
565
  for (const s of sites)
554
566
  if (s.target && s.read.fn === 'text' && !blocks.some((b) => b.contains(s.target)))
555
567
  s.target = null;
556
- // One walk of the words, shared with the run (*the run resumes from an entry*, build two: 60 ms of every manual run was this walk, done twice). The counter log is not shared, and the reason is worth a line: it is read here before the run registers the GCPM properties, and an unregistered `string-set` inherits, so a log taken now would assign every heading's string to its descendants too.
568
+ // One walk of the words, shared with the run (*the run resumes from an entry*, build two: 60 ms of every manual run was this walk, done twice), and one counter walk where a counter reference needs it: the run takes this log and lays its running elements in (`document-state.ts`' `withRunning`). It is read under the GCPM properties' registration, which `paginate` makes before the reads, since an unregistered `string-set` inherits and a log taken before it would assign every heading's string to its descendants too. Measured 2026-11-09 on a 190-section document with one `target-counter()` of a figure: the run's `cicero:document-ops` went from 79 to 82 ms to under 1 ms, the reads' measure unchanged at 163 to 177 ms.
557
569
  const index = indexBlocks(blocks, undefined, undefined, keep);
558
570
  const fixed = staticValues(sites, { doc, blocks, index, keep });
559
571
  const paged = sites.filter((s) => s.target && isPageRead(s.read));
560
572
  let delivered = reservations(sites, warm);
561
573
  if (found)
562
- deliver(doc, merged(fixed.values, delivered));
574
+ deliver(doc, merged(fixed.values, delivered), scope);
563
575
  else
564
- withdraw(doc);
576
+ withdraw(doc, scope);
565
577
  if (hide)
566
- hideReservations(doc, [...paged.filter((s) => delivered.get(s.key ?? '')?.get(s.prop) === cssString(RESERVATION)), ...(pageSites ? totalSites(doc) : [])], hide === true ? undefined : hide);
578
+ hideReservations(doc, [...paged.filter((s) => delivered.get(s.key ?? '')?.get(s.prop) === cssString(RESERVATION)), ...(pageSites ? totalSites(doc) : [])], hide === true ? undefined : hide, scope);
567
579
  performance.measure('cicero:reads', { start: t, end: (t = performance.now()), detail: { references: sites.length, pageSites: pageSites?.length ?? 0 } });
568
580
  let runs = 0;
569
581
  let moved = [];
@@ -583,7 +595,7 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
583
595
  laidUnder = merged(fixed.values, delivered);
584
596
  laidFacts = facts;
585
597
  const started = performance.now();
586
- result = await run({ attempt: runs, delivered: found ? laidUnder : null, facts: laidFacts, sites: found, index, because: because.map(name), resume });
598
+ result = await run({ attempt: runs, delivered: found ? laidUnder : null, facts: laidFacts, sites: found, index, ops: fixed.ops, because: because.map(name), resume });
587
599
  laidFacts = result.laidFacts ?? laidFacts; // the page values the layout was cut under are the run's to say; a run that says none was cut under the ones it was handed
588
600
  if (runs === 0) {
589
601
  resumed = result.resumed ?? null;
@@ -601,7 +613,7 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
601
613
  facts = laidFacts;
602
614
  break;
603
615
  }
604
- showReservations(doc); // the run is complete: what was reserved is delivered true on the next lines, or was true already
616
+ showReservations(doc, scope); // the run is complete: what was reserved is delivered true on the next lines, or was true already
605
617
  const truth = pageValues(paged, result);
606
618
  unplaced = truth.unplaced;
607
619
  const changed = new Set(changedPairs(delivered, truth.values));
@@ -618,7 +630,7 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
618
630
  const before = linesOfSites(doc, touched, result, blocks);
619
631
  const beforePage = linesOfSites(doc, shifted, result, blocks, (page) => factOf(laidFacts, page));
620
632
  if (found)
621
- deliver(doc, merged(fixed.values, truth.values));
633
+ deliver(doc, merged(fixed.values, truth.values), scope);
622
634
  delivered = truth.values;
623
635
  facts = truthFacts;
624
636
  const after = linesOfSites(doc, touched, result, blocks);
@@ -644,6 +656,7 @@ export async function settleReads(doc, { run, blocksOf, originals = null, warm =
644
656
  if (moved.length)
645
657
  console.warn(`BelType/Cicero: after ${runs} runs the page numbers of ${named(moved)} still move their own lines — the last true values stand, on a layout measured with the values before them.`);
646
658
  const pageReads = sites.filter((s) => isPageRead(s.read)).length;
647
- console.info(`BelType/Cicero: ${sites.length} reference(s) read (${pageReads} page, ${sites.length - pageReads} counter or text) and ${pageSites?.length ?? 0} site(s) reading their own page; the pages settled in ${runs} run${runs === 1 ? '' : 's'}.`);
659
+ if (moved.length)
660
+ console.info(`BelType/Cicero: ${sites.length} reference(s) read (${pageReads} page, ${sites.length - pageReads} counter or text) and ${pageSites?.length ?? 0} site(s) reading their own page; the pages settled in ${runs} run${runs === 1 ? '' : 's'}.`);
648
661
  return { ...result, resumed, why, delivered: found ? (moved.length ? laidUnder : merged(fixed.values, delivered)) : null, facts: moved.length ? laidFacts : facts, reads: { references: sites.length, pageSites: pageSites?.length ?? 0, runs, unsettled: moved.map(name) } };
649
662
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What the engine does not do yet, read from the author's boxes at intake: vertical writing below the root is refused by name, and a multi-column box too tall for any page is laid out as one column.
3
+ *
4
+ * @remarks The root's own direction and writing mode are refused by `assertRenderConditions` (`src/measure.ts`). Both refusals are out of scope for the beta (SPEC II.1, item 6). The one-column fallback is an interim: the computed fact (the box needs dividing) is known and only the presentation of its columns is not, so it says so on the console (SPEC Article I, item 11). The ceiling is the first page's geometry, and a box nested in another multi-column box is measured on its own.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ /**
9
+ * Walk the blocks' boxes once: refuse a vertical writing mode, answer the multi-column boxes.
10
+ *
11
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a box below the root that computes a `writing-mode` other than `horizontal-tb`.
12
+ */
13
+ export declare function readScope(win: Window & typeof globalThis, blocks: Iterable<Element>): Element[];
14
+ /**
15
+ * Give the word `one-column` to every multi-column box that is taller than a page area at the page's width, so its copies lay out as one column and divide like any block. A box that fits a page keeps its columns.
16
+ *
17
+ * @param columns - {@link readScope}'s answer.
18
+ * @param page - the geometry the boxes are measured against.
19
+ */
20
+ export declare function foldTallColumns(doc: Document, columns: Element[], page: {
21
+ contentW: number;
22
+ contentH: number;
23
+ }): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What the engine does not do yet, read from the author's boxes at intake: vertical writing below the root is refused by name, and a multi-column box too tall for any page is laid out as one column.
3
+ *
4
+ * @remarks The root's own direction and writing mode are refused by `assertRenderConditions` (`src/measure.ts`). Both refusals are out of scope for the beta (SPEC II.1, item 6). The one-column fallback is an interim: the computed fact (the box needs dividing) is known and only the presentation of its columns is not, so it says so on the console (SPEC Article I, item 11). The ceiling is the first page's geometry, and a box nested in another multi-column box is measured on its own.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ import { createMeasureContext } from './measure.js';
9
+ import { ONE_COLUMN } from './data-beltype.js';
10
+ import { addSourceWord, measuringCopyOf, sourceHasWord } from './word-space.js';
11
+ const named = new WeakSet();
12
+ const describe = (el) => `<${el.localName}${el.className && typeof el.className === 'string' ? ` class="${el.className}"` : ''}>`;
13
+ /**
14
+ * Walk the blocks' boxes once: refuse a vertical writing mode, answer the multi-column boxes.
15
+ *
16
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a box below the root that computes a `writing-mode` other than `horizontal-tb`.
17
+ */
18
+ export function readScope(win, blocks) {
19
+ const columns = [];
20
+ for (const block of blocks) {
21
+ for (const el of [block, ...block.querySelectorAll('*')]) {
22
+ const st = win.getComputedStyle(el);
23
+ if (st.writingMode && st.writingMode !== 'horizontal-tb') {
24
+ throw new Error(`vertical writing is outside this engine's scope: ${describe(el)} computes writing-mode '${st.writingMode}', and every read here is horizontal (the line by vertical overlap, the cut by a y). Write it horizontal-tb, or print the document with another engine.`);
25
+ }
26
+ if ((st.columnCount ?? 'auto') !== 'auto' || (st.columnWidth ?? 'auto') !== 'auto')
27
+ columns.push(el);
28
+ }
29
+ }
30
+ return columns;
31
+ }
32
+ /**
33
+ * Give the word `one-column` to every multi-column box that is taller than a page area at the page's width, so its copies lay out as one column and divide like any block. A box that fits a page keeps its columns.
34
+ *
35
+ * @param columns - {@link readScope}'s answer.
36
+ * @param page - the geometry the boxes are measured against.
37
+ */
38
+ export function foldTallColumns(doc, columns, page) {
39
+ if (!columns.length)
40
+ return;
41
+ const ctx = createMeasureContext(doc, page.contentW);
42
+ try {
43
+ for (const el of columns) {
44
+ if (sourceHasWord(el, ONE_COLUMN))
45
+ continue; // settled earlier in this call; the next call asks again
46
+ const copy = measuringCopyOf(el);
47
+ ctx.measure([copy]);
48
+ if (copy.getBoundingClientRect().height <= page.contentH)
49
+ continue;
50
+ addSourceWord(el, ONE_COLUMN);
51
+ if (!named.has(el)) {
52
+ named.add(el);
53
+ doc.defaultView?.console?.warn(`cicero: ${describe(el)} is a multi-column box taller than a page, and columns are not divided across pages yet — it is laid out as one column`);
54
+ }
55
+ }
56
+ }
57
+ finally {
58
+ ctx.dispose();
59
+ }
60
+ }
@@ -133,7 +133,7 @@ export declare function classBWindow(T: number, orphans: number, widows: number)
133
133
  */
134
134
  export declare function isMonolith(el: Element, style: CSSStyleDeclaration, live?: Element): boolean;
135
135
  /**
136
- * **A fourth ground, and a temporary one: what the engine does not yet divide, where dividing it loses words** (*every branch gets a place*, 2026-09-21). Two arrangements lay content side by side in ways a row of sibling boxes does not describe, and each lost words at a cut until it was held whole (`packages/essay/probes/conservation.js`): a multi-column container, whose columns are fragmentainers of their own; and an inline-level block container, which is an atom of a line, with paragraphs inside it that the procession used to see. Held as a monolith each moves whole, or is sliced by the edge with its remainder painted — css-break-3's own remedy. **The remedy has a price, measured the same day, and it is why the list is this short**: a slice through text leaves the line the edge crosses half on each page, and the print's text layer reads it on both. That is better than words on no page and worse than anything else, so a grid with sized tracks or placed items — which divides without losing a word and merely opens its next page on the spent track's blank — was tried here and taken out again. Each entry leaves this list when it has a design of its own.
136
+ * **A fourth ground, and a temporary one: what the engine does not yet divide, where dividing it loses words** (*every branch gets a place*, 2026-09-21). Two arrangements lay content side by side in ways a row of sibling boxes does not describe, and each lost words at a cut until it was held whole (`packages/essay/probes/conservation.js`): a multi-column container, whose columns are fragmentainers of their own; and an inline-level block container, which is an atom of a line, with paragraphs inside it that the procession used to see. Held as a monolith each moves whole, or is sliced by the edge with its remainder painted — css-break-3's own remedy. **The remedy has a price, measured the same day, and it is why the list is this short**: a slice through text leaves the line the edge crosses half on each page, and the print's text layer reads it on both. A multi-column box taller than a page no longer reaches this list: it is laid out as one column (`src/scope.ts`), and the slice is left to a box that is a monolith for another reason. That is better than words on no page and worse than anything else, so a grid with sized tracks or placed items — which divides without losing a word and merely opens its next page on the spent track's blank — was tried here and taken out again. Each entry leaves this list when it has a design of its own.
137
137
  *
138
138
  * `live` is the element a measured clone stands for, kept in the signature for the day a ground needs to ask the element rather than its computed style — a grid's declared tracks are such a question: `grid-template-rows` resolves to the used tracks, implicit ones included, and only `computedStyleMap()` tells a declared list from none (measured in Chromium).
139
139
  */
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * @packageDocumentation
13
13
  */
14
- import { isAtom, READS_ITS_CHILDREN } from './word-space.js';
15
- import { ARTIFACT, hasWord } from './data-beltype.js';
14
+ import { isAtom, READS_ITS_CHILDREN, sourceHasWord } from './word-space.js';
15
+ import { ARTIFACT, ONE_COLUMN, hasWord } from './data-beltype.js';
16
16
  /**
17
17
  * THE MAKE-UP ORDER (*breaks are classified*, I3, amended in the fifth round) — which constraint yields first, and the standing fact that a constraint which has yielded still pulls.
18
18
  *
@@ -75,13 +75,13 @@ export function isMonolith(el, style, live = el) {
75
75
  return notYetDivided(style, live) !== null;
76
76
  }
77
77
  /**
78
- * **A fourth ground, and a temporary one: what the engine does not yet divide, where dividing it loses words** (*every branch gets a place*, 2026-09-21). Two arrangements lay content side by side in ways a row of sibling boxes does not describe, and each lost words at a cut until it was held whole (`packages/essay/probes/conservation.js`): a multi-column container, whose columns are fragmentainers of their own; and an inline-level block container, which is an atom of a line, with paragraphs inside it that the procession used to see. Held as a monolith each moves whole, or is sliced by the edge with its remainder painted — css-break-3's own remedy. **The remedy has a price, measured the same day, and it is why the list is this short**: a slice through text leaves the line the edge crosses half on each page, and the print's text layer reads it on both. That is better than words on no page and worse than anything else, so a grid with sized tracks or placed items — which divides without losing a word and merely opens its next page on the spent track's blank — was tried here and taken out again. Each entry leaves this list when it has a design of its own.
78
+ * **A fourth ground, and a temporary one: what the engine does not yet divide, where dividing it loses words** (*every branch gets a place*, 2026-09-21). Two arrangements lay content side by side in ways a row of sibling boxes does not describe, and each lost words at a cut until it was held whole (`packages/essay/probes/conservation.js`): a multi-column container, whose columns are fragmentainers of their own; and an inline-level block container, which is an atom of a line, with paragraphs inside it that the procession used to see. Held as a monolith each moves whole, or is sliced by the edge with its remainder painted — css-break-3's own remedy. **The remedy has a price, measured the same day, and it is why the list is this short**: a slice through text leaves the line the edge crosses half on each page, and the print's text layer reads it on both. A multi-column box taller than a page no longer reaches this list: it is laid out as one column (`src/scope.ts`), and the slice is left to a box that is a monolith for another reason. That is better than words on no page and worse than anything else, so a grid with sized tracks or placed items — which divides without losing a word and merely opens its next page on the spent track's blank — was tried here and taken out again. Each entry leaves this list when it has a design of its own.
79
79
  *
80
80
  * `live` is the element a measured clone stands for, kept in the signature for the day a ground needs to ask the element rather than its computed style — a grid's declared tracks are such a question: `grid-template-rows` resolves to the used tracks, implicit ones included, and only `computedStyleMap()` tells a declared list from none (measured in Chromium).
81
81
  */
82
82
  export function notYetDivided(style, live) {
83
- if ((style.columnCount ?? 'auto') !== 'auto' || (style.columnWidth ?? 'auto') !== 'auto')
84
- return 'multicol';
83
+ if (((style.columnCount ?? 'auto') !== 'auto' || (style.columnWidth ?? 'auto') !== 'auto') && !sourceHasWord(live, ONE_COLUMN))
84
+ return 'multicol'; // a source the run laid out as one column (`src/scope.ts`) is an ordinary block, though its own style still says columns
85
85
  if (/^inline-(block|flex|grid|table)$/.test(style.display ?? ''))
86
86
  return 'inline block container';
87
87
  return null;
@@ -331,8 +331,16 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
331
331
  plan.push({ item, state: 'held', verdict: null }); // a monolith the height crosses resumes whole: the stated limit, as a float's (the item is the graphic, and nothing inside it is a place)
332
332
  else {
333
333
  const v = flowVerdict(w, item, h + lift, eps);
334
- if (!v)
335
- return null; // content crosses the height and the item offers no place above it: it shows nothing here
334
+ if (!v) {
335
+ // Content crosses the height and the item offers no place above it: it shows nothing here. In the first table row of the group that withdraws the offer (case 91). In a later row of a group a `rowspan` makes, that row moves whole with every row after it, the boundary before it being the break in its column, and the spanning cell is still divided at the height, as Chromium prints it (the essay's case 92).
336
+ if (item.parent === row[0].parent)
337
+ return null;
338
+ const from = row.findIndex((r) => r.parent === item.parent);
339
+ plan.splice(from);
340
+ for (const rest of row.slice(from))
341
+ plan.push({ item: rest, state: 'below', verdict: null });
342
+ break;
343
+ }
336
344
  plan.push({ item, state: 'divided', verdict: v });
337
345
  }
338
346
  }
@@ -358,7 +366,9 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
358
366
  const pair = siblings[si - 1];
359
367
  const anchor = last(siblings.slice(0, si).filter((b) => !parallelBox(b))) ?? null;
360
368
  // Before the rows of a continuation only the repeated header stands, and a copy is furniture (*an artifact is never read*): a boundary there takes nothing of the box, and would send the page back to where the box began.
361
- if (anchor && !siblings.slice(0, si).every((b) => parallelBox(b) || copy(b))) {
369
+ // A table row a `rowspan` from above reaches is inside its group's row: the boundary before it would leave the spanning cell's remainder on no page, and the group's own cut is where that boundary is taken (`rowPlan`).
370
+ const spanned = box.blockChildren.some((c) => { const r = rowOf(c); return !!r && r[0].parent !== box; });
371
+ if (anchor && !spanned && !siblings.slice(0, si).every((b) => parallelBox(b) || copy(b))) {
362
372
  candidates.push({
363
373
  kind: 'A',
364
374
  cls: 'A',
@@ -454,7 +464,8 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
454
464
  // The place it names is before the box's first in-flow child, past the gap (*the cut divides its ancestors*, 2026-09-29): the page before carries the box's border, its padding and whatever stands in the gap — an empty float, the clearance under it — and the page after resumes the box divided, its first child whole. So the position is the child's outer top, which is the content edge plus the gap; it used to be the box's top plus its border, which put the padding on the next page and offered a break point §4.1 does not provide (`packages/essay/cases/71-the-box-that-begins-a-page`).
455
465
  const startGap = box.blockChildren.length ? report.gapAt({ box, edge: 'start' }) : 0;
456
466
  const firstInFlow = startGap > eps ? box.blockChildren.find((c) => !parallelBox(c)) : undefined;
457
- if (firstInFlow && !copy(firstInFlow)) {
467
+ // A box that opens the page offers no start gap: the page would hold its block-start decoration and nothing else, and the next page would offer the same gap again wherever the box clones its decoration, so the cut is no progress (css-break-3 §4.4 drops an avoid that leaves only this, and Chromium's print never takes it: a row taller than the page under `break-inside: avoid` left a blank first page, and WPT's `table-fragmentation-001c` refused, assessment 2026-11-07, `outer-row-taller-than-page-avoid`).
468
+ if (firstInFlow && !copy(firstInFlow) && box.rect.top > pageStart + eps) {
458
469
  const prevSibling = si > 0 ? siblings[si - 1] : null;
459
470
  const bs = win.getComputedStyle(box.el);
460
471
  candidates.push({
@@ -49,6 +49,10 @@ export declare function isLineBreak(el: Element): boolean;
49
49
  * @param map - the run's words, by source element.
50
50
  */
51
51
  export declare function setSourceWords(map: WeakMap<Element, string[]>): void;
52
+ /** Give a source a word for the copies this run makes from now on; the source itself is not written (C5). */
53
+ export declare function addSourceWord(source: Element, word: string): void;
54
+ /** Does the run's record give this source the word? */
55
+ export declare const sourceHasWord: (source: Element, word: string) => boolean;
52
56
  /**
53
57
  * Write the words onto a copy and every element under it, in lockstep with the source tree, without registering the copy against its source — for a measuring clone that is read and thrown away (the Compositor's), where registering would change what an unmapped element is treated as.
54
58
  *
@@ -186,6 +190,13 @@ export declare const SIDE_BY_SIDE: Set<string>;
186
190
  * **The answer is read once per source element and kept** (*the page asks the index*, 2026-09-25). `stripFixedBoxes` asks it of every element of every feed clone and every presentation clone, about 300 000 style reads a run on the BGD manual for an answer that cannot change: the source is never written (*the source document is never written*, C5), and a host calls `run()` once per frame, so nothing moves a `position` within a run — the essay's client runs its judge and its view over one document whose values do not change between them. Only this predicate is kept: `isInlineBox` and `sourceStyle` are read far less often per clone, and a wider cache would be a wider claim.
187
191
  */
188
192
  export declare function isFixedBox(el: Element): boolean;
193
+ /**
194
+ * Is this child a float standing wholly in its parent's padding or border, a hanging number (Codeberg issue 7)? Such a float shortens none of the parent's line boxes, so it is no part of the text being set: the composed mode skips it, in the child check and in the word walk, and leaves it where the browser put it. Any other float still makes the parent a block of floats and refuses.
195
+ *
196
+ * @remarks
197
+ * Asked through the registered source and kept per source, as {@link isFixedBox} is, for the same reasons: a clone detached from the document has no rect, and the source's layout does not move within a run. An unregistered, disconnected element answers false.
198
+ */
199
+ export declare function isOutsideMarker(el: Element): boolean;
189
200
  /** Does this box hold a `position: fixed` box anywhere inside it? The question `root.js` asks to know whether a block may be presented as its original or must be presented as a stripped clone — asked through `isFixedBox`, so the two can never disagree about which boxes count. */
190
201
  export declare function holdsFixedBox(el: Element): boolean;
191
202
  /**
@@ -67,6 +67,16 @@ let WORDS_OF = new WeakMap();
67
67
  export function setSourceWords(map) {
68
68
  WORDS_OF = map;
69
69
  }
70
+ /** Give a source a word for the copies this run makes from now on; the source itself is not written (C5). */
71
+ export function addSourceWord(source, word) {
72
+ const list = WORDS_OF.get(source);
73
+ if (!list)
74
+ WORDS_OF.set(source, [word]);
75
+ else if (!list.includes(word))
76
+ list.push(word);
77
+ }
78
+ /** Does the run's record give this source the word? */
79
+ export const sourceHasWord = (source, word) => !!WORDS_OF.get(source)?.includes(word);
70
80
  /** Write the words a source carries onto a copy of it — added to whatever the copy inherited from the source's own attribute, never replacing it. */
71
81
  function dressCopy(clone, source) {
72
82
  const words = WORDS_OF.get(source);
@@ -430,6 +440,36 @@ export function isFixedBox(el) {
430
440
  }
431
441
  return fixed;
432
442
  }
443
+ const OUTSIDE = new WeakMap();
444
+ /**
445
+ * Is this child a float standing wholly in its parent's padding or border, a hanging number (Codeberg issue 7)? Such a float shortens none of the parent's line boxes, so it is no part of the text being set: the composed mode skips it, in the child check and in the word walk, and leaves it where the browser put it. Any other float still makes the parent a block of floats and refuses.
446
+ *
447
+ * @remarks
448
+ * Asked through the registered source and kept per source, as {@link isFixedBox} is, for the same reasons: a clone detached from the document has no rect, and the source's layout does not move within a run. An unregistered, disconnected element answers false.
449
+ */
450
+ export function isOutsideMarker(el) {
451
+ const live = SOURCE_OF.get(el) ?? el;
452
+ const win = live.ownerDocument?.defaultView;
453
+ const parent = live.parentElement;
454
+ if (!win || !parent || live.isConnected !== true)
455
+ return false;
456
+ let outside = OUTSIDE.get(live);
457
+ if (outside === undefined) {
458
+ outside = false;
459
+ if (win.getComputedStyle(live).float !== 'none') {
460
+ const ps = win.getComputedStyle(parent);
461
+ const p = parent.getBoundingClientRect();
462
+ const f = live.getBoundingClientRect();
463
+ const left = p.left + parseFloat(ps.borderLeftWidth) + parseFloat(ps.paddingLeft);
464
+ const right = p.right - parseFloat(ps.borderRightWidth) - parseFloat(ps.paddingRight);
465
+ const top = p.top + parseFloat(ps.borderTopWidth) + parseFloat(ps.paddingTop);
466
+ const bottom = p.bottom - parseFloat(ps.borderBottomWidth) - parseFloat(ps.paddingBottom);
467
+ outside = !(f.left < right && f.right > left && f.top < bottom && f.bottom > top);
468
+ }
469
+ OUTSIDE.set(live, outside);
470
+ }
471
+ return outside;
472
+ }
433
473
  /** Does this box hold a `position: fixed` box anywhere inside it? The question `root.js` asks to know whether a block may be presented as its original or must be presented as a stripped clone — asked through `isFixedBox`, so the two can never disagree about which boxes count. */
434
474
  export function holdsFixedBox(el) {
435
475
  for (const child of el.children) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cicero",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Cicero, a paged-media engine for the live browser: it cuts a document into pages under CSS Paged Media and presents them as real frames. Beta.",
5
5
  "type": "module",
6
6
  "license": "MIT",