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} textContent.parts - Array of text parts with formatting * @param {string} textContent.parts[].text - The text content * @param {Object} [textContent.parts[].formatting] - Formatting options (bold, italic, color, fontSize, fontFamily) - * @param {'PAGE'|'NUMPAGES'} [textContent.parts[].fieldType] - Field type for dynamic content resolution + * @param {'PAGE'|'NUMPAGES'|'SECTIONPAGES'} [textContent.parts[].fieldType] - Field type for dynamic content resolution + * @param {string} [textContent.parts[].pageNumberFormat] - PAGE/SECTIONPAGES field-local value formatting override * @param {boolean} [textContent.parts[].isLineBreak] - Whether this part represents a line break * @param {boolean} [textContent.parts[].isEmptyParagraph] - Whether this line break follows an empty paragraph * @param {string} textAlign - Text alignment ('left', 'center', 'right', 'r') @@ -77,11 +80,26 @@ export function createGradient(gradientData, gradientId) { * @param {{ top: number, right: number, bottom: number, left: number }} [options.textInsets] - Text padding insets in pixels * @param {'top'|'center'|'bottom'} [options.textVerticalAlign] - Vertical alignment of text content * @param {number} [options.pageNumber] - Current page number for PAGE field resolution + * @param {string} [options.pageNumberText] - Current formatted PAGE display text + * @param {number} [options.pageNumberDisplayNumber] - Current numeric PAGE display value for local field formatting + * @param {string} [options.pageNumberChapterText] - Current chapter prefix text for section-aware PAGE display + * @param {string} [options.pageNumberChapterSeparator] - Current chapter separator for section-aware PAGE display * @param {number} [options.totalPages] - Total page count for NUMPAGES field resolution + * @param {number} [options.sectionPageCount] - Current section page count for SECTIONPAGES field resolution * @returns {SVGForeignObjectElement} The created foreignObject element containing the formatted text */ export function createTextElement(textContent, textAlign, width, height, options = {}) { - const { textInsets, textVerticalAlign, pageNumber, totalPages } = options; + const { + textInsets, + textVerticalAlign, + pageNumber, + pageNumberText, + pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, + totalPages, + sectionPageCount, + } = options; // Use foreignObject with HTML for proper text wrapping const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); foreignObject.setAttribute('x', '0'); @@ -130,11 +148,24 @@ export function createTextElement(textContent, textAlign, width, height, options const resolveFieldText = (part) => { if (part.fieldType === 'PAGE') { - return pageNumber != null ? String(pageNumber) : '1'; + const count = pageNumberDisplayNumber ?? pageNumber ?? 1; + if (!part.pageNumberFormat) { + return pageNumberText ?? String(count); + } + return formatChapterPageNumberText({ + pageComponent: formatPageNumber(count, part.pageNumberFormat), + chapterNumberText: pageNumberChapterText, + chapterSeparator: pageNumberChapterSeparator, + }); } if (part.fieldType === 'NUMPAGES') { return totalPages != null ? String(totalPages) : '1'; } + if (part.fieldType === 'SECTIONPAGES') { + if (sectionPageCount == null) return part.text ?? '1'; + const count = sectionPageCount; + return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); + } return part.text; }; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index c144fbd1f6..641cf0ce48 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -134,6 +134,74 @@ describe('svg-utils', () => { expect(span.textContent).toBe('5'); }); + it('should apply pageNumberFormat to PAGE field type', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 5, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('V'); + }); + + it('should resolve PAGE field type to section-aware display text when provided', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3', + pageNumberDisplayNumber: 3, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3'); + }); + + it('should apply pageNumberFormat to section-aware PAGE display number when provided', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3', + pageNumberDisplayNumber: 3, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('III'); + }); + + it('should preserve chapter prefix when applying pageNumberFormat to section-aware PAGE display number', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3\u2011III', + pageNumberDisplayNumber: 3, + pageNumberChapterText: '3', + pageNumberChapterSeparator: 'hyphen', + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3\u2011III'); + }); + + it('should preserve SECTIONPAGES cached text when section page count is unavailable', () => { + const textContent = { + parts: [{ text: '3', fieldType: 'SECTIONPAGES', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + totalPages: 9, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3'); + }); + it('should resolve NUMPAGES field type to totalPages', () => { const textContent = { parts: [{ text: '', fieldType: 'NUMPAGES', formatting: {} }], diff --git a/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts index ad92705465..c2f7b2ac64 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts @@ -150,6 +150,12 @@ export interface MiscellaneousCommands { */ addTotalPageCount: () => boolean; + /** + * Insert section page count at the current position + * @note Only works in header/footer contexts + */ + addSectionPageCount: () => boolean; + // ============================================ // LINKED STYLES COMMANDS // ============================================ diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 6dcf619ecd..632651d83d 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -968,6 +968,20 @@ export interface TotalPageCountAttrs extends InlineNodeAttributes { pageNumberZeroPadding?: number | null; } +/** Section page count node attributes */ +export interface SectionPageCountAttrs extends InlineNodeAttributes { + /** @internal Marks stored as attributes */ + marksAsAttrs?: unknown[] | null; + /** Imported cached field result */ + importedCachedText?: string | null; + /** Cached display value set by an explicit field update */ + resolvedText?: string | null; + /** Imported or synthesized SECTIONPAGES field instruction */ + instruction?: string | null; + /** PAGE/SECTIONPAGES field-local value formatting override */ + pageNumberFormat?: PageNumberFormat | null; +} + // ============================================ // FIELD ANNOTATION // ============================================ @@ -1291,6 +1305,7 @@ declare module '../../core/types/NodeAttributesMap.js' { pageReference: PageReferenceAttrs; 'page-number': PageNumberAttrs; 'total-page-number': TotalPageCountAttrs; + 'section-page-count': SectionPageCountAttrs; // Field annotations fieldAnnotation: FieldAnnotationAttrs; diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 417c98a514..8aa2ad7c6b 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -163,12 +163,22 @@ export class VectorShapeView { // 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 textElement = this.createTextElement(attrs.textContent, attrs.textAlign, attrs.width, attrs.height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, + pageNumberText, + pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, + sectionPageCount, }); if (textElement) { svg.appendChild(textElement);