diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 73084acd22..6e6e961f9b 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -1078,5 +1078,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "5f439c4117bbb2e55f227e7711df415c1173f8c476954c6e412a4b8b45edd1a3"
+ "sourceHash": "fc2e513626b5bfc6383d968b9bffdcea0f086469bf867a588dd7319c8b75c7de"
}
diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx
index e590891c7c..86f8a54926 100644
--- a/apps/docs/document-api/reference/index.mdx
+++ b/apps/docs/document-api/reference/index.mdx
@@ -116,7 +116,7 @@ The tables below are grouped by namespace.
| sections.setPageSetup | editor.doc.sections.setPageSetup(...) | Set page size/orientation properties for a section. |
| sections.setColumns | editor.doc.sections.setColumns(...) | Set column configuration for a section. |
| sections.setLineNumbering | editor.doc.sections.setLineNumbering(...) | Enable or configure line numbering for a section. |
-| sections.setPageNumbering | editor.doc.sections.setPageNumbering(...) | Set page numbering format/start for a section. |
+| sections.setPageNumbering | editor.doc.sections.setPageNumbering(...) | Set page numbering format/start and chapter numbering settings for a section. |
| sections.setTitlePage | editor.doc.sections.setTitlePage(...) | Enable or disable title-page behavior for a section. |
| sections.setOddEvenHeadersFooters | editor.doc.sections.setOddEvenHeadersFooters(...) | Enable or disable odd/even header-footer mode in document settings. |
| sections.setVerticalAlign | editor.doc.sections.setVerticalAlign(...) | Set vertical page alignment for a section. |
diff --git a/apps/docs/document-api/reference/sections/get.mdx b/apps/docs/document-api/reference/sections/get.mdx
index fba6042315..8fd2c4cd4b 100644
--- a/apps/docs/document-api/reference/sections/get.mdx
+++ b/apps/docs/document-api/reference/sections/get.mdx
@@ -111,6 +111,8 @@ Returns a SectionInfo object with full section properties including margins, col
| `pageBorders.top.style` | string | no | |
| `pageBorders.zOrder` | enum | no | `"front"`, `"back"` |
| `pageNumbering` | object | no | |
+| `pageNumbering.chapterSeparator` | enum | no | `"hyphen"`, `"period"`, `"colon"`, `"emDash"`, `"enDash"` |
+| `pageNumbering.chapterStyle` | integer | no | |
| `pageNumbering.format` | enum | no | `"decimal"`, `"lowerLetter"`, `"upperLetter"`, `"lowerRoman"`, `"upperRoman"`, `"numberInDash"` |
| `pageNumbering.start` | integer | no | |
| `pageSetup` | object | no | |
@@ -614,6 +616,20 @@ Returns a SectionInfo object with full section properties including margins, col
"pageNumbering": {
"additionalProperties": false,
"properties": {
+ "chapterSeparator": {
+ "enum": [
+ "hyphen",
+ "period",
+ "colon",
+ "emDash",
+ "enDash"
+ ],
+ "type": "string"
+ },
+ "chapterStyle": {
+ "minimum": 1,
+ "type": "integer"
+ },
"format": {
"enum": [
"decimal",
diff --git a/apps/docs/document-api/reference/sections/list.mdx b/apps/docs/document-api/reference/sections/list.mdx
index ba6415b287..19c0245190 100644
--- a/apps/docs/document-api/reference/sections/list.mdx
+++ b/apps/docs/document-api/reference/sections/list.mdx
@@ -586,6 +586,20 @@ Returns a SectionsListResult with an ordered array of section summaries and thei
"pageNumbering": {
"additionalProperties": false,
"properties": {
+ "chapterSeparator": {
+ "enum": [
+ "hyphen",
+ "period",
+ "colon",
+ "emDash",
+ "enDash"
+ ],
+ "type": "string"
+ },
+ "chapterStyle": {
+ "minimum": 1,
+ "type": "integer"
+ },
"format": {
"enum": [
"decimal",
diff --git a/apps/docs/document-api/reference/sections/set-page-numbering.mdx b/apps/docs/document-api/reference/sections/set-page-numbering.mdx
index 8dee04bcb0..b5e40e1eb8 100644
--- a/apps/docs/document-api/reference/sections/set-page-numbering.mdx
+++ b/apps/docs/document-api/reference/sections/set-page-numbering.mdx
@@ -1,14 +1,14 @@
---
title: sections.setPageNumbering
sidebarTitle: sections.setPageNumbering
-description: Set page numbering format/start for a section.
+description: Set page numbering format/start and chapter numbering settings for a section.
---
{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
## Summary
-Set page numbering format/start for a section.
+Set page numbering format/start and chapter numbering settings for a section.
- Operation ID: `sections.setPageNumbering`
- API member path: `editor.doc.sections.setPageNumbering(...)`
@@ -20,7 +20,7 @@ Set page numbering format/start for a section.
## Expected result
-Returns a SectionMutationResult receipt; reports NO_OP if page numbering format already matches.
+Returns a SectionMutationResult receipt; reports NO_OP if page numbering settings already match.
## Input fields
@@ -42,6 +42,24 @@ Returns a SectionMutationResult receipt; reports NO_OP if page numbering format
| `target.kind` | `"section"` | yes | Constant: `"section"` |
| `target.sectionId` | string | yes | |
+### Variant 3 (target.kind="section")
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `chapterStyle` | integer | yes | |
+| `target` | SectionAddress | yes | SectionAddress |
+| `target.kind` | `"section"` | yes | Constant: `"section"` |
+| `target.sectionId` | string | yes | |
+
+### Variant 4 (target.kind="section")
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `chapterSeparator` | enum | yes | `"hyphen"`, `"period"`, `"colon"`, `"emDash"`, `"enDash"` |
+| `target` | SectionAddress | yes | SectionAddress |
+| `target.kind` | `"section"` | yes | Constant: `"section"` |
+| `target.sectionId` | string | yes | |
+
### Example request
```json
@@ -107,7 +125,7 @@ Returns a SectionMutationResult receipt; reports NO_OP if page numbering format
```json
{
"additionalProperties": false,
- "oneOf": [
+ "anyOf": [
{
"required": [
"target",
@@ -119,9 +137,35 @@ Returns a SectionMutationResult receipt; reports NO_OP if page numbering format
"target",
"format"
]
+ },
+ {
+ "required": [
+ "target",
+ "chapterStyle"
+ ]
+ },
+ {
+ "required": [
+ "target",
+ "chapterSeparator"
+ ]
}
],
"properties": {
+ "chapterSeparator": {
+ "enum": [
+ "hyphen",
+ "period",
+ "colon",
+ "emDash",
+ "enDash"
+ ],
+ "type": "string"
+ },
+ "chapterStyle": {
+ "minimum": 1,
+ "type": "integer"
+ },
"format": {
"enum": [
"decimal",
diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts
index df14717b87..6dfe35ff90 100644
--- a/packages/document-api/src/contract/operation-definitions.ts
+++ b/packages/document-api/src/contract/operation-definitions.ts
@@ -1259,8 +1259,8 @@ export const OPERATION_DEFINITIONS = {
},
'sections.setPageNumbering': {
memberPath: 'sections.setPageNumbering',
- description: 'Set page numbering format/start for a section.',
- expectedResult: 'Returns a SectionMutationResult receipt; reports NO_OP if page numbering format already matches.',
+ description: 'Set page numbering format/start and chapter numbering settings for a section.',
+ expectedResult: 'Returns a SectionMutationResult receipt; reports NO_OP if page numbering settings already match.',
requiresDocumentContext: true,
metadata: mutationOperation({
idempotency: 'conditional',
diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts
index c6cf1048e0..b3293411ff 100644
--- a/packages/document-api/src/contract/schemas.ts
+++ b/packages/document-api/src/contract/schemas.ts
@@ -1281,6 +1281,8 @@ const sectionLineNumberingSchema = objectSchema(
const sectionPageNumberingSchema = objectSchema({
start: { type: 'integer', minimum: 1 },
format: sectionPageNumberFormatSchema,
+ chapterStyle: { type: 'integer', minimum: 1 },
+ chapterSeparator: { type: 'string', enum: ['hyphen', 'period', 'colon', 'emDash', 'enDash'] },
});
const sectionHeaderFooterRefsSchema = objectSchema({
@@ -4018,10 +4020,17 @@ const operationSchemas: Record = {
target: sectionAddressSchema,
start: { type: 'integer', minimum: 1 },
format: sectionPageNumberFormatSchema,
+ chapterStyle: { type: 'integer', minimum: 1 },
+ chapterSeparator: { type: 'string', enum: ['hyphen', 'period', 'colon', 'emDash', 'enDash'] },
},
['target'],
),
- oneOf: [{ required: ['target', 'start'] }, { required: ['target', 'format'] }],
+ anyOf: [
+ { required: ['target', 'start'] },
+ { required: ['target', 'format'] },
+ { required: ['target', 'chapterStyle'] },
+ { required: ['target', 'chapterSeparator'] },
+ ],
},
output: sectionMutationResultSchemaFor('sections.setPageNumbering'),
success: sectionMutationSuccessSchema,
diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts
index 8607695008..1f30327386 100644
--- a/packages/document-api/src/index.ts
+++ b/packages/document-api/src/index.ts
@@ -1429,6 +1429,7 @@ export type {
SectionPageBorders,
SectionPageMargins,
SectionPageNumbering,
+ SectionPageNumberingChapterSeparator,
SectionPageNumberingFormat,
SectionPageSetup,
SectionRangeDomain,
diff --git a/packages/document-api/src/sections/sections.test.ts b/packages/document-api/src/sections/sections.test.ts
index a912c6b281..ca67091a50 100644
--- a/packages/document-api/src/sections/sections.test.ts
+++ b/packages/document-api/src/sections/sections.test.ts
@@ -105,7 +105,57 @@ describe('sections API validation', () => {
executeSectionsSetPageNumbering(adapter, {
target: { kind: 'section', sectionId: 'section-0' },
}),
- ).toThrow(/requires at least one of start or format/i);
+ ).toThrow(/requires at least one of start, format, chapterStyle, or chapterSeparator/i);
+ });
+
+ it('accepts chapterStyle for setPageNumbering', () => {
+ const setPageNumbering = mock(makeAdapter().setPageNumbering);
+ const adapter = makeAdapter({ setPageNumbering });
+
+ executeSectionsSetPageNumbering(adapter, {
+ target: { kind: 'section', sectionId: 'section-0' },
+ chapterStyle: 1,
+ });
+
+ expect(setPageNumbering).toHaveBeenCalledWith(
+ { target: { kind: 'section', sectionId: 'section-0' }, chapterStyle: 1 },
+ { changeMode: 'direct', dryRun: false, expectedRevision: undefined },
+ );
+ });
+
+ it('accepts valid chapterSeparator for setPageNumbering', () => {
+ const setPageNumbering = mock(makeAdapter().setPageNumbering);
+ const adapter = makeAdapter({ setPageNumbering });
+
+ executeSectionsSetPageNumbering(adapter, {
+ target: { kind: 'section', sectionId: 'section-0' },
+ chapterSeparator: 'enDash',
+ });
+
+ expect(setPageNumbering).toHaveBeenCalledWith(
+ { target: { kind: 'section', sectionId: 'section-0' }, chapterSeparator: 'enDash' },
+ { changeMode: 'direct', dryRun: false, expectedRevision: undefined },
+ );
+ });
+
+ it('rejects invalid chapterSeparator for setPageNumbering', () => {
+ const adapter = makeAdapter();
+ expect(() =>
+ executeSectionsSetPageNumbering(adapter, {
+ target: { kind: 'section', sectionId: 'section-0' },
+ chapterSeparator: 'slash' as any,
+ }),
+ ).toThrow(/chapterSeparator/i);
+ });
+
+ it('rejects chapterStyle less than 1 for setPageNumbering', () => {
+ const adapter = makeAdapter();
+ expect(() =>
+ executeSectionsSetPageNumbering(adapter, {
+ target: { kind: 'section', sectionId: 'section-0' },
+ chapterStyle: 0,
+ }),
+ ).toThrow(/chapterStyle/i);
});
it('requires at least one field for setPageBorders', () => {
diff --git a/packages/document-api/src/sections/sections.ts b/packages/document-api/src/sections/sections.ts
index 483a45610f..93fa6b0616 100644
--- a/packages/document-api/src/sections/sections.ts
+++ b/packages/document-api/src/sections/sections.ts
@@ -10,6 +10,7 @@ import type {
SectionHeaderFooterVariant,
SectionDirection,
SectionOrientation,
+ SectionPageNumberingChapterSeparator,
SectionVerticalAlign,
SectionsClearHeaderFooterRefInput,
SectionsClearPageBordersInput,
@@ -41,6 +42,7 @@ export type {
SectionBreakType,
SectionHeaderFooterKind,
SectionHeaderFooterVariant,
+ SectionPageNumberingChapterSeparator,
SectionsClearHeaderFooterRefInput,
SectionsClearPageBordersInput,
SectionsGetInput,
@@ -81,6 +83,13 @@ const PAGE_NUMBER_FORMATS = [
'upperRoman',
'numberInDash',
] as const;
+const PAGE_NUMBER_CHAPTER_SEPARATORS: readonly SectionPageNumberingChapterSeparator[] = [
+ 'hyphen',
+ 'period',
+ 'colon',
+ 'emDash',
+ 'enDash',
+] as const;
const PAGE_BORDER_DISPLAYS = ['allPages', 'firstPage', 'notFirstPage'] as const;
const PAGE_BORDER_OFFSET_FROM_VALUES = ['page', 'text'] as const;
const PAGE_BORDER_Z_ORDER_VALUES = ['front', 'back'] as const;
@@ -390,10 +399,12 @@ export function executeSectionsSetPageNumbering(
options?: MutationOptions,
): SectionMutationResult {
assertSectionTarget(input, 'sections.setPageNumbering');
- if (!hasAnyDefined(input as unknown as Record, ['start', 'format'])) {
+ if (
+ !hasAnyDefined(input as unknown as Record, ['start', 'format', 'chapterStyle', 'chapterSeparator'])
+ ) {
throw new DocumentApiValidationError(
'INVALID_INPUT',
- 'sections.setPageNumbering requires at least one of start or format.',
+ 'sections.setPageNumbering requires at least one of start, format, chapterStyle, or chapterSeparator.',
);
}
@@ -401,6 +412,12 @@ export function executeSectionsSetPageNumbering(
if (input.format !== undefined) {
assertOneOf(input.format, 'sections.setPageNumbering.format', PAGE_NUMBER_FORMATS);
}
+ if (input.chapterStyle !== undefined) {
+ assertPositiveInteger(input.chapterStyle, 'sections.setPageNumbering.chapterStyle');
+ }
+ if (input.chapterSeparator !== undefined) {
+ assertOneOf(input.chapterSeparator, 'sections.setPageNumbering.chapterSeparator', PAGE_NUMBER_CHAPTER_SEPARATORS);
+ }
return adapter.setPageNumbering(input, normalizeMutationOptions(options));
}
diff --git a/packages/document-api/src/sections/sections.types.ts b/packages/document-api/src/sections/sections.types.ts
index ad6a9f3647..db27bb633b 100644
--- a/packages/document-api/src/sections/sections.types.ts
+++ b/packages/document-api/src/sections/sections.types.ts
@@ -36,6 +36,8 @@ export type SectionPageNumberingFormat =
| 'upperRoman'
| 'numberInDash';
+export type SectionPageNumberingChapterSeparator = 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
+
export interface SectionPageMargins {
top?: number;
right?: number;
@@ -73,6 +75,8 @@ export interface SectionLineNumbering {
export interface SectionPageNumbering {
start?: number;
format?: SectionPageNumberingFormat;
+ chapterStyle?: number;
+ chapterSeparator?: SectionPageNumberingChapterSeparator;
}
export interface SectionHeaderFooterRefs {
@@ -227,6 +231,8 @@ export interface SectionsSetLineNumberingInput extends SectionTargetInput {
export interface SectionsSetPageNumberingInput extends SectionTargetInput {
start?: number;
format?: SectionPageNumberingFormat;
+ chapterStyle?: number;
+ chapterSeparator?: SectionPageNumberingChapterSeparator;
}
export interface SectionsSetTitlePageInput extends SectionTargetInput {
diff --git a/packages/document-api/src/types/sd-sections.ts b/packages/document-api/src/types/sd-sections.ts
index bd7c23cce6..194a9467c5 100644
--- a/packages/document-api/src/types/sd-sections.ts
+++ b/packages/document-api/src/types/sd-sections.ts
@@ -51,6 +51,8 @@ export interface SDSection {
pageNumbering?: {
start?: number;
format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
+ chapterStyle?: number;
+ chapterSeparator?: 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
};
titlePage?: boolean;
oddEvenHeadersFooters?: boolean;
diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts
index 118679ae36..c7141fd569 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -1,5 +1,9 @@
import type { TabStop } from './engines/tabs.js';
-import type { PageNumberFieldFormat, PageNumberFormat } from './page-number-formatting.js';
+import type {
+ PageNumberChapterSeparator,
+ PageNumberFieldFormat,
+ PageNumberFormat,
+} from './page-number-formatting.js';
export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js';
// Re-export TabStop for external consumers
@@ -138,9 +142,12 @@ export {
type ResolveInheritedHeaderFooterRefInput,
} from './header-footer-inheritance.js';
export {
+ formatChapterPageNumberText,
formatPageNumber,
formatPageNumberFieldValue,
+ formatSectionPageNumberText,
type PageNumberFieldFormat,
+ type PageNumberChapterSeparator,
type PageNumberFormat,
} from './page-number-formatting.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
@@ -1210,10 +1217,7 @@ export type SectionBreakBlock = {
/** Left page margin */
left?: number;
};
- numbering?: {
- format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
- start?: number;
- };
+ numbering?: SectionNumbering;
headerRefs?: {
default?: string;
first?: string;
@@ -1252,8 +1256,10 @@ export type SectionRefs = {
};
export type SectionNumbering = {
- format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
+ format?: PageNumberFormat;
start?: number;
+ chapterStyle?: number;
+ chapterSeparator?: PageNumberChapterSeparator;
};
export type SectionMetadata = {
@@ -1635,6 +1641,10 @@ export type ParagraphAttrs = {
dropCapDescriptor?: DropCapDescriptor;
frame?: ParagraphFrame;
numberingProperties?: { ilvl?: number; numId?: number } | null;
+ /** Built-in heading level resolved from style metadata, where 1 means Heading 1. */
+ headingLevel?: number;
+ /** Current list level ordinal from structured numbering metadata. */
+ listLevelOrdinal?: number;
borders?: ParagraphBorders;
shading?: ParagraphShading;
tabs?: TabStop[];
@@ -2029,13 +2039,19 @@ export type Page = {
* SD-2656: page-level footnote planning ledger. Populated by the layout
* bridge when footnotes are present. Read by the diagnostic toolkit and
* (in later phases) by body pagination itself.
- */
+ */
footnoteLedger?: FootnotePageLedger;
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
displayNumber?: number;
numberText?: string;
/** Numeric page number after section page numbering settings are applied. */
effectivePageNumber?: number;
+ /** Section PAGE number format before any run-local PAGE switch is applied. */
+ pageNumberFormat?: PageNumberFormat;
+ /** MVP chapter prefix text derived from the nearest numbered Heading N marker. */
+ pageNumberChapterText?: string;
+ /** Separator between chapter prefix and page number component. */
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
size?: { w: number; h: number };
orientation?: 'portrait' | 'landscape';
sectionRefs?: {
@@ -2265,6 +2281,12 @@ export type HeaderFooterPage = {
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
+ /** Section PAGE number format before any run-local PAGE switch is applied. */
+ pageNumberFormat?: PageNumberFormat;
+ /** MVP chapter prefix text derived from the nearest numbered Heading N marker. */
+ pageNumberChapterText?: string;
+ /** Separator between chapter prefix and page number component. */
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
/**
* Optional page-local block clones backing this page's resolved fragments.
* Present when header/footer tokens were laid out per page or per bucket.
diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts
index 413e14bb8a..8bf49b76fe 100644
--- a/packages/layout-engine/contracts/src/page-number-formatting.ts
+++ b/packages/layout-engine/contracts/src/page-number-formatting.ts
@@ -4,6 +4,7 @@ export type PageNumberFieldFormat = {
};
export type PageNumberFormat = NonNullable;
+export type PageNumberChapterSeparator = 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
function toUpperRoman(value: number): string {
if (value < 1 || value > 3999) return String(value);
@@ -57,3 +58,44 @@ export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: Pag
? formatted.padStart(fieldFormat.zeroPadding, '0')
: formatted;
}
+
+export function formatChapterPageNumberText(args: {
+ pageComponent: string;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
+}): string {
+ if (!args.chapterNumberText) {
+ return args.pageComponent;
+ }
+
+ const separator = (() => {
+ switch (args.chapterSeparator ?? 'hyphen') {
+ case 'period':
+ return '.';
+ case 'colon':
+ return ':';
+ case 'emDash':
+ return '\u2014';
+ case 'enDash':
+ return '\u2013';
+ case 'hyphen':
+ default:
+ return '\u2011';
+ }
+ })();
+
+ return `${args.chapterNumberText}${separator}${args.pageComponent}`;
+}
+
+export function formatSectionPageNumberText(args: {
+ displayNumber: number;
+ pageFormat: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
+}): string {
+ return formatChapterPageNumberText({
+ pageComponent: formatPageNumber(args.displayNumber, args.pageFormat),
+ chapterNumberText: args.chapterNumberText,
+ chapterSeparator: args.chapterSeparator,
+ });
+}
diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts
index fdad65d628..817554e45a 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -10,6 +10,8 @@ import type {
ListBlock,
ListMeasure,
PageMargins,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
ParagraphBlock,
ParagraphBorders,
ParagraphMeasure,
@@ -60,6 +62,12 @@ export type ResolvedPage = {
numberText?: string;
/** Numeric page number after section page numbering settings are applied. */
effectivePageNumber?: number;
+ /** Section PAGE number format before any run-local PAGE switch is applied. */
+ pageNumberFormat?: PageNumberFormat;
+ /** MVP chapter prefix text derived from the nearest numbered Heading N marker. */
+ pageNumberChapterText?: string;
+ /** Separator between chapter prefix and page number component. */
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
/** Vertical alignment of content within this page. */
vAlign?: SectionVerticalAlign;
/** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
@@ -455,6 +463,12 @@ export type ResolvedHeaderFooterPage = {
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
+ /** Section PAGE number format before any run-local PAGE switch is applied. */
+ pageNumberFormat?: PageNumberFormat;
+ /** MVP chapter prefix text derived from the nearest numbered Heading N marker. */
+ pageNumberChapterText?: string;
+ /** Separator between chapter prefix and page number component. */
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
items: ResolvedPaintItem[];
};
diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
index 024916ed62..28db216f91 100644
--- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
+++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
@@ -9,16 +9,25 @@ import type {
ColumnLayout,
SectionBreakBlock,
NormalizedColumnLayout,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
+} from '@superdoc/contracts';
+import {
+ cloneColumnLayout,
+ formatSectionPageNumberText,
+ normalizeColumnLayout,
+ rescaleColumnWidths,
} from '@superdoc/contracts';
-import { cloneColumnLayout, normalizeColumnLayout, rescaleColumnWidths } from '@superdoc/contracts';
import {
layoutDocument,
- layoutHeaderFooter,
type LayoutOptions,
type HeaderFooterConstraints,
computeDisplayPageNumber,
resolvePageNumberTokens,
type NumberingContext,
+ buildChapterContextByPage,
+ type ChapterPageInfo,
+ normalizeChapterMarkerText,
SEMANTIC_PAGE_HEIGHT_PX,
SINGLE_COLUMN_DEFAULT,
resolveTableFrame,
@@ -26,7 +35,12 @@ import {
import { remeasureParagraph } from './remeasure';
import { computeDirtyRegions } from './diff';
import { MeasureCache } from './cache';
-import { layoutHeaderFooterWithCache, HeaderFooterLayoutCache, type HeaderFooterBatch } from './layoutHeaderFooter';
+import {
+ layoutHeaderFooterWithCache,
+ HeaderFooterLayoutCache,
+ type HeaderFooterBatch,
+ type PageResolver,
+} from './layoutHeaderFooter';
import {
buildSectionAwareHeaderFooterLayoutKey,
buildSectionAwareHeaderFooterMeasurementGroups,
@@ -955,6 +969,7 @@ export async function incrementalLayout(
blocksByRId: Map | undefined,
constraints: HeaderFooterConstraints,
measureFn: HeaderFooterMeasureFn,
+ pageResolver?: PageResolver,
): Promise<{
heightsByRId?: Map;
heightsBySectionRef?: Map;
@@ -977,13 +992,17 @@ export async function incrementalLayout(
const blocks = blocksByRId.get(group.rId);
if (!blocks || blocks.length === 0) continue;
- const measureConstraints = {
- maxWidth: group.sectionConstraints.width,
- maxHeight: group.sectionConstraints.height,
- };
- const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
- const layout = layoutHeaderFooter(blocks, measures, group.sectionConstraints, kind);
- if (!(layout.height > 0)) continue;
+ const layouts = await layoutHeaderFooterWithCache(
+ { default: blocks },
+ group.sectionConstraints,
+ measureFn,
+ headerMeasureCache,
+ 1,
+ pageResolver,
+ kind,
+ );
+ const layout = layouts.default?.layout;
+ if (!layout || !(layout.height > 0)) continue;
const nextHeight = Math.max(0, layout.height);
const currentHeight = heightsByRId.get(group.rId) ?? 0;
@@ -1005,13 +1024,17 @@ export async function incrementalLayout(
for (const [rId, blocks] of blocksByRId) {
if (!blocks || blocks.length === 0) continue;
- const measureConstraints = {
- maxWidth: constraints.width,
- maxHeight: constraints.height,
- };
- const measures = await Promise.all(blocks.map((block) => measureFn(block, measureConstraints)));
- const layout = layoutHeaderFooter(blocks, measures, constraints, kind);
- if (layout.height > 0) {
+ const layouts = await layoutHeaderFooterWithCache(
+ { default: blocks },
+ constraints,
+ measureFn,
+ headerMeasureCache,
+ 1,
+ pageResolver,
+ kind,
+ );
+ const layout = layouts.default?.layout;
+ if (layout && layout.height > 0) {
heightsByRId.set(rId, layout.height);
}
}
@@ -1041,6 +1064,7 @@ export async function incrementalLayout(
* header height calculations. A value of 1 is sufficient as a placeholder.
*/
const HEADER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT = 1;
+ const prelayoutPageResolver = buildConservativePrelayoutPageResolver(nextBlocks, sectionMetadata);
/**
* Type guard to check if a key is a valid header variant type.
@@ -1064,7 +1088,7 @@ export async function incrementalLayout(
measureFn,
headerMeasureCache,
HEADER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
- undefined, // No page resolver needed for height calculation
+ prelayoutPageResolver,
'header',
);
@@ -1088,6 +1112,7 @@ export async function incrementalLayout(
headerFooter.headerBlocksByRId,
headerFooter.constraints,
measureFn,
+ prelayoutPageResolver,
);
headerContentHeightsByRId = measuredHeights.heightsByRId;
headerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef;
@@ -1144,6 +1169,7 @@ export async function incrementalLayout(
* footer height calculations. A value of 1 is sufficient as a placeholder.
*/
const FOOTER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT = 1;
+ const prelayoutPageResolver = buildConservativePrelayoutPageResolver(nextBlocks, sectionMetadata);
/**
* Type guard to check if a key is a valid footer variant type.
@@ -1168,7 +1194,7 @@ export async function incrementalLayout(
measureFn,
headerMeasureCache,
FOOTER_PRELAYOUT_PLACEHOLDER_PAGE_COUNT,
- undefined, // No page resolver needed for height calculation
+ prelayoutPageResolver,
'footer',
);
@@ -1192,6 +1218,7 @@ export async function incrementalLayout(
headerFooter.footerBlocksByRId,
headerFooter.constraints,
measureFn,
+ prelayoutPageResolver,
);
footerContentHeightsByRId = measuredHeights.heightsByRId;
footerContentHeightsBySectionRef = measuredHeights.heightsBySectionRef;
@@ -1228,6 +1255,10 @@ export async function incrementalLayout(
let currentBlocks = nextBlocks;
let currentMeasures = measures;
let iteration = 0;
+ // Chapter context only reads stable paragraph style/marker metadata; PAGE
+ // token convergence clones run text but does not change those block attrs.
+ const chapterBlockById = buildBlockById(currentBlocks);
+ const chapterContextCache: ChapterContextCache = {};
const pageTokenStart = performance.now();
let totalAffectedBlocks = 0;
@@ -1240,7 +1271,7 @@ export async function incrementalLayout(
while (iteration < maxIterations) {
// Build numbering context from current layout
const sections = options.sectionMetadata ?? [];
- const numberingCtx = buildNumberingContext(layout, sections);
+ const numberingCtx = buildNumberingContext(layout, sections, chapterBlockById, chapterContextCache);
// Log iteration start
PageTokenLogger.logIterationStart(iteration, layout.pages.length);
@@ -1285,9 +1316,6 @@ export async function incrementalLayout(
perfLog(`[Perf] 4.3.${iteration + 1}.1 Re-measure: ${remeasureTime.toFixed(2)}ms`);
PageTokenLogger.logRemeasure(tokenResult.affectedBlockIds.size, remeasureTime);
- // Check if page count has stabilized
- const oldPageCount = layout.pages.length;
-
// Re-run pagination with updated measures
const relayoutStart = performance.now();
layout = layoutDocument(currentBlocks, currentMeasures, {
@@ -1306,14 +1334,6 @@ export async function incrementalLayout(
totalRelayoutTime += relayoutTime;
perfLog(`[Perf] 4.3.${iteration + 1}.2 Re-layout: ${relayoutTime.toFixed(2)}ms`);
- const newPageCount = layout.pages.length;
-
- // Early exit if page count is stable (common case: no change or minor text adjustment)
- if (newPageCount === oldPageCount && iteration > 0) {
- perfLog(`[Perf] 4.3 Page count stable at ${newPageCount} - breaking convergence loop`);
- break;
- }
-
iteration++;
}
@@ -2677,6 +2697,9 @@ export async function incrementalLayout(
let headers: HeaderFooterLayoutResult[] | undefined;
let footers: HeaderFooterLayoutResult[] | undefined;
+ const sections = options.sectionMetadata ?? [];
+ const numberingCtx = buildNumberingContext(layout, sections, chapterBlockById, chapterContextCache);
+ applyNumberingContextToLayout(layout, numberingCtx);
if (headerFooter?.constraints && (headerFooter.headerBlocks || headerFooter.footerBlocks)) {
const hfStart = performance.now();
@@ -2693,16 +2716,20 @@ export async function incrementalLayout(
options.sectionMetadata,
);
- // Build numbering context from final layout for header/footer token resolution
- const sections = options.sectionMetadata ?? [];
- const numberingCtx = buildNumberingContext(layout, sections);
-
// Create page resolver for section-aware header/footer numbering
// Only use page resolver if feature flag is enabled
const pageResolver = FeatureFlags.HEADER_FOOTER_PAGE_TOKENS
? (
pageNumber: number,
- ): { displayText: string; displayNumber: number; totalPages: number; sectionPageCount: number } => {
+ ): {
+ displayText: string;
+ displayNumber: number;
+ totalPages: number;
+ sectionPageCount: number;
+ pageFormat?: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
+ } => {
const pageIndex = pageNumber - 1;
const displayInfo = numberingCtx.displayPages[pageIndex];
return {
@@ -2710,6 +2737,9 @@ export async function incrementalLayout(
displayNumber: displayInfo?.displayNumber ?? pageNumber,
totalPages: numberingCtx.totalPages,
sectionPageCount: displayInfo?.sectionPageCount ?? numberingCtx.totalPages ?? 1,
+ pageFormat: displayInfo?.pageFormat,
+ chapterNumberText: displayInfo?.chapterNumberText,
+ chapterSeparator: displayInfo?.chapterSeparator,
};
}
: undefined;
@@ -3009,6 +3039,215 @@ const serializeHeaderFooterResults = (
return results;
};
+type ChapterContextCache = {
+ signature?: string;
+ context?: Map;
+};
+
+function buildBlockById(blocks: FlowBlock[]): ReadonlyMap {
+ const blockById = new Map();
+ for (const block of blocks) {
+ blockById.set(block.id, block);
+ }
+ return blockById;
+}
+
+function getFragmentBlockId(fragment: unknown): string {
+ if (
+ typeof fragment === 'object' &&
+ fragment !== null &&
+ 'blockId' in fragment &&
+ typeof (fragment as { blockId?: unknown }).blockId === 'string'
+ ) {
+ return (fragment as { blockId: string }).blockId;
+ }
+ return '';
+}
+
+function buildChapterContextSignature(layout: Layout): string {
+ return layout.pages
+ .map((page) => {
+ return [
+ page.number,
+ page.sectionIndex ?? 0,
+ page.fragments.length,
+ page.fragments.map((fragment) => getFragmentBlockId(fragment)).join(','),
+ ].join(':');
+ })
+ .join('|');
+}
+
+function sectionsHaveChapterNumbering(sections: SectionMetadata[]): boolean {
+ return sections.some((section) => {
+ const chapterStyle = section.numbering?.chapterStyle;
+ return typeof chapterStyle === 'number' && Number.isInteger(chapterStyle) && chapterStyle > 0;
+ });
+}
+
+const PRELAYOUT_CHAPTER_MARKER_SEPARATOR_RE = /[.\-:\u2013\u2014]/;
+const PRELAYOUT_MIN_PAGE_COMPONENT = 10;
+
+function getPrelayoutHeadingLevel(block: FlowBlock): number | undefined {
+ if (block.kind !== 'paragraph') {
+ return undefined;
+ }
+
+ const attrs = (block as ParagraphBlock).attrs;
+ const headingLevel = attrs?.headingLevel;
+ if (typeof headingLevel === 'number' && Number.isInteger(headingLevel) && headingLevel > 0) {
+ return headingLevel;
+ }
+
+ const styleId = attrs?.styleId;
+ if (typeof styleId !== 'string') {
+ return undefined;
+ }
+
+ const normalizedStyleId = styleId.replace(/[\s_-]+/g, '').toLowerCase();
+ const match = /^heading(\d+)$/.exec(normalizedStyleId);
+ if (!match) {
+ return undefined;
+ }
+
+ const level = Number(match[1]);
+ return Number.isInteger(level) && level > 0 ? level : undefined;
+}
+
+function getPrelayoutChapterMarkerText(block: FlowBlock, chapterStyle: number): string | undefined {
+ const headingLevel = getPrelayoutHeadingLevel(block);
+ if (!headingLevel || headingLevel > chapterStyle || block.kind !== 'paragraph') {
+ return undefined;
+ }
+
+ const attrs = (block as ParagraphBlock).attrs;
+ const markerText = normalizeChapterMarkerText(attrs?.wordLayout?.marker?.markerText);
+ if (!markerText) {
+ const listLevelOrdinal = attrs?.listLevelOrdinal;
+ return headingLevel === 1 &&
+ typeof listLevelOrdinal === 'number' &&
+ Number.isInteger(listLevelOrdinal) &&
+ listLevelOrdinal > 0
+ ? String(listLevelOrdinal)
+ : undefined;
+ }
+
+ return markerText.split(PRELAYOUT_CHAPTER_MARKER_SEPARATOR_RE).length <= chapterStyle ? markerText : undefined;
+}
+
+function buildConservativePrelayoutPageResolver(
+ blocks: FlowBlock[],
+ sections: SectionMetadata[],
+): PageResolver | undefined {
+ if (sections.length === 0) {
+ return undefined;
+ }
+
+ type PrelayoutDisplay = {
+ displayText: string;
+ displayNumber: number;
+ totalPages: number;
+ sectionPageCount: number;
+ pageFormat: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
+ };
+
+ let longestDisplay: PrelayoutDisplay | undefined;
+ const considerDisplay = (display: PrelayoutDisplay): void => {
+ if (!longestDisplay || display.displayText.length > longestDisplay.displayText.length) {
+ longestDisplay = display;
+ }
+ };
+
+ for (const section of sections) {
+ const sectionStart =
+ typeof section.numbering?.start === 'number' && Number.isFinite(section.numbering.start)
+ ? section.numbering.start
+ : 1;
+ const displayNumber = Math.max(sectionStart, PRELAYOUT_MIN_PAGE_COMPONENT);
+ const pageFormat = section.numbering?.format ?? 'decimal';
+
+ considerDisplay({
+ displayText: formatSectionPageNumberText({ displayNumber, pageFormat }),
+ displayNumber,
+ totalPages: PRELAYOUT_MIN_PAGE_COMPONENT,
+ sectionPageCount: PRELAYOUT_MIN_PAGE_COMPONENT,
+ pageFormat,
+ });
+
+ const chapterStyle = section.numbering?.chapterStyle;
+ if (!(typeof chapterStyle === 'number' && Number.isInteger(chapterStyle) && chapterStyle > 0)) {
+ continue;
+ }
+
+ for (const block of blocks) {
+ const chapterNumberText = getPrelayoutChapterMarkerText(block, chapterStyle);
+ if (!chapterNumberText) {
+ continue;
+ }
+
+ const chapterSeparator = section.numbering?.chapterSeparator ?? 'hyphen';
+ considerDisplay({
+ displayText: formatSectionPageNumberText({
+ displayNumber,
+ pageFormat,
+ chapterNumberText,
+ chapterSeparator,
+ }),
+ displayNumber,
+ totalPages: PRELAYOUT_MIN_PAGE_COMPONENT,
+ sectionPageCount: PRELAYOUT_MIN_PAGE_COMPONENT,
+ pageFormat,
+ chapterNumberText,
+ chapterSeparator,
+ });
+ }
+ }
+
+ if (!longestDisplay) {
+ return undefined;
+ }
+
+ const resolvedDisplay = longestDisplay;
+ return () => resolvedDisplay;
+}
+
+function getChapterContextByPage(
+ layout: Layout,
+ sections: SectionMetadata[],
+ blockById: ReadonlyMap,
+ cache: ChapterContextCache,
+): Map | undefined {
+ if (!sectionsHaveChapterNumbering(sections)) {
+ return undefined;
+ }
+
+ const signature = buildChapterContextSignature(layout);
+ if (cache.signature === signature && cache.context) {
+ return cache.context;
+ }
+
+ const context = buildChapterContextByPage(layout, blockById, sections);
+ cache.signature = signature;
+ cache.context = context;
+ return context;
+}
+
+function applyNumberingContextToLayout(layout: Layout, numberingCtx: NumberingContext): void {
+ const displayInfoByPage = new Map(numberingCtx.displayPages.map((page) => [page.physicalPage, page]));
+ for (const page of layout.pages) {
+ const displayInfo = displayInfoByPage.get(page.number);
+ if (!displayInfo) {
+ continue;
+ }
+ page.numberText = displayInfo.displayText;
+ page.displayNumber = displayInfo.displayNumber;
+ page.pageNumberFormat = displayInfo.pageFormat;
+ page.pageNumberChapterText = displayInfo.chapterNumberText;
+ page.pageNumberChapterSeparator = displayInfo.chapterSeparator;
+ }
+}
+
/**
* Builds numbering context from layout and section metadata.
*
@@ -3019,9 +3258,19 @@ const serializeHeaderFooterResults = (
* @param sections - Section metadata array
* @returns Numbering context with total pages and display page info
*/
-function buildNumberingContext(layout: Layout, sections: SectionMetadata[]): NumberingContext {
+function buildNumberingContext(
+ layout: Layout,
+ sections: SectionMetadata[],
+ blockById: ReadonlyMap,
+ chapterContextCache: ChapterContextCache,
+): NumberingContext {
const totalPages = layout.pages.length;
- const displayPages = computeDisplayPageNumber(layout.pages, sections);
+ const chapterInfoByPage = getChapterContextByPage(layout, sections, blockById, chapterContextCache);
+ const sectionByIndex = new Map(sections.map((section) => [section.sectionIndex, section]));
+ const displayPages = computeDisplayPageNumber(layout.pages, sections, chapterInfoByPage).map((displayPage) => ({
+ ...displayPage,
+ pageFormat: sectionByIndex.get(displayPage.sectionIndex)?.numbering?.format ?? 'decimal',
+ }));
return {
totalPages,
diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
index 13e3e6cba8..2249aa9915 100644
--- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
+++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
@@ -5,6 +5,8 @@ import type {
Measure,
ParagraphBlock,
TableBlock,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
} from '@superdoc/contracts';
import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine';
import { MeasureCache } from './cache';
@@ -34,6 +36,9 @@ export type PageResolver = (pageNumber: number) => {
displayNumber?: number;
totalPages: number;
sectionPageCount?: number;
+ pageFormat?: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
};
/**
@@ -141,6 +146,15 @@ function paragraphHasSectionPageCountToken(para: ParagraphBlock): boolean {
return false;
}
+function paragraphHasPageNumberToken(para: ParagraphBlock): boolean {
+ for (const run of para.runs) {
+ if ('token' in run && run.token === 'pageNumber') {
+ return true;
+ }
+ }
+ return false;
+}
+
function isDigitBucketCompatiblePageNumberFormat(format?: string): boolean {
return !format || format === 'decimal' || format === 'numberInDash';
}
@@ -215,6 +229,32 @@ function hasSectionPageCountTokens(blocks: FlowBlock[]): boolean {
return false;
}
+function hasPageNumberTokens(blocks: FlowBlock[]): boolean {
+ for (const block of blocks) {
+ if (block.kind === 'paragraph') {
+ if (paragraphHasPageNumberToken(block as ParagraphBlock)) return true;
+ } else if (block.kind === 'list') {
+ const list = block as ListBlock;
+ for (const item of list.items ?? []) {
+ if (paragraphHasPageNumberToken(item.paragraph)) return true;
+ }
+ } else if (block.kind === 'table') {
+ const table = block as TableBlock;
+ for (const row of table.rows ?? []) {
+ for (const cell of row.cells ?? []) {
+ const cellBlocks: FlowBlock[] = cell.blocks
+ ? (cell.blocks as FlowBlock[])
+ : cell.paragraph
+ ? [cell.paragraph]
+ : [];
+ if (hasPageNumberTokens(cellBlocks)) return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'paragraph') {
@@ -241,6 +281,27 @@ function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean
return false;
}
+function hasChapterNumberTextForAnyPage(totalPages: number, pageResolver: PageResolver): boolean {
+ // Chapter prefixes can change width inside one page-number digit bucket
+ // ("1-1" vs "12-1"), so large chapter-numbered docs must use per-page
+ // header/footer measurement instead of bucket representatives.
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
+ if (pageResolver(pageNumber).chapterNumberText) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function hasSectionAwarePageTextForAnyPage(totalPages: number, pageResolver: PageResolver): boolean {
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
+ if (pageResolver(pageNumber).displayText !== String(pageNumber)) {
+ return true;
+ }
+ }
+ return false;
+}
+
export class HeaderFooterLayoutCache {
private readonly cache = new MeasureCache();
@@ -361,12 +422,19 @@ export async function layoutHeaderFooterWithCache(
// Determine which pages to create layouts for
let pagesToLayout: number[];
+ const hasPageNumberToken = hasPageNumberTokens(blocks);
const useBucketingForVariant =
- useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks) && !hasSectionPageCountTokens(blocks);
+ useBucketing &&
+ !hasPageNumberTokensRequiringPerPageLayout(blocks) &&
+ !hasSectionPageCountTokens(blocks) &&
+ (!hasPageNumberToken ||
+ (!hasChapterNumberTextForAnyPage(docTotalPages, pageResolver) &&
+ !hasSectionAwarePageTextForAnyPage(docTotalPages, pageResolver)));
if (!useBucketingForVariant) {
- // Per-page layout: small docs, disabled bucketing, SECTIONPAGES, or non-digit-bucket-compatible PAGE formats.
+ // Per-page layout: small docs, disabled bucketing, SECTIONPAGES, PAGE variants with
+ // chapter prefixes or section-aware display text, or non-digit-bucket-compatible PAGE formats.
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);
} else {
@@ -385,12 +453,15 @@ export async function layoutHeaderFooterWithCache(
// Create layouts for each page (or bucket representative)
const pages: Array<{
number: number;
- displayNumber?: number;
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
layout: HeaderFooterLayout;
numberText?: string;
+ displayNumber?: number;
+ pageNumberFormat?: PageNumberFormat;
+ pageNumberChapterText?: string;
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
}> = [];
for (const pageNum of pagesToLayout) {
@@ -398,9 +469,27 @@ export async function layoutHeaderFooterWithCache(
const clonedBlocks = cloneHeaderFooterBlocks(blocks);
// Resolve page number tokens for this specific page
- const { displayText, displayNumber, totalPages: totalPagesForPage, sectionPageCount } = pageResolver(pageNum);
-
- resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber, sectionPageCount);
+ const {
+ displayText,
+ displayNumber,
+ totalPages: totalPagesForPage,
+ sectionPageCount,
+ pageFormat,
+ chapterNumberText,
+ chapterSeparator,
+ } = pageResolver(pageNum);
+
+ resolveHeaderFooterTokens(
+ clonedBlocks,
+ pageNum,
+ totalPagesForPage,
+ displayText,
+ displayNumber,
+ sectionPageCount,
+ pageFormat,
+ chapterNumberText,
+ chapterSeparator,
+ );
// Measure and layout
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
@@ -427,12 +516,18 @@ export async function layoutHeaderFooterWithCache(
// Store page-specific data
pages.push({
number: pageNum,
- displayNumber,
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
layout: pageLayout,
numberText: displayText,
+ displayNumber,
+ // Mirrored from body page metadata for layout contract parity. Paint
+ // reads chapter fields from the body page context; measurement above
+ // has already resolved these tokens into page-local HF blocks.
+ pageNumberFormat: pageFormat,
+ pageNumberChapterText: chapterNumberText,
+ pageNumberChapterSeparator: chapterSeparator,
});
}
@@ -452,9 +547,11 @@ export async function layoutHeaderFooterWithCache(
renderHeight,
pages: pages.map((p) => ({
number: p.number,
- displayNumber: p.displayNumber,
fragments: p.fragments,
numberText: p.numberText,
+ pageNumberFormat: p.pageNumberFormat,
+ pageNumberChapterText: p.pageNumberChapterText,
+ pageNumberChapterSeparator: p.pageNumberChapterSeparator,
blocks: p.blocks,
measures: p.measures,
})),
diff --git a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts
index 91792f869a..e59fec4ce4 100644
--- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts
+++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts
@@ -10,8 +10,17 @@
* page number is used when calculating dimensions and caching layouts.
*/
-import type { FlowBlock, ListBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts';
-import { formatPageNumberFieldValue } from '@superdoc/layout-engine';
+import {
+ formatChapterPageNumberText,
+ formatPageNumberFieldValue,
+ formatSectionPageNumberText,
+ type FlowBlock,
+ type ListBlock,
+ type PageNumberChapterSeparator,
+ type PageNumberFormat,
+ type ParagraphBlock,
+ type TableBlock,
+} from '@superdoc/contracts';
/**
* Walk every paragraph block reachable through `blocks`, including those
@@ -80,6 +89,9 @@ export function resolveHeaderFooterTokens(
pageNumberText?: string,
displayPageNumber?: number,
sectionPageCount?: number,
+ pageNumberFormat?: PageNumberFormat,
+ chapterNumberText?: string,
+ chapterSeparator?: PageNumberChapterSeparator,
): void {
// Validate inputs
if (!blocks || blocks.length === 0) {
@@ -99,6 +111,7 @@ export function resolveHeaderFooterTokens(
const pageNumberStr = pageNumberText ?? String(pageNumber);
const totalPagesStr = String(totalPages);
const displayNumber = displayPageNumber ?? pageNumber;
+ const sectionPageNumberFormat = pageNumberFormat ?? 'decimal';
const sectionPageCountNumber = sectionPageCount || totalPages || 1;
const sectionPageCountStr = String(sectionPageCountNumber);
@@ -116,8 +129,19 @@ export function resolveHeaderFooterTokens(
// re-resolve the correct page number at render time for each page.
// The text here is for measurement purposes (digit width).
run.text = run.pageNumberFieldFormat
- ? formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat)
- : pageNumberStr;
+ ? formatChapterPageNumberText({
+ pageComponent: formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat),
+ chapterNumberText,
+ chapterSeparator,
+ })
+ : chapterNumberText
+ ? formatSectionPageNumberText({
+ displayNumber,
+ pageFormat: sectionPageNumberFormat,
+ chapterNumberText,
+ chapterSeparator,
+ })
+ : pageNumberStr;
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count for measurement.
// IMPORTANT: Keep token for painter to re-resolve if needed.
diff --git a/packages/layout-engine/layout-bridge/test/headerFooterPrelayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterPrelayout.test.ts
new file mode 100644
index 0000000000..fcac5e8569
--- /dev/null
+++ b/packages/layout-engine/layout-bridge/test/headerFooterPrelayout.test.ts
@@ -0,0 +1,208 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { FlowBlock, HeaderFooterLayout, Measure } from '@superdoc/contracts';
+
+const layoutEngineMocks = vi.hoisted(() => ({
+ layoutDocument: vi.fn(),
+ resolvePageNumberTokens: vi.fn(),
+}));
+
+const headerFooterMocks = vi.hoisted(() => ({
+ layoutHeaderFooterWithCache: vi.fn(),
+}));
+
+vi.mock('@superdoc/layout-engine', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ layoutDocument: layoutEngineMocks.layoutDocument,
+ resolvePageNumberTokens: layoutEngineMocks.resolvePageNumberTokens,
+ };
+});
+
+vi.mock('../src/layoutHeaderFooter', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ layoutHeaderFooterWithCache: headerFooterMocks.layoutHeaderFooterWithCache,
+ };
+});
+
+const { incrementalLayout, measureCache } = await import('../src/incrementalLayout');
+
+const makeMeasure = (): Measure => ({
+ kind: 'paragraph',
+ hasPageTokens: false,
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 1, width: 10, ascent: 8, descent: 2, lineHeight: 10 }],
+ totalHeight: 10,
+});
+
+const makeHeaderFooterLayout = (): HeaderFooterLayout => ({
+ height: 10,
+ pages: [{ number: 1, fragments: [], blocks: [], measures: [] }],
+});
+
+const makeParagraph = (id: string, text: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text }],
+});
+
+const makeHeaderPageNumber = (): FlowBlock => ({
+ kind: 'paragraph',
+ id: 'header-page',
+ runs: [{ kind: 'text', text: '1', token: 'pageNumber' }],
+});
+
+const makeHeading = (id: string, markerText: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text: markerText }],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText } } },
+});
+
+const makeResolvedHeading = (id: string, markerText: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text: markerText }],
+ attrs: { styleId: 'Titre1', headingLevel: 1, wordLayout: { marker: { markerText } } },
+});
+
+const makeOrdinalHeading = (id: string, ordinal: number): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text: 'Chapter' }],
+ attrs: { styleId: 'Titre1', headingLevel: 1, listLevelOrdinal: ordinal },
+});
+
+describe('header/footer pre-layout', () => {
+ beforeEach(() => {
+ measureCache.clear();
+ layoutEngineMocks.layoutDocument.mockReset();
+ layoutEngineMocks.resolvePageNumberTokens.mockReset();
+ headerFooterMocks.layoutHeaderFooterWithCache.mockReset();
+
+ layoutEngineMocks.layoutDocument.mockReturnValue({
+ pages: [{ number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'body' }] }],
+ });
+ layoutEngineMocks.resolvePageNumberTokens.mockReturnValue({
+ affectedBlockIds: new Set(),
+ updatedBlocks: new Map(),
+ });
+ headerFooterMocks.layoutHeaderFooterWithCache.mockResolvedValue({
+ default: { blocks: [makeHeaderPageNumber()], measures: [makeMeasure()], layout: makeHeaderFooterLayout() },
+ });
+ });
+
+ it('uses a chapter-aware page resolver when measuring header/footer height before body layout', async () => {
+ await incrementalLayout(
+ [],
+ null,
+ [makeHeading('heading-1', '123456789.'), makeParagraph('body', 'Body')],
+ {
+ pageSize: { w: 300, h: 300 },
+ margins: { top: 20, right: 20, bottom: 20, left: 20 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { chapterStyle: 1, chapterSeparator: 'hyphen' } }],
+ },
+ vi.fn(async () => makeMeasure()),
+ {
+ headerBlocks: { default: [makeHeaderPageNumber()] },
+ constraints: { width: 40, height: 40 },
+ },
+ );
+
+ const prelayoutPageResolver = headerFooterMocks.layoutHeaderFooterWithCache.mock.calls[0]?.[5];
+
+ expect(prelayoutPageResolver).toBeTypeOf('function');
+ expect(prelayoutPageResolver(1)).toMatchObject({
+ displayText: '123456789\u201110',
+ displayNumber: 10,
+ totalPages: 10,
+ sectionPageCount: 10,
+ pageFormat: 'decimal',
+ chapterNumberText: '123456789',
+ chapterSeparator: 'hyphen',
+ });
+ });
+
+ it('uses adapter-resolved heading levels for conservative chapter pre-layout', async () => {
+ await incrementalLayout(
+ [],
+ null,
+ [makeResolvedHeading('heading-1', '123456789.'), makeParagraph('body', 'Body')],
+ {
+ pageSize: { w: 300, h: 300 },
+ margins: { top: 20, right: 20, bottom: 20, left: 20 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { chapterStyle: 1, chapterSeparator: 'hyphen' } }],
+ },
+ vi.fn(async () => makeMeasure()),
+ {
+ headerBlocks: { default: [makeHeaderPageNumber()] },
+ constraints: { width: 40, height: 40 },
+ },
+ );
+
+ const prelayoutPageResolver = headerFooterMocks.layoutHeaderFooterWithCache.mock.calls[0]?.[5];
+
+ expect(prelayoutPageResolver).toBeTypeOf('function');
+ expect(prelayoutPageResolver(1)).toMatchObject({
+ displayText: '123456789\u201110',
+ chapterNumberText: '123456789',
+ chapterSeparator: 'hyphen',
+ });
+ });
+
+ it('uses heading ordinal fallback for conservative chapter pre-layout', async () => {
+ await incrementalLayout(
+ [],
+ null,
+ [makeOrdinalHeading('heading-1', 3), makeParagraph('body', 'Body')],
+ {
+ pageSize: { w: 300, h: 300 },
+ margins: { top: 20, right: 20, bottom: 20, left: 20 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { chapterStyle: 1, chapterSeparator: 'hyphen' } }],
+ },
+ vi.fn(async () => makeMeasure()),
+ {
+ headerBlocks: { default: [makeHeaderPageNumber()] },
+ constraints: { width: 40, height: 40 },
+ },
+ );
+
+ const prelayoutPageResolver = headerFooterMocks.layoutHeaderFooterWithCache.mock.calls[0]?.[5];
+
+ expect(prelayoutPageResolver).toBeTypeOf('function');
+ expect(prelayoutPageResolver(1)).toMatchObject({
+ displayText: '3\u201110',
+ chapterNumberText: '3',
+ chapterSeparator: 'hyphen',
+ });
+ });
+
+ it('uses a two-digit page component for conservative chapter pre-layout', async () => {
+ await incrementalLayout(
+ [],
+ null,
+ [makeHeading('heading-1', '123456789.'), makeParagraph('body', 'Body')],
+ {
+ pageSize: { w: 300, h: 300 },
+ margins: { top: 20, right: 20, bottom: 20, left: 20 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { chapterStyle: 1, chapterSeparator: 'hyphen' } }],
+ },
+ vi.fn(async () => makeMeasure()),
+ {
+ headerBlocks: { default: [makeHeaderPageNumber()] },
+ constraints: { width: 40, height: 40 },
+ },
+ );
+
+ const prelayoutPageResolver = headerFooterMocks.layoutHeaderFooterWithCache.mock.calls[0]?.[5];
+
+ expect(prelayoutPageResolver).toBeTypeOf('function');
+ expect(prelayoutPageResolver(1)).toMatchObject({
+ displayText: '123456789\u201110',
+ displayNumber: 10,
+ totalPages: 10,
+ sectionPageCount: 10,
+ });
+ });
+});
diff --git a/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts b/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts
index 7a611ae7d8..0d314425a3 100644
--- a/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts
+++ b/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts
@@ -123,4 +123,33 @@ describe('incrementalLayout semantic flow', () => {
expect(result.footers).toBeUndefined();
expect(headerMeasure).not.toHaveBeenCalled();
});
+
+ it('stamps section display numbering onto body page context without chapter prefixes', async () => {
+ const paragraph = makeParagraph('body-1', 'Body content');
+ const measureBlock = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => {
+ if (block.kind !== 'paragraph') {
+ throw new Error(`Unexpected block kind in test measure: ${block.kind}`);
+ }
+ const runLength = block.runs[0]?.text?.length ?? 1;
+ return makeParagraphMeasure(20, runLength, constraints.maxWidth);
+ });
+
+ const result = await incrementalLayout(
+ [],
+ null,
+ [paragraph],
+ {
+ flowMode: 'semantic',
+ pageSize: { w: 800, h: 900 },
+ margins: { top: 40, right: 100, bottom: 40, left: 100 },
+ semantic: { contentWidth: 600, marginTop: 40, marginBottom: 40 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { start: 5, format: 'upperRoman' } }],
+ },
+ measureBlock,
+ );
+
+ expect(result.layout.pages[0]?.numberText).toBe('V');
+ expect(result.layout.pages[0]?.displayNumber).toBe(5);
+ expect(result.layout.pages[0]?.pageNumberFormat).toBe('upperRoman');
+ });
});
diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts
index b25c583b9a..103209ed63 100644
--- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts
+++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts
@@ -458,6 +458,91 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005');
});
+ it('should disable bucketing for chapter-prefixed page number text', async () => {
+ const sections = {
+ default: [makePageTokenBlock('header-chapter-page')],
+ };
+
+ const pageResolver: PageResolver = (pageNum) => ({
+ displayText: pageNum < 75 ? `1-${pageNum}` : `12-${pageNum}`,
+ displayNumber: pageNum,
+ totalPages: 150,
+ pageFormat: 'decimal',
+ chapterNumberText: pageNum < 75 ? '1' : '12',
+ chapterSeparator: 'hyphen',
+ });
+
+ const measureBlock = vi.fn(async () => makeMeasure(20));
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 400, height: 80 },
+ measureBlock,
+ undefined,
+ undefined,
+ pageResolver,
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(150);
+ expect(measureBlock).toHaveBeenCalledTimes(150);
+ expect(result.default?.layout.pages[0].numberText).toBe('1-1');
+ expect(result.default?.layout.pages[100].numberText).toBe('12-101');
+ });
+
+ it('should disable bucketing for section-restarted page number text', async () => {
+ const sections = {
+ default: [makePageTokenBlock('header-section-restart')],
+ };
+
+ const pageResolver: PageResolver = (pageNum) => ({
+ displayText: String(pageNum >= 100 ? pageNum - 99 : pageNum),
+ displayNumber: pageNum >= 100 ? pageNum - 99 : pageNum,
+ totalPages: 150,
+ });
+
+ const measureBlock = vi.fn(async () => makeMeasure(20));
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 400, height: 80 },
+ measureBlock,
+ undefined,
+ undefined,
+ pageResolver,
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(150);
+ expect(measureBlock.mock.calls.length).toBeGreaterThan(3);
+ expect(result.default?.layout.pages[99].numberText).toBe('1');
+ });
+
+ it('should keep bucketing for total-page-count tokens with chapter-prefixed page number text', async () => {
+ const sections = {
+ default: [makeBlock('header-numpages-only', '0', 'totalPageCount')],
+ };
+
+ const pageResolver: PageResolver = (pageNum) => ({
+ displayText: pageNum < 75 ? `1-${pageNum}` : `12-${pageNum}`,
+ displayNumber: pageNum,
+ totalPages: 150,
+ pageFormat: 'decimal',
+ chapterNumberText: pageNum < 75 ? '1' : '12',
+ chapterSeparator: 'hyphen',
+ });
+
+ const measureBlock = vi.fn(async () => makeMeasure(20));
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 400, height: 80 },
+ measureBlock,
+ undefined,
+ undefined,
+ pageResolver,
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(3);
+ expect(measureBlock).toHaveBeenCalledTimes(1);
+ expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[0].text).toBe('150');
+ });
+
it.each([
['decimal', { format: 'decimal' }],
['numberInDash', { format: 'numberInDash' }],
diff --git a/packages/layout-engine/layout-bridge/test/pageTokenConvergence.test.ts b/packages/layout-engine/layout-bridge/test/pageTokenConvergence.test.ts
new file mode 100644
index 0000000000..893cf0707d
--- /dev/null
+++ b/packages/layout-engine/layout-bridge/test/pageTokenConvergence.test.ts
@@ -0,0 +1,139 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';
+
+const layoutEngineMocks = vi.hoisted(() => ({
+ layoutDocument: vi.fn(),
+ resolvePageNumberTokens: vi.fn(),
+}));
+
+vi.mock('@superdoc/layout-engine', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ layoutDocument: layoutEngineMocks.layoutDocument,
+ resolvePageNumberTokens: layoutEngineMocks.resolvePageNumberTokens,
+ };
+});
+
+const { incrementalLayout, measureCache } = await import('../src/incrementalLayout');
+
+const makeLayout = (): Layout => ({
+ pages: [{ number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'body-1' }] }],
+});
+
+const makeParagraph = (text: string): FlowBlock => ({
+ kind: 'paragraph',
+ id: 'body-1',
+ runs: [{ kind: 'text', text, token: 'pageNumber' }],
+});
+
+const makeHeading = (id: string, markerText: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text: markerText }],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText } } },
+});
+
+const makeMeasure = (): Measure => ({
+ kind: 'paragraph',
+ hasPageTokens: true,
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 1, width: 10, ascent: 8, descent: 2, lineHeight: 10 }],
+ totalHeight: 10,
+});
+
+describe('page token convergence', () => {
+ beforeEach(() => {
+ measureCache.clear();
+ layoutEngineMocks.layoutDocument.mockReset();
+ layoutEngineMocks.resolvePageNumberTokens.mockReset();
+ });
+
+ it('continues until page token output is stable when page count stays unchanged', async () => {
+ layoutEngineMocks.layoutDocument.mockReturnValue(makeLayout());
+
+ let resolveCount = 0;
+ layoutEngineMocks.resolvePageNumberTokens.mockImplementation((_layout, blocks: FlowBlock[]) => {
+ resolveCount += 1;
+ if (resolveCount <= 2) {
+ const text = resolveCount === 1 ? '1' : '2';
+ return {
+ affectedBlockIds: new Set(['body-1']),
+ updatedBlocks: new Map([['body-1', makeParagraph(text)]]),
+ };
+ }
+
+ return { affectedBlockIds: new Set(), updatedBlocks: new Map() };
+ });
+
+ const measureBlock = vi.fn(async () => makeMeasure());
+
+ await incrementalLayout(
+ [],
+ null,
+ [makeParagraph('0')],
+ { pageSize: { w: 300, h: 300 }, margins: { top: 20, right: 20, bottom: 20, left: 20 } },
+ measureBlock,
+ );
+
+ expect(layoutEngineMocks.resolvePageNumberTokens).toHaveBeenCalledTimes(3);
+ });
+
+ it('recomputes chapter context when middle page fragments change', async () => {
+ const firstLayout: Layout = {
+ pages: [
+ {
+ number: 1,
+ sectionIndex: 0,
+ fragments: [
+ { kind: 'para', blockId: 'body-start' },
+ { kind: 'para', blockId: 'heading-1' },
+ { kind: 'para', blockId: 'body-end' },
+ ],
+ },
+ ],
+ };
+ const secondLayout: Layout = {
+ pages: [
+ {
+ number: 1,
+ sectionIndex: 0,
+ fragments: [
+ { kind: 'para', blockId: 'body-start' },
+ { kind: 'para', blockId: 'heading-2' },
+ { kind: 'para', blockId: 'body-end' },
+ ],
+ },
+ ],
+ };
+ layoutEngineMocks.layoutDocument.mockReturnValueOnce(firstLayout).mockReturnValue(secondLayout);
+
+ const chapterTexts: Array = [];
+ layoutEngineMocks.resolvePageNumberTokens.mockImplementation((_layout, blocks: FlowBlock[], _measures, ctx) => {
+ chapterTexts.push(ctx.displayPages[0]?.chapterNumberText);
+ if (chapterTexts.length === 1) {
+ return {
+ affectedBlockIds: new Set(['body-start']),
+ updatedBlocks: new Map([['body-start', blocks[0]]]),
+ };
+ }
+
+ return { affectedBlockIds: new Set(), updatedBlocks: new Map() };
+ });
+
+ const measureBlock = vi.fn(async () => makeMeasure());
+
+ await incrementalLayout(
+ [],
+ null,
+ [makeParagraph('0'), makeHeading('heading-1', '1.'), makeHeading('heading-2', '2.'), makeParagraph('tail')],
+ {
+ pageSize: { w: 300, h: 300 },
+ margins: { top: 20, right: 20, bottom: 20, left: 20 },
+ sectionMetadata: [{ sectionIndex: 0, numbering: { chapterStyle: 1 } }],
+ },
+ measureBlock,
+ );
+
+ expect(chapterTexts).toEqual(['1', '2']);
+ });
+});
diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts
index de12506771..860ef585e8 100644
--- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts
+++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts
@@ -85,6 +85,30 @@ describe('resolveHeaderFooterTokens', () => {
expect((block.runs[0] as TextRun).token).toBe('pageNumber');
});
+ it('should preserve chapter prefix when run-local pageNumberFieldFormat is applied', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'header-chapter-local-format',
+ runs: [
+ {
+ text: '0',
+ token: 'pageNumber',
+ pageNumberFieldFormat: { format: 'upperRoman' },
+ fontFamily: 'Arial',
+ fontSize: 12,
+ } as TextRun,
+ ],
+ } as ParagraphBlock,
+ ];
+
+ resolveHeaderFooterTokens(blocks, 1, 10, '3:5', 5, 10, 'decimal', '3', 'colon');
+
+ const block = blocks[0] as ParagraphBlock;
+ expect(block.runs[0].text).toBe('3:V');
+ expect((block.runs[0] as TextRun).token).toBe('pageNumber');
+ });
+
it('should resolve totalPageCount token in footer blocks', () => {
const blocks: FlowBlock[] = [
{
diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts
index 434f8033d3..ac81fa2eda 100644
--- a/packages/layout-engine/layout-engine/src/index.ts
+++ b/packages/layout-engine/layout-engine/src/index.ts
@@ -3654,8 +3654,15 @@ const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: num
export { buildAnchorMap, resolvePageRefTokens, getTocBlocksForRemeasurement } from './resolvePageRefs.js';
// Export page numbering utilities
-export { formatPageNumber, formatPageNumberFieldValue, computeDisplayPageNumber } from './pageNumbering.js';
-export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js';
+export {
+ buildChapterContextByPage,
+ computeDisplayPageNumber,
+ formatPageNumber,
+ formatPageNumberFieldValue,
+ formatSectionPageNumberText,
+ normalizeChapterMarkerText,
+} from './pageNumbering.js';
+export type { ChapterPageInfo, DisplayPageInfo, PageNumberFormat } from './pageNumbering.js';
// Export page token resolution utilities
export { resolvePageNumberTokens } from './resolvePageTokens.js';
diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts
index 0cd6282ecb..60947e6ce3 100644
--- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts
+++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts
@@ -6,8 +6,14 @@
*/
import { describe, it, expect } from 'bun:test';
-import { formatPageNumber, computeDisplayPageNumber } from './pageNumbering';
-import type { Page, SectionMetadata } from '@superdoc/contracts';
+import {
+ buildChapterContextByPage,
+ computeDisplayPageNumber,
+ formatPageNumber,
+ formatSectionPageNumberText,
+ normalizeChapterMarkerText,
+} from './pageNumbering';
+import type { FlowBlock, Layout, Page, SectionMetadata } from '@superdoc/contracts';
describe('formatPageNumber', () => {
describe('decimal format', () => {
@@ -181,6 +187,305 @@ describe('formatPageNumber', () => {
});
});
+describe('formatSectionPageNumberText', () => {
+ it('formats the page component without a chapter prefix', () => {
+ expect(formatSectionPageNumberText({ displayNumber: 3, pageFormat: 'upperRoman' })).toBe('III');
+ });
+
+ it('prefixes chapter text with supported separators', () => {
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'hyphen',
+ }),
+ ).toBe('3\u20111');
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'period',
+ }),
+ ).toBe('3.1');
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'colon',
+ }),
+ ).toBe('3:1');
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'emDash',
+ }),
+ ).toBe('3\u20141');
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'enDash',
+ }),
+ ).toBe('3\u20131');
+ });
+
+ it('defaults chapter separator to hyphen and applies run-local page component format', () => {
+ expect(
+ formatSectionPageNumberText({
+ displayNumber: 4,
+ pageFormat: 'upperRoman',
+ chapterNumberText: '2',
+ }),
+ ).toBe('2\u2011IV');
+ });
+});
+
+describe('chapter page context', () => {
+ it('normalizes common visible heading markers', () => {
+ expect(normalizeChapterMarkerText('1.')).toBe('1');
+ expect(normalizeChapterMarkerText('1.2.')).toBe('1.2');
+ expect(normalizeChapterMarkerText('1-2.')).toBe('1-2');
+ expect(normalizeChapterMarkerText('1)')).toBe('1');
+ expect(normalizeChapterMarkerText('A.')).toBe('A');
+ expect(normalizeChapterMarkerText('III.')).toBe('III');
+ });
+
+ it('omits unsupported custom marker text', () => {
+ expect(normalizeChapterMarkerText('Article 1.')).toBeUndefined();
+ expect(normalizeChapterMarkerText('1/2')).toBeUndefined();
+ });
+
+ it('tracks the nearest numbered Heading N marker by physical page', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '1.' } } },
+ },
+ { kind: 'paragraph', id: 'body-1', runs: [] },
+ {
+ kind: 'paragraph',
+ id: 'heading-2',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '2.' } } },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [
+ { number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1' }] },
+ { number: 2, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'body-1' }] },
+ { number: 3, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2' }] },
+ ],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 1 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(1)?.chapterNumberText).toBe('1');
+ expect(result.get(2)?.chapterNumberText).toBe('1');
+ expect(result.get(3)?.chapterNumberText).toBe('2');
+ });
+
+ it('uses resolved heading level and structured list ordinal for localized headings', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'localized-heading-1',
+ runs: [],
+ attrs: {
+ styleId: 'Ttulo1',
+ headingLevel: 1,
+ listLevelOrdinal: 1,
+ wordLayout: { marker: { markerText: '' } },
+ },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [{ number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'localized-heading-1' }] }],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 1 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(1)?.chapterNumberText).toBe('1');
+ });
+
+ it('falls back to the nearest numbered previous heading level for chapter style', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '3.' } } },
+ },
+ { kind: 'paragraph', id: 'body-before-heading-2', runs: [] },
+ {
+ kind: 'paragraph',
+ id: 'heading-2',
+ runs: [],
+ attrs: { styleId: 'Heading2', wordLayout: { marker: { markerText: '4.' } } },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [
+ { number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1' }] },
+ { number: 2, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'body-before-heading-2' }] },
+ { number: 3, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2' }] },
+ ],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 2 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(1)?.chapterNumberText).toBe('3');
+ expect(result.get(2)?.chapterNumberText).toBe('3');
+ expect(result.get(3)?.chapterNumberText).toBe('4');
+ });
+
+ it('clears stale child heading markers when a new parent heading appears', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1-a',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '3.' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'heading-2-a',
+ runs: [],
+ attrs: { styleId: 'Heading2', wordLayout: { marker: { markerText: '2.' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'heading-1-b',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '4.' } } },
+ },
+ { kind: 'paragraph', id: 'body-after-heading-1-b', runs: [] },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [
+ { number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1-a' }] },
+ { number: 2, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2-a' }] },
+ { number: 3, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1-b' }] },
+ { number: 4, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'body-after-heading-1-b' }] },
+ ],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 2 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(1)).toEqual({ chapterNumberText: '3', chapterStyle: 1 });
+ expect(result.get(2)).toEqual({ chapterNumberText: '2', chapterStyle: 2 });
+ expect(result.get(3)).toEqual({ chapterNumberText: '4', chapterStyle: 1 });
+ expect(result.get(4)).toEqual({ chapterNumberText: '4', chapterStyle: 1 });
+ });
+
+ it('uses clean multi-level heading markers for matching chapter style', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '1.' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'heading-2',
+ runs: [],
+ attrs: { styleId: 'Heading2', wordLayout: { marker: { markerText: '1.2.' } } },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [
+ { number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1' }] },
+ { number: 2, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2' }] },
+ ],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 2 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(1)).toEqual({ chapterNumberText: '1', chapterStyle: 1 });
+ expect(result.get(2)).toEqual({ chapterNumberText: '1.2', chapterStyle: 2 });
+ });
+
+ it('uses clean hyphenated heading markers for matching chapter style', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '1.' } } },
+ },
+ {
+ kind: 'paragraph',
+ id: 'heading-2',
+ runs: [],
+ attrs: { styleId: 'Heading2', wordLayout: { marker: { markerText: '1-2.' } } },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [
+ { number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1' }] },
+ { number: 2, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2' }] },
+ ],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 2 } }];
+
+ const result = buildChapterContextByPage(layout, blocks, sections);
+
+ expect(result.get(2)).toEqual({ chapterNumberText: '1-2', chapterStyle: 2 });
+ });
+
+ it('omits chapter context when the matching heading marker is not a clean single token', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-1',
+ runs: [],
+ attrs: { styleId: 'Heading1', wordLayout: { marker: { markerText: '1.2.' } } },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [{ number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-1' }] }],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 1 } }];
+
+ expect(buildChapterContextByPage(layout, blocks, sections).get(1)).toBeUndefined();
+ });
+
+ it('does not synthesize nested chapter prefixes from list ordinal fallback', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'heading-2',
+ runs: [],
+ attrs: {
+ styleId: 'Heading2',
+ headingLevel: 2,
+ listLevelOrdinal: 2,
+ wordLayout: { marker: { markerText: 'Article 1.' } },
+ },
+ },
+ ] as FlowBlock[];
+ const layout = {
+ pages: [{ number: 1, sectionIndex: 0, fragments: [{ kind: 'para', blockId: 'heading-2' }] }],
+ } as Layout;
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 2 } }];
+
+ expect(buildChapterContextByPage(layout, blocks, sections).get(1)).toBeUndefined();
+ });
+});
+
describe('computeDisplayPageNumber', () => {
describe('empty or single section documents', () => {
it('should return empty array for empty pages', () => {
@@ -302,6 +607,53 @@ describe('computeDisplayPageNumber', () => {
sectionPageCount: 3,
});
});
+
+ it('should prefix display text when chapter context is available', () => {
+ const pages: Page[] = [{ number: 1, fragments: [] }];
+ const sections: SectionMetadata[] = [
+ {
+ sectionIndex: 0,
+ numbering: { format: 'decimal', start: 1, chapterStyle: 1, chapterSeparator: 'colon' },
+ },
+ ];
+
+ const result = computeDisplayPageNumber(pages, sections, new Map([[1, { chapterNumberText: '3' }]]));
+
+ expect(result[0]).toEqual({
+ physicalPage: 1,
+ displayNumber: 1,
+ displayText: '3:1',
+ sectionIndex: 0,
+ sectionPageCount: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'colon',
+ });
+ });
+
+ it('omits chapter prefix when section has chapterStyle but no resolved chapter context', () => {
+ const pages: Page[] = [{ number: 1, fragments: [] }];
+ const sections: SectionMetadata[] = [{ sectionIndex: 0, numbering: { chapterStyle: 1 } }];
+
+ const result = computeDisplayPageNumber(pages, sections);
+
+ expect(result[0].displayText).toBe('1');
+ expect(result[0].chapterNumberText).toBeUndefined();
+ expect(result[0].chapterSeparator).toBeUndefined();
+ });
+
+ it('uses hyphen as the default chapter separator and applies section page format', () => {
+ const pages: Page[] = [{ number: 1, fragments: [] }];
+ const sections: SectionMetadata[] = [
+ { sectionIndex: 0, numbering: { format: 'upperRoman', start: 4, chapterStyle: 1 } },
+ ];
+
+ const result = computeDisplayPageNumber(pages, sections, new Map([[1, { chapterNumberText: 'A' }]]));
+
+ expect(result[0].displayText).toBe('A\u2011IV');
+ expect(result[0].pageFormat).toBe('upperRoman');
+ expect(result[0].chapterSeparator).toBe('hyphen');
+ });
});
describe('multi-section documents', () => {
diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts
index 0dd25c98e1..1af6c5522f 100644
--- a/packages/layout-engine/layout-engine/src/pageNumbering.ts
+++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts
@@ -16,13 +16,23 @@
import {
formatPageNumber,
formatPageNumberFieldValue,
+ formatSectionPageNumberText,
+ type FlowBlock,
+ type Layout,
type Page,
+ type PageNumberChapterSeparator,
type PageNumberFormat,
+ type ParagraphBlock,
type SectionMetadata,
} from '@superdoc/contracts';
-export { formatPageNumber, formatPageNumberFieldValue };
+export { formatPageNumber, formatPageNumberFieldValue, formatSectionPageNumberText };
export type { PageNumberFormat };
+export interface ChapterPageInfo {
+ chapterNumberText?: string;
+ chapterStyle?: number;
+}
+
/**
* Display page information for a single page in the document.
* Contains both the physical page number and the section-aware display number.
@@ -38,6 +48,203 @@ export interface DisplayPageInfo {
sectionIndex: number;
/** Physical page count in the current section */
sectionPageCount: number;
+ /** Section PAGE number format before any run-local PAGE switch is applied. */
+ pageFormat?: PageNumberFormat;
+ /** MVP chapter prefix text derived from the nearest numbered Heading N marker. */
+ chapterNumberText?: string;
+ /** Separator between chapter prefix and page number component. */
+ chapterSeparator?: PageNumberChapterSeparator;
+}
+
+const HEADING_STYLE_PREFIX = 'heading';
+const CHAPTER_MARKER_SEPARATOR_RE = /[.\-:\u2013\u2014]/;
+const CLEAN_CHAPTER_MARKER_RE = /^[A-Za-z0-9]+(?:[.\-:\u2013\u2014][A-Za-z0-9]+)*$/;
+
+function normalizeHeadingStyleId(styleId: unknown): string | undefined {
+ if (typeof styleId !== 'string') {
+ return undefined;
+ }
+ return styleId.replace(/[\s_-]+/g, '').toLowerCase();
+}
+
+function getHeadingLevel(block: FlowBlock): number | undefined {
+ if (block.kind !== 'paragraph') {
+ return undefined;
+ }
+
+ const attrs = (block as ParagraphBlock).attrs;
+ const resolvedHeadingLevel = attrs?.headingLevel;
+ if (typeof resolvedHeadingLevel === 'number' && Number.isInteger(resolvedHeadingLevel) && resolvedHeadingLevel > 0) {
+ return resolvedHeadingLevel;
+ }
+
+ // Adapter-provided headingLevel is authoritative; this keeps legacy/simple
+ // projections working for English built-in style ids like Heading1.
+ const normalizedStyleId = normalizeHeadingStyleId(attrs?.styleId);
+ if (!normalizedStyleId?.startsWith(HEADING_STYLE_PREFIX)) {
+ return undefined;
+ }
+
+ const rawLevel = normalizedStyleId.slice(HEADING_STYLE_PREFIX.length);
+ if (!/^\d+$/.test(rawLevel)) {
+ return undefined;
+ }
+
+ const level = Number(rawLevel);
+ return Number.isInteger(level) && level > 0 ? level : undefined;
+}
+
+export function normalizeChapterMarkerText(markerText: unknown): string | undefined {
+ if (typeof markerText !== 'string') {
+ return undefined;
+ }
+
+ const withoutSuffix = markerText
+ .trim()
+ .replace(/[.)]\s*$/, '')
+ .trim();
+ if (!withoutSuffix) {
+ return undefined;
+ }
+
+ return CLEAN_CHAPTER_MARKER_RE.test(withoutSuffix) ? withoutSuffix : undefined;
+}
+
+function getChapterMarkerText(block: FlowBlock, headingLevel: number): string | undefined {
+ if (block.kind !== 'paragraph') {
+ return undefined;
+ }
+
+ const attrs = (block as ParagraphBlock).attrs;
+ const markerText = normalizeChapterMarkerText(attrs?.wordLayout?.marker?.markerText);
+ if (markerText && markerText.split(CHAPTER_MARKER_SEPARATOR_RE).length <= headingLevel) {
+ return markerText;
+ }
+
+ // Empty Heading 1 markers in imported DOCX can still carry a structured
+ // ordinal. Do not synthesize nested chapter prefixes from the last path
+ // component; a visible multi-level marker is the only safe source for those.
+ const listLevelOrdinal = attrs?.listLevelOrdinal;
+ if (
+ headingLevel === 1 &&
+ typeof listLevelOrdinal === 'number' &&
+ Number.isInteger(listLevelOrdinal) &&
+ listLevelOrdinal > 0
+ ) {
+ return String(listLevelOrdinal);
+ }
+
+ return undefined;
+}
+
+function getBlockIdFromFragment(fragment: unknown): string | undefined {
+ if (
+ typeof fragment === 'object' &&
+ fragment !== null &&
+ 'blockId' in fragment &&
+ typeof (fragment as { blockId?: unknown }).blockId === 'string'
+ ) {
+ return (fragment as { blockId: string }).blockId;
+ }
+ return undefined;
+}
+
+function buildBlockById(blocks: FlowBlock[] | ReadonlyMap): ReadonlyMap {
+ const blockById = new Map();
+ if (Array.isArray(blocks)) {
+ for (const block of blocks) {
+ blockById.set(block.id, block);
+ }
+ return blockById;
+ }
+
+ return blocks;
+}
+
+function getActiveChapterNumberText(
+ activeChapterByStyle: ReadonlyMap,
+ chapterStyle: number,
+): { chapterNumberText: string; chapterStyle: number } | undefined {
+ for (let headingLevel = chapterStyle; headingLevel > 0; headingLevel -= 1) {
+ const chapterNumberText = activeChapterByStyle.get(headingLevel);
+ if (chapterNumberText) {
+ return { chapterNumberText, chapterStyle: headingLevel };
+ }
+ }
+
+ return undefined;
+}
+
+function clearChildChapterNumberText(activeChapterByStyle: Map, headingLevel: number): void {
+ for (const activeHeadingLevel of activeChapterByStyle.keys()) {
+ if (activeHeadingLevel > headingLevel) {
+ activeChapterByStyle.delete(activeHeadingLevel);
+ }
+ }
+}
+
+export function buildChapterContextByPage(
+ layout: Layout,
+ blocks: FlowBlock[] | ReadonlyMap,
+ sections: SectionMetadata[],
+): Map {
+ const chapterStyles = new Set();
+ let maxChapterStyle = 0;
+ const sectionByIndex = new Map();
+ for (const section of sections) {
+ sectionByIndex.set(section.sectionIndex, section);
+ const chapterStyle = section.numbering?.chapterStyle;
+ if (typeof chapterStyle === 'number' && Number.isInteger(chapterStyle) && chapterStyle > 0) {
+ chapterStyles.add(chapterStyle);
+ maxChapterStyle = Math.max(maxChapterStyle, chapterStyle);
+ }
+ }
+
+ const chapterInfoByPage = new Map();
+ if (chapterStyles.size === 0 || layout.pages.length === 0) {
+ return chapterInfoByPage;
+ }
+
+ const blockById = buildBlockById(blocks);
+ const activeChapterByStyle = new Map();
+
+ for (const page of layout.pages) {
+ for (const fragment of page.fragments) {
+ const blockId = getBlockIdFromFragment(fragment);
+ if (!blockId) {
+ continue;
+ }
+
+ const block = blockById.get(blockId);
+ if (!block) {
+ continue;
+ }
+
+ const headingLevel = getHeadingLevel(block);
+ if (!headingLevel || headingLevel > maxChapterStyle) {
+ continue;
+ }
+
+ const chapterNumberText = getChapterMarkerText(block, headingLevel);
+ if (chapterNumberText) {
+ clearChildChapterNumberText(activeChapterByStyle, headingLevel);
+ activeChapterByStyle.set(headingLevel, chapterNumberText);
+ }
+ }
+
+ const sectionIndex = page.sectionIndex ?? 0;
+ const chapterStyle = sectionByIndex.get(sectionIndex)?.numbering?.chapterStyle;
+ if (!chapterStyle) {
+ continue;
+ }
+
+ const activeChapter = getActiveChapterNumberText(activeChapterByStyle, chapterStyle);
+ if (activeChapter) {
+ chapterInfoByPage.set(page.number, activeChapter);
+ }
+ }
+
+ return chapterInfoByPage;
}
/**
@@ -81,7 +288,11 @@ export interface DisplayPageInfo {
* // displayInfo[2]: { physicalPage: 3, displayNumber: 1, displayText: "1", sectionIndex: 1 }
* ```
*/
-export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadata[]): DisplayPageInfo[] {
+export function computeDisplayPageNumber(
+ pages: Page[],
+ sections: SectionMetadata[],
+ chapterInfoByPage?: ReadonlyMap,
+): DisplayPageInfo[] {
const result: DisplayPageInfo[] = [];
if (pages.length === 0) {
@@ -132,12 +343,23 @@ export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadat
// Get section metadata and numbering format
const sectionMetadata = sectionMap.get(pageSectionIndex);
const format: PageNumberFormat = sectionMetadata?.numbering?.format ?? 'decimal';
+ const chapterInfo = chapterInfoByPage?.get(page.number);
+ const chapterNumberText = chapterInfo?.chapterNumberText;
+ const chapterSeparator =
+ chapterNumberText && sectionMetadata?.numbering?.chapterStyle
+ ? (sectionMetadata.numbering.chapterSeparator ?? 'hyphen')
+ : undefined;
// Calculate display number
// displayNumber is the running counter for this page (can be negative or zero)
const displayNumber = runningCounter;
// formatPageNumber will clamp to 1 for display purposes
- const displayText = formatPageNumber(displayNumber, format);
+ const displayText = formatSectionPageNumberText({
+ displayNumber,
+ pageFormat: format,
+ chapterNumberText,
+ chapterSeparator,
+ });
result.push({
physicalPage: page.number,
@@ -145,6 +367,9 @@ export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadat
displayText,
sectionIndex: pageSectionIndex,
sectionPageCount: sectionPageCounts.get(pageSectionIndex) ?? pages.length,
+ ...(chapterNumberText ? { pageFormat: format } : {}),
+ ...(chapterNumberText ? { chapterNumberText } : {}),
+ ...(chapterSeparator ? { chapterSeparator } : {}),
});
// Increment counters
diff --git a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts
index 7f2433abc6..92be20b01e 100644
--- a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts
+++ b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts
@@ -75,7 +75,7 @@ describe('resolvePageNumberTokens', () => {
const updatedBlock = result.updatedBlocks.get('para-1') as ParagraphBlock;
expect(updatedBlock).toBeDefined();
expect(updatedBlock.runs[1].text).toBe('i');
- expect(updatedBlock.runs[1].token).toBeUndefined();
+ expect(updatedBlock.runs[1].token).toBe('pageNumber');
// Verify original block is not mutated
expect((blocks[0] as ParagraphBlock).runs[1].text).toBe('0');
@@ -122,6 +122,42 @@ describe('resolvePageNumberTokens', () => {
const updatedBlock = result.updatedBlocks.get('para-format') as ParagraphBlock;
expect(updatedBlock.runs[0].text).toBe('v');
+ expect(updatedBlock.runs[0].token).toBe('pageNumber');
+ expect(updatedBlock.runs[0].pageNumberFieldFormat).toEqual({ format: 'lowerRoman' });
+ });
+
+ it('should update already-resolved body page tokens when display context changes', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'para-1',
+ runs: [{ text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 } as TextRun],
+ } as ParagraphBlock,
+ ];
+ const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }];
+ const layout: Layout = {
+ pageSize: { w: 612, h: 792 },
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'para-1', fromLine: 0, toLine: 1, x: 0, y: 0, width: 100 }],
+ },
+ ],
+ };
+
+ const firstPass = resolvePageNumberTokens(layout, blocks, measures, {
+ totalPages: 1,
+ displayPages: [{ physicalPage: 1, displayNumber: 1, displayText: '1', sectionIndex: 0 }],
+ });
+ const firstBlock = firstPass.updatedBlocks.get('para-1') as ParagraphBlock;
+
+ const secondPass = resolvePageNumberTokens(layout, [firstBlock], measures, {
+ totalPages: 1,
+ displayPages: [{ physicalPage: 1, displayNumber: 2, displayText: '2', sectionIndex: 0 }],
+ });
+
+ expect(secondPass.affectedBlockIds.has('para-1')).toBe(true);
+ expect((secondPass.updatedBlocks.get('para-1') as ParagraphBlock).runs[0].text).toBe('2');
});
it('should resolve totalPageCount tokens', () => {
@@ -185,7 +221,7 @@ describe('resolvePageNumberTokens', () => {
const updatedBlock = result.updatedBlocks.get('para-1') as ParagraphBlock;
expect(updatedBlock.runs[1].text).toBe('99');
- expect(updatedBlock.runs[1].token).toBeUndefined();
+ expect(updatedBlock.runs[1].token).toBe('totalPageCount');
});
it('should resolve formatted sectionPageCount tokens', () => {
@@ -248,8 +284,8 @@ describe('resolvePageNumberTokens', () => {
expect(result.affectedBlockIds.has('para-1')).toBe(true);
expect(updatedBlock.runs[1].text).toBe('IV');
- expect(updatedBlock.runs[1].token).toBeUndefined();
- expect(updatedBlock.runs[1].pageNumberFieldFormat).toBeUndefined();
+ expect(updatedBlock.runs[1].token).toBe('sectionPageCount');
+ expect(updatedBlock.runs[1].pageNumberFieldFormat).toEqual({ format: 'upperRoman' });
});
it('should resolve both pageNumber and totalPageCount in same paragraph', () => {
@@ -707,7 +743,7 @@ describe('resolvePageNumberTokens', () => {
// Updated block should have resolved token
const updatedBlock = result.updatedBlocks.get('para-1') as ParagraphBlock;
expect(updatedBlock.runs[0].text).toBe('1');
- expect(updatedBlock.runs[0].token).toBeUndefined();
+ expect(updatedBlock.runs[0].token).toBe('pageNumber');
// Other properties should be preserved
expect(updatedBlock.runs[0].bold).toBe(true);
diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts
index d6b75ef7b8..7cdf92825a 100644
--- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts
+++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts
@@ -14,8 +14,16 @@
* - Integrates with two-pass convergence loop in incrementalLayout
*/
-import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts';
-import { formatPageNumberFieldValue, type DisplayPageInfo } from './pageNumbering';
+import {
+ formatChapterPageNumberText,
+ formatPageNumberFieldValue,
+ formatSectionPageNumberText,
+ type Layout,
+ type FlowBlock,
+ type ParagraphBlock,
+ type Measure,
+} from '@superdoc/contracts';
+import type { DisplayPageInfo } from './pageNumbering';
/**
* Numbering context for page token resolution.
@@ -143,6 +151,10 @@ export function resolvePageNumberTokens(
numberingCtx.totalPages,
sectionPageCount,
);
+ if (!clonedBlock) {
+ processedBlocks.add(blockId);
+ continue;
+ }
updatedBlocks.set(blockId, clonedBlock);
affectedBlockIds.add(blockId);
processedBlocks.add(blockId);
@@ -194,7 +206,8 @@ function hasPageTokens(block: ParagraphBlock): boolean {
* Clones a paragraph block and resolves all page number tokens in its runs.
*
* This creates a deep clone of the block's runs array and resolves any pageNumber
- * or totalPageCount tokens by replacing the text and clearing the token metadata.
+ * or totalPageCount tokens by replacing the text while preserving token metadata
+ * for later convergence passes.
*
* @param block - Original paragraph block (will not be mutated)
* @param displayPageInfo - Section-aware page number data for this physical page
@@ -207,36 +220,50 @@ function cloneBlockWithResolvedTokens(
totalPagesStr: string,
totalPages: number,
sectionPageCount: number,
-): ParagraphBlock {
+): ParagraphBlock | undefined {
+ let changed = false;
// Clone the runs array and resolve tokens
const clonedRuns = block.runs.map((run) => {
// Check if this run has a page token
if ('token' in run && run.token) {
if (run.token === 'pageNumber') {
- // Clone the run and resolve the token
- const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run;
+ const resolvedText =
+ run.pageNumberFieldFormat
+ ? formatChapterPageNumberText({
+ pageComponent: formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat),
+ chapterNumberText: displayPageInfo.chapterNumberText,
+ chapterSeparator: displayPageInfo.chapterSeparator,
+ })
+ : displayPageInfo.chapterNumberText
+ ? formatSectionPageNumberText({
+ displayNumber: displayPageInfo.displayNumber,
+ pageFormat: displayPageInfo.pageFormat ?? 'decimal',
+ chapterNumberText: displayPageInfo.chapterNumberText,
+ chapterSeparator: displayPageInfo.chapterSeparator,
+ })
+ : displayPageInfo.displayText;
+ changed ||= run.text !== resolvedText;
return {
- ...runWithoutToken,
- text: pageNumberFieldFormat
- ? formatPageNumberFieldValue(displayPageInfo.displayNumber, pageNumberFieldFormat)
- : displayPageInfo.displayText,
+ ...run,
+ text: resolvedText,
};
} else if (run.token === 'totalPageCount') {
- // Clone the run and resolve the token
- const { token: _token, ...runWithoutToken } = run;
+ const resolvedText = run.pageNumberFieldFormat
+ ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
+ : totalPagesStr;
+ changed ||= run.text !== resolvedText;
return {
- ...runWithoutToken,
- text: run.pageNumberFieldFormat
- ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
- : totalPagesStr,
+ ...run,
+ text: resolvedText,
};
} else if (run.token === 'sectionPageCount') {
- const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run;
+ const resolvedText = run.pageNumberFieldFormat
+ ? formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat)
+ : String(sectionPageCount);
+ changed ||= run.text !== resolvedText;
return {
- ...runWithoutToken,
- text: pageNumberFieldFormat
- ? formatPageNumberFieldValue(sectionPageCount, pageNumberFieldFormat)
- : String(sectionPageCount),
+ ...run,
+ text: resolvedText,
};
}
}
@@ -246,10 +273,12 @@ function cloneBlockWithResolvedTokens(
});
// Return cloned block with new runs
- return {
- ...block,
- runs: clonedRuns,
- };
+ return changed
+ ? {
+ ...block,
+ runs: clonedRuns,
+ }
+ : undefined;
}
/**
diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
index 48d8f1a22c..c6e74a7d81 100644
--- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts
@@ -31,6 +31,9 @@ export function resolveHeaderFooterLayout(
number: page.number,
displayNumber: page.displayNumber,
numberText: page.numberText,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
items: page.fragments.map((fragment, fragmentIndex) =>
resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache, story),
),
diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
index 1d62e074f5..710dc55ecc 100644
--- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts
+++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts
@@ -333,6 +333,9 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
displayNumber: page.displayNumber,
numberText: page.numberText,
effectivePageNumber: page.effectivePageNumber,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
vAlign: page.vAlign,
baseMargins: page.baseMargins,
sectionIndex: page.sectionIndex,
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index fecbfeed79..1cec6a63f4 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -15,6 +15,8 @@ import type {
Line,
LineSegment,
PageMargins,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
ParaFragment,
ParagraphBlock,
PositionedDrawingGeometry,
@@ -45,6 +47,7 @@ import {
buildLayoutSourceIdentityForFragment,
expandRunsForInlineNewlines,
formatPageNumber,
+ formatSectionPageNumberText,
getCellSpacingPx,
normalizeColumnLayout,
} from '@superdoc/contracts';
@@ -267,6 +270,9 @@ function pageContextSignature(context: FragmentRenderContext): string {
context.sectionPageCount ?? '',
context.pageNumberText ?? '',
context.displayPageNumber ?? '',
+ context.pageNumberFormat ?? '',
+ context.pageNumberChapterText ?? '',
+ context.pageNumberChapterSeparator ?? '',
].join('|');
}
@@ -365,6 +371,9 @@ export type FragmentRenderContext = {
story?: LayoutStoryLocator;
pageNumberText?: string;
displayPageNumber?: number;
+ pageNumberFormat?: PageNumberFormat;
+ pageNumberChapterText?: string;
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
sectionPageCount?: number;
pageIndex?: number;
};
@@ -1818,6 +1827,9 @@ export class DomPainter {
section: 'body',
pageNumberText: page.numberText,
displayPageNumber: page.displayNumber,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
sectionPageCount: this.getSectionPageCount(page),
pageIndex,
};
@@ -2174,6 +2186,9 @@ export class DomPainter {
story: resolveDecorationStory(kind, data),
pageNumberText: page.numberText,
displayPageNumber: page.displayNumber,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
sectionPageCount: this.getSectionPageCount(page),
pageIndex,
};
@@ -2383,6 +2398,9 @@ export class DomPainter {
section: 'body',
pageNumberText: page.numberText,
displayPageNumber: page.displayNumber,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
sectionPageCount: this.getSectionPageCount(page),
pageIndex,
};
@@ -2547,6 +2565,9 @@ export class DomPainter {
section: 'body',
pageNumberText: page.numberText,
displayPageNumber: page.displayNumber,
+ pageNumberFormat: page.pageNumberFormat,
+ pageNumberChapterText: page.pageNumberChapterText,
+ pageNumberChapterSeparator: page.pageNumberChapterSeparator,
sectionPageCount: this.getSectionPageCount(page),
pageIndex,
};
@@ -3116,8 +3137,13 @@ export class DomPainter {
private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string {
if (part.fieldType === 'PAGE') {
- if (part.pageNumberFormat) {
- return formatPageNumber(context?.displayPageNumber ?? context?.pageNumber ?? 1, part.pageNumberFormat);
+ if (part.pageNumberFormat || context?.pageNumberChapterText) {
+ return formatSectionPageNumberText({
+ displayNumber: context?.displayPageNumber ?? context?.pageNumber ?? 1,
+ pageFormat: part.pageNumberFormat ?? context?.pageNumberFormat ?? 'decimal',
+ chapterNumberText: context?.pageNumberChapterText,
+ chapterSeparator: context?.pageNumberChapterSeparator,
+ });
}
return context?.pageNumberText ?? String(context?.pageNumber ?? 1);
}
diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts
index f55d47fd2b..75da5ea842 100644
--- a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts
+++ b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts
@@ -31,6 +31,26 @@ describe('resolveRunText', () => {
expect(resolveRunText(run, context)).toBe('V');
});
+ it('preserves chapter prefix when applying run-local page number format', () => {
+ const run: TextRun = {
+ text: '0',
+ token: 'pageNumber',
+ pageNumberFieldFormat: { format: 'upperRoman' },
+ fontFamily: 'Arial',
+ fontSize: 12,
+ };
+
+ expect(
+ resolveRunText(run, {
+ ...context,
+ pageNumberText: '3:5',
+ pageNumberFormat: 'decimal',
+ pageNumberChapterText: '3',
+ pageNumberChapterSeparator: 'colon',
+ }),
+ ).toBe('3:V');
+ });
+
it('uses section page count context for SECTIONPAGES tokens', () => {
const run: TextRun = { text: '0', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 12 };
diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts
index 4faea2a5c4..6aa8523ab7 100644
--- a/packages/layout-engine/painters/dom/src/runs/text-run.ts
+++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts
@@ -1,6 +1,8 @@
import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts';
import {
+ formatChapterPageNumberText,
formatPageNumberFieldValue,
+ formatSectionPageNumberText,
normalizeBaselineShift,
resolveBaseFontSizeForVerticalText,
} from '@superdoc/contracts';
@@ -142,7 +144,19 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string
}
if (runToken === 'pageNumber') {
if (run.pageNumberFieldFormat) {
- return formatPageNumberFieldValue(context.displayPageNumber ?? context.pageNumber, run.pageNumberFieldFormat);
+ return formatChapterPageNumberText({
+ pageComponent: formatPageNumberFieldValue(context.displayPageNumber ?? context.pageNumber, run.pageNumberFieldFormat),
+ chapterNumberText: context.pageNumberChapterText,
+ chapterSeparator: context.pageNumberChapterSeparator,
+ });
+ }
+ if (context.pageNumberChapterText) {
+ return formatSectionPageNumberText({
+ displayNumber: context.displayPageNumber ?? context.pageNumber,
+ pageFormat: context.pageNumberFormat ?? 'decimal',
+ chapterNumberText: context.pageNumberChapterText,
+ chapterSeparator: context.pageNumberChapterSeparator,
+ });
}
return context.pageNumberText ?? String(context.pageNumber);
}
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
index a6e549822b..4898895f10 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts
@@ -191,6 +191,61 @@ describe('layoutPerRIdHeaderFooters', () => {
});
});
+ it('passes chapter-aware page context to per-rId header/footer layout', async () => {
+ const headerBlocksByRId = new Map([['rId-header-default', [makeBlock('block-default')]]]);
+ const headerFooterInput = {
+ headerBlocksByRId,
+ footerBlocksByRId: undefined,
+ headerBlocks: undefined,
+ footerBlocks: undefined,
+ constraints: {
+ width: 400,
+ height: 80,
+ pageWidth: 600,
+ pageHeight: 800,
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 20 },
+ },
+ };
+ const layout = {
+ pages: [
+ {
+ number: 1,
+ fragments: [],
+ sectionIndex: 0,
+ numberText: '3\u20111',
+ displayNumber: 1,
+ pageNumberFormat: 'decimal',
+ pageNumberChapterText: '3',
+ pageNumberChapterSeparator: 'hyphen',
+ },
+ ],
+ } as unknown as Layout;
+ const sectionMetadata: SectionMetadata[] = [
+ {
+ sectionIndex: 0,
+ numbering: { chapterStyle: 1, chapterSeparator: 'hyphen' },
+ headerRefs: { default: 'rId-header-default' },
+ },
+ ];
+ const deps = {
+ headerLayoutsByRId: new Map(),
+ footerLayoutsByRId: new Map(),
+ };
+
+ await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps);
+
+ const pageResolver = mockLayoutHeaderFooterWithCache.mock.calls[0][5] as (pageNumber: number) => unknown;
+ expect(pageResolver(1)).toEqual({
+ displayText: '3\u20111',
+ displayNumber: 1,
+ totalPages: 1,
+ sectionPageCount: 1,
+ pageFormat: 'decimal',
+ chapterNumberText: '3',
+ chapterSeparator: 'hyphen',
+ });
+ });
+
it('lays out first-page header refs in multi-section documents with per-section constraints', async () => {
const headerBlocksByRId = new Map([
['rId-header-default', [makeBlock('block-default')]],
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
index 8c85a021af..cf975b8cef 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts
@@ -1,4 +1,11 @@
-import type { FlowBlock, HeaderFooterLayout, Layout, SectionMetadata } from '@superdoc/contracts';
+import type {
+ FlowBlock,
+ HeaderFooterLayout,
+ Layout,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
+ SectionMetadata,
+} from '@superdoc/contracts';
import {
computeDisplayPageNumber,
layoutHeaderFooterWithCache,
@@ -25,6 +32,9 @@ type PageResolver = (pageNumber: number) => {
displayNumber: number;
totalPages: number;
sectionPageCount: number;
+ pageFormat?: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
};
/**
@@ -52,16 +62,21 @@ export async function layoutPerRIdHeaderFooters(
const { headerBlocksByRId, footerBlocksByRId, constraints } = headerFooterInput;
const displayPages = computeDisplayPageNumber(layout.pages, sectionMetadata);
+ const pageByNumber = new Map(layout.pages.map((page) => [page.number, page]));
const totalPages = layout.pages.length;
- const pageResolver: PageResolver = (pageNumber) => {
+ const pageResolver: PageResolver = (pageNumber: number) => {
const pageIndex = pageNumber - 1;
const displayInfo = displayPages[pageIndex];
+ const page = pageByNumber.get(pageNumber);
return {
- displayText: displayInfo?.displayText ?? String(pageNumber),
- displayNumber: displayInfo?.displayNumber ?? pageNumber,
+ displayText: page?.numberText ?? displayInfo?.displayText ?? String(pageNumber),
+ displayNumber: page?.displayNumber ?? displayInfo?.displayNumber ?? pageNumber,
totalPages,
sectionPageCount: displayInfo?.sectionPageCount ?? totalPages ?? 1,
+ pageFormat: page?.pageNumberFormat,
+ chapterNumberText: page?.pageNumberChapterText,
+ chapterSeparator: page?.pageNumberChapterSeparator,
};
};
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
index 62f3c10be4..0a6733e1fc 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts
@@ -311,6 +311,67 @@ describe('HeaderFooterEditorManager', () => {
expect(sectionPages.textContent).toBe('IV');
});
+ it('refreshes chapter-prefixed page number DOM text with node pageNumberFormat', () => {
+ const editor = createMockEditor();
+ const manager = new HeaderFooterEditorManager(editor);
+ const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
+ const host = document.createElement('div');
+
+ const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host });
+ expect(sectionEditor).toBeDefined();
+ const pageNumber = document.createElement('span');
+ pageNumber.dataset.id = 'auto-page-number';
+ pageNumber.textContent = '1';
+ sectionEditor!.view.dom.appendChild(pageNumber);
+ (sectionEditor!.view as unknown as { posAtDOM: ReturnType }).posAtDOM = vi.fn(() => 0);
+ (sectionEditor as unknown as { state: { doc: { nodeAt: ReturnType } } }).state = {
+ doc: { nodeAt: vi.fn(() => ({ attrs: { pageNumberFormat: 'upperRoman' } })) },
+ };
+
+ manager.ensureEditorSync(descriptor, {
+ editorHost: host,
+ currentPageNumberText: '3\u2011IV',
+ currentPageDisplayNumber: 4,
+ currentPageChapterNumberText: '3',
+ currentPageChapterSeparator: 'hyphen',
+ });
+
+ expect(pageNumber.textContent).toBe('3\u2011IV');
+ });
+
+ it('clears stale chapter context when refreshing a cached page number editor', () => {
+ const editor = createMockEditor();
+ const manager = new HeaderFooterEditorManager(editor);
+ const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
+ const host = document.createElement('div');
+
+ const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host });
+ expect(sectionEditor).toBeDefined();
+ const pageNumber = document.createElement('span');
+ pageNumber.dataset.id = 'auto-page-number';
+ pageNumber.textContent = '1';
+ sectionEditor!.view.dom.appendChild(pageNumber);
+ (sectionEditor!.view as unknown as { posAtDOM: ReturnType }).posAtDOM = vi.fn(() => 0);
+ (sectionEditor as unknown as { state: { doc: { nodeAt: ReturnType } } }).state = {
+ doc: { nodeAt: vi.fn(() => ({ attrs: { pageNumberFormat: 'upperRoman' } })) },
+ };
+
+ manager.ensureEditorSync(descriptor, {
+ editorHost: host,
+ currentPageNumberText: '3\u2011IV',
+ currentPageDisplayNumber: 4,
+ currentPageChapterNumberText: '3',
+ currentPageChapterSeparator: 'hyphen',
+ });
+ manager.ensureEditorSync(descriptor, {
+ editorHost: host,
+ currentPageNumberText: 'IV',
+ currentPageDisplayNumber: 4,
+ });
+
+ expect(pageNumber.textContent).toBe('IV');
+ });
+
it('emits contentChanged and syncs converter/Yjs data when section editor updates', async () => {
const editor = createMockEditor();
const manager = new HeaderFooterEditorManager(editor);
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
index d5c35011ad..6d6c544b7f 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts
@@ -1,6 +1,13 @@
import { toFlowBlocks } from '@core/layout-adapter';
import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js';
-import { formatPageNumber, type FlowBlock, type PageNumberFormat, type TrackedChangesMode } from '@superdoc/contracts';
+import {
+ formatPageNumber,
+ formatSectionPageNumberText,
+ type FlowBlock,
+ type PageNumberChapterSeparator,
+ type PageNumberFormat,
+ type TrackedChangesMode,
+} from '@superdoc/contracts';
import type { HeaderFooterBatch } from '@superdoc/layout-bridge';
import type { Editor } from '@core/Editor.js';
import { EventEmitter } from '@core/EventEmitter.js';
@@ -265,6 +272,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
* @param options.currentPageNumber - The current page number for PAGE field resolution. Must be a positive integer if provided.
* @param options.currentPageNumberText - The current formatted PAGE field display text if provided.
* @param options.currentPageDisplayNumber - The current numeric PAGE display value for local field formatting.
+ * @param options.currentPageChapterNumberText - The PAGE chapter prefix for local field formatting.
+ * @param options.currentPageChapterSeparator - The PAGE chapter separator for local field formatting.
* @param options.totalPageCount - The total page count for NUMPAGES field resolution. Must be a positive integer if provided.
* @param options.sectionPageCount - The current section page count for SECTIONPAGES field resolution. Must be a positive integer if provided.
* @returns The editor instance, or null if creation failed
@@ -280,6 +289,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber?: number;
currentPageNumberText?: string;
currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
sectionPageCount?: number;
},
@@ -448,6 +459,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber?: number;
currentPageNumberText?: string;
currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
sectionPageCount?: number;
},
@@ -485,6 +498,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
const currentPage = String(opts.currentPageNumberText || opts.currentPageNumber || '1');
const currentPageNumber = Number(opts.currentPageDisplayNumber || opts.currentPageNumber || 1);
+ const chapterNumberText =
+ typeof opts.currentPageChapterNumberText === 'string' ? opts.currentPageChapterNumberText : undefined;
+ const chapterSeparator =
+ typeof opts.currentPageChapterSeparator === 'string'
+ ? (opts.currentPageChapterSeparator as PageNumberChapterSeparator)
+ : undefined;
const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1');
const sectionPages = opts.sectionPageCount;
@@ -494,7 +513,14 @@ export class HeaderFooterEditorManager extends EventEmitter {
pageNumberEls.forEach((el) => {
const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el);
- const text = pageNumberFormat ? formatPageNumber(currentPageNumber, pageNumberFormat) : currentPage;
+ const text = pageNumberFormat
+ ? formatSectionPageNumberText({
+ displayNumber: currentPageNumber,
+ pageFormat: pageNumberFormat,
+ chapterNumberText,
+ chapterSeparator,
+ })
+ : currentPage;
if (el.textContent !== text) el.textContent = text;
});
totalPagesEls.forEach((el) => {
@@ -511,7 +537,9 @@ export class HeaderFooterEditorManager extends EventEmitter {
#getPageNumberFormatForDomNode(editor: Editor, el: Element): PageNumberFormat | null {
try {
- const pos = editor.view.posAtDOM(el, 0);
+ const view = editor.view;
+ if (!view) return null;
+ const pos = view.posAtDOM(el, 0);
const node = editor.state.doc.nodeAt(pos);
const format = node?.attrs?.pageNumberFormat;
return typeof format === 'string' ? (format as PageNumberFormat) : null;
@@ -779,6 +807,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber?: number;
currentPageNumberText?: string;
currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
sectionPageCount?: number;
},
@@ -802,6 +832,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber: options?.currentPageNumber ?? 1,
currentPageNumberText: options?.currentPageNumberText,
currentPageDisplayNumber: options?.currentPageDisplayNumber,
+ currentPageChapterNumberText: options?.currentPageChapterNumberText,
+ currentPageChapterSeparator: options?.currentPageChapterSeparator,
totalPageCount: options?.totalPageCount ?? 1,
sectionPageCount: options?.sectionPageCount,
}) as Editor;
@@ -921,6 +953,8 @@ export class HeaderFooterEditorManager extends EventEmitter {
currentPageNumber?: number;
currentPageNumberText?: string;
currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
sectionPageCount?: number;
},
@@ -943,6 +977,16 @@ export class HeaderFooterEditorManager extends EventEmitter {
if (options.currentPageDisplayNumber !== undefined) {
updateOptions.currentPageDisplayNumber = options.currentPageDisplayNumber;
}
+ const hasPageContext =
+ options.currentPageNumber !== undefined ||
+ options.currentPageNumberText !== undefined ||
+ options.currentPageDisplayNumber !== undefined;
+ if (hasPageContext || options.currentPageChapterNumberText !== undefined) {
+ updateOptions.currentPageChapterNumberText = options.currentPageChapterNumberText;
+ }
+ if (hasPageContext || options.currentPageChapterSeparator !== undefined) {
+ updateOptions.currentPageChapterSeparator = options.currentPageChapterSeparator;
+ }
if (options.totalPageCount !== undefined) {
updateOptions.totalPageCount = options.totalPageCount;
}
diff --git a/packages/super-editor/src/editors/v1/core/header-footer/types.ts b/packages/super-editor/src/editors/v1/core/header-footer/types.ts
index f2623b758a..74e139cca8 100644
--- a/packages/super-editor/src/editors/v1/core/header-footer/types.ts
+++ b/packages/super-editor/src/editors/v1/core/header-footer/types.ts
@@ -41,6 +41,12 @@ export type HeaderFooterRegion = {
/** Numeric section-aware display page number before PAGE field-local formatting */
displayPageNumberValue?: number;
+ /** Chapter prefix for PAGE fields on this page, when chapter numbering is enabled */
+ displayPageChapterNumberText?: string;
+
+ /** Separator between chapter prefix and PAGE component */
+ displayPageChapterSeparator?: 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
+
/** Physical page count in this region's section */
sectionPageCount?: number;
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts
index 7a706c2089..7ca808ed4b 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.test.ts
@@ -223,6 +223,53 @@ describe('computeParagraphAttrs', () => {
expect(resolvedParagraphProperties.styleId).toBe('Heading1');
});
+ it('resolves built-in heading level from localized style metadata', () => {
+ const paragraph: PMNode = {
+ type: { name: 'paragraph' },
+ attrs: {
+ paragraphProperties: { styleId: 'Ttulo1' },
+ },
+ };
+ const converterContext = {
+ translatedNumbering: {},
+ translatedLinkedStyles: {
+ docDefaults: {},
+ styles: {
+ Ttulo1: {
+ type: 'paragraph',
+ styleId: 'Ttulo1',
+ name: 'heading 1',
+ paragraphProperties: { outlineLvl: 0 },
+ },
+ },
+ },
+ };
+
+ const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
+
+ expect(paragraphAttrs.styleId).toBe('Ttulo1');
+ expect(paragraphAttrs.headingLevel).toBe(1);
+ });
+
+ it('exposes the current structured list level ordinal', () => {
+ const paragraph: PMNode = {
+ type: { name: 'paragraph' },
+ attrs: {
+ paragraphProperties: {},
+ listRendering: {
+ numberingType: 'decimal',
+ markerText: '',
+ path: [3, 1],
+ suffix: 'nothing',
+ },
+ },
+ };
+
+ const { paragraphAttrs } = computeParagraphAttrs(paragraph as never);
+
+ expect(paragraphAttrs.listLevelOrdinal).toBe(1);
+ });
+
it('passes previousParagraphFont to marker run when paragraph has listRendering and numbering', () => {
const previousFont = { fontFamily: 'MarkerFont, sans-serif', fontSize: 11 };
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts
index 74fdc5981e..f115d6c651 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts
@@ -20,6 +20,7 @@ import {
import type { PMNode, ParagraphFont } from '../types.js';
import type { ResolvedRunProperties } from '@superdoc/word-layout';
import { computeWordParagraphLayout } from '@superdoc/word-layout';
+import { getListOrdinalFromPath } from '@superdoc/common/list-rendering';
import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js';
import { normalizeAlignment, normalizeParagraphSpacing } from './spacing-indent.js';
import { normalizeOoxmlTabs } from './tabs.js';
@@ -41,6 +42,7 @@ import { resolveSectionDirection, resolveParagraphDirection } from '../direction
const DEFAULT_DECIMAL_SEPARATOR = '.';
const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch
type ParagraphDirection = 'ltr' | 'rtl';
+const BUILT_IN_HEADING_NAME_RE = /^heading\s+([1-9])$/i;
const normalizeColor = (value?: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
@@ -169,6 +171,38 @@ export const normalizeNumberingProperties = (
return value;
};
+const normalizeHeadingLevel = (value: unknown): number | undefined => {
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 9) {
+ return undefined;
+ }
+ return value;
+};
+
+const resolveHeadingLevel = (
+ styleId: string | undefined,
+ resolvedParagraphProperties: ParagraphProperties,
+ converterContext?: ConverterContext,
+): number | undefined => {
+ const directOutlineLevel = resolvedParagraphProperties.outlineLvl;
+ if (typeof directOutlineLevel === 'number' && Number.isInteger(directOutlineLevel)) {
+ return normalizeHeadingLevel(directOutlineLevel + 1);
+ }
+
+ const styleDefinition = styleId ? converterContext?.translatedLinkedStyles?.styles?.[styleId] : undefined;
+ const styleNameLevel =
+ typeof styleDefinition?.name === 'string' ? BUILT_IN_HEADING_NAME_RE.exec(styleDefinition.name.trim()) : null;
+ if (styleNameLevel?.[1]) {
+ return normalizeHeadingLevel(Number(styleNameLevel[1]));
+ }
+
+ const styleOutlineLevel = styleDefinition?.paragraphProperties?.outlineLvl;
+ if (typeof styleOutlineLevel === 'number' && Number.isInteger(styleOutlineLevel)) {
+ return normalizeHeadingLevel(styleOutlineLevel + 1);
+ }
+
+ return undefined;
+};
+
const TRACKED_CHANGE_KEYS = new Set(['trackInsert', 'trackDelete']);
export const hasExplicitParagraphRunProperties = (
@@ -379,6 +413,11 @@ export const computeParagraphAttrs = (
dropCapDescriptor: dropCapDescriptor,
frame: normalizedFramePr,
numberingProperties: normalizedNumberingProperties,
+ headingLevel: resolveHeadingLevel(
+ resolvedParagraphProperties.styleId,
+ resolvedParagraphProperties,
+ converterContext,
+ ),
borders: normalizedBorders,
shading: normalizedShading,
tabs: normalizedTabStops,
@@ -391,6 +430,11 @@ export const computeParagraphAttrs = (
directionContext,
};
+ const listLevelOrdinal = getListOrdinalFromPath(normalizedListRendering?.path);
+ if (listLevelOrdinal != null) {
+ paragraphAttrs.listLevelOrdinal = listLevelOrdinal;
+ }
+
// SD-3269: w:vanish on the paragraph-mark rPr (w:pPr/w:rPr) suppresses the
// visible paragraph break. Word 16.0 fuses the next paragraph forward when
// this flag is set, regardless of w:specVanish. ECMA-376 ยง17.3.2.36 reads as
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts
index 97b6b5b2d7..e651ed2566 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/analysis.test.ts
@@ -840,7 +840,7 @@ describe('analysis', () => {
titlePg: false,
headerRefs: { default: 'header1' },
footerRefs: { default: 'footer1' },
- numbering: { format: 'decimal' },
+ numbering: { format: 'decimal', chapterStyle: 1, chapterSeparator: 'hyphen' },
},
];
const metadata: Array> = [];
@@ -853,7 +853,7 @@ describe('analysis', () => {
sectionIndex: 0,
headerRefs: { default: 'header1' },
footerRefs: { default: 'footer1' },
- numbering: { format: 'decimal' },
+ numbering: { format: 'decimal', chapterStyle: 1, chapterSeparator: 'hyphen' },
titlePg: false,
margins: null,
pageSize: null,
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.test.ts
new file mode 100644
index 0000000000..bccd26e955
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import { signaturesEqual } from './breaks.js';
+import type { SectionSignature } from './types.js';
+
+describe('section breaks', () => {
+ describe('signaturesEqual', () => {
+ const baseSignature: SectionSignature = {
+ numbering: {
+ format: 'decimal',
+ start: 1,
+ chapterStyle: 1,
+ chapterSeparator: 'hyphen',
+ },
+ };
+
+ it('should treat matching chapter numbering settings as equal', () => {
+ expect(signaturesEqual(baseSignature, { ...baseSignature, numbering: { ...baseSignature.numbering } })).toBe(
+ true,
+ );
+ });
+
+ it('should treat differing chapterStyle values as different', () => {
+ expect(
+ signaturesEqual(baseSignature, {
+ ...baseSignature,
+ numbering: { ...baseSignature.numbering, chapterStyle: 2 },
+ }),
+ ).toBe(false);
+ });
+
+ it('should treat differing chapterSeparator values as different', () => {
+ expect(
+ signaturesEqual(baseSignature, {
+ ...baseSignature,
+ numbering: { ...baseSignature.numbering, chapterSeparator: 'period' },
+ }),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts
index a841c3debb..4d710b20f3 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts
@@ -98,7 +98,9 @@ export function signaturesEqual(a: SectionSignature, b: SectionSignature): boole
(Boolean(a?.numbering) &&
Boolean(b?.numbering) &&
(a?.numbering?.format ?? null) === (b?.numbering?.format ?? null) &&
- (a?.numbering?.start ?? null) === (b?.numbering?.start ?? null));
+ (a?.numbering?.start ?? null) === (b?.numbering?.start ?? null) &&
+ (a?.numbering?.chapterStyle ?? null) === (b?.numbering?.chapterStyle ?? null) &&
+ (a?.numbering?.chapterSeparator ?? null) === (b?.numbering?.chapterSeparator ?? null));
return (
(a.titlePg ?? false) === (b.titlePg ?? false) &&
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts
index 713a8a3746..062f9e596f 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts
@@ -351,6 +351,71 @@ describe('extraction', () => {
});
});
+ describe('extractSectionData - page numbering chapter attributes', () => {
+ function paragraphWithPgNumType(attributes: Record): PMNode {
+ return {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [{ name: 'w:pgNumType', attributes }],
+ },
+ },
+ },
+ };
+ }
+
+ it('should extract positive w:chapStyle from pgNumType', () => {
+ const result = extractSectionData(paragraphWithPgNumType({ 'w:chapStyle': '1' }));
+
+ expect(result?.numbering).toEqual({
+ format: undefined,
+ chapterStyle: 1,
+ });
+ });
+
+ it('should extract each valid w:chapSep value', () => {
+ const separators = ['hyphen', 'period', 'colon', 'emDash', 'enDash'] as const;
+
+ for (const separator of separators) {
+ const result = extractSectionData(paragraphWithPgNumType({ 'w:chapSep': separator }));
+
+ expect(result?.numbering).toEqual({
+ format: undefined,
+ chapterSeparator: separator,
+ });
+ }
+ });
+
+ it('should ignore invalid w:chapSep values', () => {
+ const result = extractSectionData(paragraphWithPgNumType({ 'w:chapSep': 'slash' }));
+
+ expect(result?.numbering).toBeUndefined();
+ });
+
+ it('should ignore invalid and non-positive w:chapStyle values', () => {
+ expect(extractSectionData(paragraphWithPgNumType({ 'w:chapStyle': '0' }))?.numbering).toBeUndefined();
+ expect(extractSectionData(paragraphWithPgNumType({ 'w:chapStyle': '-1' }))?.numbering).toBeUndefined();
+ expect(extractSectionData(paragraphWithPgNumType({ 'w:chapStyle': '1.5' }))?.numbering).toBeUndefined();
+ expect(extractSectionData(paragraphWithPgNumType({ 'w:chapStyle': 'Heading1' }))?.numbering).toBeUndefined();
+ });
+
+ it('should preserve existing start-implies-decimal behavior with chapter attributes', () => {
+ const result = extractSectionData(
+ paragraphWithPgNumType({ 'w:start': '3', 'w:chapStyle': '2', 'w:chapSep': 'colon' }),
+ );
+
+ expect(result?.numbering).toEqual({
+ format: 'decimal',
+ start: 3,
+ chapterStyle: 2,
+ chapterSeparator: 'colon',
+ });
+ });
+ });
+
// ==================== parseColumnCount Tests ====================
describe('parseColumnCount', () => {
it('should return 1 when rawValue is undefined', () => {
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts
index 234d260aa5..ba58500246 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts
@@ -6,7 +6,7 @@
import type { PMNode } from '../types.js';
import type { ParagraphProperties, SectionVerticalAlign } from './types.js';
-import type { ColumnLayout } from '@superdoc/contracts';
+import type { ColumnLayout, PageNumberChapterSeparator } from '@superdoc/contracts';
const TWIPS_PER_INCH = 1440;
const PX_PER_INCH = 96;
@@ -52,10 +52,26 @@ export function parseColumnSeparator(rawValue: string | number | undefined): boo
return rawValue === '1' || rawValue === 'true' || rawValue === 'on' || rawValue === 1;
}
+function parsePositiveInteger(rawValue: unknown): number | undefined {
+ const value = Number(rawValue);
+ return Number.isInteger(value) && value > 0 ? value : undefined;
+}
+
+function isKnownChapterSeparator(value: unknown): value is PageNumberChapterSeparator {
+ return typeof value === 'string' && (CHAPTER_SEPARATOR_VALUES as readonly string[]).includes(value);
+}
+
type SectionType = 'continuous' | 'nextPage' | 'evenPage' | 'oddPage';
type Orientation = 'portrait' | 'landscape';
type HeaderRefType = Partial>;
type NumberingFormat = 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
+const CHAPTER_SEPARATOR_VALUES: readonly PageNumberChapterSeparator[] = [
+ 'hyphen',
+ 'period',
+ 'colon',
+ 'emDash',
+ 'enDash',
+] as const;
interface SectionElement {
name: string;
@@ -210,6 +226,8 @@ function extractPageNumbering(elements: SectionElement[]):
| {
format?: NumberingFormat;
start?: number;
+ chapterStyle?: number;
+ chapterSeparator?: PageNumberChapterSeparator;
}
| undefined {
const pgNumType = elements.find((el) => el?.name === 'w:pgNumType');
@@ -228,13 +246,23 @@ function extractPageNumbering(elements: SectionElement[]):
const startRaw = pgNumType.attributes['w:start'];
const startNum = startRaw != null ? Number(startRaw) : undefined;
+ const hasStart = Number.isFinite(startNum);
+ const chapterStyle = parsePositiveInteger(pgNumType.attributes['w:chapStyle']);
+ const chapterSeparatorRaw = pgNumType.attributes['w:chapSep'];
+ const chapterSeparator = isKnownChapterSeparator(chapterSeparatorRaw) ? chapterSeparatorRaw : undefined;
// Per OOXML spec, when w:start restarts numbering without w:fmt, default to decimal (Arabic numerals)
- const effectiveFormat = fmt ?? (Number.isFinite(startNum) ? 'decimal' : undefined);
+ const effectiveFormat = fmt ?? (hasStart ? 'decimal' : undefined);
+
+ if (effectiveFormat === undefined && !hasStart && chapterStyle === undefined && chapterSeparator === undefined) {
+ return undefined;
+ }
return {
format: effectiveFormat,
- ...(Number.isFinite(startNum) ? { start: Number(startNum) } : {}),
+ ...(hasStart ? { start: Number(startNum) } : {}),
+ ...(chapterStyle !== undefined ? { chapterStyle } : {}),
+ ...(chapterSeparator !== undefined ? { chapterSeparator } : {}),
};
}
@@ -346,7 +374,12 @@ export function extractSectionData(para: PMNode): {
titlePg?: boolean;
headerRefs?: HeaderRefType;
footerRefs?: HeaderRefType;
- numbering?: { format?: NumberingFormat; start?: number };
+ numbering?: {
+ format?: NumberingFormat;
+ start?: number;
+ chapterStyle?: number;
+ chapterSeparator?: PageNumberChapterSeparator;
+ };
vAlign?: SectionVerticalAlign;
} | null {
const attrs = (para.attrs ?? {}) as Record;
diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts
index 51006470af..6d91a702d4 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/types.ts
@@ -5,7 +5,7 @@
* Includes section ranges, signatures, and OOXML structures.
*/
-import type { ColumnLayout } from '@superdoc/contracts';
+import type { ColumnLayout, SectionNumbering } from '@superdoc/contracts';
/**
* Section types in Word documents.
@@ -75,10 +75,7 @@ export type SectionSignature = {
headerRefs?: Partial>;
footerRefs?: Partial>;
columnsPx?: ColumnLayout;
- numbering?: {
- format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
- start?: number;
- };
+ numbering?: SectionNumbering;
} | null;
/**
@@ -129,9 +126,6 @@ export interface SectionRange {
titlePg: boolean;
headerRefs?: Partial>;
footerRefs?: Partial>;
- numbering?: {
- format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
- start?: number;
- };
+ numbering?: SectionNumbering;
vAlign?: SectionVerticalAlign;
}
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
index c5e8720252..e63d15a28b 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
@@ -5680,6 +5680,8 @@ export class PresentationEditor extends EventEmitter {
currentPageNumber: editorContext.currentPageNumber,
currentPageNumberText: editorContext.currentPageNumberText,
currentPageDisplayNumber: editorContext.currentPageDisplayNumber,
+ currentPageChapterNumberText: editorContext.currentPageChapterNumberText,
+ currentPageChapterSeparator: editorContext.currentPageChapterSeparator,
totalPageCount: editorContext.totalPageCount,
sectionPageCount: editorContext.sectionPageCount,
}) ?? null)
@@ -5714,6 +5716,8 @@ export class PresentationEditor extends EventEmitter {
currentPageNumber: editorContext.currentPageNumber,
currentPageNumberText: editorContext.currentPageNumberText,
currentPageDisplayNumber: editorContext.currentPageDisplayNumber,
+ currentPageChapterNumberText: editorContext.currentPageChapterNumberText,
+ currentPageChapterSeparator: editorContext.currentPageChapterSeparator,
totalPageCount: editorContext.totalPageCount,
sectionPageCount: editorContext.sectionPageCount,
editorOptions: headerFooterRefId ? { headerFooterRefId } : undefined,
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
index adc51050bf..8073890fa3 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
@@ -832,6 +832,8 @@ export class HeaderFooterSessionManager {
const headerBox = this.#computeDecorationBox('header', margins, actualPageHeight);
const displayPageNumber = page.numberText ?? String(page.number);
const displayPageNumberValue = page.displayNumber ?? page.number;
+ const displayPageChapterNumberText = page.pageNumberChapterText;
+ const displayPageChapterSeparator = page.pageNumberChapterSeparator;
this.#headerRegions.set(pageIndex, {
kind: 'header',
@@ -844,6 +846,8 @@ export class HeaderFooterSessionManager {
pageNumber: page.number,
displayPageNumber,
displayPageNumberValue,
+ displayPageChapterNumberText,
+ displayPageChapterSeparator,
sectionPageCount,
localX: headerPayload?.hitRegion?.x ?? headerBox.x,
localY: headerPayload?.hitRegion?.y ?? headerBox.offset,
@@ -866,6 +870,8 @@ export class HeaderFooterSessionManager {
pageNumber: page.number,
displayPageNumber,
displayPageNumberValue,
+ displayPageChapterNumberText,
+ displayPageChapterSeparator,
sectionPageCount,
localX: footerPayload?.hitRegion?.x ?? footerBox.x,
localY: footerPayload?.hitRegion?.y ?? footerBox.offset,
@@ -1101,6 +1107,8 @@ export class HeaderFooterSessionManager {
currentPageNumber: Math.max(1, region.pageNumber ?? 1),
currentPageNumberText: region.displayPageNumber,
currentPageDisplayNumber: Math.max(1, region.displayPageNumberValue ?? region.pageNumber ?? 1),
+ currentPageChapterNumberText: region.displayPageChapterNumberText,
+ currentPageChapterSeparator: region.displayPageChapterSeparator,
totalPageCount: Math.max(1, bodyPageCount),
sectionPageCount: Math.max(1, region.sectionPageCount ?? bodyPageCount),
surfaceKind: region.kind,
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
index d8330e8b89..6fb1588199 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts
@@ -120,6 +120,8 @@ export interface ActivateStorySessionOptions {
currentPageNumber?: number;
currentPageNumberText?: string;
currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
totalPageCount?: number;
sectionPageCount?: number;
surfaceKind?: 'header' | 'footer' | 'note' | 'endnote';
diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
index d31c0336c7..679e175d22 100644
--- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
+++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts
@@ -44,6 +44,16 @@ export interface StoryEditorOptions {
*/
currentPageDisplayNumber?: number;
+ /**
+ * The current PAGE chapter prefix for field-local formatting.
+ */
+ currentPageChapterNumberText?: string;
+
+ /**
+ * The current PAGE chapter separator for field-local formatting.
+ */
+ currentPageChapterSeparator?: 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
+
/**
* The total page count for NUMPAGES field resolution.
* Must be a positive integer.
@@ -133,6 +143,8 @@ export function createStoryEditor(
currentPageNumber = 1,
currentPageNumberText,
currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount = 1,
sectionPageCount,
element = null,
@@ -177,6 +189,8 @@ export function createStoryEditor(
currentPageNumber,
currentPageNumberText,
currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
sectionPageCount,
editable: false,
diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
index 46d8879d34..244d5c5a58 100644
--- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
+++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
@@ -462,6 +462,12 @@ export interface EditorOptions {
/** Current numeric PAGE display value for story editor field-local formatting */
currentPageDisplayNumber?: number;
+ /** Current PAGE chapter prefix for story editor field-local formatting */
+ currentPageChapterNumberText?: string;
+
+ /** Current PAGE chapter separator for story editor field-local formatting */
+ currentPageChapterSeparator?: 'hyphen' | 'period' | 'colon' | 'emDash' | 'enDash';
+
/** Total document page count for NUMPAGES field rendering in story editors */
totalPageCount?: number;
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.test.ts
new file mode 100644
index 0000000000..e3ae927743
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+import { readSectPrPageNumbering, writeSectPrPageNumbering, type XmlElement } from './sections-xml.js';
+
+describe('sections XML helpers', () => {
+ describe('readSectPrPageNumbering', () => {
+ it('should read chapter numbering attributes from pgNumType', () => {
+ const sectPr: XmlElement = {
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:pgNumType',
+ attributes: {
+ 'w:start': '2',
+ 'w:fmt': 'upperRoman',
+ 'w:chapStyle': '1',
+ 'w:chapSep': 'colon',
+ },
+ },
+ ],
+ };
+
+ expect(readSectPrPageNumbering(sectPr)).toEqual({
+ start: 2,
+ format: 'upperRoman',
+ chapterStyle: 1,
+ chapterSeparator: 'colon',
+ });
+ });
+
+ it('should ignore invalid chapter numbering attributes', () => {
+ const sectPr: XmlElement = {
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:pgNumType',
+ attributes: {
+ 'w:chapStyle': '0',
+ 'w:chapSep': 'slash',
+ },
+ },
+ ],
+ };
+
+ expect(readSectPrPageNumbering(sectPr)).toBeUndefined();
+ });
+ });
+
+ describe('writeSectPrPageNumbering', () => {
+ it('should write chapter numbering attributes to pgNumType', () => {
+ const sectPr: XmlElement = { name: 'w:sectPr', elements: [] };
+
+ writeSectPrPageNumbering(sectPr, {
+ chapterStyle: 2,
+ chapterSeparator: 'enDash',
+ });
+
+ expect(sectPr.elements).toEqual([
+ {
+ type: 'element',
+ name: 'w:pgNumType',
+ attributes: {
+ 'w:chapStyle': '2',
+ 'w:chapSep': 'enDash',
+ },
+ elements: [],
+ },
+ ]);
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.ts
index f1d5748b00..b98561f2e1 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.ts
@@ -12,6 +12,7 @@ import type {
SectionPageBorders,
SectionPageMargins,
SectionPageNumbering,
+ SectionPageNumberingChapterSeparator,
SectionPageNumberingFormat,
SectionPageSetup,
SectionVerticalAlign,
@@ -38,6 +39,13 @@ const PAGE_NUMBER_FORMAT_VALUES: readonly SectionPageNumberingFormat[] = [
'upperRoman',
'numberInDash',
] as const;
+const PAGE_NUMBER_CHAPTER_SEPARATOR_VALUES: readonly SectionPageNumberingChapterSeparator[] = [
+ 'hyphen',
+ 'period',
+ 'colon',
+ 'emDash',
+ 'enDash',
+] as const;
const SECTION_ORIENTATION_VALUES: readonly SectionOrientation[] = ['portrait', 'landscape'] as const;
const SECTION_VERTICAL_ALIGN_VALUES: readonly SectionVerticalAlign[] = ['top', 'center', 'bottom', 'both'] as const;
@@ -300,16 +308,30 @@ export function readSectPrPageNumbering(sectPr: XmlElement): SectionPageNumberin
const formatRaw = asString(pgNumType.attributes?.['w:fmt']);
const format = isKnownValue(formatRaw, PAGE_NUMBER_FORMAT_VALUES) ? formatRaw : undefined;
const start = toPositiveInteger(pgNumType.attributes?.['w:start']);
+ const chapterStyle = toPositiveInteger(pgNumType.attributes?.['w:chapStyle']);
+ const chapterSeparatorRaw = asString(pgNumType.attributes?.['w:chapSep']);
+ const chapterSeparator = isKnownValue(chapterSeparatorRaw, PAGE_NUMBER_CHAPTER_SEPARATOR_VALUES)
+ ? chapterSeparatorRaw
+ : undefined;
- if (format == null && start == null) return undefined;
- return { format, start };
+ if (format == null && start == null && chapterStyle == null && chapterSeparator == null) return undefined;
+ return { format, start, chapterStyle, chapterSeparator };
}
export function writeSectPrPageNumbering(sectPr: XmlElement, numbering: SectionPageNumbering): void {
- if (numbering.start === undefined && numbering.format === undefined) return;
+ if (
+ numbering.start === undefined &&
+ numbering.format === undefined &&
+ numbering.chapterStyle === undefined &&
+ numbering.chapterSeparator === undefined
+ ) {
+ return;
+ }
const pgNumType = ensureChild(sectPr, 'w:pgNumType');
if (numbering.start !== undefined) setStringAttr(pgNumType, 'w:start', numbering.start);
if (numbering.format !== undefined) setStringAttr(pgNumType, 'w:fmt', numbering.format);
+ if (numbering.chapterStyle !== undefined) setStringAttr(pgNumType, 'w:chapStyle', numbering.chapterStyle);
+ if (numbering.chapterSeparator !== undefined) setStringAttr(pgNumType, 'w:chapSep', numbering.chapterSeparator);
}
export function readSectPrTitlePage(sectPr: XmlElement): boolean {
diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js
index eb54125186..aa194e3c25 100644
--- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js
+++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js
@@ -1,7 +1,7 @@
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
import { isHeadless } from '@utils/headless-helpers.js';
-import { formatPageNumber } from '@superdoc/contracts';
+import { formatPageNumber, formatSectionPageNumberText } from '@superdoc/contracts';
/**
* Configuration options for PageNumber
* @typedef {Object} PageNumberOptions
@@ -361,8 +361,17 @@ const getNodeAttributes = (nodeName, editor, node = null) => {
case 'page-number': {
const currentPageNumber = editor.options.currentPageNumber || 1;
const currentPageDisplayNumber = editor.options.currentPageDisplayNumber || currentPageNumber;
+ const chapterNumberText =
+ typeof editor.options.currentPageChapterNumberText === 'string'
+ ? editor.options.currentPageChapterNumberText
+ : undefined;
const text = node?.attrs?.pageNumberFormat
- ? formatPageNumber(Number(currentPageDisplayNumber) || 1, node.attrs.pageNumberFormat)
+ ? formatSectionPageNumberText({
+ displayNumber: Number(currentPageDisplayNumber) || 1,
+ pageFormat: node.attrs.pageNumberFormat,
+ chapterNumberText,
+ chapterSeparator: editor.options.currentPageChapterSeparator,
+ })
: editor.options.currentPageNumberText || currentPageNumber;
return {
text,
diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js
index 3fc0444980..60d0c41a8a 100644
--- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js
@@ -253,6 +253,31 @@ describe('AutoPageNumberNodeView', () => {
expect(nodeView.dom.textContent).toBe('III');
});
+ it('preserves chapter prefix when applying node pageNumberFormat', () => {
+ const doc = {
+ resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }),
+ nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }),
+ };
+ const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) };
+ const state = { doc, tr };
+ const editor = {
+ options: {
+ currentPageNumber: 7,
+ currentPageNumberText: '3\u2011IV',
+ currentPageDisplayNumber: 4,
+ currentPageChapterNumberText: '3',
+ currentPageChapterSeparator: 'hyphen',
+ },
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = { type: { name: 'page-number' }, attrs: { pageNumberFormat: 'upperRoman' } };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('3\u2011IV');
+ });
+
it('formats page number node from current page number when display text is unavailable', () => {
const doc = {
resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }),
diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
index e5f110c1fa..0fbb6ab5e1 100644
--- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
+++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
@@ -98,6 +98,8 @@ const getSectionHeight = async (editor, data) => {
* @param {number} [params.currentPageNumber] - The current page number for PAGE field resolution. Must be a positive integer.
* @param {string} [params.currentPageNumberText] - The formatted PAGE field display text.
* @param {number} [params.currentPageDisplayNumber] - The numeric PAGE display value for local field formatting.
+ * @param {string} [params.currentPageChapterNumberText] - The PAGE chapter prefix for local field formatting.
+ * @param {string} [params.currentPageChapterSeparator] - The PAGE chapter separator for local field formatting.
* @param {number} [params.totalPageCount] - The total page count for NUMPAGES field resolution. Must be a positive integer.
* @param {number} [params.sectionPageCount] - The current section page count for SECTIONPAGES field resolution. Must be a positive integer.
* @returns {Editor} The created header/footer editor instance
@@ -117,6 +119,8 @@ export const createHeaderFooterEditor = ({
currentPageNumber,
currentPageNumberText,
currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
sectionPageCount,
}) => {
@@ -219,6 +223,8 @@ export const createHeaderFooterEditor = ({
currentPageNumber,
currentPageNumberText,
currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
sectionPageCount,
element: editorContainer,