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 3df0683917..add3729af8 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -1,5 +1,5 @@
import type { TabStop } from './engines/tabs.js';
-import type { PageNumberFieldFormat } 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 +138,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. */
@@ -241,7 +244,7 @@ export type SdtMetadata =
| DocumentSectionMetadata
| DocPartMetadata;
-export const CONTRACTS_VERSION = '1.0.0';
+export const CONTRACTS_VERSION = '1.1.0';
/** Unique identifier for a block in the document. Format: `${pos}-${type}`. */
export type BlockId = string;
@@ -416,7 +419,7 @@ export type TextRun = RunMarks & {
visualPlaceholder?: SdtVisualPlaceholder;
link?: FlowRunLink;
/** Token annotations for dynamic content (page numbers, etc.). */
- token?: 'pageNumber' | 'totalPageCount' | 'pageReference';
+ token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount';
/** Explicit formatting requested by PAGE/NUMPAGES field switches. */
pageNumberFieldFormat?: PageNumberFieldFormat;
/** Absolute ProseMirror position (inclusive) of first character in this run. */
@@ -961,8 +964,10 @@ export type TextFormatting = {
export type TextPart = {
text: string;
formatting?: TextFormatting;
- /** Optional field token (e.g., PAGE/NUMPAGES) resolved at render time. */
- fieldType?: 'PAGE' | 'NUMPAGES';
+ /** Optional field token (e.g., PAGE/NUMPAGES/SECTIONPAGES) resolved at render time. */
+ fieldType?: 'PAGE' | 'NUMPAGES' | 'SECTIONPAGES';
+ /** PAGE/SECTIONPAGES field-local value formatting override. */
+ pageNumberFormat?: PageNumberFormat;
/** Indicates this part represents a line break between paragraphs. */
isLineBreak?: boolean;
/** Indicates this line break follows an empty paragraph (creates extra spacing). */
@@ -1220,10 +1225,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;
@@ -1262,8 +1264,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 = {
@@ -1645,6 +1649,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[];
@@ -2046,6 +2054,12 @@ export type Page = {
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?: {
@@ -2275,6 +2289,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 2ef0e7d7c0..cf2e4d4149 100644
--- a/packages/layout-engine/contracts/src/resolved-layout.ts
+++ b/packages/layout-engine/contracts/src/resolved-layout.ts
@@ -11,6 +11,8 @@ import type {
ListBlock,
ListMeasure,
PageMargins,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
ParagraphBlock,
ParagraphBorders,
ParagraphMeasure,
@@ -63,6 +65,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. */
@@ -458,6 +466,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/cacheInvalidation.ts b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts
index 7bc0bd8ddc..7fc1deb9cc 100644
--- a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts
+++ b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts
@@ -51,7 +51,9 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string {
if ('bold' in run && run.bold) parts.push('b');
if ('italic' in run && run.italic) parts.push('i');
if ('token' in run && run.token) parts.push(`token:${run.token}`);
- if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`);
+ if ('pageNumberFieldFormat' in run && run.pageNumberFieldFormat) {
+ parts.push(`pnf:${JSON.stringify(run.pageNumberFieldFormat)}`);
+ }
}
}
}
diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
index c867d56d06..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,20 +2716,30 @@ 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 } => {
+ ? (
+ pageNumber: 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 {
displayText: displayInfo?.displayText ?? String(pageNumber),
displayNumber: displayInfo?.displayNumber ?? pageNumber,
totalPages: numberingCtx.totalPages,
+ sectionPageCount: displayInfo?.sectionPageCount ?? numberingCtx.totalPages ?? 1,
+ pageFormat: displayInfo?.pageFormat,
+ chapterNumberText: displayInfo?.chapterNumberText,
+ chapterSeparator: displayInfo?.chapterSeparator,
};
}
: undefined;
@@ -3006,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.
*
@@ -3016,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 5c263d7157..46e06efa13 100644
--- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
+++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
@@ -4,10 +4,13 @@ import type {
ListBlock,
Measure,
PageNumberFieldFormat,
+ PageNumberChapterSeparator,
+ PageNumberFormat,
ParagraphBlock,
TableBlock,
TextRun,
} from '@superdoc/contracts';
+import { formatChapterPageNumberText } from '@superdoc/contracts';
import { formatPageNumberFieldValue, layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine';
import { MeasureCache } from './cache';
import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens';
@@ -35,6 +38,10 @@ export type PageResolver = (pageNumber: number) => {
displayText: string;
displayNumber?: number;
totalPages: number;
+ sectionPageCount?: number;
+ pageFormat?: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
};
/**
@@ -211,7 +218,11 @@ function canUseDigitBucketingForVariant(
const renderedText =
strategy.kind === 'fieldFormat'
? Number.isFinite(pageInfo.displayNumber)
- ? formatPageNumberFieldValue(pageInfo.displayNumber ?? pageNumber, strategy.fieldFormat)
+ ? formatChapterPageNumberText({
+ pageComponent: formatPageNumberFieldValue(pageInfo.displayNumber ?? pageNumber, strategy.fieldFormat),
+ chapterNumberText: pageInfo.chapterNumberText,
+ chapterSeparator: pageInfo.chapterSeparator,
+ })
: null
: pageInfo.displayText;
return renderedText ? getBucketForRenderedPageNumberText(renderedText) : null;
@@ -251,11 +262,32 @@ function canUseDigitBucketingForVariant(
* for header/footer variants that don't contain page tokens.
*
* @param blocks - FlowBlocks to check for tokens
- * @returns True if any block contains pageNumber or totalPageCount tokens
+ * @returns True if any block contains pageNumber, totalPageCount, or sectionPageCount tokens
*/
function paragraphHasPageToken(para: ParagraphBlock): boolean {
for (const run of para.runs) {
- if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
+ if (
+ 'token' in run &&
+ (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount')
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function paragraphHasSectionPageCountToken(para: ParagraphBlock): boolean {
+ for (const run of para.runs) {
+ if ('token' in run && run.token === 'sectionPageCount') {
+ return true;
+ }
+ }
+ return false;
+}
+
+function paragraphHasPageNumberToken(para: ParagraphBlock): boolean {
+ for (const run of para.runs) {
+ if ('token' in run && run.token === 'pageNumber') {
return true;
}
}
@@ -310,6 +342,57 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
return false;
}
+function hasSectionPageCountTokens(blocks: FlowBlock[]): boolean {
+ for (const block of blocks) {
+ if (block.kind === 'paragraph') {
+ if (paragraphHasSectionPageCountToken(block as ParagraphBlock)) return true;
+ } else if (block.kind === 'list') {
+ const list = block as ListBlock;
+ for (const item of list.items ?? []) {
+ if (paragraphHasSectionPageCountToken(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 (hasSectionPageCountTokens(cellBlocks)) return true;
+ }
+ }
+ }
+ }
+ 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') {
@@ -456,14 +539,17 @@ export async function layoutHeaderFooterWithCache(
// Determine which pages to create layouts for
let pagesToLayout: number[];
+ const hasPageNumberToken = hasPageNumberTokens(blocks);
const useBucketingForVariant =
useBucketing &&
!hasPageNumberTokensRequiringPerPageLayout(blocks) &&
- canUseDigitBucketingForVariant(blocks, docTotalPages, pageResolver);
+ !hasSectionPageCountTokens(blocks) &&
+ (!hasPageNumberToken || canUseDigitBucketingForVariant(blocks, docTotalPages, pageResolver));
if (!useBucketingForVariant) {
- // Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats.
+ // Per-page layout: small docs, disabled bucketing, SECTIONPAGES, or PAGE variants
+ // whose rendered digit buckets diverge within one physical-page bucket.
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);
} else {
@@ -486,7 +572,11 @@ export async function layoutHeaderFooterWithCache(
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
+ layout: HeaderFooterLayout;
numberText?: string;
+ pageNumberFormat?: PageNumberFormat;
+ pageNumberChapterText?: string;
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
}> = [];
for (const pageNum of pagesToLayout) {
@@ -494,9 +584,27 @@ export async function layoutHeaderFooterWithCache(
const clonedBlocks = cloneHeaderFooterBlocks(blocks);
// Resolve page number tokens for this specific page
- const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum);
-
- resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber);
+ 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);
@@ -527,26 +635,39 @@ export async function layoutHeaderFooterWithCache(
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
+ layout: pageLayout,
numberText: displayText,
+ // 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,
});
}
// Construct final HeaderFooterLayout with all pages
- // Use the first page's measurements for overall dimensions
- const firstPageLayout = pages[0]
- ? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints, kind)
- : { height: 0, pages: [] };
+ // Use the widest visual/measurement bounds from the page-specific layouts.
+ const pageLayouts = pages.map((page) => page.layout);
+ const minYValues = pageLayouts.map((layout) => layout.minY).filter((value): value is number => value !== undefined);
+ const maxYValues = pageLayouts.map((layout) => layout.maxY).filter((value): value is number => value !== undefined);
+ const minY = minYValues.length > 0 ? Math.min(...minYValues) : undefined;
+ const maxY = maxYValues.length > 0 ? Math.max(...maxYValues) : undefined;
+ const renderHeight = minY !== undefined && maxY !== undefined ? maxY - minY : undefined;
const finalLayout: HeaderFooterLayout = {
- height: firstPageLayout.height,
- minY: firstPageLayout.minY,
- maxY: firstPageLayout.maxY,
- renderHeight: firstPageLayout.renderHeight,
+ height: pageLayouts.reduce((maxHeight, layout) => Math.max(maxHeight, layout.height), 0),
+ minY,
+ maxY,
+ 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 2ecd495f9a..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, 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
@@ -23,6 +32,11 @@ function forEachParagraphBlock(blocks: FlowBlock[], visit: (para: ParagraphBlock
for (const block of blocks) {
if (block.kind === 'paragraph') {
visit(block as ParagraphBlock);
+ } else if (block.kind === 'list') {
+ const list = block as ListBlock;
+ for (const item of list.items ?? []) {
+ forEachParagraphBlock([item.paragraph], visit);
+ }
} else if (block.kind === 'table') {
const table = block as TableBlock;
for (const row of table.rows ?? []) {
@@ -42,7 +56,7 @@ function forEachParagraphBlock(blocks: FlowBlock[], visit: (para: ParagraphBlock
* Resolves page number tokens in a batch of header or footer blocks.
*
* Headers and footers can contain the same token types as body content
- * (pageNumber, totalPageCount), but they need to be resolved to the specific
+ * (pageNumber, totalPageCount, sectionPageCount), but they need to be resolved to the specific
* page where the header/footer will appear. This function mutates the blocks
* in-place to replace token placeholders with actual values.
*
@@ -74,6 +88,10 @@ export function resolveHeaderFooterTokens(
totalPages: number,
pageNumberText?: string,
displayPageNumber?: number,
+ sectionPageCount?: number,
+ pageNumberFormat?: PageNumberFormat,
+ chapterNumberText?: string,
+ chapterSeparator?: PageNumberChapterSeparator,
): void {
// Validate inputs
if (!blocks || blocks.length === 0) {
@@ -93,6 +111,9 @@ 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);
// Process every paragraph block, including those nested in table cells
// (SD-1332). The page-number field can live in `tableCell > paragraph >
@@ -108,14 +129,29 @@ 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.
run.text = run.pageNumberFieldFormat
? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
: totalPagesStr;
+ } else if (run.token === 'sectionPageCount') {
+ run.text = run.pageNumberFieldFormat
+ ? formatPageNumberFieldValue(sectionPageCountNumber, run.pageNumberFieldFormat)
+ : sectionPageCountStr;
}
// Note: pageReference tokens should not appear in headers/footers typically,
// but if they do, they'll be handled by the PAGEREF resolution logic
@@ -183,6 +219,16 @@ function cloneHeaderFooterBlock(block: FlowBlock): FlowBlock {
})),
} as TableBlock;
}
+ if (block.kind === 'list') {
+ const list = block as ListBlock;
+ return {
+ ...list,
+ items: (list.items ?? []).map((item) => ({
+ ...item,
+ paragraph: cloneHeaderFooterBlock(item.paragraph) as ParagraphBlock,
+ })),
+ } as ListBlock;
+ }
// For other block types, shallow copy is sufficient (they don't contain tokens)
return { ...block };
}
diff --git a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts
index fb3398face..72d8eb5f2a 100644
--- a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts
+++ b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts
@@ -57,14 +57,14 @@ describe('Cache Invalidation', () => {
{
kind: 'paragraph',
id: 'p1',
- runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }],
+ runs: [{ text: '0', token: 'pageNumber', pageNumberFieldFormat: { format: 'decimal' } }],
} as ParagraphBlock,
];
const romanBlocks: FlowBlock[] = [
{
kind: 'paragraph',
id: 'p1',
- runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }],
+ runs: [{ text: '0', token: 'pageNumber', pageNumberFieldFormat: { format: 'upperRoman' } }],
} as ParagraphBlock,
];
diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
index 690f251187..997bdfe8f6 100644
--- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
+++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts
@@ -113,6 +113,45 @@ describe('layoutHeaderFooterWithCache', () => {
expect(result.default?.layout.pages[1].measures).toHaveLength(1);
});
+ it('uses the largest page-specific metrics when section page counts change layout height', async () => {
+ const sections = {
+ default: [
+ {
+ kind: 'paragraph',
+ id: 'section-pages-footer',
+ runs: [
+ { text: 'Section pages ', fontFamily: 'Arial', fontSize: 16 },
+ { text: '0', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ } satisfies FlowBlock,
+ ],
+ };
+ const measureBlock = vi.fn(async (block: FlowBlock) => {
+ const sectionPageText = block.kind === 'paragraph' ? block.runs[1]?.text : undefined;
+ return makeMeasure(sectionPageText === '100' ? 36 : 12);
+ });
+
+ const result = await layoutHeaderFooterWithCache(
+ sections,
+ { width: 300, height: 80 },
+ measureBlock,
+ undefined,
+ undefined,
+ (pageNumber) => ({
+ displayText: String(pageNumber),
+ totalPages: 2,
+ sectionPageCount: pageNumber === 1 ? 1 : 100,
+ }),
+ 'footer',
+ );
+
+ expect(result.default?.layout.pages).toHaveLength(2);
+ expect(result.default?.layout.pages[0].measures?.[0]?.totalHeight).toBe(12);
+ expect(result.default?.layout.pages[1].measures?.[0]?.totalHeight).toBe(36);
+ expect(result.default?.layout.height).toBe(36);
+ expect(result.default?.layout.renderHeight).toBe(36);
+ });
+
describe('integration test', () => {
it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => {
// 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter)
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/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts
index 9eadd9b629..db7fdb2d04 100644
--- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts
+++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts
@@ -134,7 +134,6 @@ describe('headerFooterUtils', () => {
contentId: 'rIdEven',
});
});
-
it('uses default only for odd pages when alternating slots are missing', () => {
const identifier = extractIdentifierFromConverter({
headerIds: { default: 'rId1' },
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 fe349dc094..f0541a8582 100644
--- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts
+++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts
@@ -458,6 +458,90 @@ 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' }],
@@ -485,6 +569,36 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
expect(result.default?.layout.pages).toHaveLength(3);
expect(measureBlock).toHaveBeenCalledTimes(3);
});
+
+ it('should disable bucketing for chapter-prefixed run-local page number formats', async () => {
+ const sections = {
+ default: [makeFormattedPageTokenBlock('header-decimal-chapter', { format: 'decimal' })],
+ };
+
+ 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');
+ });
});
describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
@@ -555,6 +669,7 @@ describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
const sections = {
default: [makePageTokenBlock('header-section-restart')],
};
+ const cache = new HeaderFooterLayoutCache();
const pageResolver: PageResolver = (pageNum) => ({
displayText: pageNum <= 150 ? (pageNum < 100 ? String(pageNum) : String(900 + pageNum)) : String(pageNum),
@@ -567,7 +682,7 @@ describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
sections,
{ width: 400, height: 80 },
measureBlock,
- undefined,
+ cache,
undefined,
pageResolver,
);
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 35d26065cd..e612215268 100644
--- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts
+++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts
@@ -85,6 +85,29 @@ 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[] = [
{
@@ -138,6 +161,34 @@ describe('resolveHeaderFooterTokens', () => {
expect((block.runs[0] as TextRun).token).toBe('totalPageCount');
});
+ it('should resolve formatted sectionPageCount token from section context', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'footer-section-pages',
+ runs: [
+ {
+ text: 'Section pages: ',
+ fontFamily: 'Arial',
+ fontSize: 12,
+ },
+ {
+ text: '0',
+ token: 'sectionPageCount',
+ pageNumberFieldFormat: { format: 'upperRoman' },
+ fontFamily: 'Arial',
+ fontSize: 12,
+ } as TextRun,
+ ],
+ } as ParagraphBlock,
+ ];
+
+ resolveHeaderFooterTokens(blocks, 1, 99, '1', 1, 4);
+
+ const block = blocks[0] as ParagraphBlock;
+ expect(block.runs[1].text).toBe('IV');
+ expect((block.runs[1] as TextRun).token).toBe('sectionPageCount');
+ });
it('should resolve both tokens in the same block', () => {
const blocks: FlowBlock[] = [
{
diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts
index 3bddfbe377..01363fb6ac 100644
--- a/packages/layout-engine/layout-engine/src/index.ts
+++ b/packages/layout-engine/layout-engine/src/index.ts
@@ -3657,8 +3657,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 777f2ee828..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', () => {
@@ -200,6 +505,7 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 1,
displayText: '1',
sectionIndex: 0,
+ sectionPageCount: 1,
});
});
@@ -219,18 +525,21 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 1,
displayText: '1',
sectionIndex: 0,
+ sectionPageCount: 3,
});
expect(result[1]).toEqual({
physicalPage: 2,
displayNumber: 2,
displayText: '2',
sectionIndex: 0,
+ sectionPageCount: 3,
});
expect(result[2]).toEqual({
physicalPage: 3,
displayNumber: 3,
displayText: '3',
sectionIndex: 0,
+ sectionPageCount: 3,
});
});
@@ -281,20 +590,70 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 5,
displayText: '5',
sectionIndex: 0,
+ sectionPageCount: 3,
});
expect(result[1]).toEqual({
physicalPage: 2,
displayNumber: 6,
displayText: '6',
sectionIndex: 0,
+ sectionPageCount: 3,
});
expect(result[2]).toEqual({
physicalPage: 3,
displayNumber: 7,
displayText: '7',
sectionIndex: 0,
+ 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', () => {
@@ -530,12 +889,14 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 1,
displayText: 'i',
sectionIndex: 0,
+ sectionPageCount: 2,
});
expect(result[1]).toEqual({
physicalPage: 2,
displayNumber: 2,
displayText: 'ii',
sectionIndex: 0,
+ sectionPageCount: 2,
});
// Section 1: pages 3-4 in decimal (restarted at 1)
expect(result[2]).toEqual({
@@ -543,12 +904,14 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 1,
displayText: '1',
sectionIndex: 1,
+ sectionPageCount: 2,
});
expect(result[3]).toEqual({
physicalPage: 4,
displayNumber: 2,
displayText: '2',
sectionIndex: 1,
+ sectionPageCount: 2,
});
// Section 2: page 5 in upperLetter (restarted at 1)
expect(result[4]).toEqual({
@@ -556,6 +919,7 @@ describe('computeDisplayPageNumber', () => {
displayNumber: 1,
displayText: 'A',
sectionIndex: 2,
+ sectionPageCount: 1,
});
});
diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts
index ce3ed67c62..c21f20af06 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.
@@ -36,8 +46,206 @@ export interface DisplayPageInfo {
displayText: string;
/** Index of the section this page belongs to */
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;
+}
/**
* Computes section-aware display page numbers for all pages in a document.
*
@@ -79,7 +287,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) {
@@ -92,6 +304,12 @@ export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadat
sectionMap.set(section.sectionIndex, section);
}
+ const sectionPageCounts = new Map();
+ for (const page of pages) {
+ const sectionIndex = page.sectionIndex ?? 0;
+ sectionPageCounts.set(sectionIndex, (sectionPageCounts.get(sectionIndex) ?? 0) + 1);
+ }
+
// Track running page counter across sections
let runningCounter = 1;
let currentSectionIndex = -1;
@@ -124,18 +342,33 @@ 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,
displayNumber,
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 7a378f43eb..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,71 @@ 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', () => {
+ const blocks: FlowBlock[] = [
+ {
+ kind: 'paragraph',
+ id: 'para-1',
+ runs: [
+ {
+ text: 'Section pages: ',
+ fontFamily: 'Arial',
+ fontSize: 12,
+ },
+ {
+ text: '0',
+ token: 'sectionPageCount',
+ pageNumberFieldFormat: { format: 'upperRoman' },
+ 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 numberingCtx: NumberingContext = {
+ totalPages: 9,
+ displayPages: [
+ {
+ physicalPage: 1,
+ displayNumber: 1,
+ displayText: '1',
+ sectionIndex: 0,
+ sectionPageCount: 4,
+ },
+ ],
+ };
+
+ const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx);
+ const updatedBlock = result.updatedBlocks.get('para-1') as ParagraphBlock;
+
+ expect(result.affectedBlockIds.has('para-1')).toBe(true);
+ expect(updatedBlock.runs[1].text).toBe('IV');
+ 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', () => {
@@ -643,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 0b3c01e2e3..7cdf92825a 100644
--- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts
+++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts
@@ -1,7 +1,7 @@
/**
* Page Number Token Resolution Module
*
- * Resolves dynamic page number tokens (pageNumber, totalPageCount) in layout fragments.
+ * Resolves dynamic page number tokens (pageNumber, totalPageCount, sectionPageCount) in layout fragments.
* This module follows the same pattern as resolvePageRefs.ts for PAGEREF token resolution.
*
* Tokens are created during PM-to-FlowBlock conversion with placeholder text ('0'),
@@ -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.
@@ -118,6 +126,7 @@ export function resolvePageNumberTokens(
continue;
}
+ const sectionPageCount = displayPageInfo.sectionPageCount || numberingCtx.totalPages || 1;
for (const fragment of page.fragments) {
// Paragraph fragments — original behaviour.
if (fragment.kind === 'para') {
@@ -140,7 +149,12 @@ export function resolvePageNumberTokens(
displayPageInfo,
totalPagesStr,
numberingCtx.totalPages,
+ sectionPageCount,
);
+ if (!clonedBlock) {
+ processedBlocks.add(blockId);
+ continue;
+ }
updatedBlocks.set(blockId, clonedBlock);
affectedBlockIds.add(blockId);
processedBlocks.add(blockId);
@@ -174,11 +188,14 @@ export function resolvePageNumberTokens(
* Checks if a paragraph block contains any page number tokens.
*
* @param block - Paragraph block to check
- * @returns True if block contains pageNumber or totalPageCount tokens
+ * @returns True if block contains pageNumber, totalPageCount, or sectionPageCount tokens
*/
function hasPageTokens(block: ParagraphBlock): boolean {
for (const run of block.runs) {
- if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
+ if (
+ 'token' in run &&
+ (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount')
+ ) {
return true;
}
}
@@ -189,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
@@ -201,28 +219,51 @@ function cloneBlockWithResolvedTokens(
displayPageInfo: DisplayPageInfo,
totalPagesStr: string,
totalPages: number,
-): ParagraphBlock {
+ sectionPageCount: number,
+): 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 resolvedText = run.pageNumberFieldFormat
+ ? formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat)
+ : String(sectionPageCount);
+ changed ||= run.text !== resolvedText;
+ return {
+ ...run,
+ text: resolvedText,
};
}
}
@@ -232,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 07559ec863..6330c94b62 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/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 499be5f8f6..2e0bcf3468 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -6868,6 +6868,84 @@ describe('DomPainter', () => {
expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 2');
});
+ it('renders formatted PAGE fields in drawing text', () => {
+ const vectorShapeBlock: FlowBlock = {
+ kind: 'drawing',
+ id: 'drawing-formatted-page-field',
+ drawingKind: 'vectorShape',
+ geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false },
+ shapeKind: 'rect',
+ textContent: {
+ parts: [
+ { text: 'Page ', formatting: { fontFamily: 'Arial', fontSize: 18 } },
+ {
+ text: '',
+ fieldType: 'PAGE',
+ pageNumberFormat: 'upperRoman',
+ formatting: { fontFamily: 'Arial', fontSize: 18 },
+ },
+ ],
+ },
+ textAlign: 'center',
+ };
+
+ const vectorShapeMeasure: Measure = {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ width: 100,
+ height: 50,
+ scale: 1,
+ naturalWidth: 100,
+ naturalHeight: 50,
+ geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false },
+ };
+
+ const painter = createTestPainter({ blocks: [vectorShapeBlock], measures: [vectorShapeMeasure] });
+ painter.paint(
+ {
+ pageSize: layout.pageSize,
+ pages: [
+ {
+ number: 7,
+ displayNumber: 5,
+ numberText: '5',
+ fragments: [
+ {
+ kind: 'drawing',
+ drawingKind: 'vectorShape',
+ blockId: 'drawing-formatted-page-field',
+ x: 30,
+ y: 40,
+ width: 100,
+ height: 50,
+ geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false },
+ scale: 1,
+ },
+ ],
+ },
+ ],
+ },
+ mount,
+ );
+
+ expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page V');
+ });
+
+ it('preserves cached SECTIONPAGES drawing text when section context is unavailable', () => {
+ const painter = new DomPainter();
+ const resolvePartText = (
+ painter as unknown as {
+ resolveShapeTextPartText: (
+ part: { text: string; fieldType: string; pageNumberFormat?: string },
+ context: { pageNumber: number; totalPages: number; section: 'body' },
+ ) => string;
+ }
+ ).resolveShapeTextPartText.bind(painter);
+
+ expect(
+ resolvePartText({ text: '3', fieldType: 'SECTIONPAGES' }, { pageNumber: 1, totalPages: 9, section: 'body' }),
+ ).toBe('3');
+ });
describe('resolved paragraph rendering', () => {
it('renders resolved paragraph lines with precomputed indent styles', () => {
const paragraphBlock: FlowBlock = {
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 3765f26e77..85ad767c1a 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,
@@ -44,6 +46,8 @@ import {
LAYOUT_BOUNDARY_SCHEMA,
buildLayoutSourceIdentityForFragment,
expandRunsForInlineNewlines,
+ formatPageNumber,
+ formatSectionPageNumberText,
getCellSpacingPx,
normalizeColumnLayout,
} from '@superdoc/contracts';
@@ -260,15 +264,24 @@ type PageDomState = {
};
function pageContextSignature(context: FragmentRenderContext): string {
- return [context.pageNumber, context.totalPages, context.pageNumberText ?? '', context.displayPageNumber ?? ''].join(
- '|',
- );
+ return [
+ context.pageNumber,
+ context.totalPages,
+ context.sectionPageCount ?? '',
+ context.pageNumberText ?? '',
+ context.displayPageNumber ?? '',
+ context.pageNumberFormat ?? '',
+ context.pageNumberChapterText ?? '',
+ context.pageNumberChapterSeparator ?? '',
+ ].join('|');
}
function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean {
return (
Array.isArray(textContent?.parts) &&
- textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES')
+ textContent.parts.some(
+ (part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES' || part.fieldType === 'SECTIONPAGES',
+ )
);
}
@@ -288,7 +301,10 @@ function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean {
if (!block) return false;
if (block.kind === 'paragraph') {
for (const run of (block as ParagraphBlock).runs) {
- if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
+ if (
+ 'token' in run &&
+ (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount')
+ ) {
return true;
}
}
@@ -346,6 +362,7 @@ function needsRebuildForPageContext(
* @property {'body'|'header'|'footer'} section - Document section being rendered
* @property {string} [pageNumberText] - Optional formatted page number text (e.g., "Page 1 of 10")
* @property {number} [displayPageNumber] - Section-aware numeric page value before formatting
+ * @property {number} [sectionPageCount] - Physical page count in the current section
*/
export type FragmentRenderContext = {
pageNumber: number;
@@ -354,9 +371,22 @@ export type FragmentRenderContext = {
story?: LayoutStoryLocator;
pageNumberText?: string;
displayPageNumber?: number;
+ pageNumberFormat?: PageNumberFormat;
+ pageNumberChapterText?: string;
+ pageNumberChapterSeparator?: PageNumberChapterSeparator;
+ sectionPageCount?: number;
pageIndex?: number;
};
+function buildSectionPageCounts(pages: ResolvedPage[]): Map {
+ const counts = new Map();
+ for (const page of pages) {
+ const sectionIndex = page.sectionIndex ?? 0;
+ counts.set(sectionIndex, (counts.get(sectionIndex) ?? 0) + 1);
+ }
+ return counts;
+}
+
export type PaintSnapshotLineStyle = {
paddingLeftPx?: number;
paddingRightPx?: number;
@@ -855,6 +885,7 @@ export class DomPainter {
private headerProvider?: PageDecorationProvider;
private footerProvider?: PageDecorationProvider;
private totalPages = 0;
+ private sectionPageCounts = new Map();
private linkIdCounter = 0; // Counter for generating unique link IDs
private sdtLabelsRendered = new Set(); // Tracks SDT labels rendered across pages
@@ -1281,6 +1312,7 @@ export class DomPainter {
this.beginPaintSnapshot(resolvedLayout);
this.totalPages = resolvedLayout.pages.length;
+ this.sectionPageCounts = buildSectionPageCounts(resolvedLayout.pages);
const previousLayout = this.currentLayout;
this.currentLayout = resolvedLayout;
if (this.isSemanticFlow) {
@@ -1796,6 +1828,10 @@ 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,
};
@@ -2151,6 +2187,10 @@ 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,
};
@@ -2293,6 +2333,10 @@ export class DomPainter {
this.mountedPageIndices = [];
}
+ private getSectionPageCount(page: ResolvedPage): number {
+ return this.sectionPageCounts.get(page.sectionIndex ?? 0) ?? this.totalPages ?? 1;
+ }
+
private fullRender(layout: ResolvedLayout): void {
if (!this.mount || !this.doc) return;
this.mount.innerHTML = '';
@@ -2355,6 +2399,10 @@ 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,
};
@@ -2518,6 +2566,10 @@ 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,
};
@@ -3090,11 +3142,26 @@ export class DomPainter {
private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string {
if (part.fieldType === 'PAGE') {
+ 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);
}
if (part.fieldType === 'NUMPAGES') {
return String(context?.totalPages ?? 1);
}
+ if (part.fieldType === 'SECTIONPAGES') {
+ if (context?.sectionPageCount == null) return part.text ?? '1';
+ const sectionPageCount = context.sectionPageCount;
+ return part.pageNumberFormat
+ ? formatPageNumber(sectionPageCount, part.pageNumberFormat)
+ : String(sectionPageCount);
+ }
return part.text;
}
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 0647645ef8..b67dca92d5 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,49 @@ 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 };
+
+ expect(resolveRunText(run, { ...context, sectionPageCount: 7 })).toBe('7');
+ });
+
+ it('preserves cached SECTIONPAGES text when section page count context is missing', () => {
+ const run: TextRun = { text: '42', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 12 };
+
+ expect(resolveRunText(run, context)).toBe('42');
+ });
+
+ it('formats SECTIONPAGES tokens with run-local page number format', () => {
+ const run: TextRun = {
+ text: '0',
+ token: 'sectionPageCount',
+ pageNumberFieldFormat: { format: 'upperRoman' },
+ fontFamily: 'Arial',
+ fontSize: 12,
+ };
+
+ expect(resolveRunText(run, { ...context, sectionPageCount: 7 })).toBe('VII');
+ });
it('changes merge signature when pageNumberFieldFormat changes', () => {
const baseRun: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 };
const formattedRun: TextRun = { ...baseRun, pageNumberFieldFormat: { format: 'upperRoman' } };
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 9111bee913..c64800af3c 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';
@@ -164,7 +166,22 @@ 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);
}
@@ -174,6 +191,16 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string
}
return context.totalPages ? String(context.totalPages) : (run.text ?? '');
}
+ if (runToken === 'sectionPageCount') {
+ const sectionPageCount = context.sectionPageCount;
+ if (sectionPageCount == null) {
+ return run.text ?? '';
+ }
+ if (run.pageNumberFieldFormat) {
+ return formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat);
+ }
+ return String(sectionPageCount);
+ }
return run.text ?? '';
};
diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css b/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css
index 7ae846bd60..8322c7388a 100644
--- a/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css
+++ b/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css
@@ -1,12 +1,14 @@
.super-editor .sd-editor-auto-page-number,
-.super-editor .sd-editor-auto-total-pages {
+.super-editor .sd-editor-auto-total-pages,
+.super-editor .sd-editor-auto-section-pages {
transition: all 250ms ease;
border-bottom: 1px solid #9a9a9a;
cursor: not-allowed;
}
.super-editor .sd-editor-auto-page-number:hover,
-.super-editor .sd-editor-auto-total-pages:hover {
+.super-editor .sd-editor-auto-total-pages:hover,
+.super-editor .sd-editor-auto-section-pages:hover {
border-bottom-color: #4f4f4f;
}
@@ -16,7 +18,8 @@
.super-editor .ProseMirror.view-mode {
.sd-editor-auto-page-number,
- .sd-editor-auto-total-pages {
+ .sd-editor-auto-total-pages,
+ .sd-editor-auto-section-pages {
border: none;
}
}
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 98f0581207..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
@@ -187,6 +187,62 @@ describe('layoutPerRIdHeaderFooters', () => {
displayText: 'i',
displayNumber: 1,
totalPages: 10,
+ sectionPageCount: 10,
+ });
+ });
+
+ 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',
});
});
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 a64adc00b0..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,
@@ -20,7 +27,15 @@ export type HeaderFooterPerRidLayoutInput = {
};
type Constraints = HeaderFooterConstraints;
-type PageResolver = (pageNumber: number) => { displayText: string; displayNumber: number; totalPages: number };
+type PageResolver = (pageNumber: number) => {
+ displayText: string;
+ displayNumber: number;
+ totalPages: number;
+ sectionPageCount: number;
+ pageFormat?: PageNumberFormat;
+ chapterNumberText?: string;
+ chapterSeparator?: PageNumberChapterSeparator;
+};
/**
* Layout header/footer blocks per rId, respecting per-section margins.
@@ -47,15 +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 d5959c3dd8..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
@@ -11,6 +11,7 @@ type MockEditorEmitter = {
type MockSectionEditor = MockEditorEmitter & {
destroy: ReturnType;
+ setOptions: ReturnType;
view: {
dom: HTMLDivElement;
focus: ReturnType;
@@ -58,6 +59,9 @@ const { mockCreateHeaderFooterEditor, mockOnHeaderFooterDataUpdate, mockToFlowBl
once: emitter.once,
emit: emitter.emit,
destroy: vi.fn(),
+ setOptions: vi.fn((options: Record) => {
+ Object.assign(editorStub.options, options);
+ }),
view: {
dom: document.createElement('div'),
focus: vi.fn(),
@@ -215,6 +219,22 @@ describe('HeaderFooterEditorManager', () => {
expect(host.children).toHaveLength(1);
});
+ it('does not synthesize section page count when creating a header/footer editor without section context', () => {
+ 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, totalPageCount: 9 });
+
+ expect(sectionEditor).toBeDefined();
+ expect(mockCreateHeaderFooterEditor).toHaveBeenCalledTimes(1);
+ expect(mockCreateHeaderFooterEditor.mock.calls[0][0]).toMatchObject({
+ totalPageCount: 9,
+ sectionPageCount: undefined,
+ });
+ });
+
it('ensureEditorSync reattaches the cached editor container to a new host', () => {
const editor = createMockEditor();
const manager = new HeaderFooterEditorManager(editor);
@@ -233,6 +253,125 @@ describe('HeaderFooterEditorManager', () => {
expect(secondHost.children).toHaveLength(1);
});
+ it('preserves section page count DOM text when refreshed without section context', () => {
+ 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 sectionPages = document.createElement('span');
+ sectionPages.dataset.id = 'auto-section-pages';
+ sectionPages.textContent = '3';
+ sectionEditor!.view.dom.appendChild(sectionPages);
+
+ manager.ensureEditorSync(descriptor, { editorHost: host, totalPageCount: 9 });
+
+ expect(sectionPages.textContent).toBe('3');
+ });
+
+ it('refreshes section page count DOM text when section context is available', () => {
+ 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 sectionPages = document.createElement('span');
+ sectionPages.dataset.id = 'auto-section-pages';
+ sectionPages.textContent = '3';
+ sectionEditor!.view.dom.appendChild(sectionPages);
+
+ manager.ensureEditorSync(descriptor, { editorHost: host, sectionPageCount: 5 });
+
+ expect(sectionPages.textContent).toBe('5');
+ });
+
+ it('refreshes section page count 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 sectionPages = document.createElement('span');
+ sectionPages.dataset.id = 'auto-section-pages';
+ sectionPages.textContent = '3';
+ sectionEditor!.view.dom.appendChild(sectionPages);
+ (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, sectionPageCount: 4 });
+
+ 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 edfb9ad782..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 type { FlowBlock, 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';
@@ -263,7 +270,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
* @param options.availableWidth - The width of the editing region in pixels. Must be a positive number if provided.
* @param options.availableHeight - The height of the editing region in pixels. Must be a positive number if provided.
* @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
*
* @throws Never throws - errors are logged and emitted as events. Invalid parameters return null with error logged.
@@ -275,7 +287,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
availableWidth?: number;
availableHeight?: number;
currentPageNumber?: number;
+ currentPageNumberText?: string;
+ currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
+ sectionPageCount?: number;
},
): Promise {
if (!descriptor?.id) return null;
@@ -352,6 +369,21 @@ export class HeaderFooterEditorManager extends EventEmitter {
return null;
}
}
+
+ if (options.sectionPageCount !== undefined) {
+ if (
+ typeof options.sectionPageCount !== 'number' ||
+ !Number.isInteger(options.sectionPageCount) ||
+ options.sectionPageCount < 1
+ ) {
+ console.error('[HeaderFooterEditorManager] sectionPageCount must be a positive integer');
+ this.emit('error', {
+ descriptor,
+ error: new TypeError('sectionPageCount must be a positive integer'),
+ });
+ return null;
+ }
+ }
}
const existing = this.#editorEntries.get(descriptor.id);
@@ -425,7 +457,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
availableWidth?: number;
availableHeight?: number;
currentPageNumber?: number;
+ currentPageNumberText?: string;
+ currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
+ sectionPageCount?: number;
},
): Editor | null {
if (!descriptor?.id) return null;
@@ -459,18 +496,56 @@ export class HeaderFooterEditorManager extends EventEmitter {
const opts = editor.options as Record;
const parentEditor = opts.parentEditor as Record | undefined;
- const currentPage = String(opts.currentPageNumber || '1');
+ 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;
const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]');
const totalPagesEls = container.querySelectorAll('[data-id="auto-total-pages"]');
+ const sectionPagesEls = container.querySelectorAll('[data-id="auto-section-pages"]');
pageNumberEls.forEach((el) => {
- if (el.textContent !== currentPage) el.textContent = currentPage;
+ const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el);
+ const text = pageNumberFormat
+ ? formatSectionPageNumberText({
+ displayNumber: currentPageNumber,
+ pageFormat: pageNumberFormat,
+ chapterNumberText,
+ chapterSeparator,
+ })
+ : currentPage;
+ if (el.textContent !== text) el.textContent = text;
});
totalPagesEls.forEach((el) => {
if (el.textContent !== totalPages) el.textContent = totalPages;
});
+ sectionPagesEls.forEach((el) => {
+ if (sectionPages == null) return;
+ const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el);
+ const sectionPageCount = Number(sectionPages) || 1;
+ const text = pageNumberFormat ? formatPageNumber(sectionPageCount, pageNumberFormat) : String(sectionPageCount);
+ if (el.textContent !== text) el.textContent = text;
+ });
+ }
+
+ #getPageNumberFormatForDomNode(editor: Editor, el: Element): PageNumberFormat | null {
+ try {
+ 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;
+ } catch {
+ return null;
+ }
}
/**
@@ -730,7 +805,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
availableWidth?: number;
availableHeight?: number;
currentPageNumber?: number;
+ currentPageNumberText?: string;
+ currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
+ sectionPageCount?: number;
},
): HeaderFooterEditorEntry | null {
const json = this.getDocumentJson(descriptor);
@@ -750,7 +830,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
availableWidth: options?.availableWidth,
availableHeight: options?.availableHeight ?? DEFAULT_HEADER_FOOTER_HEIGHT,
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;
} catch (error) {
console.error('[HeaderFooterEditorManager] Editor creation failed:', error);
@@ -866,7 +951,12 @@ export class HeaderFooterEditorManager extends EventEmitter {
availableWidth?: number;
availableHeight?: number;
currentPageNumber?: number;
+ currentPageNumberText?: string;
+ currentPageDisplayNumber?: number;
+ currentPageChapterNumberText?: string;
+ currentPageChapterSeparator?: PageNumberChapterSeparator;
totalPageCount?: number;
+ sectionPageCount?: number;
},
): void {
if (entry.container && options?.editorHost && entry.container.parentElement !== options.editorHost) {
@@ -881,9 +971,28 @@ export class HeaderFooterEditorManager extends EventEmitter {
if (options.currentPageNumber !== undefined) {
updateOptions.currentPageNumber = options.currentPageNumber;
}
+ if (options.currentPageNumberText !== undefined) {
+ updateOptions.currentPageNumberText = options.currentPageNumberText;
+ }
+ 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;
}
+ if (options.sectionPageCount !== undefined) {
+ updateOptions.sectionPageCount = options.sectionPageCount;
+ }
if (options.availableWidth !== undefined) {
updateOptions.availableWidth = options.availableWidth;
}
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 561cd6c02d..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
@@ -38,6 +38,18 @@ export type HeaderFooterRegion = {
/** Section-aware display page number (e.g. "7" when physical page is 10 due to section numbering) */
displayPageNumber?: string;
+ /** 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;
+
/** X coordinate relative to page */
localX: 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 b1666090f5..82bc22cff8 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 { numberingDefinesMarkerFontFamily } from '../numbering-marker-font.js';
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/constants.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts
index 91e46f4f01..e33ff3240a 100644
--- a/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts
+++ b/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts
@@ -86,7 +86,7 @@ export const DEFAULT_HYPERLINK_CONFIG: HyperlinkConfig = {
* Node types with atom: true:
* - image: Inline images
* - hardBreak, lineBreak: Line breaks
- * - page-number, total-page-number: Document tokens
+ * - page-number, total-page-number, section-page-count: Document tokens
* - indexEntry: Index entry field markers (see index-entry.js)
* - tab: Tab stops (see tab.js)
* - noBreakHyphen: Non-breaking hyphen (U+2011 from ; see no-break-hyphen.js)
@@ -102,6 +102,7 @@ export const ATOMIC_INLINE_TYPES = new Set([
'lineBreak',
'page-number',
'total-page-number',
+ 'section-page-count',
'indexEntry',
'tab',
'noBreakHyphen',
@@ -119,4 +120,5 @@ export const ATOMIC_INLINE_TYPES = new Set([
export const TOKEN_INLINE_TYPES = new Map([
['page-number', 'pageNumber'],
['total-page-number', 'totalPageCount'],
+ ['section-page-count', 'sectionPageCount'],
]);
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 42d37fd806..5262968a43 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
@@ -5826,7 +5826,12 @@ export class PresentationEditor extends EventEmitter {
availableWidth: editorContext.availableWidth,
availableHeight: editorContext.availableHeight,
currentPageNumber: editorContext.currentPageNumber,
+ currentPageNumberText: editorContext.currentPageNumberText,
+ currentPageDisplayNumber: editorContext.currentPageDisplayNumber,
+ currentPageChapterNumberText: editorContext.currentPageChapterNumberText,
+ currentPageChapterSeparator: editorContext.currentPageChapterSeparator,
totalPageCount: editorContext.totalPageCount,
+ sectionPageCount: editorContext.sectionPageCount,
}) ?? null)
: null;
@@ -5857,7 +5862,12 @@ export class PresentationEditor extends EventEmitter {
headless: false,
element: hostElement,
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 1b53997e9d..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
@@ -806,11 +806,13 @@ export class HeaderFooterSessionManager {
// Build section first page numbers map
const sectionFirstPageNumbers = new Map();
+ const sectionPageCounts = new Map();
for (const p of resolvedLayout.pages) {
const idx = p.sectionIndex ?? 0;
if (!sectionFirstPageNumbers.has(idx)) {
sectionFirstPageNumbers.set(idx, p.number);
}
+ sectionPageCounts.set(idx, (sectionPageCounts.get(idx) ?? 0) + 1);
}
// Resolve section projections to map sectionIndex → sectionId
@@ -823,11 +825,15 @@ export class HeaderFooterSessionManager {
const actualPageHeight = page.height ?? fallbackPageHeight;
const sectionIndex = page.sectionIndex ?? 0;
const sectionId = sectionIdBySectionIndex.get(sectionIndex) ?? `section-${sectionIndex}`;
+ const sectionPageCount = sectionPageCounts.get(sectionIndex) ?? resolvedLayout.pages.length ?? 1;
// Header region
const headerPayload = this.#headerDecorationProvider?.(page.number, margins, page);
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',
@@ -839,6 +845,10 @@ export class HeaderFooterSessionManager {
pageIndex,
pageNumber: page.number,
displayPageNumber,
+ displayPageNumberValue,
+ displayPageChapterNumberText,
+ displayPageChapterSeparator,
+ sectionPageCount,
localX: headerPayload?.hitRegion?.x ?? headerBox.x,
localY: headerPayload?.hitRegion?.y ?? headerBox.offset,
width: headerPayload?.hitRegion?.width ?? headerBox.width,
@@ -859,6 +869,10 @@ export class HeaderFooterSessionManager {
pageIndex,
pageNumber: page.number,
displayPageNumber,
+ displayPageNumberValue,
+ displayPageChapterNumberText,
+ displayPageChapterSeparator,
+ sectionPageCount,
localX: footerPayload?.hitRegion?.x ?? footerBox.x,
localY: footerPayload?.hitRegion?.y ?? footerBox.offset,
width: footerPayload?.hitRegion?.width ?? footerBox.width,
@@ -1091,7 +1105,12 @@ export class HeaderFooterSessionManager {
availableWidth: Math.max(1, region.width),
availableHeight: Math.max(1, region.height),
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 37f7ad0ab6..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
@@ -118,7 +118,12 @@ export interface ActivateStorySessionOptions {
availableWidth?: number;
availableHeight?: number;
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/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
index 5cef005381..076002919f 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
@@ -403,6 +403,8 @@ describe('HeaderFooterSessionManager', () => {
sectionIndex: 0,
pageIndex: 0,
pageNumber: 1,
+ displayPageNumber: 'i',
+ displayPageNumberValue: 1,
localX: 36,
localY: 24,
width: 480,
@@ -430,6 +432,8 @@ describe('HeaderFooterSessionManager', () => {
availableWidth: 480,
availableHeight: 72,
currentPageNumber: 1,
+ currentPageNumberText: 'i',
+ currentPageDisplayNumber: 1,
totalPageCount: 3,
surfaceKind: 'header',
}),
@@ -1967,5 +1971,29 @@ describe('HeaderFooterSessionManager', () => {
expect(manager.headerRegions.get(2)!.sectionType).toBe('even');
expect(manager.footerRegions.get(2)!.sectionType).toBe('even');
});
+
+ it('propagates section-aware page display values onto built regions', () => {
+ manager = buildManager();
+ const layout: ResolvedLayout = {
+ version: 1,
+ flowMode: 'paginated',
+ pageGap: 0,
+ pages: [
+ makePage({
+ number: 7,
+ height: 792,
+ numberText: 'iii',
+ displayNumber: 3,
+ }),
+ ],
+ };
+
+ manager.rebuildRegions(layout);
+
+ expect(manager.headerRegions.get(0)!.displayPageNumber).toBe('iii');
+ expect(manager.headerRegions.get(0)!.displayPageNumberValue).toBe(3);
+ expect(manager.footerRegions.get(0)!.displayPageNumber).toBe('iii');
+ expect(manager.footerRegions.get(0)!.displayPageNumberValue).toBe(3);
+ });
});
});
diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
index dc102ef0cd..4f5c3c4716 100644
--- a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
+++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts
@@ -119,6 +119,44 @@ describe('createStoryEditor', () => {
expect(note.options.telemetry).toEqual({ enabled: false });
});
+ it('does not synthesize sectionPageCount when the caller lacks section context', () => {
+ const parent = trackEditor(
+ initTestEditor({
+ mode: 'text',
+ content: 'parent
',
+ }).editor as Editor,
+ );
+
+ const child = trackEditor(
+ createStoryEditor(
+ parent,
+ { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h/f' }] }] },
+ { documentId: 'hf:part:rId1', isHeaderOrFooter: true, headless: true },
+ ),
+ );
+
+ expect(child.options.sectionPageCount).toBeUndefined();
+ });
+
+ it('preserves explicit sectionPageCount when provided by the caller', () => {
+ const parent = trackEditor(
+ initTestEditor({
+ mode: 'text',
+ content: 'parent
',
+ }).editor as Editor,
+ );
+
+ const child = trackEditor(
+ createStoryEditor(
+ parent,
+ { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h/f' }] }] },
+ { documentId: 'hf:part:rId1', isHeaderOrFooter: true, headless: true, sectionPageCount: 4 },
+ ),
+ );
+
+ expect(child.options.sectionPageCount).toBe(4);
+ });
+
it('keeps telemetry disabled even when a caller passes telemetry overrides', () => {
const parent = trackEditor(
initTestEditor({
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 afb6b173f0..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
@@ -34,6 +34,26 @@ export interface StoryEditorOptions {
*/
currentPageNumber?: number;
+ /**
+ * The current formatted PAGE field display text for section-aware story editors.
+ */
+ currentPageNumberText?: string;
+
+ /**
+ * The current numeric PAGE display value for field-local formatting.
+ */
+ 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.
@@ -41,6 +61,13 @@ export interface StoryEditorOptions {
*/
totalPageCount?: number;
+ /**
+ * The current section's physical page count for SECTIONPAGES field resolution.
+ * Must be a positive integer.
+ * @default 1
+ */
+ sectionPageCount?: number;
+
/**
* The container element to mount the editor into.
* Required for non-headless mode; ignored when headless.
@@ -114,7 +141,12 @@ export function createStoryEditor(
isHeaderOrFooter = true,
headless,
currentPageNumber = 1,
+ currentPageNumberText,
+ currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount = 1,
+ sectionPageCount,
element = null,
editorOptions = {},
} = options;
@@ -155,7 +187,12 @@ export function createStoryEditor(
pagination: false,
annotations: true,
currentPageNumber,
+ currentPageNumberText,
+ currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
+ sectionPageCount,
editable: false,
documentMode: 'viewing',
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js
index 4ab1e89a7c..2fc64e5311 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js
@@ -35,6 +35,7 @@ import { translator as sdIndexTranslator } from '@converter/v3/handlers/sd/index
import { translator as sdIndexEntryTranslator } from '@converter/v3/handlers/sd/indexEntry';
import { translator as sdAutoPageNumberTranslator } from '@converter/v3/handlers/sd/autoPageNumber';
import { translator as sdTotalPageNumberTranslator } from '@converter/v3/handlers/sd/totalPageNumber';
+import { translator as sdSectionPageCountTranslator } from '@converter/v3/handlers/sd/sectionPageCount';
import { translator as sdDocumentStatFieldTranslator } from '@converter/v3/handlers/sd/documentStatField/documentStatField-translator.js';
import { translator as pictTranslator } from './v3/handlers/w/pict/pict-translator';
import { translateVectorShape, translateShapeGroup } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers';
@@ -242,6 +243,7 @@ export function exportSchemaToJson(params) {
documentSection: wSdtNodeTranslator,
'page-number': sdAutoPageNumberTranslator,
'total-page-number': sdTotalPageNumberTranslator,
+ 'section-page-count': sdSectionPageCountTranslator,
pageReference: sdPageReferenceTranslator,
crossReference: sdCrossReferenceTranslator,
citation: sdCitationTranslator,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js
index f1b5ff97fb..f0b46891d0 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js
@@ -1,5 +1,6 @@
import { preProcessPageInstruction } from './page-preprocessor.js';
import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js';
+import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js';
import { preProcessPageRefInstruction } from './page-ref-preprocessor.js';
import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js';
import { preProcessTocInstruction } from './toc-preprocessor.js';
@@ -47,6 +48,8 @@ export const getInstructionPreProcessor = (instruction) => {
return preProcessPageInstruction;
case 'NUMPAGES':
return preProcessNumPagesInstruction;
+ case 'SECTIONPAGES':
+ return preProcessSectionPagesInstruction;
case 'NUMWORDS':
case 'NUMCHARS':
return preProcessDocumentStatInstruction;
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js
index 227be4e206..c1c2dfd3b8 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js
@@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';
import { getInstructionPreProcessor } from './index.js';
import { preProcessPageInstruction } from './page-preprocessor.js';
import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js';
+import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js';
import { preProcessPageRefInstruction } from './page-ref-preprocessor.js';
import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js';
import { preProcessTocInstruction } from './toc-preprocessor.js';
@@ -50,6 +51,13 @@ describe('getInstructionPreProcessor', () => {
},
);
+ it.each(['sectionpages', 'SectionPages', 'SECTIONPAGES \\* roman'])(
+ 'should return preProcessSectionPagesInstruction for case-insensitive SECTIONPAGES instruction %s',
+ (instruction) => {
+ const processor = getInstructionPreProcessor(instruction);
+ expect(processor).toBe(preProcessSectionPagesInstruction);
+ },
+ );
it('should return preProcessPageRefInstruction for PAGEREF instruction', () => {
const instruction = 'PAGEREF _Toc123456789 h';
const processor = getInstructionPreProcessor(instruction);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js
index b2e6789979..80be6e1517 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js
@@ -9,17 +9,18 @@ const PAGE_VALUE_FORMAT_SWITCHES = {
};
/**
- * Parses the supported PAGE value-format switches from an OOXML field instruction.
+ * Parses the supported PAGE/SECTIONPAGES value-format switches from an OOXML field instruction.
* Field dispatch is case-insensitive; value-format switches preserve ECMA casing.
*
* @param {string} instruction
+ * @param {string} [expectedKeyword='PAGE']
* @returns {{ instruction: string, pageNumberFormat?: string }}
*/
-export function parsePageInstruction(instruction) {
+export function parsePageInstruction(instruction, expectedKeyword = 'PAGE') {
const rawInstruction = String(instruction ?? '').trim();
const tokens = rawInstruction.match(/"[^"]*"|'[^']*'|\\\*|\\[^\s]+|[^\s]+/g) ?? [];
const keyword = tokens[0]?.toUpperCase();
- if (keyword !== 'PAGE') {
+ if (keyword !== expectedKeyword.toUpperCase()) {
return { instruction: rawInstruction };
}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js
new file mode 100644
index 0000000000..adbeee7d83
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js
@@ -0,0 +1,72 @@
+import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js';
+
+/**
+ * Processes a SECTIONPAGES instruction and creates a `sd:sectionPageCount` node.
+ *
+ * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end.
+ * @param {string} [instrText] The SECTIONPAGES instruction text.
+ * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null } | import('../../v2/types/index.js').OpenXmlNode | null} [options] Generic field preprocessing options, or legacy positional w:rPr.
+ * @param {Array<{type: string, text?: string}> | null} [_instructionTokens] Raw instruction tokens.
+ * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes.
+ * @returns {import('../../v2/types/index.js').OpenXmlNode[]}
+ */
+export function preProcessSectionPagesInstruction(
+ nodesToCombine,
+ instrText = '',
+ options = null,
+ _instructionTokens,
+ fieldRunRPr = null,
+) {
+ const effectiveFieldRunRPr = fieldRunRPr ?? options?.fieldRunRPr ?? (options?.name === 'w:rPr' ? options : null);
+ const normalizedInstruction =
+ typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'SECTIONPAGES';
+ const parsedInstruction = parsePageNumberFieldSwitches(normalizedInstruction, 'SECTIONPAGES');
+ const sectionPageCountNode = {
+ name: 'sd:sectionPageCount',
+ type: 'element',
+ attributes: {
+ instruction: normalizedInstruction,
+ ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}),
+ ...(parsedInstruction.pageNumberZeroPadding != null
+ ? { pageNumberZeroPadding: parsedInstruction.pageNumberZeroPadding }
+ : {}),
+ },
+ };
+
+ const cachedText = extractCachedText(nodesToCombine);
+ if (cachedText) {
+ sectionPageCountNode.attributes.importedCachedText = cachedText;
+ }
+
+ let foundContentRPr = false;
+ nodesToCombine.forEach((n) => {
+ const rPrNode = n.elements?.find((el) => el.name === 'w:rPr');
+ if (rPrNode) {
+ sectionPageCountNode.elements = [rPrNode];
+ foundContentRPr = true;
+ }
+ });
+
+ if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') {
+ sectionPageCountNode.elements = [effectiveFieldRunRPr];
+ }
+
+ return [sectionPageCountNode];
+}
+
+/**
+ * Extracts cached display text from content runs (between separate and end).
+ * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodes
+ * @returns {string}
+ */
+function extractCachedText(nodes) {
+ const texts = [];
+ for (const node of nodes) {
+ const textEl = node.elements?.find((el) => el.name === 'w:t');
+ if (textEl) {
+ const text = textEl.elements?.[0]?.text ?? '';
+ if (text) texts.push(text);
+ }
+ }
+ return texts.join('');
+}
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js
new file mode 100644
index 0000000000..610f319444
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js
@@ -0,0 +1,55 @@
+// @ts-check
+import { describe, it, expect } from 'vitest';
+import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js';
+
+describe('preProcessSectionPagesInstruction', () => {
+ it.each([
+ ['SECTIONPAGES', undefined, undefined],
+ ['sectionpages', undefined, undefined],
+ ['SectionPages', undefined, undefined],
+ ['SECTIONPAGES \\* roman', 'lowerRoman', undefined],
+ ['SECTIONPAGES \\* Roman \\* MERGEFORMAT', 'upperRoman', undefined],
+ ['SECTIONPAGES \\# "000"', 'decimal', 3],
+ ['SECTIONPAGES \\* Unsupported \\* MERGEFORMAT', undefined, undefined],
+ ])(
+ 'creates sd:sectionPageCount and parses supported value format: %s',
+ (instruction, pageNumberFormat, pageNumberZeroPadding) => {
+ const result = preProcessSectionPagesInstruction([], instruction, null);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ name: 'sd:sectionPageCount',
+ type: 'element',
+ attributes: {
+ instruction: instruction.trim().replace(/\s+/g, ' '),
+ ...(pageNumberFormat ? { pageNumberFormat } : {}),
+ ...(pageNumberZeroPadding != null ? { pageNumberZeroPadding } : {}),
+ },
+ });
+ },
+ );
+
+ it('preserves cached text and content run styling', () => {
+ const rPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] };
+ const result = preProcessSectionPagesInstruction(
+ [
+ {
+ name: 'w:r',
+ elements: [rPr, { name: 'w:t', elements: [{ type: 'text', text: '4' }] }],
+ },
+ ],
+ 'SECTIONPAGES',
+ null,
+ );
+
+ expect(result[0].attributes.importedCachedText).toBe('4');
+ expect(result[0].elements).toEqual([rPr]);
+ });
+
+ it('uses fieldRunRPr when cached content has no run properties', () => {
+ const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] };
+ const result = preProcessSectionPagesInstruction([], 'SECTIONPAGES', undefined, null, fieldRunRPr);
+
+ expect(result[0].elements).toEqual([fieldRunRPr]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js
index 88ee37cf9f..b4c29007fc 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js
@@ -86,6 +86,29 @@ describe('preProcessNodesForFldChar', () => {
},
);
+ it('preserves SECTIONPAGES field run properties when cached result has no run properties', () => {
+ const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] };
+ const { processedNodes } = preProcessNodesForFldChar(
+ [
+ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] },
+ {
+ name: 'w:r',
+ elements: [fieldRunRPr, { name: 'w:instrText', elements: [{ type: 'text', text: 'SECTIONPAGES' }] }],
+ },
+ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] },
+ { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '4' }] }] },
+ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] },
+ ],
+ mockDocx,
+ );
+
+ expect(processedNodes).toHaveLength(1);
+ expect(processedNodes[0]).toMatchObject({
+ name: 'sd:sectionPageCount',
+ attributes: { importedCachedText: '4' },
+ elements: [fieldRunRPr],
+ });
+ });
it('should process non-page field instructions case-insensitively', () => {
const docx = {
'word/_rels/document.xml.rels': {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js
index 08c7117bf7..2208479796 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js
@@ -3,6 +3,7 @@
*/
import { preProcessPageInstruction } from './fld-preprocessors/page-preprocessor.js';
import { preProcessNumPagesInstruction } from './fld-preprocessors/num-pages-preprocessor.js';
+import { preProcessSectionPagesInstruction } from './fld-preprocessors/section-pages-preprocessor.js';
import { preProcessDocumentStatInstruction } from './fld-preprocessors/document-stat-preprocessor.js';
import { extractFieldKeyword } from './field-keyword.js';
@@ -11,7 +12,7 @@ const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']);
const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name);
/**
- * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering.
+ * Pre-processes nodes to convert PAGE, NUMPAGES, and SECTIONPAGES field codes for header/footer rendering.
*
* NOTE: This function is used exclusively when constructing a standalone header/footer
* editor for on-screen display/editing. It is NOT part of the DOCX import pipeline.
@@ -20,6 +21,7 @@ const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has
* This function specifically handles:
* - PAGE fields → sd:autoPageNumber (displays current page number)
* - NUMPAGES fields → sd:totalPageNumber (displays total page count)
+ * - SECTIONPAGES fields → sd:sectionPageCount (displays current section page count)
* - Unhandled fldSimple fields (FILENAME, DOCPROPERTY, etc.) → unwrapped to their
* cached display text (the value Word rendered when the document was last saved),
* so the header renders meaningful content rather than an empty box.
@@ -231,6 +233,8 @@ function getHeaderFooterFieldPreprocessor(fieldType) {
return preProcessPageInstruction;
case 'NUMPAGES':
return preProcessNumPagesInstruction;
+ case 'SECTIONPAGES':
+ return preProcessSectionPagesInstruction;
case 'NUMWORDS':
case 'NUMCHARS':
return preProcessDocumentStatInstruction;
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js
index 63fd22720a..3e1f00541b 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js
@@ -146,6 +146,52 @@ describe('preProcessPageFieldsOnly', () => {
expect(result.processedNodes[0].name).toBe('sd:totalPageNumber');
},
);
+
+ it.each([' sectionpages ', ' SectionPages ', ' SECTIONPAGES \\* roman '])(
+ 'should process SECTIONPAGES field case-insensitively with fldChar syntax: %s',
+ (instruction) => {
+ const result = preProcessPageFieldsOnly(complexFieldNodes(instruction, '4'));
+
+ expect(result.processedNodes).toHaveLength(1);
+ expect(result.processedNodes[0].name).toBe('sd:sectionPageCount');
+ expect(result.processedNodes[0].attributes.importedCachedText).toBe('4');
+ },
+ );
+
+ it('should preserve SECTIONPAGES field sequence styling when cached result has no rPr', () => {
+ const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] };
+ const nodes = [
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }],
+ },
+ {
+ name: 'w:r',
+ elements: [fieldRunRPr, { name: 'w:instrText', elements: [{ type: 'text', text: ' SECTIONPAGES ' }] }],
+ },
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }],
+ },
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:t', elements: [{ type: 'text', text: '4' }] }],
+ },
+ {
+ name: 'w:r',
+ elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }],
+ },
+ ];
+
+ const result = preProcessPageFieldsOnly(nodes);
+
+ expect(result.processedNodes).toHaveLength(1);
+ expect(result.processedNodes[0]).toMatchObject({
+ name: 'sd:sectionPageCount',
+ attributes: { importedCachedText: '4' },
+ elements: [fieldRunRPr],
+ });
+ });
});
describe('simple field syntax (w:fldSimple)', () => {
@@ -238,6 +284,34 @@ describe('preProcessPageFieldsOnly', () => {
},
);
+ it('should process SECTIONPAGES field with fldSimple syntax and preserve parsed format', () => {
+ const instruction = ' SECTIONPAGES \\* roman \\* MERGEFORMAT ';
+ const nodes = [
+ {
+ name: 'w:fldSimple',
+ attributes: { 'w:instr': instruction },
+ elements: [
+ {
+ name: 'w:r',
+ elements: [
+ { name: 'w:rPr', elements: [{ name: 'w:noProof' }] },
+ { name: 'w:t', elements: [{ type: 'text', text: 'iv' }] },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = preProcessPageFieldsOnly(nodes);
+
+ expect(result.processedNodes).toHaveLength(1);
+ expect(result.processedNodes[0].name).toBe('sd:sectionPageCount');
+ expect(result.processedNodes[0].attributes).toMatchObject({
+ instruction: instruction.trim().replace(/\s+/g, ' '),
+ pageNumberFormat: 'lowerRoman',
+ importedCachedText: 'iv',
+ });
+ });
it('should preserve rPr styling from fldSimple content', () => {
const nodes = [
{
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js
index 969ea83a9b..c006d15e9c 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js
@@ -17,7 +17,7 @@ const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([
/**
* @param {string} instruction
- * @param {'PAGE' | 'NUMPAGES'} fieldType
+ * @param {'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'} fieldType
* @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }}
*/
export function parsePageNumberFieldSwitches(instruction, fieldType) {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js
index 3c4a11b96f..287723b79f 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js
@@ -32,6 +32,14 @@ describe('parsePageNumberFieldSwitches', () => {
expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected);
});
+ it('parses SECTIONPAGES zero-padding picture switches', () => {
+ expect(parsePageNumberFieldSwitches('SECTIONPAGES \\# "000"', 'SECTIONPAGES')).toEqual({
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ });
+ });
+
it('preserves unsupported switched instructions without format metadata', () => {
expect(parsePageNumberFieldSwitches('PAGE \\* OrdText', 'PAGE')).toEqual({ instruction: 'PAGE \\* OrdText' });
});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js
index 017eef54e4..ea3aa1bd9b 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js
@@ -1,6 +1,7 @@
import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils';
import { translator as autoPageNumberTranslator } from '../../v3/handlers/sd/autoPageNumber/index.js';
import { translator as totalPageNumberTranslator } from '../../v3/handlers/sd/totalPageNumber/index.js';
+import { translator as sectionPageCountTranslator } from '../../v3/handlers/sd/sectionPageCount/index.js';
/**
* @type {import("docxImporter").NodeHandlerEntry}
@@ -11,3 +12,8 @@ export const autoPageHandlerEntity = generateV2HandlerEntity('autoPageNumberHand
* @type {import("docxImporter").NodeHandlerEntry}
*/
export const autoTotalPageCountEntity = generateV2HandlerEntity('autoTotalPageCountEntity', totalPageNumberTranslator);
+
+/**
+ * @type {import("docxImporter").NodeHandlerEntry}
+ */
+export const sectionPageCountEntity = generateV2HandlerEntity('sectionPageCountEntity', sectionPageCountTranslator);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
index db841207a4..afba65010a 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js
@@ -14,7 +14,7 @@ import { lineBreakNodeHandlerEntity } from './lineBreakImporter.js';
import { bookmarkStartNodeHandlerEntity } from './bookmarkStartImporter.js';
import { bookmarkEndNodeHandlerEntity } from './bookmarkEndImporter.js';
import { alternateChoiceHandler } from './alternateChoiceImporter.js';
-import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js';
+import { autoPageHandlerEntity, autoTotalPageCountEntity, sectionPageCountEntity } from './autoPageNumberImporter.js';
import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js';
import { pageReferenceEntity } from './pageReferenceImporter.js';
import { crossReferenceEntity } from './crossReferenceImporter.js';
@@ -372,6 +372,7 @@ export const defaultNodeListHandler = () => {
indexEntryHandlerEntity,
autoPageHandlerEntity,
autoTotalPageCountEntity,
+ sectionPageCountEntity,
documentStatFieldHandlerEntity,
pageReferenceEntity,
crossReferenceEntity,
@@ -951,6 +952,7 @@ export function filterOutRootInlineNodes(content = []) {
'hardBreak',
'pageNumber',
'totalPageCount',
+ 'section-page-count',
'runItem',
'image',
'tab',
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
index d5f8d93449..8fd53598ce 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js
@@ -108,6 +108,7 @@ describe('filterOutRootInlineNodes', () => {
n('table'),
n('pageNumber'),
n('totalPageCount'),
+ n('section-page-count'),
n('runItem'),
n('image'),
n('tab'),
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js
index c674101d3c..3006f4b187 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js
@@ -29,6 +29,7 @@ const INLINE_FALLBACK_TYPES = new Set([
'passthroughInline',
'page-number',
'total-page-number',
+ 'section-page-count',
'pageReference',
'crossReference',
'citation',
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
index 544e950224..a0c721ac78 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js
@@ -13,6 +13,7 @@ import { translator as sd_authorityEntry_translator } from './sd/authorityEntry/
import { translator as sd_tableOfAuthorities_translator } from './sd/tableOfAuthorities/tableOfAuthorities-translator.js';
import { translator as sd_autoPageNumber_translator } from './sd/autoPageNumber/autoPageNumber-translator.js';
import { translator as sd_totalPageNumber_translator } from './sd/totalPageNumber/totalPageNumber-translator.js';
+import { translator as sd_sectionPageCount_translator } from './sd/sectionPageCount/sectionPageCount-translator.js';
import { translator as sd_documentStatField_translator } from './sd/documentStatField/documentStatField-translator.js';
import { translator as w_abstractNum_translator } from './w/abstractNum/abstractNum-translator.js';
import { translator as w_abstractNumId_translator } from './w/abstractNumId/abstractNumId-translator.js';
@@ -226,6 +227,7 @@ const translatorList = Array.from(
sd_tableOfAuthorities_translator,
sd_autoPageNumber_translator,
sd_totalPageNumber_translator,
+ sd_sectionPageCount_translator,
sd_documentStatField_translator,
w_abstractNum_translator,
w_abstractNumId_translator,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js
new file mode 100644
index 0000000000..433f64c730
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js
@@ -0,0 +1 @@
+export * from './sectionPageCount-translator.js';
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js
new file mode 100644
index 0000000000..756e0a28e7
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js
@@ -0,0 +1,123 @@
+// @ts-check
+import { NodeTranslator } from '@translator';
+import { processOutputMarks } from '../../../../exporter.js';
+import { parseMarks } from './../../../../v2/importer/markImporter.js';
+import { buildComplexFieldRuns } from '../build-complex-field-runs.js';
+import { pageNumberFormatToInstructionSwitch } from '../../../../field-references/fld-preprocessors/page-instruction.js';
+
+/** @type {import('@translator').XmlNodeName} */
+const XML_NODE_NAME = 'sd:sectionPageCount';
+
+/** @type {import('@translator').SuperDocNodeOrKeyName} */
+const SD_NODE_NAME = 'section-page-count';
+
+/**
+ * Encode a node as a SuperDoc section-page-count node.
+ * @param {import('@translator').SCEncoderConfig} [params]
+ * @returns {import('@translator').SCEncoderResult}
+ */
+const encode = (params) => {
+ const { nodes = [] } = params || {};
+ const node = nodes[0];
+
+ const rPr = node.elements?.find((el) => el.name === 'w:rPr');
+ const marks = parseMarks(rPr || { elements: [] });
+ const processedNode = {
+ type: 'section-page-count',
+ attrs: {
+ marksAsAttrs: marks,
+ },
+ };
+
+ if (typeof node.attributes?.instruction === 'string') {
+ processedNode.attrs.instruction = node.attributes.instruction;
+ }
+ if (typeof node.attributes?.pageNumberFormat === 'string') {
+ processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat;
+ }
+ if (node.attributes?.pageNumberZeroPadding != null) {
+ processedNode.attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding);
+ }
+ if (node.attributes?.importedCachedText) {
+ processedNode.attrs.importedCachedText = node.attributes.importedCachedText;
+ }
+
+ return processedNode;
+};
+
+/**
+ * Decode the section-page-count node back into OOXML structure.
+ * @param {import('@translator').SCDecoderConfig} params
+ * @returns {import('@translator').SCDecoderResult[]}
+ */
+const decode = (params) => {
+ const { node } = params;
+
+ const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []);
+ const instruction = getSectionPagesInstructionText(node.attrs);
+ const cachedText = resolveCachedSectionPageCount(node);
+
+ return buildComplexFieldRuns({ instruction, cachedText, outputMarks, dirty: true });
+};
+
+/**
+ * @param {Record | undefined} attrs
+ * @returns {string}
+ */
+function getSectionPagesInstructionText(attrs = {}) {
+ if (typeof attrs.instruction === 'string' && attrs.instruction.trim()) {
+ return attrs.instruction.trim();
+ }
+
+ if (typeof attrs.pageNumberFormat === 'string') {
+ const instructionSwitch = pageNumberFormatToInstructionSwitch(attrs.pageNumberFormat);
+ if (instructionSwitch) {
+ const numericPicture =
+ typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0
+ ? ` \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}`
+ : '';
+ return `SECTIONPAGES \\* ${instructionSwitch}${numericPicture}`;
+ }
+ }
+
+ if (typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0) {
+ return `SECTIONPAGES \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}`;
+ }
+
+ return 'SECTIONPAGES';
+}
+
+/**
+ * Priority: resolvedText, importedCachedText, then node text content.
+ * @param {{ attrs?: Record, content?: Array<{ type?: string, text?: string }> }} node
+ */
+function resolveCachedSectionPageCount(node) {
+ if (node.attrs?.resolvedText) {
+ return String(node.attrs.resolvedText);
+ }
+
+ if (node.attrs?.importedCachedText) {
+ return String(node.attrs.importedCachedText);
+ }
+
+ const textContent = node.content
+ ?.filter((n) => n.type === 'text')
+ .map((n) => n.text || '')
+ .join('');
+ return textContent || '';
+}
+
+/** @type {import('@translator').NodeTranslatorConfig} */
+export const config = {
+ xmlName: XML_NODE_NAME,
+ sdNodeOrKeyName: SD_NODE_NAME,
+ type: NodeTranslator.translatorTypes.NODE,
+ encode,
+ decode,
+};
+
+/**
+ * The NodeTranslator instance.
+ * @type {import('@translator').NodeTranslator}
+ */
+export const translator = NodeTranslator.from(config);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js
new file mode 100644
index 0000000000..b2861ceaf2
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js
@@ -0,0 +1,121 @@
+// @ts-check
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { config, translator } from './sectionPageCount-translator.js';
+import { NodeTranslator } from '../../../node-translator/node-translator.js';
+import { processOutputMarks } from '../../../../exporter.js';
+import { parseMarks } from './../../../../v2/importer/markImporter.js';
+
+vi.mock('../../../../exporter.js', () => ({
+ processOutputMarks: vi.fn(() => []),
+}));
+
+vi.mock('./../../../../v2/importer/markImporter.js', () => ({
+ parseMarks: vi.fn(() => []),
+}));
+
+describe('sd:sectionPageCount translator', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('exposes correct config meta', () => {
+ expect(config.xmlName).toBe('sd:sectionPageCount');
+ expect(config.sdNodeOrKeyName).toBe('section-page-count');
+ expect(config.type).toBe(NodeTranslator.translatorTypes.NODE);
+ expect(translator).toBeInstanceOf(NodeTranslator);
+ });
+
+ it('encodes sd:sectionPageCount with marks, instruction, cached text, and page-number formatting attrs', () => {
+ const marks = [{ type: 'textStyle', attrs: { fontSize: '12pt' } }];
+ vi.mocked(parseMarks).mockReturnValue(marks);
+
+ const result = config.encode({
+ nodes: [
+ {
+ name: 'sd:sectionPageCount',
+ attributes: {
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ importedCachedText: 'iv',
+ },
+ elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }],
+ },
+ ],
+ });
+
+ expect(result).toEqual({
+ type: 'section-page-count',
+ attrs: {
+ marksAsAttrs: marks,
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ importedCachedText: 'iv',
+ },
+ });
+ });
+
+ it('preserves imported instruction and marks SECTIONPAGES dirty on export', () => {
+ vi.mocked(processOutputMarks).mockReturnValue([{ name: 'w:b' }]);
+
+ const result = config.decode({
+ node: {
+ type: 'section-page-count',
+ attrs: {
+ marksAsAttrs: [{ type: 'bold' }],
+ instruction: 'SECTIONPAGES \\* Roman \\* MERGEFORMAT',
+ importedCachedText: 'IV',
+ },
+ },
+ });
+
+ expect(result[0].elements[1].attributes).toEqual({ 'w:fldCharType': 'begin', 'w:dirty': 'true' });
+ expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* Roman \\* MERGEFORMAT');
+ expect(result[3].elements[1].elements[0].text).toBe('IV');
+ });
+
+ it('synthesizes SECTIONPAGES switch when only pageNumberFormat is present', () => {
+ const result = config.decode({
+ node: {
+ type: 'section-page-count',
+ attrs: {
+ pageNumberFormat: 'lowerRoman',
+ resolvedText: 'iii',
+ },
+ },
+ });
+
+ expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* roman');
+ expect(result[3].elements[1].elements[0].text).toBe('iii');
+ });
+
+ it('synthesizes SECTIONPAGES numeric picture switches when only zero-padding attrs are present', () => {
+ const result = config.decode({
+ node: {
+ type: 'section-page-count',
+ attrs: {
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ resolvedText: '007',
+ },
+ },
+ });
+
+ expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* Arabic \\# 000');
+ expect(result[3].elements[1].elements[0].text).toBe('007');
+ });
+
+ it('falls back to plain SECTIONPAGES without instruction or supported format', () => {
+ const result = config.decode({
+ node: {
+ type: 'section-page-count',
+ attrs: {},
+ content: [{ type: 'text', text: '2' }],
+ },
+ });
+
+ expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES');
+ expect(result[3].elements[1].elements[0].text).toBe('2');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js
index 1079a5b816..2c702b0910 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js
@@ -998,7 +998,8 @@ const handleChartDrawing = (params, node, graphicData, size, padding, marginOffs
* parts: Array<{
* text: string,
* formatting?: { bold?: boolean, italic?: boolean, color?: string, fontSize?: number, fontFamily?: string },
- * fieldType?: 'PAGE' | 'NUMPAGES',
+ * fieldType?: 'PAGE' | 'NUMPAGES' | 'SECTIONPAGES',
+ * pageNumberFormat?: string,
* isLineBreak?: boolean,
* isEmptyParagraph?: boolean
* }>,
@@ -1017,15 +1018,24 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) {
let horizontalAlign = null;
/**
- * Appends a field part (PAGE or NUMPAGES) to textParts with formatting.
- * @param {'PAGE' | 'NUMPAGES'} fieldType - The field type
+ * Appends a field part (PAGE, NUMPAGES, or SECTIONPAGES) to textParts with formatting.
+ * @param {'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'} fieldType - The field type
* @param {Object} node - The field node element
* @param {Object} paragraphProperties - Resolved paragraph properties
*/
const appendFieldPart = (fieldType, node, paragraphProperties) => {
const rPr = node?.elements?.find((el) => el.name === 'w:rPr');
const formatting = extractRunFormatting(rPr, paragraphProperties, params);
- textParts.push({ text: '', formatting, fieldType });
+ const cachedText =
+ fieldType === 'SECTIONPAGES'
+ ? (node?.attributes?.resolvedText ?? node?.attributes?.importedCachedText ?? '')
+ : '';
+ textParts.push({
+ text: cachedText,
+ formatting,
+ fieldType,
+ ...(node?.attributes?.pageNumberFormat ? { pageNumberFormat: node.attributes.pageNumberFormat } : {}),
+ });
};
/**
@@ -1061,6 +1071,9 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) {
} else if (el.name === 'sd:totalPageNumber') {
hasText = true;
appendFieldPart('NUMPAGES', el, paragraphProperties);
+ } else if (el.name === 'sd:sectionPageCount') {
+ hasText = true;
+ appendFieldPart('SECTIONPAGES', el, paragraphProperties);
} else if (el.name === 'w:drawing') {
// SD-2804 / ECMA-376 §20.4.2.38: a textbox can hold body-level
// content, including runs with inline w:drawing images. Defer to
@@ -1117,6 +1130,10 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) {
appendFieldPart('NUMPAGES', el, paragraphProperties);
return true;
}
+ if (el.name === 'sd:sectionPageCount') {
+ appendFieldPart('SECTIONPAGES', el, paragraphProperties);
+ return true;
+ }
if ((el.name === 'w:hyperlink' || el.name === 'sd:pageReference') && Array.isArray(el.elements)) {
let hasText = false;
el.elements.forEach((child) => {
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js
index d704216d24..3ef9df4015 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js
@@ -1760,6 +1760,33 @@ describe('getVectorShape', () => {
expect(result.attrs.textContent.parts[0].text).toBe('Hello World');
});
+ it('preserves cached SECTIONPAGES text in textbox field parts', () => {
+ const graphicData = makeGraphicDataWithTextbox('');
+ const paragraph = graphicData.elements[0].elements.find((el) => el.name === 'wps:txbx').elements[0].elements[0];
+ paragraph.elements = [
+ {
+ name: 'sd:sectionPageCount',
+ attributes: {
+ importedCachedText: '3',
+ resolvedText: '4',
+ },
+ },
+ ];
+
+ const result = getVectorShape({
+ params: makeParams(),
+ node: {},
+ graphicData,
+ size: { width: 100, height: 100 },
+ });
+
+ expect(result.attrs.textContent.parts).toHaveLength(1);
+ expect(result.attrs.textContent.parts[0]).toMatchObject({
+ text: '4',
+ fieldType: 'SECTIONPAGES',
+ });
+ });
+
it('handles [[sdspace]] at the beginning of text', () => {
const graphicData = makeGraphicDataWithTextbox('[[sdspace]]Hello');
const result = getVectorShape({
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 c7dc9194d0..7aec710c37 100644
--- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
+++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
@@ -474,6 +474,27 @@ export interface EditorOptions {
/** OOXML relationship id for this header/footer part (e.g. `rId7`). */
headerFooterRefId?: string;
+ /** Current page number for PAGE field rendering in story editors */
+ currentPageNumber?: number;
+
+ /** Current formatted PAGE display text for story editors */
+ currentPageNumberText?: string;
+
+ /** 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;
+
+ /** Current section page count for SECTIONPAGES field rendering in story editors */
+ sectionPageCount?: number;
+
/** Optional pagination metadata */
lastSelection?: unknown | null;
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts
new file mode 100644
index 0000000000..205dc72810
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model';
+import { findAllFields } from './field-resolver.js';
+
+const schema = new Schema({
+ nodes: {
+ doc: { content: 'block+' },
+ paragraph: {
+ group: 'block',
+ content: 'inline*',
+ attrs: { sdBlockId: { default: null } },
+ },
+ text: { group: 'inline' },
+ 'section-page-count': {
+ group: 'inline',
+ inline: true,
+ atom: true,
+ content: 'text*',
+ attrs: {
+ instruction: { default: null },
+ importedCachedText: { default: null },
+ resolvedText: { default: null },
+ },
+ },
+ },
+});
+
+function createDocWithSectionPageCount(attrs: Record, text?: string): ProseMirrorNode {
+ const content = text ? schema.text(text) : undefined;
+ const field = schema.nodes['section-page-count'].create(attrs, content);
+ const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field);
+ return schema.nodes.doc.create(null, paragraph);
+}
+
+describe('field-resolver synthetic section page count fields', () => {
+ it('discovers section-page-count as SECTIONPAGES with imported instruction', () => {
+ const doc = createDocWithSectionPageCount({ instruction: 'SECTIONPAGES \\* roman' }, 'iii');
+
+ expect(findAllFields(doc)).toEqual([
+ {
+ pos: 1,
+ blockId: 'block-1',
+ occurrenceIndex: 0,
+ nestingDepth: 0,
+ instruction: 'SECTIONPAGES \\* roman',
+ fieldType: 'SECTIONPAGES',
+ resolvedText: 'iii',
+ },
+ ]);
+ });
+
+ it('falls back to plain SECTIONPAGES and imported cached text', () => {
+ const doc = createDocWithSectionPageCount({ importedCachedText: '4' });
+
+ expect(findAllFields(doc)).toEqual([
+ {
+ pos: 1,
+ blockId: 'block-1',
+ occurrenceIndex: 0,
+ nestingDepth: 0,
+ instruction: 'SECTIONPAGES',
+ fieldType: 'SECTIONPAGES',
+ resolvedText: '4',
+ },
+ ]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts
index d05c1a8344..60939eb5a4 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts
@@ -52,8 +52,19 @@ const FIELD_NODE_TYPES = new Set([
* Node types that represent fields but derive their instruction synthetically
* rather than from an `instruction` attribute.
*/
-const SYNTHETIC_FIELD_NODE_TYPES: Record = {
+const SYNTHETIC_FIELD_NODE_TYPES: Record<
+ string,
+ { fieldType: string; instruction: string; resolveInstruction?: (node: ProseMirrorNode) => string }
+> = {
'total-page-number': { fieldType: 'NUMPAGES', instruction: 'NUMPAGES' },
+ 'section-page-count': {
+ fieldType: 'SECTIONPAGES',
+ instruction: 'SECTIONPAGES',
+ resolveInstruction: (node) =>
+ typeof node.attrs?.instruction === 'string' && node.attrs.instruction.trim()
+ ? node.attrs.instruction
+ : 'SECTIONPAGES',
+ },
};
export function findAllFields(doc: ProseMirrorNode): ResolvedField[] {
@@ -82,7 +93,7 @@ export function findAllFields(doc: ProseMirrorNode): ResolvedField[] {
blockId,
occurrenceIndex: counter,
nestingDepth: 0,
- instruction: synthetic.instruction,
+ instruction: synthetic.resolveInstruction?.(node) ?? synthetic.instruction,
fieldType: synthetic.fieldType,
resolvedText,
});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts
new file mode 100644
index 0000000000..98c8ee7c9b
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts
@@ -0,0 +1,27 @@
+import { formatPageNumberFieldValue, type PageNumberFieldFormat } from '@superdoc/contracts';
+import type { Editor } from '../../core/Editor.js';
+
+export function resolveSectionPageCountFieldValue(
+ editor: Editor,
+ node: { attrs?: Record },
+): string | null {
+ const sectionPageCount = editor.options?.sectionPageCount;
+ if (sectionPageCount == null) return null;
+
+ const pageNumberFormat =
+ typeof node.attrs?.pageNumberFormat === 'string' && node.attrs.pageNumberFormat
+ ? node.attrs.pageNumberFormat
+ : undefined;
+ const pageNumberZeroPadding =
+ typeof node.attrs?.pageNumberZeroPadding === 'number' && Number.isFinite(node.attrs.pageNumberZeroPadding)
+ ? node.attrs.pageNumberZeroPadding
+ : undefined;
+
+ if (pageNumberFormat || pageNumberZeroPadding != null) {
+ return formatPageNumberFieldValue(Number(sectionPageCount) || 1, {
+ ...(pageNumberFormat ? { format: pageNumberFormat as PageNumberFieldFormat['format'] } : {}),
+ ...(pageNumberZeroPadding != null ? { zeroPadding: pageNumberZeroPadding } : {}),
+ });
+ }
+ return String(sectionPageCount);
+}
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/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts
new file mode 100644
index 0000000000..15c8f01e17
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts
@@ -0,0 +1,181 @@
+import { Schema } from 'prosemirror-model';
+import { EditorState } from 'prosemirror-state';
+import { describe, expect, it } from 'vitest';
+import type { Editor } from '../../core/Editor.js';
+import { registerBuiltInExecutors } from './register-executors.js';
+import { fieldsInsertWrapper, fieldsRebuildWrapper } from './field-wrappers.js';
+
+registerBuiltInExecutors();
+
+const schema = new Schema({
+ nodes: {
+ doc: { content: 'block+' },
+ paragraph: {
+ group: 'block',
+ content: 'inline*',
+ attrs: { sdBlockId: { default: null } },
+ toDOM: () => ['p', 0],
+ },
+ text: { group: 'inline' },
+ sequenceField: {
+ group: 'inline',
+ inline: true,
+ atom: true,
+ attrs: {
+ instruction: { default: null },
+ identifier: { default: null },
+ format: { default: null },
+ resolvedNumber: { default: null },
+ sdBlockId: { default: null },
+ },
+ toDOM: () => ['span', 0],
+ },
+ 'section-page-count': {
+ group: 'inline',
+ inline: true,
+ atom: true,
+ content: 'text*',
+ attrs: {
+ instruction: { default: null },
+ importedCachedText: { default: null },
+ resolvedText: { default: null },
+ pageNumberFormat: { default: null },
+ pageNumberZeroPadding: { default: null },
+ },
+ toDOM: () => ['span', 0],
+ },
+ },
+});
+
+function createEditorWithSectionPageCount(
+ sectionPageCount?: number,
+ initialValue = '1',
+ pageNumberFormat?: string,
+): Editor {
+ const field = schema.nodes['section-page-count'].create(
+ { instruction: 'SECTIONPAGES', resolvedText: initialValue, pageNumberFormat },
+ schema.text(initialValue),
+ );
+ const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field);
+ const doc = schema.nodes.doc.create(null, paragraph);
+ const options = sectionPageCount == null ? {} : { sectionPageCount };
+
+ const editor = {
+ schema,
+ state: EditorState.create({ schema, doc }),
+ options,
+ view: { dispatch: () => {} },
+ dispatch(tr) {
+ this.state = this.state.apply(tr);
+ },
+ };
+
+ return editor as unknown as Editor;
+}
+
+function createEditorForInsert(sectionPageCount?: number): Editor {
+ const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, schema.text('x'));
+ const doc = schema.nodes.doc.create(null, paragraph);
+ const options = sectionPageCount == null ? {} : { sectionPageCount };
+
+ const editor = {
+ schema,
+ state: EditorState.create({ schema, doc }),
+ options,
+ view: { dispatch: () => {} },
+ dispatch(tr) {
+ this.state = this.state.apply(tr);
+ },
+ };
+
+ return editor as unknown as Editor;
+}
+
+describe('fieldsRebuildWrapper SECTIONPAGES fields', () => {
+ it('inserts SECTIONPAGES as a section-page-count node with parsed formatting attrs', () => {
+ const editor = createEditorForInsert(7);
+
+ const result = fieldsInsertWrapper(editor, {
+ mode: 'raw',
+ instruction: 'SECTIONPAGES \\# "000"',
+ at: { kind: 'text', segments: [{ blockId: 'block-1', range: { start: 0, end: 0 } }] },
+ });
+
+ expect(result.success).toBe(true);
+ const insertedField = editor.state.doc.nodeAt(1);
+ expect(insertedField?.type.name).toBe('section-page-count');
+ expect(insertedField?.attrs).toMatchObject({
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ resolvedText: '007',
+ });
+ expect(insertedField?.textContent).toBe('007');
+ });
+
+ it('updates section-page-count text content and resolvedText from editor section page count', () => {
+ const editor = createEditorWithSectionPageCount(4);
+
+ const result = fieldsRebuildWrapper(editor, {
+ target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 },
+ });
+
+ expect(result.success).toBe(true);
+ const updatedField = editor.state.doc.nodeAt(1);
+ expect(updatedField?.type.name).toBe('section-page-count');
+ expect(updatedField?.attrs.resolvedText).toBe('4');
+ expect(updatedField?.textContent).toBe('4');
+ });
+
+ it('formats rebuilt section-page-count values with pageNumberFormat', () => {
+ const editor = createEditorWithSectionPageCount(4, '1', 'upperRoman');
+
+ const result = fieldsRebuildWrapper(editor, {
+ target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 },
+ });
+
+ expect(result.success).toBe(true);
+ const updatedField = editor.state.doc.nodeAt(1);
+ expect(updatedField?.type.name).toBe('section-page-count');
+ expect(updatedField?.attrs.resolvedText).toBe('IV');
+ expect(updatedField?.textContent).toBe('IV');
+ });
+
+ it('formats rebuilt section-page-count values with zero-padding picture switches', () => {
+ const editor = createEditorWithSectionPageCount(4, '1');
+ const field = editor.state.doc.nodeAt(1);
+ const currentAttrs = field?.attrs ?? {};
+ const { tr } = editor.state;
+ tr.setNodeMarkup(1, undefined, {
+ ...currentAttrs,
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ });
+ editor.dispatch(tr);
+
+ const result = fieldsRebuildWrapper(editor, {
+ target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 },
+ });
+
+ expect(result.success).toBe(true);
+ const updatedField = editor.state.doc.nodeAt(1);
+ expect(updatedField?.type.name).toBe('section-page-count');
+ expect(updatedField?.attrs.resolvedText).toBe('004');
+ expect(updatedField?.textContent).toBe('004');
+ });
+
+ it('preserves existing section-page-count text when section page context is unavailable', () => {
+ const editor = createEditorWithSectionPageCount(undefined, '3');
+
+ const result = fieldsRebuildWrapper(editor, {
+ target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 },
+ });
+
+ expect(result.success).toBe(true);
+ const updatedField = editor.state.doc.nodeAt(1);
+ expect(updatedField?.type.name).toBe('section-page-count');
+ expect(updatedField?.attrs.resolvedText).toBe('3');
+ expect(updatedField?.textContent).toBe('3');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts
index 8367870665..4f597a432f 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts
@@ -29,6 +29,8 @@ import { rejectTrackedMode } from '../helpers/mutation-helpers.js';
import { clearIndexCache } from '../helpers/index-cache.js';
import { DocumentApiAdapterError } from '../errors.js';
import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from '../helpers/word-statistics.js';
+import { resolveSectionPageCountFieldValue } from '../helpers/section-page-count.js';
+import { parsePageNumberFieldSwitches } from '../../core/super-converter/field-references/shared/page-number-field-switches.js';
// ---------------------------------------------------------------------------
// Result helpers
@@ -111,6 +113,10 @@ export function fieldsInsertWrapper(
return insertNumPagesField(editor, resolved, options);
}
+ if (fieldType === 'SECTIONPAGES') {
+ return insertSectionPagesField(editor, input, resolved, options);
+ }
+
return insertRawField(editor, input, resolved, options);
}
@@ -192,6 +198,55 @@ function insertNumPagesField(
return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from));
}
+function insertSectionPagesField(
+ editor: Editor,
+ input: FieldInsertInput,
+ resolved: { from: number },
+ options?: MutationOptions,
+): FieldMutationResult {
+ const nodeType = editor.schema.nodes['section-page-count'];
+ if (!nodeType) {
+ throw new DocumentApiAdapterError(
+ 'CAPABILITY_UNAVAILABLE',
+ 'fields.insert: section-page-count node type not in schema.',
+ );
+ }
+
+ const normalizedInstruction = input.instruction.trim().replace(/\s+/g, ' ');
+ const parsedInstruction = parsePageNumberFieldSwitches(normalizedInstruction, 'SECTIONPAGES');
+ const initialAttrs = {
+ instruction: normalizedInstruction,
+ ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}),
+ ...(parsedInstruction.pageNumberZeroPadding != null
+ ? { pageNumberZeroPadding: parsedInstruction.pageNumberZeroPadding }
+ : {}),
+ };
+ const initialValue = resolveSectionPageCountFieldValue(editor, { attrs: initialAttrs }) ?? '';
+
+ const receipt = executeDomainCommand(
+ editor,
+ (): boolean => {
+ const textChild = initialValue ? editor.schema.text(initialValue) : null;
+ const node = nodeType.create(
+ {
+ ...initialAttrs,
+ ...(initialValue ? { resolvedText: initialValue } : {}),
+ },
+ textChild,
+ );
+ const { tr } = editor.state;
+ tr.insert(resolved.from, node);
+ editor.dispatch(tr);
+ clearIndexCache(editor);
+ return true;
+ },
+ { expectedRevision: options?.expectedRevision },
+ );
+
+ if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Insert produced no change.');
+ return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from));
+}
+
function insertRawField(
editor: Editor,
input: FieldInsertInput,
@@ -259,6 +314,10 @@ export function fieldsRebuildWrapper(
return rebuildTotalPageNumber(editor, resolved, address, options);
}
+ if (node.type.name === 'section-page-count') {
+ return rebuildSectionPageCount(editor, resolved, address, options);
+ }
+
// Default: clear resolvedNumber to force re-evaluation (sequence fields, etc.)
const receipt = executeDomainCommand(
editor,
@@ -362,6 +421,43 @@ function rebuildTotalPageNumber(
return fieldSuccess(address);
}
+/**
+ * Rebuilds a section-page-count field by writing the current section page count
+ * into both resolvedText and the node's text content.
+ */
+function rebuildSectionPageCount(
+ editor: Editor,
+ resolved: { pos: number },
+ address: FieldAddress,
+ options?: MutationOptions,
+): FieldMutationResult {
+ const node = editor.state.doc.nodeAt(resolved.pos);
+ if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found.');
+
+ const freshValue = resolveSectionPageCountFieldValue(editor, node);
+ if (freshValue == null) return fieldSuccess(address);
+
+ const receipt = executeDomainCommand(
+ editor,
+ () => {
+ const { tr } = editor.state;
+ const currentNode = tr.doc.nodeAt(resolved.pos);
+ if (!currentNode) return false;
+
+ const textChild = freshValue ? editor.schema.text(freshValue) : null;
+ const newNode = currentNode.type.create({ ...currentNode.attrs, resolvedText: freshValue }, textChild);
+ tr.replaceWith(resolved.pos, resolved.pos + currentNode.nodeSize, newNode);
+ editor.dispatch(tr);
+ clearIndexCache(editor);
+ return true;
+ },
+ { expectedRevision: options?.expectedRevision },
+ );
+
+ if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Rebuild produced no change.');
+ return fieldSuccess(address);
+}
+
export function fieldsRemoveWrapper(
editor: Editor,
input: FieldRemoveInput,
diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js
index 2c05458f75..f2a1882981 100644
--- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js
+++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js
@@ -6,9 +6,10 @@ import {
resolveDocumentStatFieldValue,
resolveMainBodyEditor,
} from '../../document-api-adapters/helpers/word-statistics.js';
+import { resolveSectionPageCountFieldValue } from '../../document-api-adapters/helpers/section-page-count.js';
/** Stat-field types refreshed by F9 when the doc has no TOCs. */
-const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES']);
+const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES', 'SECTIONPAGES']);
/**
* @module FieldUpdate
@@ -99,14 +100,17 @@ export const FieldUpdate = Extension.create({
const sorted = [...updatable].sort((a, b) => b.pos - a.pos);
for (const field of sorted) {
- const freshValue = resolveDocumentStatFieldValue(field.fieldType, stats);
- if (freshValue == null) continue;
-
const node = tr.doc.nodeAt(field.pos);
if (!node) continue;
- if (node.type.name === 'total-page-number') {
- // total-page-number stores its display value as a text child,
+ const freshValue =
+ field.fieldType === 'SECTIONPAGES'
+ ? resolveSectionPageCountFieldValue(editor, node)
+ : resolveDocumentStatFieldValue(field.fieldType, stats);
+ if (freshValue == null) continue;
+
+ if (node.type.name === 'total-page-number' || node.type.name === 'section-page-count') {
+ // Page-count fields store their display value as a text child,
// not just an attr. Replace the entire node so both the text
// content and resolvedText stay in sync.
const textChild = freshValue ? state.schema.text(freshValue) : null;
diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js
index b65bcf32d6..cdda0f7eea 100644
--- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js
@@ -294,6 +294,20 @@ const mixedSchema = new Schema({
},
toDOM: (n) => ['span', n.attrs.resolvedText ?? ''],
},
+ 'section-page-count': {
+ group: 'inline',
+ inline: true,
+ atom: true,
+ content: 'text*',
+ attrs: {
+ instruction: { default: null },
+ importedCachedText: { default: null },
+ resolvedText: { default: null },
+ pageNumberFormat: { default: null },
+ pageNumberZeroPadding: { default: null },
+ },
+ toDOM: () => ['span', 0],
+ },
text: { group: 'inline' },
},
});
@@ -338,6 +352,118 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)',
expect(dispatch).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
});
+
+ it('updates SECTIONPAGES fields from the current header/footer section page count', () => {
+ const para = (children) => mixedSchema.nodes.paragraph.create({}, children);
+ const sectionPageCountField = mixedSchema.nodes['section-page-count'].create(
+ {
+ instruction: 'SECTIONPAGES',
+ resolvedText: '1',
+ },
+ mixedSchema.text('1'),
+ );
+ const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]);
+ const editorState = EditorState.create({ schema: mixedSchema, doc });
+ const editor = {
+ options: { sectionPageCount: 4 },
+ state: editorState,
+ };
+
+ const commands = FieldUpdate.config.addCommands.call({ editor });
+ const command = commands.updateFieldsInSelection();
+ const outerTr = editorState.tr;
+ const dispatch = vi.fn();
+ const state = {
+ doc,
+ selection: { from: 0, to: doc.content.size },
+ schema: mixedSchema,
+ tr: outerTr,
+ };
+
+ const result = command({ editor, state, tr: outerTr, dispatch });
+
+ expect(result).toBe(true);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ const updatedDoc = dispatch.mock.calls[0][0].doc;
+ const updatedField = updatedDoc.nodeAt(1);
+ expect(updatedField.type.name).toBe('section-page-count');
+ expect(updatedField.attrs.resolvedText).toBe('4');
+ expect(updatedField.textContent).toBe('4');
+ });
+
+ it('updates SECTIONPAGES zero-padded fields from the current header/footer section page count', () => {
+ const para = (children) => mixedSchema.nodes.paragraph.create({}, children);
+ const sectionPageCountField = mixedSchema.nodes['section-page-count'].create(
+ {
+ instruction: 'SECTIONPAGES \\# "000"',
+ pageNumberFormat: 'decimal',
+ pageNumberZeroPadding: 3,
+ resolvedText: '001',
+ },
+ mixedSchema.text('001'),
+ );
+ const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]);
+ const editorState = EditorState.create({ schema: mixedSchema, doc });
+ const editor = {
+ options: { sectionPageCount: 4 },
+ state: editorState,
+ };
+
+ const commands = FieldUpdate.config.addCommands.call({ editor });
+ const command = commands.updateFieldsInSelection();
+ const outerTr = editorState.tr;
+ const dispatch = vi.fn();
+ const state = {
+ doc,
+ selection: { from: 0, to: doc.content.size },
+ schema: mixedSchema,
+ tr: outerTr,
+ };
+
+ const result = command({ editor, state, tr: outerTr, dispatch });
+
+ expect(result).toBe(true);
+ const updatedDoc = dispatch.mock.calls[0][0].doc;
+ const updatedField = updatedDoc.nodeAt(1);
+ expect(updatedField.attrs.resolvedText).toBe('004');
+ expect(updatedField.textContent).toBe('004');
+ });
+
+ it('leaves SECTIONPAGES fields unchanged when section page context is unavailable', () => {
+ const para = (children) => mixedSchema.nodes.paragraph.create({}, children);
+ const sectionPageCountField = mixedSchema.nodes['section-page-count'].create(
+ {
+ instruction: 'SECTIONPAGES',
+ resolvedText: '3',
+ },
+ mixedSchema.text('3'),
+ );
+ const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]);
+ const editorState = EditorState.create({ schema: mixedSchema, doc });
+ const editor = {
+ options: {},
+ state: editorState,
+ };
+
+ const commands = FieldUpdate.config.addCommands.call({ editor });
+ const command = commands.updateFieldsInSelection();
+ const outerTr = editorState.tr;
+ const dispatch = vi.fn();
+ const state = {
+ doc,
+ selection: { from: 0, to: doc.content.size },
+ schema: mixedSchema,
+ tr: outerTr,
+ };
+
+ const result = command({ editor, state, tr: outerTr, dispatch });
+
+ expect(result).toBe(false);
+ expect(dispatch).not.toHaveBeenCalled();
+ const unchangedField = editorState.doc.nodeAt(1);
+ expect(unchangedField.attrs.resolvedText).toBe('3');
+ expect(unchangedField.textContent).toBe('3');
+ });
});
describe('FieldUpdate extension shortcuts', () => {
diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js
index a5e16e8548..dcbc408aaa 100644
--- a/packages/super-editor/src/editors/v1/extensions/index.js
+++ b/packages/super-editor/src/editors/v1/extensions/index.js
@@ -41,7 +41,7 @@ import { Image } from './image/index.js';
import { BookmarkStart, BookmarkEnd } from './bookmarks/index.js';
import { SmartTag } from './smart-tag/index.js';
import { Mention } from './mention/index.js';
-import { PageNumber, TotalPageCount } from './page-number/index.js';
+import { PageNumber, TotalPageCount, SectionPageCount } from './page-number/index.js';
import { PageReference } from './page-reference/index.js';
import { ShapeContainer } from './shape-container/index.js';
import { ShapeTextbox } from './shape-textbox/index.js';
@@ -203,6 +203,7 @@ const getStarterExtensions = () => {
AiLoaderNode,
PageNumber,
TotalPageCount,
+ SectionPageCount,
PageReference,
IndexEntry,
TableOfContentsEntry,
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 6b5dc1cb9e..0641287272 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,6 +1,7 @@
import { Node } from '@core/Node.js';
import { Attribute } from '@core/Attribute.js';
import { isHeadless } from '@utils/headless-helpers.js';
+import { formatPageNumberFieldValue, formatSectionPageNumberText } from '@superdoc/contracts';
/**
* Configuration options for PageNumber
* @typedef {Object} PageNumberOptions
@@ -74,7 +75,7 @@ export const PageNumber = Node.create({
},
parseDOM() {
- return [{ tag: 'span[data-id="auto-page-number"' }];
+ return [{ tag: 'span[data-id="auto-page-number"]' }];
},
renderDOM({ htmlAttributes }) {
@@ -215,7 +216,7 @@ export const TotalPageCount = Node.create({
},
parseDOM() {
- return [{ tag: 'span[data-id="auto-total-pages"' }];
+ return [{ tag: 'span[data-id="auto-total-pages"]' }];
},
renderDOM({ htmlAttributes }) {
@@ -263,15 +264,126 @@ export const TotalPageCount = Node.create({
},
});
-const getNodeAttributes = (nodeName, editor) => {
+/**
+ * @module SectionPageCount
+ * @sidebarTitle Section Page Count
+ */
+export const SectionPageCount = Node.create({
+ name: 'section-page-count',
+ group: 'inline',
+ inline: true,
+ atom: true,
+ draggable: false,
+ selectable: false,
+
+ content: 'text*',
+
+ addOptions() {
+ return {
+ htmlAttributes: {
+ contenteditable: false,
+ 'data-id': 'auto-section-pages',
+ 'aria-label': 'Section page count node',
+ class: 'sd-editor-auto-section-pages',
+ },
+ };
+ },
+
+ addAttributes() {
+ return {
+ marksAsAttrs: {
+ default: null,
+ rendered: false,
+ },
+ importedCachedText: {
+ default: null,
+ rendered: false,
+ },
+ resolvedText: {
+ default: null,
+ rendered: false,
+ },
+ instruction: {
+ default: null,
+ rendered: false,
+ },
+ pageNumberFormat: {
+ default: null,
+ rendered: false,
+ },
+ pageNumberZeroPadding: {
+ default: null,
+ rendered: false,
+ },
+ };
+ },
+
+ addNodeView() {
+ return ({ node, editor, getPos, decorations }) => {
+ const htmlAttributes = this.options.htmlAttributes;
+ return new AutoPageNumberNodeView(node, getPos, decorations, editor, htmlAttributes);
+ };
+ },
+
+ parseDOM() {
+ return [{ tag: 'span[data-id="auto-section-pages"]' }];
+ },
+
+ renderDOM({ htmlAttributes }) {
+ return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0];
+ },
+
+ addCommands() {
+ return {
+ addSectionPageCount:
+ () =>
+ ({ tr, dispatch, state, editor }) => {
+ const { options } = editor;
+ if (!options.isHeaderOrFooter) return false;
+
+ const { schema } = state;
+ const sectionPageCountType = schema.nodes?.['section-page-count'];
+ if (!sectionPageCountType) return false;
+
+ const sectionPageCount = editor?.options?.sectionPageCount || 1;
+ const sectionPageCountNode = {
+ type: 'section-page-count',
+ content: [{ type: 'text', text: String(sectionPageCount) }],
+ };
+ const pageNode = schema.nodeFromJSON(sectionPageCountNode);
+ if (dispatch) {
+ tr.replaceSelectionWith(pageNode, false);
+ }
+ return true;
+ },
+ };
+ },
+});
+
+const getNodeAttributes = (nodeName, editor, node = null) => {
switch (nodeName) {
- case 'page-number':
+ 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
+ ? formatSectionPageNumberText({
+ displayNumber: Number(currentPageDisplayNumber) || 1,
+ pageFormat: node.attrs.pageNumberFormat,
+ chapterNumberText,
+ chapterSeparator: editor.options.currentPageChapterSeparator,
+ })
+ : editor.options.currentPageNumberText || currentPageNumber;
return {
- text: editor.options.currentPageNumber || '1',
+ text,
className: 'sd-editor-auto-page-number',
dataId: 'auto-page-number',
ariaLabel: 'Page number node',
};
+ }
case 'total-page-number':
return {
text: editor.options.totalPageCount || editor.options.parentEditor?.currentTotalPages || '1',
@@ -279,6 +391,31 @@ const getNodeAttributes = (nodeName, editor) => {
dataId: 'auto-total-pages',
ariaLabel: 'Total page count node',
};
+ case 'section-page-count': {
+ const sectionPageCount = editor.options.sectionPageCount;
+ const cachedText = node?.attrs?.resolvedText ?? node?.attrs?.importedCachedText ?? node?.textContent ?? '1';
+ const pageNumberFormat =
+ typeof node?.attrs?.pageNumberFormat === 'string' ? node.attrs.pageNumberFormat : undefined;
+ const pageNumberZeroPadding =
+ typeof node?.attrs?.pageNumberZeroPadding === 'number' && Number.isFinite(node.attrs.pageNumberZeroPadding)
+ ? node.attrs.pageNumberZeroPadding
+ : undefined;
+ const text =
+ sectionPageCount != null
+ ? pageNumberFormat || pageNumberZeroPadding != null
+ ? formatPageNumberFieldValue(Number(sectionPageCount) || 1, {
+ ...(pageNumberFormat ? { format: pageNumberFormat } : {}),
+ ...(pageNumberZeroPadding != null ? { zeroPadding: pageNumberZeroPadding } : {}),
+ })
+ : sectionPageCount
+ : cachedText;
+ return {
+ text,
+ className: 'sd-editor-auto-section-pages',
+ dataId: 'auto-section-pages',
+ ariaLabel: 'Section page count node',
+ };
+ }
default:
return {};
}
@@ -296,7 +433,7 @@ export class AutoPageNumberNodeView {
}
#renderDom(node, htmlAttributes) {
- const attrs = getNodeAttributes(this.node.type.name, this.editor);
+ const attrs = getNodeAttributes(this.node.type.name, this.editor, this.node);
const content = document.createTextNode(String(attrs.text));
const nodeContent = document.createElement('span');
@@ -354,7 +491,7 @@ export class AutoPageNumberNodeView {
this.node = node;
// Refresh displayed text when editor options change (e.g. currentPageNumber)
- const attrs = getNodeAttributes(this.node.type.name, this.editor);
+ const attrs = getNodeAttributes(this.node.type.name, this.editor, this.node);
const newText = String(attrs.text);
if (this.dom.textContent !== newText) {
this.dom.textContent = newText;
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 6ad214e92e..bd2ea400ef 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
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { PageNumber, TotalPageCount, AutoPageNumberNodeView } from './page-number.js';
+import { PageNumber, TotalPageCount, SectionPageCount, AutoPageNumberNodeView } from './page-number.js';
describe('PageNumber commands', () => {
it('addAutoPageNumber aborts when not in header/footer', () => {
@@ -69,6 +69,42 @@ describe('PageNumber commands', () => {
false,
);
});
+
+ it('addSectionPageCount inserts section pages only in header/footer contexts', () => {
+ const commands = SectionPageCount.config.addCommands();
+ expect(
+ commands.addSectionPageCount()({
+ editor: { options: { isHeaderOrFooter: false } },
+ state: { schema: {} },
+ }),
+ ).toBe(false);
+
+ const replaceSelectionWith = vi.fn();
+ const schema = {
+ nodes: { 'section-page-count': {} },
+ nodeFromJSON: vi.fn().mockImplementation((json) => json),
+ };
+
+ const result = commands.addSectionPageCount()({
+ editor: { options: { isHeaderOrFooter: true, sectionPageCount: 4 } },
+ tr: { replaceSelectionWith },
+ dispatch: vi.fn(),
+ state: { schema },
+ });
+
+ expect(result).toBe(true);
+ expect(schema.nodeFromJSON).toHaveBeenCalledWith({
+ type: 'section-page-count',
+ content: [{ type: 'text', text: '4' }],
+ });
+ expect(replaceSelectionWith).toHaveBeenCalledWith(
+ {
+ type: 'section-page-count',
+ content: [{ type: 'text', text: '4' }],
+ },
+ false,
+ );
+ });
});
describe('AutoPageNumberNodeView', () => {
@@ -179,6 +215,88 @@ describe('AutoPageNumberNodeView', () => {
expect(nodeView.update({ type: { name: 'total-page-number' } })).toBe(false);
});
+ it('renders page number node with section-aware display text when provided', () => {
+ 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: 'iii' },
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = { type: { name: 'page-number' }, attrs: {} };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('iii');
+ });
+
+ it('formats page number node from section-aware display number when provided', () => {
+ 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', currentPageDisplayNumber: 3 },
+ 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('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 }),
+ nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }),
+ };
+ const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) };
+ const state = { doc, tr };
+ const editor = {
+ options: { currentPageNumber: 4 },
+ 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('IV');
+ });
+
it('renders total page count node with parent editor value', () => {
const doc = {
resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }),
@@ -199,4 +317,85 @@ describe('AutoPageNumberNodeView', () => {
expect(nodeView.dom.className).toBe('sd-editor-auto-total-pages');
expect(nodeView.dom.getAttribute('data-id')).toBe('auto-total-pages');
});
+
+ it('renders formatted section page count node', () => {
+ 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: { sectionPageCount: 4, totalPageCount: 9 },
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = { type: { name: 'section-page-count' }, attrs: { pageNumberFormat: 'upperRoman' } };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('IV');
+ expect(nodeView.dom.className).toBe('sd-editor-auto-section-pages');
+ expect(nodeView.dom.getAttribute('data-id')).toBe('auto-section-pages');
+ });
+
+ it('renders zero-padded section page count node', () => {
+ 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: { sectionPageCount: 4, totalPageCount: 9 },
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = { type: { name: 'section-page-count' }, attrs: { pageNumberZeroPadding: 3 } };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('004');
+ });
+
+ it('renders imported SECTIONPAGES cached text when section page context is unavailable', () => {
+ 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: {},
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = { type: { name: 'section-page-count' }, attrs: { importedCachedText: '3' } };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('3');
+ });
+
+ it('renders resolved SECTIONPAGES text before imported cached text when section context is unavailable', () => {
+ 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: {},
+ state,
+ view: { state, dispatch: vi.fn() },
+ };
+
+ const node = {
+ type: { name: 'section-page-count' },
+ attrs: { resolvedText: '4', importedCachedText: '3' },
+ };
+ const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor);
+
+ expect(nodeView.dom.textContent).toBe('4');
+ });
});
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 29b4217b72..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
@@ -96,7 +96,12 @@ const getSectionHeight = async (editor, data) => {
* @param {number} [params.availableWidth] - The width of the editing region in pixels. Must be positive.
* @param {number} [params.availableHeight] - The height of the editing region in pixels. Must be positive.
* @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
*
* @throws {TypeError} If required parameters are missing or have invalid types
@@ -112,7 +117,12 @@ export const createHeaderFooterEditor = ({
availableWidth,
availableHeight,
currentPageNumber,
+ currentPageNumberText,
+ currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
+ sectionPageCount,
}) => {
// Validate required parameters
if (!editor) {
@@ -160,6 +170,12 @@ export const createHeaderFooterEditor = ({
}
}
+ if (sectionPageCount !== undefined) {
+ if (typeof sectionPageCount !== 'number' || !Number.isInteger(sectionPageCount) || sectionPageCount < 1) {
+ throw new RangeError('sectionPageCount must be a positive integer');
+ }
+ }
+
// --- DOM layout & styling (UI-only concerns) ---
const parentStyles = editor.converter.getDocumentDefaultStyles();
@@ -205,7 +221,12 @@ export const createHeaderFooterEditor = ({
documentId: headerFooterRefId || 'headerFooterRefId',
isHeaderOrFooter: true,
currentPageNumber,
+ currentPageNumberText,
+ currentPageDisplayNumber,
+ currentPageChapterNumberText,
+ currentPageChapterSeparator,
totalPageCount,
+ sectionPageCount,
element: editorContainer,
editorOptions: {
headerFooterRefId,
diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js
index 0b85757946..6cf05348f0 100644
--- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js
+++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js
@@ -282,12 +282,22 @@ export class ShapeGroupView {
// Add text content if present
if (attrs.textContent && attrs.textContent.parts) {
const pageNumber = this.editor?.options?.currentPageNumber;
+ const pageNumberText = this.editor?.options?.currentPageNumberText;
+ const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber;
+ const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText;
+ const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator;
const totalPages = this.editor?.options?.totalPageCount;
+ const sectionPageCount = this.editor?.options?.sectionPageCount;
const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, {
textVerticalAlign: attrs.textVerticalAlign,
textInsets: attrs.textInsets,
pageNumber,
+ pageNumberText,
+ pageNumberDisplayNumber,
+ pageNumberChapterText,
+ pageNumberChapterSeparator,
totalPages,
+ sectionPageCount,
});
if (textGroup) {
g.appendChild(textGroup);
@@ -357,12 +367,22 @@ export class ShapeGroupView {
// Add text content if present
if (attrs.textContent && attrs.textContent.parts) {
const pageNumber = this.editor?.options?.currentPageNumber;
+ const pageNumberText = this.editor?.options?.currentPageNumberText;
+ const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber;
+ const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText;
+ const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator;
const totalPages = this.editor?.options?.totalPageCount;
+ const sectionPageCount = this.editor?.options?.sectionPageCount;
const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, {
textVerticalAlign: attrs.textVerticalAlign,
textInsets: attrs.textInsets,
pageNumber,
+ pageNumberText,
+ pageNumberDisplayNumber,
+ pageNumberChapterText,
+ pageNumberChapterSeparator,
totalPages,
+ sectionPageCount,
});
if (textGroup) {
g.appendChild(textGroup);
@@ -483,12 +503,22 @@ export class ShapeGroupView {
// Add text content if present
if (attrs.textContent && attrs.textContent.parts) {
const pageNumber = this.editor?.options?.currentPageNumber;
+ const pageNumberText = this.editor?.options?.currentPageNumberText;
+ const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber;
+ const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText;
+ const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator;
const totalPages = this.editor?.options?.totalPageCount;
+ const sectionPageCount = this.editor?.options?.sectionPageCount;
const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, {
textVerticalAlign: attrs.textVerticalAlign,
textInsets: attrs.textInsets,
pageNumber,
+ pageNumberText,
+ pageNumberDisplayNumber,
+ pageNumberChapterText,
+ pageNumberChapterSeparator,
totalPages,
+ sectionPageCount,
});
if (textGroup) {
g.appendChild(textGroup);
diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js
index dc8e250654..8ed9faf3a8 100644
--- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js
+++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js
@@ -1,3 +1,5 @@
+import { formatChapterPageNumberText, formatPageNumber } from '@superdoc/contracts';
+
/**
* Shared utility functions for SVG shape rendering
* Used by VectorShapeView and ShapeGroupView
@@ -67,7 +69,8 @@ export function createGradient(gradientData, gradientId) {
* @param {Array