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