@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.
@@ -26,11 +26,13 @@ import { WORDS_RULES } from '../src/data-beltype.js';
26
26
  *
27
27
  * The value is the addendum's own 1.65 rather than an invention, so on the one surface where it can actually cascade — the HTML lane, which ships no addendum — the engine's provision and BelType's default agree. Below `body` it is inert: the addendum's own `body` rule wins by proximity.
28
28
  *
29
+ * It is a floor, not an override, so it is written at zero specificity: `:where(:root)` still beats the browser's `normal`, which no stylesheet sets, and loses to any author rule on the root, a plain `html { font: 10pt/13.4pt serif }` among them. As `:root` (0,1,0) it beat that rule's (0,0,1) silently, and five of the six specimens printed at 1.65 instead of their declared leading (trattato: 220 pages for 189; Codeberg issue 4, 2026-11-07).
30
+ *
29
31
  * It is a literal here, and not a line in the linked sheet beside it, because a conformance provision that can 404 is not a provision. A missing sheet — a table entry lost in a refactor, a server restarted mid-edit — would arrive as `I5 (DPR ruling): explicit line-height required`, a law refusing on a typographic claim when the real fault is a missing file, sending the reader to their own stylesheet. Cicero's refusals are load-bearing because they are true; one that names the wrong thing spends that.
30
32
  *
31
33
  * Recorded as a beta limitation, because the check the law performs is at the root and this satisfies it there: a document whose deeper typography still relies on `normal` is outside what *render-identical conditions* (I5) can see.
32
34
  */
33
- const I5_PROVISION = ':root { line-height: 1.65; }';
35
+ const I5_PROVISION = ':where(:root) { line-height: 1.65; }';
34
36
  /**
35
37
  * What the document says about a PDF `<object>` before the engine has met it (*the embedded PDF*, 2026-10-14; `seam/facsimile.js`).
36
38
  *
@@ -137,7 +137,14 @@ export type ReachContext = {
137
137
  /** Whether any top-level box is not the one that stood in its place before. */
138
138
  changed: boolean;
139
139
  };
140
- /** A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base and anything relative refuses by name (css-page-3 §7.1). */
140
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
141
+ export declare const isOrientationOnly: (tokens: string[]) => boolean;
142
+ /**
143
+ * A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base, and a percentage refuses by name (css-page-3 §7.1).
144
+ *
145
+ * @remarks
146
+ * An orientation keyword turns whatever sheet stands beside it: `landscape` puts the longer side across, `portrait` down, for a named size and for a pair of lengths alike. §7.1 says a keyword alone means the UA's default sheet turned that way; this engine pins geometry (I5) and has no default sheet, so `pageFor` hands the keyword the sheet the cascade's less specific rule gave (the WPT print reftests' `@page :first { size: portrait }` on their 5 in by 3 in default), and a keyword with no such sheet refuses by name (Codeberg issue 10; until 2026-11-07 it refused as the length `undefined`).
147
+ */
141
148
  export declare function sizeToPx(tokens: string[], resolve: Resolve): [Px, Px];
142
149
  /**
143
150
  * A declared border width takes room only where the same side's style draws a border (*a page box has a border and a padding*, 2026-09-07).
@@ -180,6 +187,15 @@ export declare function assertPageArea({ contentW, contentH }: {
180
187
  contentW: Px;
181
188
  contentH: Px;
182
189
  }, where: string): void;
190
+ /**
191
+ * Every `@page` rule the document wears, wherever it stands: at a sheet's top, or inside an `@layer`, a `@supports` or an `@media` that holds, or an imported sheet. `layered` loses to `unlayered`, as the cascade has it, whatever their order.
192
+ *
193
+ * @remarks ponytail: the layers keep their document order, not their declared order; a page rule that must win over another layer is unlayered, as BelType's own is.
194
+ */
195
+ export declare function pageRulesOf(doc: Document): {
196
+ layered: CSSPageRule[];
197
+ unlayered: CSSPageRule[];
198
+ };
183
199
  /**
184
200
  * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
185
201
  *
@@ -531,11 +531,12 @@ function createLengthResolver(doc) {
531
531
  const fast = absoluteToPx(raw);
532
532
  if (fast !== null)
533
533
  return fast;
534
- if (basePx === null)
535
- throw refuse(raw, where, 'a `size` descriptor is resolved before either page dimension exists, so only absolute lengths and the named sizes can be answered there (css-page-3 §7.1 gives `size` no percentage values at all)');
534
+ // A `size` is resolved before either page dimension exists, so it has no percentage base; css-page-3 §7.1 gives `size` no percentage values at all. Every other length the browser resolves is measured as a margin's is: an `em` resolves against the root's font, which the probe's wrapper wears (Codeberg issue 10; until 2026-11-07 every relative unit refused here).
535
+ if (basePx === null && raw.includes('%'))
536
+ throw refuse(raw, where, 'a `size` descriptor is resolved before either page dimension exists, and css-page-3 §7.1 gives `size` no percentage values at all');
536
537
  if (raw === 'auto')
537
538
  throw refuse(raw, where, '`auto` on a page margin means UA-determined, and the probe would read it as a plain 0 — a number this engine would then lay out against as though the author had written one');
538
- const px = measured(raw, basePx, prop);
539
+ const px = measured(raw, basePx ?? 0, prop);
539
540
  if (px === null)
540
541
  throw refuse(raw, where, 'the browser resolves it to nothing — an unknown unit, a malformed value, or a custom property that is never declared');
541
542
  return px;
@@ -560,11 +561,20 @@ const SIZE_NAMES = {
560
561
  legal: [215.9, 355.6], // css-page-3 legal 8.5 × 14in; Chromium 215.90 × 355.60mm
561
562
  ledger: [279.4, 431.8] // css-page-3 ledger 11 × 17in; Chromium 279.40 × 431.80mm
562
563
  };
563
- /** A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base and anything relative refuses by name (css-page-3 §7.1). */
564
+ /** css-page-3 §7.1's two orientation keywords. */
565
+ const ORIENTATIONS = new Set(['portrait', 'landscape']);
566
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
567
+ export const isOrientationOnly = (tokens) => tokens.length === 1 && ORIENTATIONS.has(tokens[0].toLowerCase());
568
+ /**
569
+ * A `size` descriptor's tokens → [widthPx, heightPx]. Handles lengths, named sizes, and orientation keywords. A `size` is resolved before either page dimension is known, so its lengths get a null percentage base, and a percentage refuses by name (css-page-3 §7.1).
570
+ *
571
+ * @remarks
572
+ * An orientation keyword turns whatever sheet stands beside it: `landscape` puts the longer side across, `portrait` down, for a named size and for a pair of lengths alike. §7.1 says a keyword alone means the UA's default sheet turned that way; this engine pins geometry (I5) and has no default sheet, so `pageFor` hands the keyword the sheet the cascade's less specific rule gave (the WPT print reftests' `@page :first { size: portrait }` on their 5 in by 3 in default), and a keyword with no such sheet refuses by name (Codeberg issue 10; until 2026-11-07 it refused as the length `undefined`).
573
+ */
564
574
  export function sizeToPx(tokens, resolve) {
565
- const t = tokens.map((s) => s.toLowerCase()).filter((s) => s !== 'portrait');
566
- const landscape = t.includes('landscape');
567
- const rest = t.filter((s) => s !== 'landscape');
575
+ const t = tokens.map((s) => s.toLowerCase());
576
+ const orientation = t.find((s) => ORIENTATIONS.has(s)) ?? null;
577
+ const rest = t.filter((s) => !ORIENTATIONS.has(s));
568
578
  const where = 'a `size` descriptor';
569
579
  let w;
570
580
  let h;
@@ -574,10 +584,33 @@ export function sizeToPx(tokens, resolve) {
574
584
  else if (rest.length === 1) {
575
585
  w = h = resolve(rest[0], null, where);
576
586
  }
587
+ else if (rest.length === 0) {
588
+ throw refuse(orientation ?? '', where, 'an orientation keyword alone means the UA\'s default sheet turned that way, and this engine has none: declare the sheet in a less specific @page rule, or beside the keyword');
589
+ }
577
590
  else {
578
591
  [w, h] = [resolve(rest[0], null, where), resolve(rest[1], null, where)];
579
592
  }
580
- return landscape ? [h, w] : [w, h];
593
+ if (orientation === 'landscape')
594
+ return [Math.max(w, h), Math.min(w, h)];
595
+ if (orientation === 'portrait')
596
+ return [Math.min(w, h), Math.max(w, h)];
597
+ return [w, h];
598
+ }
599
+ /**
600
+ * The root's custom properties substituted into a page-context value (css-page-3 §4.1: the page context inherits from the root element, so a `var()` there reads the root's value). The probe already inherits them for a longhand it seats, but a shorthand goes through `expandBox` before any probe, and `--margin: 20px 40px` must be split before it is seated (Codeberg issue 10, `page-context-inherits-from-root-custom-property`). A property the root does not declare takes the `var()`'s fallback, or stays as written for the probe to refuse by name.
601
+ */
602
+ function substituteRootVars(doc, text) {
603
+ if (!text.includes('var('))
604
+ return text;
605
+ const root = doc.defaultView.getComputedStyle(doc.documentElement);
606
+ let out = text;
607
+ for (let i = 0; i < 8 && out.includes('var('); i++) {
608
+ const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => root.getPropertyValue(name).trim() || fallback?.trim() || whole);
609
+ if (next === out)
610
+ break;
611
+ out = next;
612
+ }
613
+ return out;
581
614
  }
582
615
  /**
583
616
  * What Cicero owns: the one criterion, and it is deliberately not a CSS opinion.
@@ -600,6 +633,12 @@ const AREA_PREFIXES = ['border', 'padding'];
600
633
  function ownedByCicero(prop) {
601
634
  return OWNED_EXACTLY.has(prop) || OWNED_PREFIXES.some((p) => prop.startsWith(p));
602
635
  }
636
+ /**
637
+ * What the Chase owns on a margin cell, which is less than on the page host: a cell's `margin-*`, `border-*` and `padding-*` are the box's own (css-page-3 §5.3.2 distributes the boxes' outer widths, padding and border included, and `chase.js`'s `layoutBands` measures them on the cell), where the page box's are the geometry the capture models and the content area wears. Until 2026-11-07 the host's filter was applied to the boxes too, and `@top-left { padding-bottom: 5mm }` reached no cell (Codeberg issue 3). The `width` family stays withheld: §5.3.2's answer for a box that declares one is not built, and the cell's grid seat is the Chase's.
638
+ */
639
+ function ownedOnCell(prop) {
640
+ return OWNED_EXACTLY.has(prop) || prop.startsWith('grid-template');
641
+ }
603
642
  /** One declaration block's `border-*` and `padding-*` triples, in the block's own order — the page box's border and padding, which the Chase writes on the content area. Order and `!important` carried for the same reason `declarationsOf` carries them: they are the cascade. */
604
643
  function areaDeclarationsOf(style) {
605
644
  const out = [];
@@ -610,11 +649,11 @@ function areaDeclarationsOf(style) {
610
649
  }
611
650
  return out;
612
651
  }
613
- /** One declaration block as `[property, value, priority]` triples, in the block's own order, minus what Cicero owns. Order and `!important` are carried because they are the cascade: the triples are re-emitted into one rule, where later wins and important beats normal exactly as the author wrote them. */
614
- function declarationsOf(style) {
652
+ /** One declaration block as `[property, value, priority]` triples, in the block's own order, minus what Cicero owns — on the page host by default, on a margin cell where `owned` is {@link ownedOnCell}. Order and `!important` are carried because they are the cascade: the triples are re-emitted into one rule, where later wins and important beats normal exactly as the author wrote them. */
653
+ function declarationsOf(style, owned = ownedByCicero) {
615
654
  const out = [];
616
655
  for (const prop of style ?? []) {
617
- if (ownedByCicero(prop))
656
+ if (owned(prop))
618
657
  continue;
619
658
  out.push([prop, style.getPropertyValue(prop), style.getPropertyPriority(prop)]);
620
659
  }
@@ -655,7 +694,7 @@ function marginBoxStylesOf(rule) {
655
694
  const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
656
695
  if (!MARGIN_BOXES.includes(name))
657
696
  continue;
658
- const decls = declarationsOf(child.style).filter(([prop]) => prop !== 'content');
697
+ const decls = declarationsOf(child.style, ownedOnCell).filter(([prop]) => prop !== 'content');
659
698
  if (decls.length)
660
699
  out[name] = decls;
661
700
  }
@@ -796,27 +835,57 @@ function marginBoxesOf(rule) {
796
835
  return out;
797
836
  }
798
837
  /**
799
- * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
838
+ * Every `@page` rule the document wears, wherever it stands: at a sheet's top, or inside an `@layer`, a `@supports` or an `@media` that holds, or an imported sheet. `layered` loses to `unlayered`, as the cascade has it, whatever their order.
800
839
  *
801
- * Margin-box chrome is delivered exclusively as a shadow-DOM literal (chase.js's own note under *content is one document*, I6: "it does not live once, so it cannot live twice"), but the author's own @page rules stay linked in the document throughout — their non-page rules keep styling the light-DOM content, so the whole stylesheet can never simply be disabled. Once the press sheet (root.js) renames every `<beltype-page>` to a synthetic geometry name, no author rule with a page-name selector (`@page title`, `@page :left`, …) can be name-matched by the browser's own native cascade any more — only the bare, unnamed base rule still matches every page unconditionally, becoming a second, uncontrolled channel that paints the very same chrome the shadow DOM already delivers correctly. `cases/17-the-borrowed-dress` found this the hard way: a named page's `content: normal` override was honored inside the shadow DOM (confirmed by direct inspection) while the printed page still showed the un-overridden base string, painted by this exact channel. Clearing `content` closes it by construction, for every page, independent of the precise cascade mechanism that let it through.
840
+ * @remarks ponytail: the layers keep their document order, not their declared order; a page rule that must win over another layer is unlayered, as BelType's own is.
802
841
  */
803
- export function neutralizeMarginBoxes(doc) {
804
- for (const sheet of styleSheetsOf(doc)) {
805
- let rules;
842
+ export function pageRulesOf(doc) {
843
+ const win = doc.defaultView;
844
+ const found = { layered: [], unlayered: [] };
845
+ const rulesOf = (sheet) => {
806
846
  try {
807
- rules = sheet.cssRules;
847
+ return sheet.cssRules;
808
848
  }
809
849
  catch {
810
- continue;
811
- } // cross-origin sheet: not ours to touch
850
+ return [];
851
+ } // cross-origin sheet: not ours to read
852
+ };
853
+ const walk = (rules, inLayer) => {
812
854
  for (const rule of rules) {
813
- if (!(rule instanceof doc.defaultView.CSSPageRule) || !rule.cssRules)
814
- continue;
815
- for (const child of rule.cssRules) {
816
- const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
817
- if (MARGIN_BOXES.includes(name))
818
- child.style?.removeProperty('content');
855
+ if (rule instanceof win.CSSPageRule)
856
+ (inLayer ? found.layered : found.unlayered).push(rule);
857
+ else if (rule.constructor?.name === 'CSSLayerBlockRule')
858
+ walk(rule.cssRules, true);
859
+ else if (rule.constructor?.name === 'CSSSupportsRule') {
860
+ if (win.CSS?.supports?.(rule.conditionText))
861
+ walk(rule.cssRules, inLayer);
862
+ }
863
+ else if (rule.constructor?.name === 'CSSMediaRule') {
864
+ if (win.matchMedia(rule.media.mediaText).matches)
865
+ walk(rule.cssRules, inLayer);
819
866
  }
867
+ else if (rule.constructor?.name === 'CSSImportRule' && rule.styleSheet)
868
+ walk(rulesOf(rule.styleSheet), inLayer || rule.layerName != null);
869
+ }
870
+ };
871
+ for (const sheet of styleSheetsOf(doc))
872
+ walk(rulesOf(sheet), false);
873
+ return found;
874
+ }
875
+ /**
876
+ * Clear every author @page margin-box's own `content` declaration in the live CSSOM — never its `size`/`margin`, never anything outside a margin-at-rule. Called once the geometry table above has already read what it needs from these rules (the second channel a named page cannot close).
877
+ *
878
+ * Margin-box chrome is delivered exclusively as a shadow-DOM literal (chase.js's own note under *content is one document*, I6: "it does not live once, so it cannot live twice"), but the author's own @page rules stay linked in the document throughout — their non-page rules keep styling the light-DOM content, so the whole stylesheet can never simply be disabled. Once the press sheet (root.js) renames every `<beltype-page>` to a synthetic geometry name, no author rule with a page-name selector (`@page title`, `@page :left`, …) can be name-matched by the browser's own native cascade any more — only the bare, unnamed base rule still matches every page unconditionally, becoming a second, uncontrolled channel that paints the very same chrome the shadow DOM already delivers correctly. `cases/17-the-borrowed-dress` found this the hard way: a named page's `content: normal` override was honored inside the shadow DOM (confirmed by direct inspection) while the printed page still showed the un-overridden base string, painted by this exact channel. Clearing `content` closes it by construction, for every page, independent of the precise cascade mechanism that let it through.
879
+ */
880
+ export function neutralizeMarginBoxes(doc) {
881
+ const { layered, unlayered } = pageRulesOf(doc);
882
+ for (const rule of [...layered, ...unlayered]) {
883
+ if (!rule.cssRules)
884
+ continue;
885
+ for (const child of rule.cssRules) {
886
+ const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
887
+ if (MARGIN_BOXES.includes(name))
888
+ child.style?.removeProperty('content');
820
889
  }
821
890
  }
822
891
  }
@@ -943,47 +1012,37 @@ export function registerGcpmProperties(win) {
943
1012
  */
944
1013
  export function readGeometryTable(doc) {
945
1014
  const rules = [];
946
- for (const sheet of styleSheetsOf(doc)) {
947
- let list;
948
- try {
949
- list = sheet.cssRules;
950
- }
951
- catch {
952
- continue; // cross-origin sheet: not ours to capture
953
- }
954
- for (const rule of list) {
955
- if (!(rule instanceof doc.defaultView.CSSPageRule))
956
- continue;
957
- const sel = parseSelector(rule.selectorText);
958
- // The rule's own declaration block, never its serialized text: `rule.cssText` carries the nested margin boxes inside it, so a regex over it credits a folio's `margin-top` to the page and reads a running head's `font-size` as the page's `size` (a page rule reads its own declarations; the arithmetic is on the checklist, the case is `15-the-margin-box`). `size` is a descriptor rather than a property and is exposed here anyway — measured, not assumed.
959
- const declared = (prop) => rule.style?.getPropertyValue(prop).trim() || null;
960
- const size = declared('size')?.split(/\s+/) ?? null;
961
- // Longhands only: the CSSOM has already expanded whatever shorthand was written, and re-expanding it here would re-implement the browser's own work.
962
- //
963
- // Except where it has expanded nothing. A shorthand carrying a `var()` is held in pending substitution — the block answers `margin` with the author's text and every one of `margin-top`…`margin-left` with the empty string — so reading longhands alone reports no declaration at all, and the page lays out against a default. That is not a plausible failure: measured 2026-09-09, `@page { margin: var(--outer) }` with `--outer: 12mm` composed against BelType's own inlined default of 20mm and against 0 in a bare document, both silently, and the case is `33-the-shorthand-the-cssom-cannot-expand`. So the shorthand is consulted only when all four longhands are empty, which is exactly the pending-substitution state and nothing else — the browser expands every shorthand it can, so a non-empty longhand means there is nothing here to do. `expandBox` hands back the author's raw values, which the resolver then seats and measures per side exactly as it does a longhand's; nothing about lengths is decided here.
964
- //
965
- // Its reach is one level: `border: 5px var(--style)` leaves `border-width` empty too, and that stays unread. Naming it rather than widening the door — a shorthand of shorthands is a second expansion with a second grammar, and no document has asked for it.
966
- const perSide = (prefix, suffix = '') => {
967
- const sides = { top: declared(`${prefix}-top${suffix}`), right: declared(`${prefix}-right${suffix}`), bottom: declared(`${prefix}-bottom${suffix}`), left: declared(`${prefix}-left${suffix}`) };
968
- if (sides.top || sides.right || sides.bottom || sides.left)
969
- return sides;
970
- const shorthand = declared(`${prefix}${suffix}`);
971
- return (shorthand && expandBox(shorthand)) || sides;
972
- };
973
- rules.push({
974
- sel,
975
- size,
976
- style: declarationsOf(rule.style),
977
- area: areaDeclarationsOf(rule.style),
978
- boxStyles: marginBoxStylesOf(rule),
979
- margins: perSide('margin'),
980
- // A border width's used value is zero unless a style is set, so the style is read beside the width and the two are accumulated separately — a `@page { border: 5px }` declares a width and no style, and takes no room at all.
981
- borderWidths: perSide('border', '-width'),
982
- borderStyles: perSide('border', '-style'),
983
- paddings: perSide('padding'),
984
- marginBoxes: marginBoxesOf(rule)
985
- });
986
- }
1015
+ const { layered, unlayered } = pageRulesOf(doc);
1016
+ for (const rule of [...layered, ...unlayered]) {
1017
+ const sel = parseSelector(rule.selectorText);
1018
+ // The rule's own declaration block, never its serialized text: `rule.cssText` carries the nested margin boxes inside it, so a regex over it credits a folio's `margin-top` to the page and reads a running head's `font-size` as the page's `size` (a page rule reads its own declarations; the arithmetic is on the checklist, the case is `15-the-margin-box`). `size` is a descriptor rather than a property and is exposed here anyway — measured, not assumed.
1019
+ const declared = (prop) => rule.style?.getPropertyValue(prop).trim() || null;
1020
+ const size = declared('size')?.split(/\s+/) ?? null;
1021
+ // Longhands only: the CSSOM has already expanded whatever shorthand was written, and re-expanding it here would re-implement the browser's own work.
1022
+ //
1023
+ // Except where it has expanded nothing. A shorthand carrying a `var()` is held in pending substitution — the block answers `margin` with the author's text and every one of `margin-top`…`margin-left` with the empty string — so reading longhands alone reports no declaration at all, and the page lays out against a default. That is not a plausible failure: measured 2026-09-09, `@page { margin: var(--outer) }` with `--outer: 12mm` composed against BelType's own inlined default of 20mm and against 0 in a bare document, both silently, and the case is `33-the-shorthand-the-cssom-cannot-expand`. So the shorthand is consulted only when all four longhands are empty, which is exactly the pending-substitution state and nothing else — the browser expands every shorthand it can, so a non-empty longhand means there is nothing here to do. `expandBox` hands back the author's raw values, which the resolver then seats and measures per side exactly as it does a longhand's; nothing about lengths is decided here.
1024
+ //
1025
+ // Its reach is one level: `border: 5px var(--style)` leaves `border-width` empty too, and that stays unread. Naming it rather than widening the door — a shorthand of shorthands is a second expansion with a second grammar, and no document has asked for it.
1026
+ const perSide = (prefix, suffix = '') => {
1027
+ const sides = { top: declared(`${prefix}-top${suffix}`), right: declared(`${prefix}-right${suffix}`), bottom: declared(`${prefix}-bottom${suffix}`), left: declared(`${prefix}-left${suffix}`) };
1028
+ if (sides.top || sides.right || sides.bottom || sides.left)
1029
+ return sides;
1030
+ const shorthand = declared(`${prefix}${suffix}`);
1031
+ return (shorthand && expandBox(substituteRootVars(doc, shorthand))) || sides;
1032
+ };
1033
+ rules.push({
1034
+ sel,
1035
+ size,
1036
+ style: declarationsOf(rule.style),
1037
+ area: areaDeclarationsOf(rule.style),
1038
+ boxStyles: marginBoxStylesOf(rule),
1039
+ margins: perSide('margin'),
1040
+ // A border width's used value is zero unless a style is set, so the style is read beside the width and the two are accumulated separately — a `@page { border: 5px }` declares a width and no style, and takes no room at all.
1041
+ borderWidths: perSide('border', '-width'),
1042
+ borderStyles: perSide('border', '-style'),
1043
+ paddings: perSide('padding'),
1044
+ marginBoxes: marginBoxesOf(rule)
1045
+ });
987
1046
  }
988
1047
  if (!rules.length)
989
1048
  throw new Error('no @page rule found — the corpus and the venue always pin geometry (I5)');
@@ -1021,7 +1080,7 @@ export function readGeometryTable(doc) {
1021
1080
  const pageStyle = { host: [], cell: [], area: [], boxes: {} };
1022
1081
  for (const r of applicable) {
1023
1082
  if (r.size)
1024
- size = r.size;
1083
+ size = isOrientationOnly(r.size) && size ? [...size.filter((t) => !ORIENTATIONS.has(t.toLowerCase())), ...r.size] : r.size; // a keyword alone turns the sheet the cascade gave (`sizeToPx`)
1025
1084
  for (const side of SIDES) {
1026
1085
  if (r.margins[side] != null)
1027
1086
  margins[side] = r.margins[side];
package/dist/src/chase.js CHANGED
@@ -153,6 +153,7 @@ export class BeltypePage extends HTMLElement {
153
153
  for (const [band, row] of [['top', 1], ['bottom', 5]]) {
154
154
  const el = doc.createElement('div');
155
155
  el.className = 'band';
156
+ el.setAttribute('aria-hidden', 'true'); // pagination furniture is never read (*an artifact is never read*)
156
157
  el.dataset.band = band;
157
158
  el.style.gridArea = `${row} / 2 / ${row + 1} / 5`;
158
159
  this.#bands[band] = el;
@@ -161,6 +162,7 @@ export class BeltypePage extends HTMLElement {
161
162
  for (const [name, row, col] of MARGIN_CELLS) {
162
163
  const cell = doc.createElement('div');
163
164
  cell.className = 'margin-cell';
165
+ cell.setAttribute('aria-hidden', 'true');
164
166
  cell.dataset.box = name;
165
167
  const band = Object.keys(HORIZONTAL_BANDS).find((b) => HORIZONTAL_BANDS[b].includes(name));
166
168
  if (band) {
@@ -128,6 +128,33 @@ export declare function counterStateAt(ops: ReturnType<typeof documentOpsOf>, po
128
128
  from: number;
129
129
  to: number;
130
130
  }>): Record<string, number[]>;
131
+ /**
132
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, or, where an element reset the page counter, that element's value counted on from the page it begins on.
133
+ *
134
+ * @remarks
135
+ * css-page-3 names a reset of the page counter only in the page context, but an author writes `counter-reset: page 1` on a chapter or on each letter of a print run to number its pages from one, and once the engine applies it the pages after must count on from there rather than stay at the value (Codeberg issue 1). The page counter is the page's own, reset on the host as `page N` (`chase.js`'s `setCounters`), so the reset is folded into N here and never delivered as an author counter: delivered, it reset the flow and the margin cells to the same value on every later page in the reset's scope. `target-counter()` to a page reads the same N (`reads.js`'s `pageValues`), so a reference answers the value the counter has at its target (Codeberg issue 2).
136
+ *
137
+ * The last `counter-reset` or `counter-set` of `page` at or before `pos` is the one in force: **at**, not strictly before, because the element that begins on the page is the one that numbers it, where an author counter's reset waits for the next page (css-page-3 §6.1 is about author counters). Scope is not replayed, since the page counter has one instance: a reset stands until the next one, whatever the depth of either. A reset on an element that begins mid-page numbers the next page.
138
+ *
139
+ * **A blank page the engine makes to bring the element to its side begins where the element does and stands before it** (Codeberg issue 13): the reset is not in force on it, so it continues the count it stands in, and it is not counted toward the pages after it, so the element's page bears the value. A blank page inside the scope, after the element began, counts as every page does.
140
+ *
141
+ * @param pages - each page before `pageIndex`, by index: its first word position and whether it is blank.
142
+ * @param blank - whether page `pageIndex` is one the engine made blank.
143
+ * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
144
+ */
145
+ export declare function pageNumberAt(ops: ReadonlyArray<Op>, pageIndex: number, pos: number, { pages, blank, away }: {
146
+ pages: (i: number) => {
147
+ start: number;
148
+ blank: boolean;
149
+ };
150
+ blank?: boolean;
151
+ away?: ReadonlyArray<{
152
+ from: number;
153
+ to: number;
154
+ }>;
155
+ }): number;
156
+ /** The author's counters alone: the page's own two, `page` and `pages`, are the host's (`pageNumberAt`), and delivered as an author's they would reset the flow and the cells under the host's. */
157
+ export declare function withoutPageCounters(state: Record<string, number[]>): Record<string, number[]>;
131
158
  /**
132
159
  * Every named-string assignment the document makes, in document order.
133
160
  *
@@ -289,6 +289,53 @@ const runningOp = (r, at) => ({ reset: [], increment: [], set: [], strings: null
289
289
  export function counterStateAt(ops, pos, away = []) {
290
290
  return replay(ops, { until: pos, away });
291
291
  }
292
+ /**
293
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, or, where an element reset the page counter, that element's value counted on from the page it begins on.
294
+ *
295
+ * @remarks
296
+ * css-page-3 names a reset of the page counter only in the page context, but an author writes `counter-reset: page 1` on a chapter or on each letter of a print run to number its pages from one, and once the engine applies it the pages after must count on from there rather than stay at the value (Codeberg issue 1). The page counter is the page's own, reset on the host as `page N` (`chase.js`'s `setCounters`), so the reset is folded into N here and never delivered as an author counter: delivered, it reset the flow and the margin cells to the same value on every later page in the reset's scope. `target-counter()` to a page reads the same N (`reads.js`'s `pageValues`), so a reference answers the value the counter has at its target (Codeberg issue 2).
297
+ *
298
+ * The last `counter-reset` or `counter-set` of `page` at or before `pos` is the one in force: **at**, not strictly before, because the element that begins on the page is the one that numbers it, where an author counter's reset waits for the next page (css-page-3 §6.1 is about author counters). Scope is not replayed, since the page counter has one instance: a reset stands until the next one, whatever the depth of either. A reset on an element that begins mid-page numbers the next page.
299
+ *
300
+ * **A blank page the engine makes to bring the element to its side begins where the element does and stands before it** (Codeberg issue 13): the reset is not in force on it, so it continues the count it stands in, and it is not counted toward the pages after it, so the element's page bears the value. A blank page inside the scope, after the element began, counts as every page does.
301
+ *
302
+ * @param pages - each page before `pageIndex`, by index: its first word position and whether it is blank.
303
+ * @param blank - whether page `pageIndex` is one the engine made blank.
304
+ * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
305
+ */
306
+ export function pageNumberAt(ops, pageIndex, pos, { pages, blank = false, away = [] }) {
307
+ const inForce = (at, start, isBlank) => at < start || (at === start && !isBlank);
308
+ let reset = null;
309
+ for (const op of ops) {
310
+ if (op.pos > pos)
311
+ break;
312
+ if (!inForce(op.pos, pos, blank))
313
+ continue;
314
+ if (away.length && inRemainders(away, op.pos))
315
+ continue;
316
+ for (const [name, value] of [...op.reset, ...op.set])
317
+ if (name === 'page')
318
+ reset = { pos: op.pos, value };
319
+ }
320
+ if (!reset)
321
+ return pageIndex + 1;
322
+ let since = 0;
323
+ for (let i = 0; i < pageIndex; i++) {
324
+ const page = pages(i);
325
+ if (inForce(reset.pos, page.start, page.blank))
326
+ since++;
327
+ }
328
+ return reset.value + since;
329
+ }
330
+ /** The author's counters alone: the page's own two, `page` and `pages`, are the host's (`pageNumberAt`), and delivered as an author's they would reset the flow and the cells under the host's. */
331
+ export function withoutPageCounters(state) {
332
+ if (!('page' in state) && !('pages' in state))
333
+ return state;
334
+ const out = { ...state };
335
+ delete out.page;
336
+ delete out.pages;
337
+ return out;
338
+ }
292
339
  /** 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. */
293
340
  function replay(ops, { until = Infinity, onOp = null, away = [] } = {}) {
294
341
  /**
@@ -47,6 +47,8 @@ export type ForcingEvent = {
47
47
  sealedBy: number;
48
48
  /** It stands inside a box that lays its children side by side. */
49
49
  rowish: boolean;
50
+ /** For a run of lines after a block child, the event is the run's first word in `el`, which holds it, and this is the block child it follows; the feed is cut after it (*the anonymous box is in the procession*). */
51
+ runAfter?: Element;
50
52
  };
51
53
  /** One block of a page's feed: its clone, the source it was cloned from, and the address it resumes at. */
52
54
  export type FeedItem = {
@@ -111,7 +113,7 @@ export declare function pageNameDownFrom(el: Element, { styleOf, runBefore, bloc
111
113
  blockChildren?: typeof blockChildrenOf;
112
114
  }): string;
113
115
  /**
114
- * Read the document's @page geometry from the CSSOM — every sheet the document wears, adopted ones included (`capture.js`'s `styleSheetsOf`; the capture reads adopted sheets). Returns page and content box sizes in px.
116
+ * Read the document's @page geometry from the CSSOM — every sheet the document wears, adopted ones included, and the rules inside its layers and conditions (`capture.ts`'s `pageRulesOf`). Returns page and content box sizes in px.
115
117
  *
116
118
  * **Margins only, and that is now a stated limit rather than the whole story** *(a page box has a border and a padding, 2026-09-07)*. The capture models a page's `border` and `padding` and subtracts both from the page area; this reader does not, so a document declaring either gets a content box too large here. Its one caller is `packages/essay`'s benchmark, over a corpus that writes `size` and margins into its page rules and nothing else — so the difference is unreachable there. It is not widened rather than left quietly wrong: this is the legacy single-geometry reader, and a second implementation of the page area is exactly what `readGeometryTable` exists to be the only one of.
117
119
  */
@@ -198,14 +200,18 @@ export declare const combineParity: (earlier: 'left' | 'right' | null, later: 'l
198
200
  *
199
201
  * @param blocks - the top-level blocks, in document order.
200
202
  */
201
- export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf, cutOf, posOf, posOfCut, isMonolith: monolith, blockChildren }: {
203
+ export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf, cutOf, posOf, posOfCut, isMonolith: monolith, blockChildren, extentOf }: {
202
204
  styleOf: (el: Element) => CSSStyleDeclaration;
203
205
  pageNameOf: (el: Element) => string;
204
- cutOf: (el: Element, b: number) => Address;
206
+ cutOf: (el: Element, b: number, word?: number | null) => Address;
205
207
  posOf: (el: Element, b: number) => number;
206
208
  posOfCut?: ((cut: Address) => number) | null;
207
209
  isMonolith?: typeof isMonolith;
208
210
  blockChildren?: typeof blockChildrenOf;
211
+ extentOf?: ((el: Element) => {
212
+ before: number;
213
+ count: number;
214
+ }) | null;
209
215
  }): {
210
216
  size: number;
211
217
  /** The first forcing event strictly after `cursorCut` for a page named `name`, or null. */
@@ -226,6 +232,7 @@ export declare function forcingIndexOf(blocks: Element[], { styleOf, pageNameOf,
226
232
  };
227
233
  parity: "left" | "right" | null;
228
234
  rowish: boolean;
235
+ runAfter: Element | undefined;
229
236
  } | null;
230
237
  /**
231
238
  * The Cast-off's place (*the Cast-off bounds the feed*, 2026-09-26): the first block boundary strictly after `cursorCut` whose word position is at least `pos`, where a feed may be cut without moving any box before it — or null, where no such place is left and the feed is the whole remainder.