@beltype/cicero 0.1.6 → 0.1.8

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
  *
@@ -10,7 +10,7 @@
10
10
  * @packageDocumentation
11
11
  */
12
12
  import type { Decl } from './margin-boxes.ts';
13
- import type { counterStateAt } from './document-state.ts';
13
+ import { type counterStateAt, type PageCounterOps } from './document-state.ts';
14
14
  import type { Px } from './gauge.ts';
15
15
  export type Side = 'top' | 'right' | 'bottom' | 'left';
16
16
  /** One value per side of a box. */
@@ -40,6 +40,8 @@ export type PageGeometry = {
40
40
  /** Raw `content` values per margin box, transliterated per page by {@link transliterateContent}. */
41
41
  marginBoxes: Record<string, string>;
42
42
  pageStyle: PageStyle;
43
+ /** What the page context's rules do to the page counter, which `document-state.ts`'s `pageNumberAt` applies and the host never wears. */
44
+ pageCounter: PageCounterOps;
43
45
  };
44
46
  /** What a page is asked for by: the four conditions css-page-3 §4 selects `@page` rules on. */
45
47
  export type PageCondition = {
@@ -102,6 +104,8 @@ export declare function styleSheetsOf(doc: Document): CSSStyleSheet[];
102
104
  * A box outside a fragment's interval is kept hollow, an element with no children, and every structural selector matches it as it matched the deep clone it replaced — except a `:has()` whose argument reaches inside it. Since 2026-10-10 the host rewrites every `:has()` it can (`seam/rewrite.js`) into a union whose second arm reads a word the engine writes on the copy (`data-beltype.js`, `has-words.js`), so a hollow copy matches as its original does and the answer here is NO for a document whose `:has()` arguments are all rewritten. What is left is the `:has()` whose argument reads state a word computed once cannot follow — `:hover`, `:checked`, `:focus-within`, `:target` and their kin — and a sheet no host rewrote at all: for those the fragments are built deep, as before. Grouping rules, nested rules and imported sheets are walked, since a selector inside `@media` or `@layer` still matches; a sheet the engine cannot read answers yes, because it might. The reading is textual and counts: a rule whose selector holds more `:has(` than unions has one that nothing answers for.
103
105
  */
104
106
  export declare function sheetsUseHas(doc: Document): boolean;
107
+ /** Whether a rule sets a counter on a `::before` or an `::after`: only then does the counter walk ask every element's pseudo-elements for their counters (`document-state.ts`'s `documentOpsOf`), which costs two style reads an element. A sheet the engine cannot read answers yes. */
108
+ export declare function sheetsCountInPseudos(doc: Document): boolean;
105
109
  /** The arguments the host rewrote, read back from the registry rules (`:not(*) { --beltype-has-<h>: "<A>" }`, one per rewritten sheet): hash → the argument's canonical text. The capture reads the CSSOM and nothing else, so the arguments come from the rule the rewriter appended and no selector is ever parsed back. */
106
110
  export declare function hasArgumentsOf(doc: Document): Map<string, string>;
107
111
  /**
@@ -137,7 +141,14 @@ export type ReachContext = {
137
141
  /** Whether any top-level box is not the one that stood in its place before. */
138
142
  changed: boolean;
139
143
  };
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). */
144
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
145
+ export declare const isOrientationOnly: (tokens: string[]) => boolean;
146
+ /**
147
+ * 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).
148
+ *
149
+ * @remarks
150
+ * 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`).
151
+ */
141
152
  export declare function sizeToPx(tokens: string[], resolve: Resolve): [Px, Px];
142
153
  /**
143
154
  * 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 +191,15 @@ export declare function assertPageArea({ contentW, contentH }: {
180
191
  contentW: Px;
181
192
  contentH: Px;
182
193
  }, where: string): void;
194
+ /**
195
+ * 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.
196
+ *
197
+ * @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.
198
+ */
199
+ export declare function pageRulesOf(doc: Document): {
200
+ layered: CSSPageRule[];
201
+ unlayered: CSSPageRule[];
202
+ };
183
203
  /**
184
204
  * 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
205
  *
@@ -13,6 +13,7 @@ import { MARGIN_BOXES } from './margin-boxes.js';
13
13
  import { expandBox } from './box-shorthand.js';
14
14
  import { CUSTOM_PROPS, decodeContentFn, decodePageName, encodeStringProp } from './gcpm-encoding.js';
15
15
  import { HAS_PROPERTY_PREFIX, bareHasCount, decodeHasArgument, isHasRegistryRule } from './data-beltype.js';
16
+ import { pairsOf } from './document-state.js';
16
17
  const PX_PER = { px: 1, pt: 96 / 72, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 };
17
18
  /**
18
19
  * Every sheet the document wears — `document.styleSheets` first, then `document.adoptedStyleSheets`.
@@ -32,6 +33,10 @@ export function styleSheetsOf(doc) {
32
33
  export function sheetsUseHas(doc) {
33
34
  return someRule(doc, (rule) => !!rule.selectorText && bareHasCount(rule.selectorText) > 0);
34
35
  }
36
+ /** Whether a rule sets a counter on a `::before` or an `::after`: only then does the counter walk ask every element's pseudo-elements for their counters (`document-state.ts`'s `documentOpsOf`), which costs two style reads an element. A sheet the engine cannot read answers yes. */
37
+ export function sheetsCountInPseudos(doc) {
38
+ return someRule(doc, (rule) => !!rule.selectorText && /::?(?:before|after)\b/i.test(rule.selectorText) && /\bcounter-(?:reset|increment|set)\s*:/.test(rule.style?.cssText ?? ''));
39
+ }
35
40
  /** The arguments the host rewrote, read back from the registry rules (`:not(*) { --beltype-has-<h>: "<A>" }`, one per rewritten sheet): hash → the argument's canonical text. The capture reads the CSSOM and nothing else, so the arguments come from the rule the rewriter appended and no selector is ever parsed back. */
36
41
  export function hasArgumentsOf(doc) {
37
42
  const found = new Map();
@@ -531,11 +536,12 @@ function createLengthResolver(doc) {
531
536
  const fast = absoluteToPx(raw);
532
537
  if (fast !== null)
533
538
  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)');
539
+ // 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).
540
+ if (basePx === null && raw.includes('%'))
541
+ 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
542
  if (raw === 'auto')
537
543
  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);
544
+ const px = measured(raw, basePx ?? 0, prop);
539
545
  if (px === null)
540
546
  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
547
  return px;
@@ -560,11 +566,20 @@ const SIZE_NAMES = {
560
566
  legal: [215.9, 355.6], // css-page-3 legal 8.5 × 14in; Chromium 215.90 × 355.60mm
561
567
  ledger: [279.4, 431.8] // css-page-3 ledger 11 × 17in; Chromium 279.40 × 431.80mm
562
568
  };
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). */
569
+ /** css-page-3 §7.1's two orientation keywords. */
570
+ const ORIENTATIONS = new Set(['portrait', 'landscape']);
571
+ /** Is this `size` an orientation keyword and nothing else? Then it turns the sheet the cascade already gave ({@link sizeToPx}). */
572
+ export const isOrientationOnly = (tokens) => tokens.length === 1 && ORIENTATIONS.has(tokens[0].toLowerCase());
573
+ /**
574
+ * 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).
575
+ *
576
+ * @remarks
577
+ * 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`).
578
+ */
564
579
  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');
580
+ const t = tokens.map((s) => s.toLowerCase());
581
+ const orientation = t.find((s) => ORIENTATIONS.has(s)) ?? null;
582
+ const rest = t.filter((s) => !ORIENTATIONS.has(s));
568
583
  const where = 'a `size` descriptor';
569
584
  let w;
570
585
  let h;
@@ -574,10 +589,33 @@ export function sizeToPx(tokens, resolve) {
574
589
  else if (rest.length === 1) {
575
590
  w = h = resolve(rest[0], null, where);
576
591
  }
592
+ else if (rest.length === 0) {
593
+ 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');
594
+ }
577
595
  else {
578
596
  [w, h] = [resolve(rest[0], null, where), resolve(rest[1], null, where)];
579
597
  }
580
- return landscape ? [h, w] : [w, h];
598
+ if (orientation === 'landscape')
599
+ return [Math.max(w, h), Math.min(w, h)];
600
+ if (orientation === 'portrait')
601
+ return [Math.min(w, h), Math.max(w, h)];
602
+ return [w, h];
603
+ }
604
+ /**
605
+ * 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.
606
+ */
607
+ function substituteRootVars(doc, text) {
608
+ if (!text.includes('var('))
609
+ return text;
610
+ const root = doc.defaultView.getComputedStyle(doc.documentElement);
611
+ let out = text;
612
+ for (let i = 0; i < 8 && out.includes('var('); i++) {
613
+ const next = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => root.getPropertyValue(name).trim() || fallback?.trim() || whole);
614
+ if (next === out)
615
+ break;
616
+ out = next;
617
+ }
618
+ return out;
581
619
  }
582
620
  /**
583
621
  * What Cicero owns: the one criterion, and it is deliberately not a CSS opinion.
@@ -600,6 +638,12 @@ const AREA_PREFIXES = ['border', 'padding'];
600
638
  function ownedByCicero(prop) {
601
639
  return OWNED_EXACTLY.has(prop) || OWNED_PREFIXES.some((p) => prop.startsWith(p));
602
640
  }
641
+ /**
642
+ * 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.
643
+ */
644
+ function ownedOnCell(prop) {
645
+ return OWNED_EXACTLY.has(prop) || prop.startsWith('grid-template');
646
+ }
603
647
  /** 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
648
  function areaDeclarationsOf(style) {
605
649
  const out = [];
@@ -610,16 +654,46 @@ function areaDeclarationsOf(style) {
610
654
  }
611
655
  return out;
612
656
  }
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) {
657
+ /** 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. */
658
+ function declarationsOf(style, owned = ownedByCicero) {
615
659
  const out = [];
616
660
  for (const prop of style ?? []) {
617
- if (ownedByCicero(prop))
661
+ if (owned(prop))
618
662
  continue;
619
663
  out.push([prop, style.getPropertyValue(prop), style.getPropertyPriority(prop)]);
620
664
  }
621
665
  return out;
622
666
  }
667
+ /** The three counter properties, each with the value an unnumbered name takes. */
668
+ const PAGE_COUNTER_PROPS = { 'counter-reset': 0, 'counter-increment': 1, 'counter-set': 0 };
669
+ /**
670
+ * A page rule's declarations with the page counter taken out of its `counter-reset`, `counter-increment` and `counter-set`: the page number is `pageNumberAt`'s, written on the host as `page N`, and an increment the host wore beside it was added to N (`@page { counter-increment: page 2 }` printed 3, 4, 5 where Chromium prints 2, 4, 6) while the host's inline reset hid every `@page` reset. Another counter's pair stays where it was.
671
+ */
672
+ function withoutPageCounter(decls) {
673
+ return decls.flatMap((d) => {
674
+ const fallback = PAGE_COUNTER_PROPS[d[0]];
675
+ if (fallback === undefined)
676
+ return [d];
677
+ const pairs = pairsOf(d[1], fallback);
678
+ if (!pairs.some(([name]) => name === 'page'))
679
+ return [d];
680
+ const rest = pairs.filter(([name]) => name !== 'page');
681
+ return rest.length ? [[d[0], rest.map(([name, value]) => `${name} ${value}`).join(' '), d[2]]] : [];
682
+ });
683
+ }
684
+ /** The page counter's value in each of a condition's three winning counter declarations, `null` where the winner names no `page`: the cascade over the applicable rules, in ascending specificity, `!important` beating normal. */
685
+ function pageCounterOf(rules) {
686
+ const winners = {};
687
+ for (const r of rules)
688
+ for (const d of r.style)
689
+ if (d[0] in PAGE_COUNTER_PROPS && (d[2] === 'important' || winners[d[0]]?.[2] !== 'important'))
690
+ winners[d[0]] = d;
691
+ const pageOf = (prop) => {
692
+ const pair = pairsOf(winners[prop]?.[1], PAGE_COUNTER_PROPS[prop]).findLast(([name]) => name === 'page');
693
+ return pair ? pair[1] : null;
694
+ };
695
+ return { reset: pageOf('counter-reset'), increment: pageOf('counter-increment'), set: pageOf('counter-set') };
696
+ }
623
697
  /**
624
698
  * The faces page furniture asks for, as `FontFaceSet.load()` arguments (the platform's font preloading) — read from the `@page` rules' own declarations, where the families are already in hand.
625
699
  *
@@ -655,7 +729,7 @@ function marginBoxStylesOf(rule) {
655
729
  const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
656
730
  if (!MARGIN_BOXES.includes(name))
657
731
  continue;
658
- const decls = declarationsOf(child.style).filter(([prop]) => prop !== 'content');
732
+ const decls = declarationsOf(child.style, ownedOnCell).filter(([prop]) => prop !== 'content');
659
733
  if (decls.length)
660
734
  out[name] = decls;
661
735
  }
@@ -796,27 +870,57 @@ function marginBoxesOf(rule) {
796
870
  return out;
797
871
  }
798
872
  /**
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).
873
+ * 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
874
  *
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.
875
+ * @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
876
  */
803
- export function neutralizeMarginBoxes(doc) {
804
- for (const sheet of styleSheetsOf(doc)) {
805
- let rules;
877
+ export function pageRulesOf(doc) {
878
+ const win = doc.defaultView;
879
+ const found = { layered: [], unlayered: [] };
880
+ const rulesOf = (sheet) => {
806
881
  try {
807
- rules = sheet.cssRules;
882
+ return sheet.cssRules;
808
883
  }
809
884
  catch {
810
- continue;
811
- } // cross-origin sheet: not ours to touch
885
+ return [];
886
+ } // cross-origin sheet: not ours to read
887
+ };
888
+ const walk = (rules, inLayer) => {
812
889
  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');
890
+ if (rule instanceof win.CSSPageRule)
891
+ (inLayer ? found.layered : found.unlayered).push(rule);
892
+ else if (rule.constructor?.name === 'CSSLayerBlockRule')
893
+ walk(rule.cssRules, true);
894
+ else if (rule.constructor?.name === 'CSSSupportsRule') {
895
+ if (win.CSS?.supports?.(rule.conditionText))
896
+ walk(rule.cssRules, inLayer);
819
897
  }
898
+ else if (rule.constructor?.name === 'CSSMediaRule') {
899
+ if (win.matchMedia(rule.media.mediaText).matches)
900
+ walk(rule.cssRules, inLayer);
901
+ }
902
+ else if (rule.constructor?.name === 'CSSImportRule' && rule.styleSheet)
903
+ walk(rulesOf(rule.styleSheet), inLayer || rule.layerName != null);
904
+ }
905
+ };
906
+ for (const sheet of styleSheetsOf(doc))
907
+ walk(rulesOf(sheet), false);
908
+ return found;
909
+ }
910
+ /**
911
+ * 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).
912
+ *
913
+ * 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.
914
+ */
915
+ export function neutralizeMarginBoxes(doc) {
916
+ const { layered, unlayered } = pageRulesOf(doc);
917
+ for (const rule of [...layered, ...unlayered]) {
918
+ if (!rule.cssRules)
919
+ continue;
920
+ for (const child of rule.cssRules) {
921
+ const name = (child.name ?? child.selectorText ?? '').replace(/^@/, '');
922
+ if (MARGIN_BOXES.includes(name))
923
+ child.style?.removeProperty('content');
820
924
  }
821
925
  }
822
926
  }
@@ -943,47 +1047,37 @@ export function registerGcpmProperties(win) {
943
1047
  */
944
1048
  export function readGeometryTable(doc) {
945
1049
  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
- }
1050
+ const { layered, unlayered } = pageRulesOf(doc);
1051
+ for (const rule of [...layered, ...unlayered]) {
1052
+ const sel = parseSelector(rule.selectorText);
1053
+ // 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.
1054
+ const declared = (prop) => rule.style?.getPropertyValue(prop).trim() || null;
1055
+ const size = declared('size')?.split(/\s+/) ?? null;
1056
+ // Longhands only: the CSSOM has already expanded whatever shorthand was written, and re-expanding it here would re-implement the browser's own work.
1057
+ //
1058
+ // 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.
1059
+ //
1060
+ // 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.
1061
+ const perSide = (prefix, suffix = '') => {
1062
+ const sides = { top: declared(`${prefix}-top${suffix}`), right: declared(`${prefix}-right${suffix}`), bottom: declared(`${prefix}-bottom${suffix}`), left: declared(`${prefix}-left${suffix}`) };
1063
+ if (sides.top || sides.right || sides.bottom || sides.left)
1064
+ return sides;
1065
+ const shorthand = declared(`${prefix}${suffix}`);
1066
+ return (shorthand && expandBox(substituteRootVars(doc, shorthand))) || sides;
1067
+ };
1068
+ rules.push({
1069
+ sel,
1070
+ size,
1071
+ style: declarationsOf(rule.style),
1072
+ area: areaDeclarationsOf(rule.style),
1073
+ boxStyles: marginBoxStylesOf(rule),
1074
+ margins: perSide('margin'),
1075
+ // 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.
1076
+ borderWidths: perSide('border', '-width'),
1077
+ borderStyles: perSide('border', '-style'),
1078
+ paddings: perSide('padding'),
1079
+ marginBoxes: marginBoxesOf(rule)
1080
+ });
987
1081
  }
988
1082
  if (!rules.length)
989
1083
  throw new Error('no @page rule found — the corpus and the venue always pin geometry (I5)');
@@ -992,7 +1086,7 @@ export function readGeometryTable(doc) {
992
1086
  try {
993
1087
  for (const r of rules) {
994
1088
  r.split = { host: [], cell: [] };
995
- for (const decl of r.style)
1089
+ for (const decl of withoutPageCounter(r.style))
996
1090
  (inheritance.reaches(decl) ? r.split.cell : r.split.host).push(decl);
997
1091
  }
998
1092
  }
@@ -1021,7 +1115,7 @@ export function readGeometryTable(doc) {
1021
1115
  const pageStyle = { host: [], cell: [], area: [], boxes: {} };
1022
1116
  for (const r of applicable) {
1023
1117
  if (r.size)
1024
- size = r.size;
1118
+ 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
1119
  for (const side of SIDES) {
1026
1120
  if (r.margins[side] != null)
1027
1121
  margins[side] = r.margins[side];
@@ -1055,7 +1149,7 @@ export function readGeometryTable(doc) {
1055
1149
  const b = Object.fromEntries(SIDES.map((side) => [side, borderIsDrawn(borderStyles[side]) ? resolve(borderWidths[side] ?? 'medium', basePx(side), `${label} border-${side}-width`, 'borderTopWidth') : 0]));
1056
1150
  const area = pageArea({ pageW, pageH, margins: m, borders: b, paddings: p });
1057
1151
  assertPageArea(area, label);
1058
- return { pageW, pageH, margins: m, borders: b, paddings: p, ...area, marginBoxes, pageStyle };
1152
+ return { pageW, pageH, margins: m, borders: b, paddings: p, ...area, marginBoxes, pageStyle, pageCounter: pageCounterOf(applicable) };
1059
1153
  }
1060
1154
  /**
1061
1155
  * The intake sweep (*page lengths are measured*, decision 3). Every reachable condition is resolved once, here — where the document is live and the fonts are awaited — so an unresolvable length refuses at intake rather than throwing from inside the per-page loop, pages deep, after the measure context has been built. The set is finite and small: each declared page name plus the unnamed page, × `:first`, × parity. Afterwards the memo answers every per-page call, so `pageFor` stays synchronous and keeps its signature.
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) {
@@ -153,9 +153,9 @@ export declare const CUT_OFF: string;
153
153
  * The rules, as text. Each is a sentence about a word.
154
154
  *
155
155
  * - `away`: a box put away on this page is not displayed, and it stays an element so every structural selector reads as it does in the source (*every branch gets a place*, 2026-09-21).
156
- * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border and padding at that edge.
156
+ * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
157
157
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
158
- * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border and padding there.
158
+ * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
159
159
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
160
160
  */
161
161
  export declare const WORDS_RULES: string;
@@ -292,18 +292,18 @@ const RESUMED_BOX = `${RESUMED}:not(${w(SHELL)})`;
292
292
  * The rules, as text. Each is a sentence about a word.
293
293
  *
294
294
  * - `away`: a box put away on this page is not displayed, and it stays an element so every structural selector reads as it does in the source (*every branch gets a place*, 2026-09-21).
295
- * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border and padding at that edge.
295
+ * - A resumed piece gives up the margin that adjoins the break, and the indent (the box is not beginning); and, unless the box wraps each fragment whole (`wrapped`, css-break-3 §5.4's `clone`), its border, its padding and its corners' rounding at that edge: a slice is the box cut through, so the cut edge is square (Chromium's print, measured 2026-11-07).
296
296
  * - A resumed piece's counter operations already happened where the box began (`counter-reset`, `counter-increment` and `counter-set` are `none` on it): the page's start delivers the counters as they stood (`chase.js`'s `setCounters`), and a clone that fired them again would count the box twice.
297
- * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border and padding there.
297
+ * - A cut-off piece gives up the margin before the break, and unless `wrapped` its border, padding and rounding there.
298
298
  * - `adjoin`: the box a bare-path cut resumes at, and the first-child chain through which its margin collapses, give up the margin that adjoins the break (css-break-3 §5.2) and nothing else — they are not divided.
299
299
  */
300
300
  export const WORDS_RULES = `
301
301
  @layer ${LAYER} {
302
302
  ${w(AWAY)} { display: none !important; }
303
303
  ${RESUMED_BOX} { margin-top: 0 !important; text-indent: 0 !important; counter-reset: none !important; counter-increment: none !important; counter-set: none !important; }
304
- ${RESUMED_BOX}:not(${w(WRAPPED)}) { border-top-width: 0 !important; padding-top: 0 !important; }
304
+ ${RESUMED_BOX}:not(${w(WRAPPED)}) { border-top-width: 0 !important; padding-top: 0 !important; border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; }
305
305
  ${CUT_OFF} { margin-bottom: 0 !important; }
306
- ${CUT_OFF}:not(${w(WRAPPED)}) { border-bottom-width: 0 !important; padding-bottom: 0 !important; }
306
+ ${CUT_OFF}:not(${w(WRAPPED)}) { border-bottom-width: 0 !important; padding-bottom: 0 !important; border-bottom-left-radius: 0 !important; border-bottom-right-radius: 0 !important; }
307
307
  ${w(ADJOIN)} { margin-top: 0 !important; }
308
308
  }
309
309
  `;
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * **A position is the flat word index** (`positions.js`), and an operation applies when its position is strictly before the one asked about — and not inside a remainder a carried flow has still to show, which stands before the page's first word in document order and is on that page or after it (beltype#43; `positions.js`' `remaindersOf`). That is §6.1's *"the deepest element in the normal flow that spans the page break"* said in this engine's own space: an element whose first word is the page's first word begins on the new page and does not span the break, so its own operation is not yet in force, while an element that spans the break started before that word and is included. The ancestors above the top-level blocks sit at position **−1** — they open before the document's first word and span every break there is — which is what makes the first page's answer fall out of the same comparison rather than needing a case of its own.
15
15
  *
16
- * **What is not read, each a real gap and each showing as a counter reading low.** A counter operation on a `::before` or `::after`; a list item's implicit increment; css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
16
+ * **What is not read, each a real gap and each showing as a counter reading low.** A list item's implicit increment, so an `<ol>` that runs onto a second page numbers its items from 1 there (measured 2026-11-07, `<ol reversed>` and a `counter-set` of `list-item` likewise); css-lists-3's `reversed()` counters; and a top-level `position: fixed` box, which is not among the flow blocks the position index is built over. `display: none` elements are skipped, which is CSS 2.1's own rule rather than an omission. A `::before`'s and an `::after`'s counters are read since 2026-11-07, where a sheet sets one (`pseudoStyleOf`). **And `content(before)`, `content(after)` and `content(first-letter)` are read as `content()`** — a stated limit rather than a discovered one: a pseudo-element's own string is generated content this walk does not see, and answering the element's text is the nearest honest answer there is.
17
17
  *
18
18
  * @packageDocumentation
19
19
  */
@@ -62,6 +62,8 @@ export type Op = {
62
62
  at: number;
63
63
  };
64
64
  };
65
+ /** `counter-reset: a 2 b` → `[['a', 2], ['b', 0]]`. The computed value normalizes name/value pairs, but an absent number still defaults per property, so the default is a parameter. `none` and the empty string yield nothing. */
66
+ export declare function pairsOf(value: unknown, fallback: number): [string, number][];
65
67
  /**
66
68
  * A `<content-list>` into terms — GCPM §1.1.1's own grammar and no more of CSS than it needs.
67
69
  *
@@ -78,11 +80,13 @@ export declare const collapse: (text: unknown) => string;
78
80
  /** One top-level block's part of the log: its operations in document order, each with its position and depth relative to the block — the words of the block before the element, and its depth below the block (*a resumed run keeps its reads*, 2026-10-22). {@link documentOpsOf} lays the parts end to end, a part's positions offset by its block's base and its depths by the ancestors above the blocks. */
79
81
  export declare function blockOpsOf(block: any, index: {
80
82
  wordsBefore: (el: any) => number;
83
+ wordCountIn?: (el: any) => number;
81
84
  }, reads: {
82
85
  styleOf: (el: any) => any;
83
86
  textOf: (el: any) => string;
84
87
  attrOf: (el: any, name: string) => string | null;
85
88
  marks: Set<object> | null;
89
+ pseudoStyleOf?: ((el: any, pseudo: '::before' | '::after') => any) | null;
86
90
  }): Op[];
87
91
  /**
88
92
  * The counter operations of a document, in document order, each with the flat word position it sits at and its depth in the tree.
@@ -90,6 +94,7 @@ export declare function blockOpsOf(block: any, index: {
90
94
  * @param blocks - the top-level flow blocks, in order — `driver.js`'s `topLevelBlocks`.
91
95
  * @param index - the position index (`positions.js`), which holds every element inside every block.
92
96
  * @param opts.styleOf - the computed style of an element — injected, so a plain object tree exercises this without a browser.
97
+ * @param opts.pseudoStyleOf - the computed style of an element's `::before` or `::after`, whose counter operations are logged as the element's first and last child's; absent, a pseudo-element's counters are unread. The `::after` needs the index's `wordCountIn`.
93
98
  * @param opts.textOf - an element's text; absent, its `textContent`.
94
99
  * @param opts.attrOf - an element's attribute; absent, its `getAttribute`.
95
100
  * @param opts.ancestors - the elements above the blocks, outermost first (`html`, `body`). They open before the document's first word and are recorded at position −1.
@@ -100,11 +105,13 @@ export declare function blockOpsOf(block: any, index: {
100
105
  export declare function documentOpsOf(blocks: ArrayLike<Element>, index: {
101
106
  bases: number[];
102
107
  wordsBefore: (el: any) => number;
108
+ wordCountIn?: (el: any) => number;
103
109
  entryFor?: (el: any) => {
104
110
  block: number;
105
111
  } | null;
106
- }, { styleOf, textOf, attrOf, ancestors, marks, running, keep }: {
112
+ }, { styleOf, pseudoStyleOf, textOf, attrOf, ancestors, marks, running, keep }: {
107
113
  styleOf: (el: any) => object;
114
+ pseudoStyleOf?: ((el: any, pseudo: '::before' | '::after') => object) | null;
108
115
  textOf?: (el: any) => string;
109
116
  attrOf?: (el: any, name: string) => string | null;
110
117
  ancestors?: Element[];
@@ -128,6 +135,46 @@ export declare function counterStateAt(ops: ReturnType<typeof documentOpsOf>, po
128
135
  from: number;
129
136
  to: number;
130
137
  }>): Record<string, number[]>;
138
+ /** What the page context's own rules do to the page counter on one page: the `page` value of the winning `counter-reset`, `counter-increment` and `counter-set` of the `@page` rules that apply to it, `null` where the winning declaration names no `page` (`capture.ts`'s `pageFor`). */
139
+ export type PageCounterOps = {
140
+ reset: number | null;
141
+ increment: number | null;
142
+ set: number | null;
143
+ };
144
+ /** No page-context rule touches the page counter. */
145
+ export declare const NO_PAGE_COUNTER_OPS: PageCounterOps;
146
+ /**
147
+ * The page number of page `pageIndex`, which begins at `pos`: the run's own count, with an element's reset of the page counter counted on from the page it begins on, and the page context's own `counter-reset`, `counter-increment` and `counter-set` applied as Chromium's print applies them.
148
+ *
149
+ * @remarks
150
+ * 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).
151
+ *
152
+ * 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.
153
+ *
154
+ * **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.
155
+ *
156
+ * **The page context's rules are the page box's, and each page box is its own scope.** Measured 2026-11-07 on Chromium's print (`test/browser/page-context-counters.browser.ts`): every page adds its `counter-increment` of `page`, or 1 where none is declared, to the count the pages carry; a `counter-set` sets that count, and the pages after count on from it (`@page :first { counter-set: page 7 }` prints 7, 8, 9); a `counter-reset` opens a counter of the page box's own, so the page prints the reset value plus that page's own declared increment and the pages after continue the count as though it had not been there (`@page :first { counter-reset: page 10 }` prints 10, 1, 2, and with `@page { counter-increment: page 2 }` beside it 12, 2, 4). An element's reset is applied to the count before the page's own rules, and the page it begins on takes no implicit increment. Until 2026-11-07 these declarations reached the page host as author style, where `counter-increment: page 2` added to the host's `page N` and the host's inline reset hid every `@page` reset: 3, 4, 5 for Chromium's 7, 2, 4.
157
+ *
158
+ * @param pages - each page before `pageIndex`, by index: its first word position, whether it is blank, and its page context's rules.
159
+ * @param blank - whether page `pageIndex` is one the engine made blank.
160
+ * @param counter - page `pageIndex`'s page context's rules.
161
+ * @param away - the remainders the page's opening cut carries in, as for {@link counterStateAt}.
162
+ */
163
+ export declare function pageNumberAt(ops: ReadonlyArray<Op>, pageIndex: number, pos: number, { pages, blank, counter, away }: {
164
+ pages: (i: number) => {
165
+ start: number;
166
+ blank: boolean;
167
+ counter?: PageCounterOps;
168
+ };
169
+ blank?: boolean;
170
+ counter?: PageCounterOps;
171
+ away?: ReadonlyArray<{
172
+ from: number;
173
+ to: number;
174
+ }>;
175
+ }): number;
176
+ /** 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. */
177
+ export declare function withoutPageCounters(state: Record<string, number[]>): Record<string, number[]>;
131
178
  /**
132
179
  * Every named-string assignment the document makes, in document order.
133
180
  *