@beltype/cicero 0.1.6 → 0.1.7

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.
@@ -8,6 +8,7 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  import enUs from './hyphen-patterns/en-us.js';
11
+ import it from './hyphen-patterns/it.js';
11
12
  const LANGS = new Map();
12
13
  /** Build a language's trie once. Exposed as data-in, function-out so a future language module registers the same way. */
13
14
  export function registerLanguage(data) {
@@ -36,12 +37,13 @@ export function registerLanguage(data) {
36
37
  for (const ex of data.exceptions ?? []) {
37
38
  exceptions.set(ex.replace(/-/g, ''), ex);
38
39
  }
39
- const lang = { trie, exceptions, leftmin: data.leftmin, rightmin: data.rightmin };
40
+ const lang = { trie, exceptions, leftmin: data.leftmin, rightmin: data.rightmin, word: data.word ?? /^()([a-zA-Z]+)()$/ };
40
41
  for (const id of data.id)
41
42
  LANGS.set(id, lang);
42
43
  return lang;
43
44
  }
44
45
  registerLanguage(enUs);
46
+ registerLanguage(it);
45
47
  /** Resolve a lang attribute value to a registered language, or null (no language, no candidates — auto-hyphenation is never guessed). */
46
48
  export function languageFor(langAttr) {
47
49
  if (!langAttr)
@@ -66,13 +68,19 @@ export function resolveLimits(lang, limits = {}) {
66
68
  *
67
69
  * @param lang - from `languageFor`; null yields no candidates.
68
70
  */
69
- export function hyphenationOffsets(word, lang, limits = {}) {
71
+ export function hyphenationOffsets(text, lang, limits = {}) {
70
72
  if (!lang)
71
73
  return [];
72
74
  const { before, after, total } = resolveLimits(lang, limits);
73
- if (word.length < total || word.length < before + after || !/^[a-zA-Z]+$/.test(word))
75
+ const found = lang.word.exec(text);
76
+ if (!found)
74
77
  return [];
75
- const lower = word.toLowerCase();
78
+ const lead = found[1].length;
79
+ const word = found[2];
80
+ if (word.length < total || word.length < before + after)
81
+ return [];
82
+ // Both apostrophes are one letter to a pattern; the replacement keeps every offset.
83
+ const lower = word.toLowerCase().replace(/’/g, "'");
76
84
  const exception = lang.exceptions.get(lower);
77
85
  if (exception !== undefined) {
78
86
  const offsets = [];
@@ -80,7 +88,7 @@ export function hyphenationOffsets(word, lang, limits = {}) {
80
88
  for (const part of exception.split('-').slice(0, -1)) {
81
89
  pos += part.length;
82
90
  if (pos >= before && word.length - pos >= after)
83
- offsets.push(pos);
91
+ offsets.push(lead + pos);
84
92
  }
85
93
  return offsets;
86
94
  }
@@ -106,7 +114,7 @@ export function hyphenationOffsets(word, lang, limits = {}) {
106
114
  for (let p = 2; p < marked.length - 1; p++) {
107
115
  const off = p - 1;
108
116
  if (points[p] % 2 === 1 && off >= before && word.length - off >= after)
109
- offsets.push(off);
117
+ offsets.push(lead + off);
110
118
  }
111
119
  return offsets;
112
120
  }
@@ -12,7 +12,9 @@ export type MeasureContext = ReturnType<typeof createMeasureContext>;
12
12
  /**
13
13
  * The intake under *render-identical conditions* (I5): refuse to adjudicate under conditions print will not reproduce. `relaxDpr` is the preview's escape hatch only — decisions are then valid for the screen being looked at, not for print; no gate ever sets it.
14
14
  *
15
- * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, or a document root whose line-height is not explicit (*render-identical conditions*, I5).
15
+ * A vertical or sideways root is refused by name too (Codeberg issue 12): every read in this engine is horizontal, the lines by vertical overlap, the cut by a y, the page area by its height, so under `writing-mode: sideways-lr` on the root the text of a positioned paragraph fell below the clip and was never resumed, and the printer's tiling invariant refused what the engine should have. Vertical writing is outside the beta, and a refusal that says so at intake is cheaper than one three tools later.
16
+ *
17
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), or a document root whose writing mode is not `horizontal-tb`.
16
18
  */
17
19
  export declare function assertRenderConditions(doc: Document, { relaxDpr }?: {
18
20
  relaxDpr?: boolean;
@@ -64,13 +66,13 @@ export type PagePublish = {
64
66
  * @param contentWidthPx - the page's true content width — the one geometry decisions are valid under (*render-identical conditions*, I5).
65
67
  */
66
68
  export declare function createMeasureContext(doc: Document, contentWidthPx: number): {
67
- container: HTMLElement;
69
+ container: HTMLDivElement;
68
70
  /**
69
71
  * Lay out a feed of nodes and force one layout. The caller obtains geometry only through a Gauge report taken afterward — obtaining the report seals the mutation phase (*render-identical conditions*, I5, by type; see gauge.js).
70
72
  *
71
73
  * @param nodes - cloned feed content; never source elements (*the source document is never written*, C5, and never moved).
72
74
  * @param publish - what the page the feed is for publishes on its host (*the page carries its conditions*, 2026-10-21), written on the container so a flow site that reads a page fact is measured with it standing; absent, the container carries none, and such a site reads its reservation.
73
75
  */
74
- measure(nodes: Node[], publish?: PagePublish | null): HTMLElement;
76
+ measure(nodes: Node[], publish?: PagePublish | null): HTMLDivElement;
75
77
  dispose(): void;
76
78
  };
@@ -15,17 +15,23 @@ const NEUTRALIZE_ID = '__beltype-neutralize';
15
15
  /**
16
16
  * The intake under *render-identical conditions* (I5): refuse to adjudicate under conditions print will not reproduce. `relaxDpr` is the preview's escape hatch only — decisions are then valid for the screen being looked at, not for print; no gate ever sets it.
17
17
  *
18
- * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, or a document root whose line-height is not explicit (*render-identical conditions*, I5).
18
+ * A vertical or sideways root is refused by name too (Codeberg issue 12): every read in this engine is horizontal, the lines by vertical overlap, the cut by a y, the page area by its height, so under `writing-mode: sideways-lr` on the root the text of a positioned paragraph fell below the clip and was never resumed, and the printer's tiling invariant refused what the engine should have. Vertical writing is outside the beta, and a refusal that says so at intake is cheaper than one three tools later.
19
+ *
20
+ * @throws Error — bare and deliberate (`src/` may not import BelType's error types): a device pixel ratio other than 1 without `relaxDpr`, a document root whose line-height is not explicit (*render-identical conditions*, I5), or a document root whose writing mode is not `horizontal-tb`.
19
21
  */
20
22
  export function assertRenderConditions(doc, { relaxDpr = false } = {}) {
21
23
  const win = doc.defaultView;
22
24
  if (!relaxDpr && win.devicePixelRatio !== 1) {
23
25
  throw new Error(`I5: unit device pixel ratio required for adjudication, got ${win.devicePixelRatio}`);
24
26
  }
25
- const lh = win.getComputedStyle(doc.documentElement).lineHeight;
27
+ const root = win.getComputedStyle(doc.documentElement);
28
+ const lh = root.lineHeight;
26
29
  if (!/^-?[\d.]+px$/.test(lh)) {
27
30
  throw new Error(`I5 (DPR ruling): explicit line-height required, document root computes '${lh}'`);
28
31
  }
32
+ if (root.writingMode && root.writingMode !== 'horizontal-tb') {
33
+ throw new Error(`a vertical or sideways root is outside this engine's beta: the document root computes writing-mode '${root.writingMode}', and every read here is horizontal (the line by vertical overlap, the cut by a y). Write the document horizontal-tb, or print it with another engine.`);
34
+ }
29
35
  }
30
36
  /**
31
37
  * Await font readiness once — late-arriving truth handled before the first measure, never during adjudication.
@@ -134,16 +140,14 @@ export function createMeasureContext(doc, contentWidthPx) {
134
140
  style.textContent = `#${CONTAINER_ID}, #${CONTAINER_ID} * { break-before: auto !important; break-after: auto !important; break-inside: auto !important; orphans: 2 !important; widows: 2 !important; transform: none !important; rotate: none !important; scale: none !important; translate: none !important; perspective: none !important; }`;
135
141
  doc.head.appendChild(style);
136
142
  }
137
- let container = doc.getElementById(CONTAINER_ID);
138
- if (!container) {
139
- container = doc.createElement('div');
140
- container.id = CONTAINER_ID;
141
- doc.body.appendChild(container);
142
- }
143
+ // Each context has a container of its own, so two fills in flight never measure in each other's. They share the one id on purpose, transiently: the neutralizing sheet and `reads.ts` select by it, at the specificity the sheet needs against an author's `!important`.
144
+ const container = doc.createElement('div');
145
+ container.id = CONTAINER_ID;
146
+ doc.body.appendChild(container);
143
147
  // flow-root: child margins stay inside, so the container's box is the measured flow's coordinate space. Off-viewport, never display:none — display:none has no geometry.
144
148
  const css = `position: absolute; left: -100000px; top: 0; width: ${contentWidthPx}px; display: flow-root;`;
145
149
  container.style.cssText = css;
146
- /** The container is one element for every context, and a run that stops at a page shares it (*the pages are cut as far as they are asked*, 2026-10-12): the presentation composes a page with a context of its own, at its own width, and puts it away — which removes the container and the neutralizing style from under a run that is waiting to go on. So every measure makes sure of both, and of the width it was created for, before it lays anything out. Nothing else changes: where no other context ran in between, all three are already so. */
150
+ /** A context can stand idle while another runs (*the pages are cut as far as they are asked*, 2026-10-12), and a host may move what it wears, so every measure makes sure of the sheet, the container and the width before it lays anything out. Where nothing moved, all three are already so. */
147
151
  const ensure = () => {
148
152
  if (!style.isConnected)
149
153
  doc.head.appendChild(style);
@@ -171,7 +175,8 @@ export function createMeasureContext(doc, contentWidthPx) {
171
175
  },
172
176
  dispose() {
173
177
  container.remove();
174
- style.remove();
178
+ if (!doc.querySelector(`#${CONTAINER_ID}`))
179
+ style.remove();
175
180
  }
176
181
  };
177
182
  }
package/dist/src/reads.js CHANGED
@@ -26,7 +26,7 @@ export const MAX_RUNS = 3;
26
26
  /** The reservation a forward layout read stands at during the first run, Egildo's ruling of 2026-10-03: wrong for nearly every reference, so nothing rests on it, and never printed, because the loop keeps the last true values. */
27
27
  export const RESERVATION = '0';
28
28
  /** A CSS string literal for any text: a quote, a backslash and a line break escaped, nothing else. */
29
- export const cssString = (text) => `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\a ')}"`;
29
+ export const cssString = (text) => `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\u0000-\u001f\u007f]/g, (ch) => `\\${ch.charCodeAt(0).toString(16)} `)}"`;
30
30
  /** The selector a delivery is keyed by: the attribute the function reads, at the value this element carries. */
31
31
  export const attrSelector = (attr, value) => `[${attr}=${cssString(value)}]`;
32
32
  /**
@@ -12,6 +12,7 @@
12
12
  * @packageDocumentation
13
13
  */
14
14
  import { isAtom, READS_ITS_CHILDREN } from './word-space.js';
15
+ import { ARTIFACT, hasWord } from './data-beltype.js';
15
16
  /**
16
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.
17
18
  *
@@ -158,7 +159,7 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
158
159
  const monolithic = (box) => {
159
160
  if (box.anonymous)
160
161
  return false; // a run of lines, never one unit
161
- if (box.laidOutLast)
162
+ if (box.laidOutLast || box.laidOutFirst)
162
163
  return true; // a footer written before its rows: its words stand before the rows in the word space, so no place inside it could be addressed (*the footer before the rows*)
163
164
  const live = sourceOf(box.el) ?? box.el;
164
165
  const held = isMonolith(box.el, styleOfBox(box), live);
@@ -197,9 +198,11 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
197
198
  /** A row's items are parallel flows too (*every branch gets a place*, 2026-09-21; §2.1's own list — the cells of a row, grid items, flex items). `report.rowOf` is the Gauge's answer: the side-by-side siblings a box stands among, or null. An item's interior offers nothing, exactly as a float's does, and the boundary between two items of one row is not a place in any procession — they stand beside each other, so nothing lies between them on the block axis. A row therefore offers two readings and no more: the boundary before it, on its first item, and the page edge through it. */
198
199
  const rowOf = (box) => (box === root ? null : report.rowOf?.(box) ?? null);
199
200
  const insideRowItem = (box) => climb(box, (p) => !!rowOf(p), false);
200
- /** Does the walk read this box at all? Not an inline box, nothing inside a monolith, a parallel flow or a row item — and a row answers once, at its first item (see `rowOf`). */
201
+ /** A copy the engine repeats (`artifact`), the table's header on a continuation: furniture with a footprint, no content of the flow, so no break point stands in it (*an artifact is never read*). */
202
+ const copy = (box) => !box.anonymous && hasWord(box.el, ARTIFACT);
203
+ /** Does the walk read this box at all? Not an inline box, nothing inside a monolith, a parallel flow, a row item or a repeated copy — and a row answers once, at its first item (see `rowOf`). */
201
204
  const walks = (box) => {
202
- if (box.inline || insideMonolith(box) || insideParallel(box) || insideRowItem(box))
205
+ if (box.inline || copy(box) || insideMonolith(box) || insideParallel(box) || insideRowItem(box) || climb(box, copy, false))
203
206
  return false;
204
207
  const row = rowOf(box);
205
208
  return !row || row[0] === box;
@@ -210,7 +213,7 @@ function walkOf(report, sourceOf, root = null, styleCache = new Map(), forcing =
210
213
  * A float, an absolutely positioned box and a row are not where it stops: a float's top is where the float was pushed, not the procession's, and a row's first item is not the row's top. The premise is stated rather than proved — that in-flow tops do not go backward in document order — and a negative margin taller than the box above it, or a relative offset, would break it. A table's footer written before its rows broke it too, and is walked where it is laid out instead (`gauge.js`'s `laidOutLast`, case 110).
211
214
  */
212
215
  const standsBelow = (box, limit, eps) => !rowOf(box) && !parallelBox(box) && box.rect.top > limit + eps;
213
- return { report, sourceOf, boxes, win, root, styleCache, forcing, styleOfBox, monolithic, insideMonolith, parallelBox, insideParallel, rowOf, insideRowItem, walks, standsBelow };
216
+ return { report, sourceOf, boxes, win, root, styleCache, forcing, styleOfBox, monolithic, insideMonolith, parallelBox, insideParallel, rowOf, insideRowItem, copy, walks, standsBelow };
214
217
  }
215
218
  /** Does this feed hold the box the retreat walk stops at — the first in-flow box whose top lies below the page's limit? The check a bounded feed must pass before anything is decided on it (*the Cast-off bounds the feed*, 2026-09-26): a feed that holds that box gives the walk everything it reads, and one that does not stopped above the edge and is poured again. */
216
219
  export function reachesBelowTheEdge({ report, sourceOf, pageStart, contentHeight, eps = 0.25 }) {
@@ -261,7 +264,7 @@ export function firstBelowTheEdge({ report, sourceOf, limit, eps = 0.25, sourced
261
264
  * @param a.stats - when given, counts what the collection costs: the boxes it visited, the line stacks it measured and the ones it skipped, and the milliseconds the lines and the end gaps took (`adjudicate` publishes it as the measure's detail).
262
265
  */
263
266
  function collect(w, { boxes, limit, pageStart, eps, stats }) {
264
- const { report, win, root, styleOfBox, monolithic, parallelBox, rowOf, walks, standsBelow } = w;
267
+ const { report, win, root, styleOfBox, monolithic, parallelBox, rowOf, copy, walks, standsBelow } = w;
265
268
  /**
266
269
  * §5.3's *leaving room*, priced (*the fragment that wraps itself*, 2026-09-15). *"When a box breaks, its content box extends to fill any remaining fragmentainer extent (leaving room for any margins/borders/padding applied by `box-decoration-break: clone`)."* So a cut inside a box that clones its decoration costs the page that box's block-end border and padding, on top of the content above it — and every ancestor the same cut breaks costs its own.
267
270
  *
@@ -344,11 +347,13 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
344
347
  const siblings = box === root ? [] : report.processionOf(box).filter((b) => !isAbsolutelyPositioned(styleOfBox(b)));
345
348
  const si = siblings.indexOf(box);
346
349
  // A footer written before its rows offers no boundary before it (*the footer before the rows*, 2026-10-15): the place is after the last row in the layout and before the first in the document, and an address can only say the second.
347
- if (si > 0 && !box.laidOutLast) {
350
+ // Nor does the box after a header written after its rows (the mirror): the place is after the header in the layout and before every row in the document, so the page would hold the header's copy alone.
351
+ if (si > 0 && !box.laidOutLast && !siblings[si - 1].laidOutFirst) {
348
352
  // Two siblings answer here, and they are not always the same one. **The keeps** are the immediately-adjacent box's, because §4.1 names *"a float and an immediately-adjacent in-flow or floated box"* as a Class A pair in as many words — a float's own `break-after` binds the boundary after it, which is §3.1's *should* on floats that this engine honours and Chromium declines. **The position** is the previous in-flow sibling's bottom border edge, because a parallel flow's extent is not a place in the procession the cuts divide (§2.1): the float is laid out beside the content, so the page closes where the previous in-flow box ended and the float's own bottom says nothing about it. Pricing this at the float's bottom is how a 96mm rail put every slab standing beside it off the page — measured, `cases/21-parallel-flows`, where the boundary before the first slab was priced at y 400 in a 264px page area.
349
353
  const pair = siblings[si - 1];
350
354
  const anchor = last(siblings.slice(0, si).filter((b) => !parallelBox(b))) ?? null;
351
- if (anchor) {
355
+ // 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.
356
+ if (anchor && !siblings.slice(0, si).every((b) => parallelBox(b) || copy(b))) {
352
357
  candidates.push({
353
358
  kind: 'A',
354
359
  cls: 'A',
@@ -444,7 +449,7 @@ function collect(w, { boxes, limit, pageStart, eps, stats }) {
444
449
  // 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`).
445
450
  const startGap = box.blockChildren.length ? report.gapAt({ box, edge: 'start' }) : 0;
446
451
  const firstInFlow = startGap > eps ? box.blockChildren.find((c) => !parallelBox(c)) : undefined;
447
- if (firstInFlow) {
452
+ if (firstInFlow && !copy(firstInFlow)) {
448
453
  const prevSibling = si > 0 ? siblings[si - 1] : null;
449
454
  const bs = win.getComputedStyle(box.el);
450
455
  candidates.push({
@@ -216,6 +216,14 @@ export declare function blockChildrenOf(el: Element, isInline?: (el: Element) =>
216
216
  * @param el - a clone or a source element.
217
217
  */
218
218
  export declare function footerBeforeRows(el: Element): boolean;
219
+ /**
220
+ * Is this a table's header group written after a row or row group of its table, which CSS 2.1 §17.2 lays out before them all the same (*"always displayed before all other rows and row groups"*)?
221
+ *
222
+ * @remarks The mirror of {@link footerBeforeRows} (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the Gauge walks such a header first, the Stonehand offers no place inside it or right after it, and its words stand after the rows in the word space, so a page before its words shows it by its repeated copy alone. One that does not repeat, in a divided table, is refused by the driver. Read by computed display, so a clone answers for its source.
223
+ *
224
+ * @param el - a clone or a source element.
225
+ */
226
+ export declare function headerAfterRows(el: Element): boolean;
219
227
  /**
220
228
  * The computed style of the box an author declared — the clone's registered source, or the element itself where it is live and connected. `null` for everything else, which is the honest answer rather than a default: a detached clone with no registration is a `<br>` or a filler span the Compositor inserted, and an element nobody declared has no declaration to read.
221
229
  *
@@ -495,6 +495,21 @@ export function footerBeforeRows(el) {
495
495
  return true;
496
496
  return false;
497
497
  }
498
+ /**
499
+ * Is this a table's header group written after a row or row group of its table, which CSS 2.1 §17.2 lays out before them all the same (*"always displayed before all other rows and row groups"*)?
500
+ *
501
+ * @remarks The mirror of {@link footerBeforeRows} (*thead-after-rows-and-tall-footer-lose-rows*, 2026-11-07): the Gauge walks such a header first, the Stonehand offers no place inside it or right after it, and its words stand after the rows in the word space, so a page before its words shows it by its repeated copy alone. One that does not repeat, in a divided table, is refused by the driver. Read by computed display, so a clone answers for its source.
502
+ *
503
+ * @param el - a clone or a source element.
504
+ */
505
+ export function headerAfterRows(el) {
506
+ if (sourceStyle(el)?.display !== 'table-header-group')
507
+ return false;
508
+ for (let s = el.previousElementSibling; s; s = s.previousElementSibling)
509
+ if (/^table-row(-group)?$/.test(sourceStyle(s)?.display ?? ''))
510
+ return true;
511
+ return false;
512
+ }
498
513
  /**
499
514
  * The computed style of the box an author declared — the clone's registered source, or the element itself where it is live and connected. `null` for everything else, which is the honest answer rather than a default: a detached clone with no registration is a `<br>` or a filler span the Compositor inserted, and an element nobody declared has no declaration to read.
500
515
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cicero",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",