From 273d531f17c8f2a39af2d859a6af1330aaa5b7fe Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 11:50:26 -0300 Subject: [PATCH 01/63] fix(layout-engine): use section-aware page number for odd/even header parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OOXML (ECMA-376 §17.10.1) selects even/odd headers based on the printed page number — which respects per-section numbering restarts and offsets — not the physical page index. Track the post-restart/offset value as `displayNumber` on each page and thread it through pagination, header/footer resolution, and the HeaderFooterSessionManager so a section that starts at page 2 picks the `even` variant on its first page. --- packages/layout-engine/contracts/src/index.ts | 3 + .../contracts/src/resolved-layout.ts | 2 + .../layout-bridge/src/headerFooterUtils.ts | 33 ++++++--- .../test/headerFooterUtils.test.ts | 46 ++++++++++++ .../layout-engine/src/index.test.ts | 71 +++++++++++++------ .../layout-engine/layout-engine/src/index.ts | 13 ++-- .../src/resolveHeaderFooter.ts | 1 + .../layout-resolved/src/resolveLayout.ts | 1 + .../HeaderFooterSessionManager.ts | 13 +++- 9 files changed, 141 insertions(+), 42 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e12ab7ea53..9f701475b4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -2001,6 +2001,8 @@ export type Page = { * (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; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; @@ -2228,6 +2230,7 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; export type HeaderFooterPage = { number: number; fragments: Fragment[]; + displayNumber?: number; numberText?: string; /** * Optional page-local block clones backing this page's resolved fragments. diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 97e9b6c21d..f5c8f75dfe 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -54,6 +54,8 @@ export type ResolvedPage = { margins?: PageMargins; /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */ footnoteReserved?: number; + /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ + displayNumber?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; /** Vertical alignment of content within this page. */ diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 754eaff259..cec5fe64ab 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -64,11 +64,12 @@ export const extractIdentifierFromConverter = (converter?: ConverterLike | null) export const getHeaderFooterType = ( pageNumber: number, identifier: HeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ): HeaderFooterType | null => { if (pageNumber <= 0) return null; const kind = options?.kind ?? 'header'; + const parityPageNumber = options?.parityPageNumber ?? pageNumber; const ids = kind === 'header' ? identifier.headerIds : identifier.footerIds; const hasFirst = Boolean(ids.first); @@ -83,10 +84,10 @@ export const getHeaderFooterType = ( } if (identifier.alternateHeaders) { - if (pageNumber % 2 === 0 && hasEven) { + if (parityPageNumber % 2 === 0 && hasEven) { return 'even'; } - if (pageNumber % 2 === 1 && (hasOdd || hasDefault)) { + if (parityPageNumber % 2 === 1 && (hasOdd || hasDefault)) { return hasOdd ? 'odd' : 'default'; } return null; @@ -103,10 +104,12 @@ export const resolveHeaderFooterForPage = ( layout: Layout, pageIndex: number, identifier: HeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ) => { - const pageNumber = layout.pages[pageIndex]?.number ?? pageIndex + 1; - const type = getHeaderFooterType(pageNumber, identifier, options); + const layoutPage = layout.pages[pageIndex]; + const pageNumber = layoutPage?.number ?? pageIndex + 1; + const parityPageNumber = options?.parityPageNumber ?? layoutPage?.displayNumber ?? pageNumber; + const type = getHeaderFooterType(pageNumber, identifier, { ...options, parityPageNumber }); if (!type) { return null; } @@ -295,7 +298,7 @@ export function buildMultiSectionIdentifier( * This function determines which header/footer variant (default, first, even, odd) * should be used for a given page number within a specific section. It respects: * - Per-section titlePg (first page of section uses 'first' variant) - * - Alternate headers (even/odd pages based on physical page number) + * - Alternate headers (even/odd pages based on section-aware page numbering) * - Fallback to default variant * * **Important**: When `titlePg` is enabled, this function returns 'first' even if the @@ -307,7 +310,7 @@ export function buildMultiSectionIdentifier( * @param pageNumber - Physical page number (1-indexed) * @param sectionIndex - Index of the section this page belongs to * @param identifier - Multi-section identifier with per-section mappings - * @param options - Optional settings (kind: 'header' | 'footer', sectionPageNumber) + * @param options - Optional settings (kind, sectionPageNumber, parityPageNumber) * @returns HeaderFooterType ('default' | 'first' | 'even' | 'odd') or null if no header/footer content exists * * @example @@ -326,12 +329,13 @@ export function getHeaderFooterTypeForSection( pageNumber: number, sectionIndex: number, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number }, + options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number }, ): HeaderFooterType | null { if (pageNumber <= 0) return null; const kind = options?.kind ?? 'header'; const sectionPageNumber = options?.sectionPageNumber ?? pageNumber; + const parityPageNumber = options?.parityPageNumber ?? pageNumber; // Get section-specific IDs, falling back to legacy IDs for backward compatibility const sectionIds = @@ -381,7 +385,7 @@ export function getHeaderFooterTypeForSection( // Keep parity-based variant selection even when this section doesn't // explicitly define that variant. Resolution/inheritance happens later. if (!hasAny) return null; - return pageNumber % 2 === 0 ? 'even' : 'odd'; + return parityPageNumber % 2 === 0 ? 'even' : 'odd'; } if (hasDefault) { @@ -418,11 +422,13 @@ export function getHeaderFooterIdForPage( const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const sectionPageNumber = options?.sectionPageNumber ?? page.number; + const parityPageNumber = page.displayNumber ?? page.number; // Determine which variant type to use (default, first, even, odd) const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, { kind, sectionPageNumber, + parityPageNumber, }); if (!variantType) return null; @@ -505,9 +511,14 @@ export function resolveHeaderFooterForPageAndSection( } const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; + const parityPageNumber = page.displayNumber ?? pageNumber; // Determine variant type for this section - const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { kind, sectionPageNumber }); + const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { + kind, + sectionPageNumber, + parityPageNumber, + }); if (!type) return null; // Get content ID for this page/section diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index b731147d6a..0a68700949 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -72,6 +72,15 @@ describe('headerFooterUtils', () => { expect(getHeaderFooterType(3, identifier)).toBe('odd'); }); + it('uses display page number parity when provided', () => { + const identifier = extractIdentifierFromConverter({ + headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' }, + pageStyles: { alternateHeaders: true }, + }); + + expect(getHeaderFooterType(1, identifier, { parityPageNumber: 2 })).toBe('even'); + }); + it('uses default only for odd pages when alternating slots are missing', () => { const identifier = extractIdentifierFromConverter({ headerIds: { default: 'rId1' }, @@ -687,6 +696,43 @@ describe('headerFooterUtils', () => { expect(oddPageHeader?.contentId).toBe('h0-default'); }); + it('uses section-aware display page number for odd/even parity', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-odd', even: 'h0-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { + number: 1, + displayNumber: 2, + fragments: [], + sectionIndex: 0, + sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } }, + }, + ], + headerFooter: { + even: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const type = getHeaderFooterTypeForSection(1, 0, identifier, { + kind: 'header', + sectionPageNumber: 1, + parityPageNumber: 2, + }); + const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(type).toBe('even'); + expect(evenPageHeader?.type).toBe('even'); + expect(evenPageHeader?.contentId).toBe('h0-even'); + }); + it('does not use section default content id for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 14c53262a4..32c4be6e8b 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6077,19 +6077,46 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages).toHaveLength(2); - // Page 1 is odd (documentPageNumber=1) → uses 'odd' header height (80px) + // Page 1 has display number 1 (odd) -> uses 'odd' header height (80px) // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+80) = 110 const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); expect(p1Fragment).toBeDefined(); expect(p1Fragment!.y).toBeCloseTo(110, 0); - // Page 2 is even (documentPageNumber=2) → uses 'even' header height (40px) + // Page 2 has display number 2 (even) -> uses 'even' header height (40px) // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+40) = 70 const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); expect(p2Fragment).toBeDefined(); expect(p2Fragment!.y).toBeCloseTo(70, 0); }); + it('uses section page-numbering start for odd/even header parity', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, numbering: { start: 2 } }], + headerContentHeights: { + odd: 80, + even: 40, + }, + }; + + const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].displayNumber).toBe(2); + expect(layout.pages[0].numberText).toBe('2'); + expect(layout.pages[1].displayNumber).toBe(3); + + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); + expect(p1Fragment).toBeDefined(); + expect(p2Fragment).toBeDefined(); + expect(p1Fragment!.y).toBeCloseTo(70, 0); + expect(p2Fragment!.y).toBeCloseTo(110, 0); + }); + it('uses default header height for all pages when alternateHeaders is false', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -6166,29 +6193,29 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(3); - // Page 1 (first page of section, titlePg=true) → 'first' variant → 100px + // Page 1 (first page of section, titlePg=true) -> 'first' variant -> 100px // Body start = max(50, 30+100) = 130 const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); expect(p1Fragment).toBeDefined(); expect(p1Fragment!.y).toBeCloseTo(130, 0); - // Page 2 (documentPageNumber=2, even) → 'even' variant → 40px + // Page 2 has display number 2 (even) -> 'even' variant -> 40px // Body start = max(50, 30+40) = 70 const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); expect(p2Fragment).toBeDefined(); expect(p2Fragment!.y).toBeCloseTo(70, 0); - // Page 3 (documentPageNumber=3, odd) → 'odd' variant → 80px + // Page 3 has display number 3 (odd) -> 'odd' variant -> 80px // Body start = max(50, 30+80) = 110 const p3Fragment = layout.pages[2].fragments.find((f) => f.blockId === 'p3'); expect(p3Fragment).toBeDefined(); expect(p3Fragment!.y).toBeCloseTo(110, 0); }); - it('multi-section: uses document page number for even/odd, not section-relative', () => { + it('multi-section: uses display page number for even/odd, not section-relative', () => { // Section 1 has 3 pages (pages 1-3), section 2 starts on page 4. - // Page 4 is even by document number, but sectionPageNumber=1 (odd). - // The fix ensures document page number is used for even/odd. + // Page 4 has display number 4 (even), but sectionPageNumber=1 (odd). + // The fix ensures the page-numbering value is used for even/odd. const sb1: SectionBreakBlock = { kind: 'sectionBreak', id: 'sb1', @@ -6224,7 +6251,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(4); - // Page 4 (documentPageNumber=4, even) → should use 'even' header (40px) + // Page 4 has display number 4 (even) -> should use 'even' header (40px) // NOT 'odd' which would happen if sectionPageNumber (1) were used // Body start = max(50, 30+40) = 70 const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4'); @@ -6253,8 +6280,8 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages).toHaveLength(2); - // Page 1 is odd → 'odd' footer (80px) → bottom = max(50, 30+80) = 110 - // Page 2 is even → 'even' footer (40px) → bottom = max(50, 30+40) = 70 + // Page 1 has display number 1 (odd) -> 'odd' footer (80px) -> bottom = max(50, 30+80) = 110 + // Page 2 has display number 2 (even) -> 'even' footer (40px) -> bottom = max(50, 30+40) = 70 // Body-top Y is footer-independent, so assert on the effective bottom margin // the paginator stamped on each page. expect(layout.pages[0].margins?.bottom).toBeCloseTo(110, 0); @@ -6308,14 +6335,14 @@ describe('alternateHeaders (odd/even header differentiation)', () => { }); it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => { - // Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2 - // has titlePg=true and starts on docPN=4. - // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true → 'first' - // - Page 5 is docPN=5 (odd) → 'odd' (regardless of section-relative number) - // - Page 6 is docPN=6 (even) → 'even' + // Most realistic mixed case. Section 1 has 3 pages (display numbers 1-3). Section 2 + // has titlePg=true and starts with display number 4. + // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true -> 'first' + // - Page 5 has display number 5 (odd) -> 'odd' (regardless of section-relative number) + // - Page 6 has display number 6 (even) -> 'even' // If the code used sectionPageNumber for even/odd, pages 5 and 6 would be // swapped (section-relative 2 and 3 respectively). This guards both titlePg - // and the docPN rule across a section boundary. + // and the page-numbering parity rule across a section boundary. const sb1: SectionBreakBlock = { kind: 'sectionBreak', id: 'sb1', @@ -6361,19 +6388,19 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(6); - // Page 4: section 2 first page + titlePg → 'first' (100px) → y = max(50, 30+100) = 130 + // Page 4: section 2 first page + titlePg -> 'first' (100px) -> y = max(50, 30+100) = 130 const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4'); expect(p4Fragment).toBeDefined(); expect(p4Fragment!.y).toBeCloseTo(130, 0); - // Page 5: docPN=5, odd → 'odd' (80px) → y = max(50, 30+80) = 110 - // If sectionPageNumber were used: sectionPN=2 → 'even' (40) → y = 70 (wrong) + // Page 5: display number 5, odd -> 'odd' (80px) -> y = max(50, 30+80) = 110 + // If sectionPageNumber were used: sectionPN=2 -> 'even' (40) -> y = 70 (wrong) const p5Fragment = layout.pages[4]?.fragments.find((f) => f.blockId === 'p5'); expect(p5Fragment).toBeDefined(); expect(p5Fragment!.y).toBeCloseTo(110, 0); - // Page 6: docPN=6, even → 'even' (40px) → y = max(50, 30+40) = 70 - // If sectionPageNumber were used: sectionPN=3 → 'odd' (80) → y = 110 (wrong) + // Page 6: display number 6, even -> 'even' (40px) -> y = max(50, 30+40) = 70 + // If sectionPageNumber were used: sectionPN=3 -> 'odd' (80) -> y = 110 (wrong) const p6Fragment = layout.pages[5]?.fragments.find((f) => f.blockId === 'p6'); expect(p6Fragment).toBeDefined(); expect(p6Fragment!.y).toBeCloseTo(70, 0); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 00cb67ea06..89f356b7b3 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -724,14 +724,14 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options * names and types — a positional call site is easy to get wrong. * * @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg - * @param documentPageNumber - The absolute document page number (1-indexed), used for even/odd + * @param parityPageNumber - The section-aware page number used for even/odd * @param titlePgEnabled - Whether the section has "different first page" enabled * @param alternateHeaders - Whether the document has odd/even differentiation enabled * @returns The variant type: 'first', 'even', 'odd', or 'default' */ const getVariantTypeForPage = (args: { sectionPageNumber: number; - documentPageNumber: number; + parityPageNumber: number; titlePgEnabled: boolean; alternateHeaders: boolean; }): 'default' | 'first' | 'even' | 'odd' => { @@ -739,10 +739,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (args.sectionPageNumber === 1 && args.titlePgEnabled) { return 'first'; } - // Alternate headers: even/odd based on document page number, matching - // the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts) + // Alternate headers: even/odd based on the section-aware page number, + // matching ECMA-376 section 17.10.1. if (args.alternateHeaders) { - return args.documentPageNumber % 2 === 0 ? 'even' : 'odd'; + return args.parityPageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; }; @@ -1637,7 +1637,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Determine which header/footer variant applies to this page const variantType = getVariantTypeForPage({ sectionPageNumber, - documentPageNumber: newPageNumber, + parityPageNumber: activePageCounter, titlePgEnabled, alternateHeaders, }); @@ -1728,6 +1728,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // second callback: after page creation -> stamp display number, section refs, section index, and advance counter if (state?.page) { + state.page.displayNumber = activePageCounter; state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat); // Stamp section index on the page for section-aware page numbering and header/footer selection state.page.sectionIndex = activeSectionIndex; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 944c0b138a..48d8f1a22c 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -29,6 +29,7 @@ export function resolveHeaderFooterLayout( return { number: page.number, + displayNumber: page.displayNumber, numberText: page.numberText, 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 6e1a8dd4d5..786e293f84 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -330,6 +330,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { ), margins: page.margins, footnoteReserved: page.footnoteReserved, + displayNumber: page.displayNumber, numberText: page.numberText, vAlign: page.vAlign, baseMargins: page.baseMargins, 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 15a1b3052a..2fb8cc5aa1 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 @@ -1742,7 +1742,8 @@ export class HeaderFooterSessionManager { return 'first'; } if (hasAlternateHeaders) { - return page.number % 2 === 0 ? 'even' : 'odd'; + const parityPageNumber = page.displayNumber ?? page.number; + return parityPageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; } @@ -2237,6 +2238,7 @@ export class HeaderFooterSessionManager { pageSize: { w: pageWidth, h: pageHeight }, pages: activeLayoutResult.layout.pages.map((page: Page) => ({ number: page.number, + displayNumber: page.displayNumber, numberText: page.numberText, fragments: page.fragments, })), @@ -2375,9 +2377,14 @@ export class HeaderFooterSessionManager { const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; + const parityPageNumber = page?.displayNumber ?? pageNumber; const headerFooterType = multiSectionId - ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber }) - : getHeaderFooterType(pageNumber, legacyIdentifier, { kind }); + ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { + kind, + sectionPageNumber, + parityPageNumber, + }) + : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber }); // Resolve section-specific rId using Word's OOXML inheritance model let sectionRId: string | undefined; From 0afd1dc2497fe82665d0bcab517b91ad91ece829 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 13:20:51 -0300 Subject: [PATCH 02/63] fix(layout-bridge): handle negative odd header parity --- .../layout-engine/layout-bridge/src/headerFooterUtils.ts | 2 +- .../layout-bridge/test/headerFooterUtils.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index cec5fe64ab..4e44421fed 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -87,7 +87,7 @@ export const getHeaderFooterType = ( if (parityPageNumber % 2 === 0 && hasEven) { return 'even'; } - if (parityPageNumber % 2 === 1 && (hasOdd || hasDefault)) { + if (parityPageNumber % 2 !== 0 && (hasOdd || hasDefault)) { return hasOdd ? 'odd' : 'default'; } return null; diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 0a68700949..91191a5470 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -81,6 +81,15 @@ describe('headerFooterUtils', () => { expect(getHeaderFooterType(1, identifier, { parityPageNumber: 2 })).toBe('even'); }); + it('treats negative odd display page numbers as odd', () => { + const identifier = extractIdentifierFromConverter({ + headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' }, + pageStyles: { alternateHeaders: true }, + }); + + expect(getHeaderFooterType(1, identifier, { parityPageNumber: -1 })).toBe('odd'); + }); + it('uses default only for odd pages when alternating slots are missing', () => { const identifier = extractIdentifierFromConverter({ headerIds: { default: 'rId1' }, From d5654deecee59555127014894f1f24c555dd764f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:11:27 -0300 Subject: [PATCH 03/63] fix(layout-resolved): expose header footer display numbers --- .../layout-engine/contracts/src/resolved-layout.ts | 2 ++ .../layout-resolved/src/resolveHeaderFooter.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index f5c8f75dfe..70d3f3a924 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -450,6 +450,8 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved /** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ export type ResolvedHeaderFooterPage = { number: number; + /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ + displayNumber?: number; numberText?: string; items: ResolvedPaintItem[]; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts index 27629ad7d4..2534e3c273 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts @@ -95,6 +95,16 @@ describe('resolveHeaderFooterLayout', () => { expect(result.pages[1].numberText).toBe('ii'); }); + it('preserves displayNumber on pages', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, displayNumber: 2, fragments: [] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages[0].displayNumber).toBe(2); + }); + it('returns empty items array for empty fragments array', () => { const layout: HeaderFooterLayout = { height: 50, From 993efdac9392ff83943719b2d73f9f53ba7e8f13 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:47:53 -0300 Subject: [PATCH 04/63] test(super-editor): cover header footer display parity --- .../tests/HeaderFooterSessionManager.test.ts | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index dc4d22c059..d82688658a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -23,7 +23,7 @@ import type { ResolvedLayout, ResolvedPage, } from '@superdoc/contracts'; -import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; +import { buildMultiSectionIdentifier, type HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { HeaderFooterSessionManager, type SessionManagerDependencies, @@ -933,6 +933,86 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items).toHaveLength(2); expect(payload!.items!.every((item) => item.blockId === 'p1')).toBe(true); }); + + it('uses displayNumber parity when resolving per-rId header layouts', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'rId-default', even: 'rId-even' } }], { + alternateHeaders: true, + }), + ); + + const evenFragment: ParaFragment = { + kind: 'para', + blockId: 'even-header', + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + manager.headerLayoutsByRId.set('rId-default', buildHeaderResult()); + manager.headerLayoutsByRId.set('rId-even', { + kind: 'header', + type: 'even', + layout: { height: 50, pages: [{ number: 1, fragments: [evenFragment] }] }, + blocks: [{ kind: 'paragraph', id: 'even-header', runs: [] }], + measures: [ + { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }, + ], + totalHeight: 18, + }, + ], + }); + + const page = { + number: 1, + displayNumber: 2, + sectionIndex: 0, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-default', even: 'rId-even' }, footerRefs: {} }, + } as unknown as ResolvedPage; + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [page], + }; + + const provider = manager.createDecorationProvider('header', layout); + const payload = provider!(1, page.margins, page); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('even'); + expect(payload!.headerFooterRefId).toBe('rId-even'); + expect(payload!.fragments[0]!.blockId).toBe('even-header'); + }); }); describe('rebuildRegions — ResolvedLayout entry', () => { @@ -1037,5 +1117,42 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(2)!.sectionIndex).toBe(1); expect(manager.footerRegions.get(2)!.sectionIndex).toBe(1); }); + + it('uses displayNumber parity when inferring header/footer region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { pageStyles: { alternateHeaders: true } }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [makePage({ number: 1, displayNumber: 2, height: 792 })], + }); + + expect(manager.headerRegions.get(0)!.sectionType).toBe('even'); + expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); + }); }); }); From a23cd0def0fc852319a999a74c2388b74848d0c2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:49:57 -0300 Subject: [PATCH 05/63] fix(layout-bridge): allow section parity override --- .../layout-bridge/src/headerFooterUtils.ts | 14 ++++---- .../test/headerFooterUtils.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 4e44421fed..384c467e33 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -404,7 +404,7 @@ export function getHeaderFooterTypeForSection( * * @param page - The Page object containing sectionIndex and sectionRefs * @param identifier - Multi-section identifier (can be used for variant resolution) - * @param options - Optional settings (kind: 'header' | 'footer') + * @param options - Optional settings (kind, sectionPageNumber, parityPageNumber) * @returns The content ID string, or null if not available * * @example @@ -417,12 +417,12 @@ export function getHeaderFooterTypeForSection( export function getHeaderFooterIdForPage( page: Page, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number }, + options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number }, ): string | null { const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const sectionPageNumber = options?.sectionPageNumber ?? page.number; - const parityPageNumber = page.displayNumber ?? page.number; + const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? page.number; // Determine which variant type to use (default, first, even, odd) const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, { @@ -469,7 +469,7 @@ export function getHeaderFooterIdForPage( * @param layout - The complete Layout object with pages and headerFooter slots * @param pageIndex - Index of the page in layout.pages array (0-indexed) * @param identifier - Multi-section identifier with per-section mappings - * @param options - Optional settings (kind: 'header' | 'footer') + * @param options - Optional settings (kind, parityPageNumber) * @returns Resolution result with type, layout slot, page, and section info, or null * * @example @@ -488,7 +488,7 @@ export function resolveHeaderFooterForPageAndSection( layout: Layout, pageIndex: number, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ): { type: HeaderFooterType; layout: NonNullable[HeaderFooterType]>; @@ -511,7 +511,7 @@ export function resolveHeaderFooterForPageAndSection( } const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; - const parityPageNumber = page.displayNumber ?? pageNumber; + const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? pageNumber; // Determine variant type for this section const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { @@ -522,7 +522,7 @@ export function resolveHeaderFooterForPageAndSection( if (!type) return null; // Get content ID for this page/section - const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber }); + const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber, parityPageNumber }); // Look up the header/footer layout slot const slot = layout.headerFooter?.[type]; diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 91191a5470..8ce200a8ab 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -742,6 +742,39 @@ describe('headerFooterUtils', () => { expect(evenPageHeader?.contentId).toBe('h0-even'); }); + it('allows callers to override section-aware odd/even parity', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-odd', even: 'h0-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { + number: 1, + fragments: [], + sectionIndex: 0, + sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } }, + }, + ], + headerFooter: { + even: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { + kind: 'header', + parityPageNumber: 2, + }); + + expect(evenPageHeader?.type).toBe('even'); + expect(evenPageHeader?.contentId).toBe('h0-even'); + }); + it('does not use section default content id for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { From 332e3f16cd098a16be46c320a572c8f8f6f6ad86 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:34:55 -0300 Subject: [PATCH 06/63] feat(super-editor): honor PAGE/NUMPAGES field format switches Parse `\*` general-format and `\#` numeric-picture switches when importing PAGE/NUMPAGES fields and thread the requested format (roman/alphabetic/zero-padded decimal/etc.) plus the section-aware numeric page value through the converter, pm-adapter, layout engine, and DOM painter so page-number fields render in the format Word stored rather than always decimal. The original instruction is preserved on the node so export round-trips back to the same field code. --- packages/layout-engine/contracts/src/index.ts | 7 +++ .../layout-bridge/src/incrementalLayout.ts | 3 +- .../layout-bridge/src/layoutHeaderFooter.ts | 8 ++- .../src/resolveHeaderFooterTokens.ts | 11 +++- .../test/resolveHeaderFooterTokens.test.ts | 48 ++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 2 +- .../layout-engine/src/pageNumbering.ts | 11 +++- .../src/resolvePageNumberTokens.test.ts | 42 +++++++++++++ .../layout-engine/src/resolvePageTokens.ts | 24 ++++--- .../src/resolveHeaderFooter.ts | 1 + .../layout-resolved/src/resolveLayout.ts | 1 + .../painters/dom/src/index.test.ts | 56 +++++++++++++++++ .../painters/dom/src/renderer.ts | 6 ++ .../painters/dom/src/runs/text-run.ts | 63 +++++++++++++++++++ .../generic-token-format.test.ts | 48 ++++++++++++++ .../inline-converters/generic-token.test.ts | 18 ++++++ .../inline-converters/generic-token.ts | 20 ++++++ .../converters/inline-converters/text-run.ts | 20 ++++++ .../num-pages-preprocessor.js | 7 ++- .../num-pages-preprocessor.test.js | 9 +++ .../fld-preprocessors/page-preprocessor.js | 6 +- .../page-preprocessor.test.js | 16 +++++ .../shared/page-number-field-switches.js | 49 +++++++++++++++ .../shared/page-number-field-switches.test.js | 30 +++++++++ .../autoPageNumber-translator.js | 14 ++++- .../autoPageNumber-translator.test.js | 36 +++++++++++ .../totalPageNumber-translator.js | 13 +++- .../totalPageNumber-translator.test.js | 39 ++++++++++++ .../v1/extensions/page-number/page-number.js | 30 +++++++++ .../v1/extensions/types/node-attributes.ts | 12 ++++ 30 files changed, 630 insertions(+), 20 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token-format.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 9f701475b4..ceb60cdd9a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -334,6 +334,11 @@ export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text'; export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt'; +export type PageNumberFieldFormat = { + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + zeroPadding?: number; +}; + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. @@ -391,6 +396,8 @@ export type TextRun = RunMarks & { link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; + /** Explicit formatting requested by PAGE/NUMPAGES field switches. */ + pageNumberFieldFormat?: PageNumberFieldFormat; /** Absolute ProseMirror position (inclusive) of first character in this run. */ pmStart?: number; /** Absolute ProseMirror position (exclusive) after the last character. */ diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index d035f504f8..c867d56d06 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -2700,11 +2700,12 @@ export async function incrementalLayout( // 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; totalPages: number } => { + ? (pageNumber: number): { displayText: string; displayNumber: number; totalPages: number } => { const pageIndex = pageNumber - 1; const displayInfo = numberingCtx.displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), + displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages: numberingCtx.totalPages, }; } diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 6385a3065f..7deacba188 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -24,6 +24,7 @@ export type HeaderFooterBatchResult = Partial< */ export type PageResolver = (pageNumber: number) => { displayText: string; + displayNumber?: number; totalPages: number; }; @@ -285,6 +286,7 @@ 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']; @@ -295,9 +297,9 @@ export async function layoutHeaderFooterWithCache( const clonedBlocks = cloneHeaderFooterBlocks(blocks); // Resolve page number tokens for this specific page - const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum); + const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum); - resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText); + resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber); // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); @@ -324,6 +326,7 @@ export async function layoutHeaderFooterWithCache( // Store page-specific data pages.push({ number: pageNum, + displayNumber, blocks: clonedBlocks, measures, fragments: fragmentsWithLines, @@ -343,6 +346,7 @@ export async function layoutHeaderFooterWithCache( renderHeight: firstPageLayout.renderHeight, pages: pages.map((p) => ({ number: p.number, + displayNumber: p.displayNumber, fragments: p.fragments, 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 d7eeaade60..2ecd495f9a 100644 --- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts +++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts @@ -11,6 +11,7 @@ */ import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import { formatPageNumberFieldValue } from '@superdoc/layout-engine'; /** * Walk every paragraph block reachable through `blocks`, including those @@ -72,6 +73,7 @@ export function resolveHeaderFooterTokens( pageNumber: number, totalPages: number, pageNumberText?: string, + displayPageNumber?: number, ): void { // Validate inputs if (!blocks || blocks.length === 0) { @@ -90,6 +92,7 @@ export function resolveHeaderFooterTokens( const pageNumberStr = pageNumberText ?? String(pageNumber); const totalPagesStr = String(totalPages); + const displayNumber = displayPageNumber ?? pageNumber; // Process every paragraph block, including those nested in table cells // (SD-1332). The page-number field can live in `tableCell > paragraph > @@ -104,11 +107,15 @@ export function resolveHeaderFooterTokens( // IMPORTANT: Do NOT delete run.token - the painter needs it to // re-resolve the correct page number at render time for each page. // The text here is for measurement purposes (digit width). - run.text = pageNumberStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat) + : pageNumberStr; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count for measurement. // IMPORTANT: Keep token for painter to re-resolve if needed. - run.text = totalPagesStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr; } // Note: pageReference tokens should not appear in headers/footers typically, // but if they do, they'll be handled by the PAGEREF resolution logic diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 8bf4a48bf9..7598711fd8 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -61,6 +61,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); + it('should prefer explicit PAGE field format metadata over pageNumberText', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'header-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'numberInDash' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('-7-'); + expect((block.runs[0] as TextRun).token).toBe('pageNumber'); + }); + it('should resolve totalPageCount token in footer blocks', () => { const blocks: FlowBlock[] = [ { @@ -90,6 +114,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[1] as TextRun).token).toBe('totalPageCount'); }); + it('should zero-pad explicit NUMPAGES field metadata', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'footer-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { format: 'decimal', zeroPadding: 2 }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 5, 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('07'); + expect((block.runs[0] as TextRun).token).toBe('totalPageCount'); + }); + it('should resolve both tokens in the same block', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 89f356b7b3..68fcc84692 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3675,7 +3675,7 @@ const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: num export { buildAnchorMap, resolvePageRefTokens, getTocBlocksForRemeasurement } from './resolvePageRefs.js'; // Export page numbering utilities -export { formatPageNumber, computeDisplayPageNumber } from './pageNumbering.js'; +export { formatPageNumber, formatPageNumberFieldValue, computeDisplayPageNumber } from './pageNumbering.js'; export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js'; // Export page token resolution utilities diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index fa544310b4..ed4d7b58a6 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,7 +13,7 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, SectionMetadata } from '@superdoc/contracts'; +import type { Page, PageNumberFieldFormat, SectionMetadata } from '@superdoc/contracts'; /** * Page number format types supported by the layout engine. @@ -195,6 +195,15 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): } } +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + if (fieldFormat?.zeroPadding && format === 'decimal') { + return formatted.padStart(fieldFormat.zeroPadding, '0'); + } + return formatted; +} + /** * Computes section-aware display page numbers for all pages in a document. * diff --git a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts index c73bf03d60..7a378f43eb 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts @@ -82,6 +82,48 @@ describe('resolvePageNumberTokens', () => { expect((blocks[0] as ParagraphBlock).runs[1].token).toBe('pageNumber'); }); + it('should resolve explicit PAGE field format using section-aware display number', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'para-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'lowerRoman' }, + 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: 4, + fragments: [{ kind: 'para', blockId: 'para-format', fromLine: 0, toLine: 1, x: 0, y: 0, width: 100 }], + }, + ], + }; + const numberingCtx: NumberingContext = { + totalPages: 12, + displayPages: [ + { physicalPage: 1, displayNumber: 1, displayText: '1', sectionIndex: 0 }, + { physicalPage: 2, displayNumber: 2, displayText: '2', sectionIndex: 0 }, + { physicalPage: 3, displayNumber: 3, displayText: '3', sectionIndex: 0 }, + { physicalPage: 4, displayNumber: 5, displayText: '5', sectionIndex: 1 }, + ], + }; + + const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx); + + const updatedBlock = result.updatedBlocks.get('para-format') as ParagraphBlock; + expect(updatedBlock.runs[0].text).toBe('v'); + }); + it('should resolve totalPageCount tokens', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 779d218d70..b8880041ba 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -15,7 +15,7 @@ */ import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts'; -import type { DisplayPageInfo } from './pageNumbering'; +import { formatPageNumberFieldValue, type DisplayPageInfo } from './pageNumbering'; /** * Numbering context for page token resolution. @@ -118,8 +118,6 @@ export function resolvePageNumberTokens( continue; } - const displayPageText = displayPageInfo.displayText; - for (const fragment of page.fragments) { // Paragraph fragments — original behaviour. if (fragment.kind === 'para') { @@ -137,7 +135,12 @@ export function resolvePageNumberTokens( continue; } - const clonedBlock = cloneBlockWithResolvedTokens(block, displayPageText, totalPagesStr); + const clonedBlock = cloneBlockWithResolvedTokens( + block, + displayPageInfo, + totalPagesStr, + numberingCtx.totalPages, + ); updatedBlocks.set(blockId, clonedBlock); affectedBlockIds.add(blockId); processedBlocks.add(blockId); @@ -189,14 +192,15 @@ function hasPageTokens(block: ParagraphBlock): boolean { * or totalPageCount tokens by replacing the text and clearing the token metadata. * * @param block - Original paragraph block (will not be mutated) - * @param displayPageText - Formatted display page number (e.g., "i", "III", "23") + * @param displayPageInfo - Section-aware page number data for this physical page * @param totalPagesStr - Total page count as string * @returns Cloned block with resolved tokens */ function cloneBlockWithResolvedTokens( block: ParagraphBlock, - displayPageText: string, + displayPageInfo: DisplayPageInfo, totalPagesStr: string, + totalPages: number, ): ParagraphBlock { // Clone the runs array and resolve tokens const clonedRuns = block.runs.map((run) => { @@ -207,14 +211,18 @@ function cloneBlockWithResolvedTokens( const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: displayPageText, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat) + : displayPageInfo.displayText, }; } else if (run.token === 'totalPageCount') { // Clone the run and resolve the token const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: totalPagesStr, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr, }; } } diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 48d8f1a22c..f5c2960c45 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -31,6 +31,7 @@ export function resolveHeaderFooterLayout( number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, 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 786e293f84..cb72521d81 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -332,6 +332,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { footnoteReserved: page.footnoteReserved, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, vAlign: page.vAlign, baseMargins: page.baseMargins, sectionIndex: page.sectionIndex, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index de1a88bfda..76c62c6cc5 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5710,6 +5710,62 @@ describe('DomPainter', () => { expect(footerEl?.textContent).toBe('Footer: 3'); }); + it('renders footer page-number tokens with explicit field format metadata', () => { + const footerBlock: FlowBlock = { + kind: 'paragraph', + id: 'footer-formatted-page', + runs: [ + { + text: '0', + fontFamily: 'Arial', + fontSize: 12, + token: 'pageNumber', + pageNumberFieldFormat: { format: 'numberInDash' }, + }, + ], + }; + const footerMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 40, + ascent: 10, + descent: 2, + lineHeight: 14, + }, + ], + totalHeight: 14, + }; + const footerFragment = { + kind: 'para' as const, + blockId: 'footer-formatted-page', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }; + + const painter = createTestPainter({ + blocks: [block, footerBlock], + measures: [measure, footerMeasure], + footerProvider: () => ({ fragments: [footerFragment], height: 14 }), + }); + + painter.paint( + { ...layout, pages: [{ ...layout.pages[0], number: 10, displayNumber: 4, numberText: 'iv' }] }, + mount, + ); + + const footerEl = mount.querySelector('.superdoc-page-footer'); + expect(footerEl).toBeTruthy(); + expect(footerEl?.textContent).toBe('-4-'); + }); + it('bottom-aligns footer content within the footer box', () => { const footerBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 910e320008..18390c37e8 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -267,6 +267,7 @@ type PageDomState = { * @property {number} totalPages - Total number of pages in the document * @property {'body'|'header'|'footer'} section - Document section being rendered * @property {string} [pageNumberText] - Optional formatted page number text (e.g., "Page 1 of 10") + * @property {number} [displayPageNumber] - Section-aware numeric page value before formatting */ export type FragmentRenderContext = { pageNumber: number; @@ -274,6 +275,7 @@ export type FragmentRenderContext = { section: 'body' | 'header' | 'footer'; story?: LayoutStoryLocator; pageNumberText?: string; + displayPageNumber?: number; pageIndex?: number; }; @@ -1714,6 +1716,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2068,6 +2071,7 @@ export class DomPainter { section: kind, story: resolveDecorationStory(kind, data), pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2271,6 +2275,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2431,6 +2436,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; 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 d9927998c1..efd57c9b9d 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -137,14 +137,77 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string return run.text ?? ''; } if (runToken === 'pageNumber') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.displayPageNumber ?? context.pageNumber, run.pageNumberFieldFormat); + } return context.pageNumberText ?? String(context.pageNumber); } if (runToken === 'totalPageCount') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.totalPages || 1, run.pageNumberFieldFormat); + } return context.totalPages ? String(context.totalPages) : (run.text ?? ''); } return run.text ?? ''; }; +const formatPageNumberFieldValue = ( + value: number, + fieldFormat: NonNullable, +): string => { + const num = Math.max(1, Math.trunc(Number.isFinite(value) ? value : 1)); + const format = fieldFormat.format ?? 'decimal'; + const formatted = formatPageNumberByFormat(num, format); + return fieldFormat.zeroPadding && format === 'decimal' ? formatted.padStart(fieldFormat.zeroPadding, '0') : formatted; +}; + +const formatPageNumberByFormat = ( + value: number, + format: NonNullable['format'], +): string => { + switch (format) { + case 'upperRoman': + return toRoman(value); + case 'lowerRoman': + return toRoman(value).toLowerCase(); + case 'upperLetter': + return toLetters(value); + case 'lowerLetter': + return toLetters(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +}; + +const toRoman = (value: number): string => { + if (value < 1 || value > 3999) return String(value); + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + return result; +}; + +const toLetters = (value: number): string => { + let n = Math.max(1, value); + let result = ''; + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + return result; +}; + export const extractLinkData = (run: Run) => { if (run.kind === 'tab' || run.kind === 'image' || run.kind === 'lineBreak' || run.kind === 'math') { return null; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token-format.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token-format.test.ts new file mode 100644 index 0000000000..21effa1a2e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token-format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PMNode, PositionMap } from '../types.js'; +import { tokenNodeToRun } from './generic-token.js'; + +vi.mock('../../marks/index.js', () => ({ + applyMarksToRun: vi.fn(), +})); + +vi.mock('./common.js', () => ({ + applyInlineRunProperties: vi.fn((run) => run), +})); + +describe('generic tokenNodeToRun field formatting', () => { + it('forwards normalized page-number field format metadata', () => { + const node: PMNode = { + type: 'total-page-number', + attrs: { + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + }; + + const run = tokenNodeToRun({ + node, + positions: new WeakMap() as PositionMap, + inheritedMarks: [], + defaultFont: 'Arial', + defaultSize: 16, + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: { + translatedNumbering: {}, + translatedLinkedStyles: {}, + }, + enableComments: true, + visitNode: () => {}, + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: () => 'b1', + }); + + expect(run?.pageNumberFieldFormat).toEqual({ format: 'decimal', zeroPadding: 2 }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts index 8f3d263cd6..0798dce8af 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts @@ -172,6 +172,24 @@ describe('tokenNodeToRun', () => { expect(result.text).toBe('0'); }); + it('forwards page-number field format metadata', () => { + const tokenNode: PMNode = { + type: 'page-number', + attrs: { + pageNumberFormat: 'numberInDash', + pageNumberZeroPadding: 2, + }, + }; + const positions: PositionMap = new WeakMap(); + + const result = tokenNodeToRun(tokenNode, positions, 'Arial', 16, [], 'pageNumber'); + + expect(result.pageNumberFieldFormat).toEqual({ + format: 'numberInDash', + zeroPadding: 2, + }); + }); + it('handles token with various token types', () => { const tokenTypes: Array = ['pageNumber', 'totalPageCount']; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts index 2b92b38410..e90f8ca050 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts @@ -42,6 +42,10 @@ export function tokenNodeToRun({ fontFamily: defaultFont, fontSize: defaultSize, }; + const pageNumberFieldFormat = getPageNumberFieldFormat(node.attrs); + if (pageNumberFieldFormat) { + run.pageNumberFieldFormat = pageNumberFieldFormat; + } // Attach PM position tracking const pos = positions.get(node); @@ -77,3 +81,19 @@ export function tokenNodeToRun({ } return run; } + +function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts index 11ae20ed05..f51dedd144 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts @@ -101,6 +101,10 @@ export function tokenNodeToRun( fontFamily: defaultFont, fontSize: defaultSize, }; + const pageNumberFieldFormat = getPageNumberFieldFormat(node.attrs); + if (pageNumberFieldFormat) { + run.pageNumberFieldFormat = pageNumberFieldFormat; + } // Attach PM position tracking const pos = positions.get(node); @@ -127,3 +131,19 @@ export function tokenNodeToRun( } return run; } + +function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index d99b9a9dfd..8195b51c56 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -1,3 +1,5 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; + /** * Processes a NUMPAGES instruction and creates a `sd:totalPageNumber` node. * @@ -7,11 +9,12 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', fieldRunRPr = null) { + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', type: 'element', - attributes: {}, + attributes: { ...fieldAttrs }, }; // Extract the cached display text from content nodes so the encoder can diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index dbde3e45ac..745a7014bc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -88,4 +88,13 @@ describe('preProcessNumPagesInstruction', () => { const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); expect(result[0].attributes.importedCachedText).toBeUndefined(); }); + + it('preserves NUMPAGES zero-padding switches as normalized attributes', () => { + const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"', null); + expect(result[0].attributes).toEqual({ + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index b1cba7c12f..c640f19d92 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -1,3 +1,5 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; + /** * Processes a PAGE instruction and creates a `sd:autoPageNumber` node. * @@ -7,10 +9,12 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fieldRunRPr = null) { + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', + ...(Object.keys(fieldAttrs).length > 0 ? { attributes: fieldAttrs } : {}), }; // First, try to get rPr from content nodes (between separate and end) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index cc645a06d4..ddc1e1e9c1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -98,4 +98,20 @@ describe('preProcessPageInstruction', () => { }, ]); }); + + it('preserves PAGE general format switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* roman', null); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); + + it('preserves PAGE ArabicDash switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash', null); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* ArabicDash', + pageNumberFormat: 'numberInDash', + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js new file mode 100644 index 0000000000..66e7f21e9c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -0,0 +1,49 @@ +const GENERAL_FORMATS = new Map([ + ['Arabic', 'decimal'], + ['roman', 'lowerRoman'], + ['ROMAN', 'upperRoman'], + ['alphabetic', 'lowerLetter'], + ['ALPHABETIC', 'upperLetter'], + ['ArabicDash', 'numberInDash'], +]); + +/** + * @param {string} instruction + * @param {'PAGE' | 'NUMPAGES'} fieldType + * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }} + */ +export function parsePageNumberFieldSwitches(instruction, fieldType) { + const normalizedInstruction = typeof instruction === 'string' ? instruction.trim().replace(/\s+/g, ' ') : fieldType; + const result = {}; + + if (normalizedInstruction && normalizedInstruction !== fieldType) { + result.instruction = normalizedInstruction; + } + + for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { + const rawValue = unquote(match[1]); + const mapped = GENERAL_FORMATS.get(rawValue); + if (mapped) { + result.pageNumberFormat = mapped; + break; + } + } + + for (const match of normalizedInstruction.matchAll(/\\#\s+("[^"]+"|\S+)/g)) { + const picture = unquote(match[1]); + if (/^0+$/.test(picture)) { + result.pageNumberFormat ??= 'decimal'; + result.pageNumberZeroPadding = picture.length; + break; + } + } + + return result; +} + +/** + * @param {string} value + */ +function unquote(value) { + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js new file mode 100644 index 0000000000..8ce9e11d09 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { parsePageNumberFieldSwitches } from './page-number-field-switches.js'; + +describe('parsePageNumberFieldSwitches', () => { + it.each([ + ['PAGE \\* Arabic', { instruction: 'PAGE \\* Arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* roman', { instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman' }], + ['PAGE \\* ROMAN', { instruction: 'PAGE \\* ROMAN', pageNumberFormat: 'upperRoman' }], + ['PAGE \\* alphabetic', { instruction: 'PAGE \\* alphabetic', pageNumberFormat: 'lowerLetter' }], + ['PAGE \\* ALPHABETIC', { instruction: 'PAGE \\* ALPHABETIC', pageNumberFormat: 'upperLetter' }], + ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], + ])('parses general format switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); + }); + + it.each([ + ['NUMPAGES \\# "00"', { instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', pageNumberZeroPadding: 2 }], + ['NUMPAGES \\# 000', { instruction: 'NUMPAGES \\# 000', pageNumberFormat: 'decimal', pageNumberZeroPadding: 3 }], + ])('parses zero-padding picture switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); + }); + + it('preserves unsupported switched instructions without format metadata', () => { + expect(parsePageNumberFieldSwitches('PAGE \\* OrdText', 'PAGE')).toEqual({ instruction: 'PAGE \\* OrdText' }); + }); + + it('omits default instruction metadata', () => { + expect(parsePageNumberFieldSwitches(' PAGE ', 'PAGE')).toEqual({}); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index ff0fc66bcc..9f4fc1bf21 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -24,6 +24,7 @@ const encode = (params) => { type: 'page-number', attrs: { marksAsAttrs: marks, + ...getPageNumberFieldAttrs(node), }, }; @@ -39,6 +40,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const instruction = node.attrs?.instruction || 'PAGE'; const translated = [ { name: 'w:r', @@ -68,7 +70,7 @@ const decode = (params) => { elements: [ { type: 'text', - text: ' PAGE', + text: ` ${instruction}`, }, ], }, @@ -109,6 +111,16 @@ const decode = (params) => { return translated; }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js index 28cfb48510..5de2b2e30c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js @@ -76,6 +76,29 @@ describe('sd:autoPageNumber translator', () => { expect(parseMarks).toHaveBeenCalledTimes(1); expect(parseMarks).toHaveBeenCalledWith({ elements: [] }); }); + + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); }); describe('decode', () => { @@ -175,5 +198,18 @@ describe('sd:autoPageNumber translator', () => { expect(processOutputMarks).toHaveBeenCalledTimes(1); expect(processOutputMarks).toHaveBeenCalledWith([]); }); + + it('exports the preserved switched PAGE instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'page-number', + attrs: { instruction: 'PAGE \\* ArabicDash' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' PAGE \\* ArabicDash'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index 60b9dd9f27..b27ee634de 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -31,6 +31,7 @@ const encode = (params) => { attrs: { marksAsAttrs: marks, importedCachedText, + ...getPageNumberFieldAttrs(node), }, }; @@ -54,9 +55,19 @@ const decode = (params) => { const hasFreshPageCount = params.statFieldCacheMap?.has?.('NUMPAGES'); const dirty = !hasFreshPageCount; - return buildComplexFieldRuns({ instruction: 'NUMPAGES', cachedText, outputMarks, dirty }); + return buildComplexFieldRuns({ instruction: node.attrs?.instruction || 'NUMPAGES', cachedText, outputMarks, dirty }); }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** * Resolves the cached page count text for export. * diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 3f0f042be1..0a27de38c9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -81,6 +81,32 @@ describe('sd:totalPageNumber translator', () => { expect(result.attrs.importedCachedText).toBe('5'); }); + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + importedCachedText: null, + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); + it('falls back to an empty rPr object when run properties are missing', () => { config.encode({ nodes: [ @@ -181,5 +207,18 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe(''); expect(result[4].elements[1].attributes['w:fldCharType']).toBe('end'); }); + + it('exports the preserved switched NUMPAGES instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { instruction: 'NUMPAGES \\# "00"', importedCachedText: '07' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "00"'); + }); }); }); 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 8242a8d6ee..6b5dc1cb9e 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 @@ -13,6 +13,9 @@ import { isHeadless } from '@utils/headless-helpers.js'; * @typedef {Object} PageNumberAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original PAGE field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -48,6 +51,18 @@ export const PageNumber = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, }; }, @@ -120,6 +135,9 @@ export const PageNumber = Node.create({ * @typedef {Object} TotalPageCountAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original NUMPAGES field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -155,6 +173,18 @@ export const TotalPageCount = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, /** * Preserves the imported OOXML cached field result for NUMPAGES. * Used as a fallback when pagination is unavailable (headless context) diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 3b7a109a4c..dcd23a2cc2 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -948,12 +948,24 @@ export interface PageReferenceAttrs extends InlineNodeAttributes { export interface PageNumberAttrs extends InlineNodeAttributes { /** @internal Marks stored as attributes */ marksAsAttrs?: unknown[] | null; + /** @internal Original PAGE field instruction when switched */ + instruction?: string | null; + /** @internal Normalized field switch format */ + pageNumberFormat?: string | null; + /** @internal Zero-padding width from numeric picture switch */ + pageNumberZeroPadding?: number | null; } /** Total page count node attributes */ export interface TotalPageCountAttrs extends InlineNodeAttributes { /** @internal Marks stored as attributes */ marksAsAttrs?: unknown[] | null; + /** @internal Original NUMPAGES field instruction when switched */ + instruction?: string | null; + /** @internal Normalized field switch format */ + pageNumberFormat?: string | null; + /** @internal Zero-padding width from numeric picture switch */ + pageNumberZeroPadding?: number | null; } // ============================================ From f4b988e75b1e8975c9bd404d625db35f3da05ffb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:49:14 -0300 Subject: [PATCH 07/63] fix(super-editor): format NUMPAGES cached exports --- .../shared/page-number-field-switches.js | 67 +++++++++++++++++++ .../totalPageNumber-translator.js | 5 ++ .../totalPageNumber-translator.test.js | 22 ++++++ 3 files changed, 94 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index 66e7f21e9c..2bb6e49e23 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -41,9 +41,76 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { return result; } +/** + * @param {number} pageNumber + * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs + */ +export function formatPageNumberFieldValue(pageNumber, attrs = {}) { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + const format = attrs.pageNumberFormat || 'decimal'; + const formatted = formatPageNumberByFormat(value, format); + return attrs.pageNumberZeroPadding && format === 'decimal' + ? formatted.padStart(attrs.pageNumberZeroPadding, '0') + : formatted; +} + /** * @param {string} value */ function unquote(value) { return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; } + +/** + * @param {number} value + * @param {string} format + */ +function formatPageNumberByFormat(value, format) { + switch (format) { + case 'upperRoman': + return toRoman(value); + case 'lowerRoman': + return toRoman(value).toLowerCase(); + case 'upperLetter': + return toLetters(value); + case 'lowerLetter': + return toLetters(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +/** + * @param {number} value + */ +function toRoman(value) { + if (value < 1 || value > 3999) return String(value); + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + return result; +} + +/** + * @param {number} value + */ +function toLetters(value) { + let n = Math.max(1, value); + let result = ''; + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + return result; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index b27ee634de..d122782051 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -3,6 +3,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; +import { formatPageNumberFieldValue } from '../../../../field-references/shared/page-number-field-switches.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:totalPageNumber'; @@ -80,6 +81,10 @@ function getPageNumberFieldAttrs(node) { function resolveCachedPageCount(params, node) { const cacheMap = params.statFieldCacheMap; if (cacheMap?.has?.('NUMPAGES')) { + const pageCount = Number(cacheMap.get('NUMPAGES')); + if (node.attrs?.pageNumberFormat || node.attrs?.pageNumberZeroPadding) { + return formatPageNumberFieldValue(pageCount, node.attrs); + } return String(cacheMap.get('NUMPAGES')); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 0a27de38c9..bbebd44b5e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -164,6 +164,28 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe('12'); }); + it('formats fresh NUMPAGES cached text with preserved field switches', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + importedCachedText: '05', + }, + }, + statFieldCacheMap: new Map([['NUMPAGES', 7]]), + }); + + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + }); + expect(result[3].elements[1].elements[0].text).toBe('07'); + }); + it('falls back to resolvedText when cache map is absent', () => { vi.mocked(processOutputMarks).mockReturnValue([]); From e2f51ccd6a98a4f2c05a6f7065564f42293a9a5a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:54:04 -0300 Subject: [PATCH 08/63] fix(super-editor): pass display number to rId header layouts --- .../HeaderFooterPerRidLayout.test.ts | 51 +++++++++++++++++++ .../header-footer/HeaderFooterPerRidLayout.ts | 8 +-- 2 files changed, 56 insertions(+), 3 deletions(-) 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 8036a56c98..98f0581207 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 @@ -139,6 +139,57 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + it('passes section-aware display numbers into rId header/footer page resolution', async () => { + mockComputeDisplayPageNumber.mockReturnValue( + Array.from({ length: 10 }, (_, index) => ({ + physicalPage: index + 1, + displayNumber: index === 9 ? 1 : index + 1, + displayText: index === 9 ? 'i' : String(index + 1), + sectionIndex: index === 9 ? 1 : 0, + })), + ); + + const headerFooterInput = { + headerBlocksByRId: new Map([['rId-header-default', [makeBlock('block-default')]]]), + footerBlocksByRId: undefined, + headerBlocks: undefined, + footerBlocks: undefined, + constraints: { + width: 400, + height: 80, + }, + }; + + const layout = { + pages: Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + fragments: [], + sectionIndex: index === 9 ? 1 : 0, + })), + } as unknown as Layout; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + 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]; + expect(pageResolver(10)).toEqual({ + displayText: 'i', + displayNumber: 1, + totalPages: 10, + }); + }); + 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 1228456dc2..a64adc00b0 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 @@ -20,6 +20,7 @@ export type HeaderFooterPerRidLayoutInput = { }; type Constraints = HeaderFooterConstraints; +type PageResolver = (pageNumber: number) => { displayText: string; displayNumber: number; totalPages: number }; /** * Layout header/footer blocks per rId, respecting per-section margins. @@ -48,11 +49,12 @@ export async function layoutPerRIdHeaderFooters( const displayPages = computeDisplayPageNumber(layout.pages, sectionMetadata); const totalPages = layout.pages.length; - const pageResolver = (pageNumber: number): { displayText: string; totalPages: number } => { + const pageResolver: PageResolver = (pageNumber) => { const pageIndex = pageNumber - 1; const displayInfo = displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), + displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages, }; }; @@ -108,7 +110,7 @@ async function layoutBlocksByRId( blocksByRId: Map | undefined, referencedRIds: Set, constraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId || referencedRIds.size === 0) return; @@ -208,7 +210,7 @@ async function layoutWithPerSectionConstraints( blocksByRId: Map | undefined, sectionMetadata: SectionMetadata[], fallbackConstraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId) return; From cbe45e9f0626bedb55c51b20efeba8099528066c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:59:43 -0300 Subject: [PATCH 09/63] fix(layout-bridge): avoid bucketing formatted page tokens --- .../layout-bridge/src/layoutHeaderFooter.ts | 37 ++++++++++++++++++- .../test/layoutHeaderFooterBucketing.test.ts | 25 +++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 7deacba188..682cdc0546 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -121,6 +121,15 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } +function paragraphHasFormattedPageNumberToken(para: ParagraphBlock): boolean { + for (const run of para.runs) { + if ('token' in run && run.token === 'pageNumber' && run.pageNumberFieldFormat) { + return true; + } + } + return false; +} + function hasPageTokens(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { @@ -146,6 +155,27 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } +function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { + for (const block of blocks) { + if (block.kind === 'paragraph') { + if (paragraphHasFormattedPageNumberToken(block as ParagraphBlock)) 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 (hasFormattedPageNumberTokens(cellBlocks)) return true; + } + } + } + } + return false; +} + export class HeaderFooterLayoutCache { private readonly cache = new MeasureCache(); @@ -201,6 +231,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache(); * 2. If variant has no tokens: creates one layout reused across all pages (fast path) * 3. For small docs (<100 pages): creates per-page layouts * 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4) + * unless PAGE tokens have explicit field formatting * * @param sections - Header/footer variants (default, first, even, odd) * @param constraints - Layout constraints (width, height, margins) @@ -266,8 +297,10 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - if (!useBucketing) { - // Small doc: create layout for every page + const useBucketingForVariant = useBucketing && !hasFormattedPageNumberTokens(blocks); + + if (!useBucketingForVariant) { + // Per-page layout: small docs, disabled bucketing, or explicit PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 92a8c36d9f..36f3441eee 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -389,6 +389,31 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(pageNumbers).toContain(500); // d3 expect(pageNumbers).not.toContain(5000); // d4 not needed }); + + it('should not digit-bucket explicitly formatted page-number tokens', async () => { + const block = makePageTokenBlock('header-formatted-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'lowerRoman' }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(150); + expect(measureBlock).toHaveBeenCalledTimes(150); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { From afd1cb43f79fbb7c354cfc217ecf9dbc9a112cb2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 18:53:38 -0300 Subject: [PATCH 10/63] fix(super-editor): preserve field-run page number styling --- .../num-pages-preprocessor.js | 23 ++++++++++++++----- .../num-pages-preprocessor.test.js | 20 ++++++++++++++++ .../fld-preprocessors/page-preprocessor.js | 23 ++++++++++++++----- .../page-preprocessor.test.js | 21 +++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index 8195b51c56..376c8ebbff 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -5,11 +5,23 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for NUMPAGES). - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. + * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. + * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', fieldRunRPr = null) { +export function preProcessNumPagesInstruction( + nodesToCombine, + instrText = 'NUMPAGES', + _docxOrFieldRunRPr = null, + instructionTokensOrFieldRunRPr = null, + fieldRunRPr = null, +) { + const effectiveFieldRunRPr = + fieldRunRPr ?? + (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? + (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', @@ -36,10 +48,9 @@ export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPA }); // If no rPr was found in content nodes, use the rPr captured from the field sequence - // (begin, instrText, or separate nodes) where Word stores the styling for page numbers - // Validate that fieldRunRPr is actually a w:rPr node before using it - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - totalPageNumNode.elements = [fieldRunRPr]; + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + totalPageNumNode.elements = [effectiveFieldRunRPr]; } return [totalPageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index 745a7014bc..c41e8353c9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -44,6 +44,26 @@ describe('preProcessNumPagesInstruction', () => { expect(result[0].elements).toEqual([fieldRunRPr]); }); + it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + expect(result[0]).toEqual({ + name: 'sd:totalPageNumber', + type: 'element', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [fieldRunRPr], + }); + }); + it('should prefer content node rPr over fieldRunRPr', () => { const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const nodesToCombine = [ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index c640f19d92..0adf339391 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -5,11 +5,23 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for PAGE). - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. + * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. + * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fieldRunRPr = null) { +export function preProcessPageInstruction( + nodesToCombine, + instrText = 'PAGE', + _docxOrFieldRunRPr = null, + instructionTokensOrFieldRunRPr = null, + fieldRunRPr = null, +) { + const effectiveFieldRunRPr = + fieldRunRPr ?? + (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? + (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', @@ -29,10 +41,9 @@ export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fi }); // If no rPr was found in content nodes, use the rPr captured from the field sequence - // (begin, instrText, or separate nodes) where Word stores the styling for page numbers - // Validate that fieldRunRPr is actually a w:rPr node before using it - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - pageNumNode.elements = [fieldRunRPr]; + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + pageNumNode.elements = [effectiveFieldRunRPr]; } return [pageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index ddc1e1e9c1..6913d77765 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -61,6 +61,27 @@ describe('preProcessPageInstruction', () => { ]); }); + it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + expect(result).toEqual([ + { + name: 'sd:autoPageNumber', + type: 'element', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [fieldRunRPr], + }, + ]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; From e5c5e3fe09d28cec81cd9a48b4b90654561a83cc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 09:58:29 -0300 Subject: [PATCH 11/63] fix(contracts): centralize page number formatting --- packages/layout-engine/contracts/src/index.ts | 61 ++++++ .../src/page-number-formatting.test.ts | 28 +++ .../layout-engine/src/pageNumbering.test.ts | 4 + .../layout-engine/src/pageNumbering.ts | 184 +----------------- .../painters/dom/src/runs/text-run.ts | 63 +----- .../shared/page-number-field-switches.js | 66 +------ 6 files changed, 113 insertions(+), 293 deletions(-) create mode 100644 packages/layout-engine/contracts/src/page-number-formatting.test.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ceb60cdd9a..e1619c84e4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -339,6 +339,67 @@ export type PageNumberFieldFormat = { zeroPadding?: number; }; +export type PageNumberFormat = NonNullable; + +function toUpperRoman(value: number): string { + if (value < 1 || value > 3999) return String(value); + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toUpperLetter(value: number): string { + let n = Math.max(1, value); + let result = ''; + + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + + return result; +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + + switch (format) { + case 'upperRoman': + return toUpperRoman(value); + case 'lowerRoman': + return toUpperRoman(value).toLowerCase(); + case 'upperLetter': + return toUpperLetter(value); + case 'lowerLetter': + return toUpperLetter(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + return fieldFormat?.zeroPadding && format === 'decimal' + ? formatted.padStart(fieldFormat.zeroPadding, '0') + : formatted; +} + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts new file mode 100644 index 0000000000..c899f58bd1 --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { formatPageNumber, formatPageNumberFieldValue } from './index.js'; + +describe('page number formatting', () => { + it('formats the supported Word page number formats', () => { + expect(formatPageNumber(5, 'decimal')).toBe('5'); + expect(formatPageNumber(5, 'upperRoman')).toBe('V'); + expect(formatPageNumber(5, 'lowerRoman')).toBe('v'); + expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + }); + + it('normalizes page numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + expect(formatPageNumber(0, 'upperLetter')).toBe('A'); + expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1'); + }); + + it('falls back to decimal for roman numerals beyond 3999', () => { + expect(formatPageNumber(4000, 'upperRoman')).toBe('4000'); + }); + + it('applies decimal zero padding for field values', () => { + expect(formatPageNumberFieldValue(7, { format: 'decimal', zeroPadding: 3 })).toBe('007'); + expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii'); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index d550dab76f..87ef3473ea 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -27,6 +27,10 @@ describe('formatPageNumber', () => { expect(formatPageNumber(-1, 'decimal')).toBe('1'); expect(formatPageNumber(-100, 'decimal')).toBe('1'); }); + + it('should truncate fractional numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + }); }); describe('numberInDash format', () => { diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index ed4d7b58a6..ce3ed67c62 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,13 +13,15 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, PageNumberFieldFormat, SectionMetadata } from '@superdoc/contracts'; - -/** - * Page number format types supported by the layout engine. - * These match MS Word's page numbering format options. - */ -export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; +import { + formatPageNumber, + formatPageNumberFieldValue, + type Page, + type PageNumberFormat, + type SectionMetadata, +} from '@superdoc/contracts'; +export { formatPageNumber, formatPageNumberFieldValue }; +export type { PageNumberFormat }; /** * Display page information for a single page in the document. @@ -36,174 +38,6 @@ export interface DisplayPageInfo { sectionIndex: number; } -/** - * Converts a decimal number to uppercase Roman numeral format. - * - * Supports numbers from 1 to 3999. Uses standard Roman numeral rules - * including subtractive notation (IV, IX, XL, XC, CD, CM). - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in uppercase - * - * @example - * ```typescript - * toUpperRoman(1); // "I" - * toUpperRoman(4); // "IV" - * toUpperRoman(49); // "XLIX" - * toUpperRoman(1994); // "MCMXCIV" - * ``` - */ -function toUpperRoman(num: number): string { - if (num < 1 || num > 3999) { - // For numbers outside valid range, fall back to decimal - return String(num); - } - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - - let result = ''; - let remaining = num; - - for (let i = 0; i < values.length; i++) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -/** - * Converts a decimal number to lowercase Roman numeral format. - * - * Same conversion logic as uppercase Roman numerals, but returns - * lowercase characters. - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in lowercase - * - * @example - * ```typescript - * toLowerRoman(1); // "i" - * toLowerRoman(4); // "iv" - * toLowerRoman(49); // "xlix" - * ``` - */ -function toLowerRoman(num: number): string { - return toUpperRoman(num).toLowerCase(); -} - -/** - * Converts a decimal number to uppercase letter format (A-Z, AA-ZZ, etc.). - * - * Uses Excel-style column naming: A, B, ..., Z, AA, AB, ..., AZ, BA, ... - * This provides an alphabetical sequence that continues beyond 26. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in uppercase - * - * @example - * ```typescript - * toUpperLetter(1); // "A" - * toUpperLetter(26); // "Z" - * toUpperLetter(27); // "AA" - * toUpperLetter(52); // "AZ" - * ``` - */ -function toUpperLetter(num: number): string { - if (num < 1) { - return 'A'; - } - - let result = ''; - let n = num; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -/** - * Converts a decimal number to lowercase letter format (a-z, aa-zz, etc.). - * - * Same conversion logic as uppercase letters, but returns lowercase characters. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in lowercase - * - * @example - * ```typescript - * toLowerLetter(1); // "a" - * toLowerLetter(26); // "z" - * toLowerLetter(27); // "aa" - * ``` - */ -function toLowerLetter(num: number): string { - return toUpperLetter(num).toLowerCase(); -} - -/** - * Formats a page number according to the specified format. - * - * This function provides MS Word-compatible page number formatting. - * Edge cases are handled as follows: - * - Numbers <= 0 are clamped to 1 - * - Roman numerals outside 1-3999 fall back to decimal - * - All formats handle arbitrarily large positive numbers - * - * @param pageNumber - Page number to format (will be clamped to minimum 1) - * @param format - Desired output format - * @returns Formatted page number string - * - * @example - * ```typescript - * formatPageNumber(5, 'decimal'); // "5" - * formatPageNumber(5, 'upperRoman'); // "V" - * formatPageNumber(5, 'lowerRoman'); // "v" - * formatPageNumber(5, 'upperLetter'); // "E" - * formatPageNumber(5, 'lowerLetter'); // "e" - * formatPageNumber(0, 'decimal'); // "1" (clamped) - * formatPageNumber(-5, 'decimal'); // "1" (clamped) - * ``` - */ -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - // Clamp to minimum of 1 for edge cases - const num = Math.max(1, pageNumber); - - switch (format) { - case 'decimal': - return String(num); - case 'upperRoman': - return toUpperRoman(num); - case 'lowerRoman': - return toLowerRoman(num); - case 'upperLetter': - return toUpperLetter(num); - case 'lowerLetter': - return toLowerLetter(num); - case 'numberInDash': - return `-${num}-`; - default: - // TypeScript exhaustiveness check - should never reach here - return String(num); - } -} - -export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { - const format = fieldFormat?.format ?? 'decimal'; - const formatted = formatPageNumber(pageNumber, format); - if (fieldFormat?.zeroPadding && format === 'decimal') { - return formatted.padStart(fieldFormat.zeroPadding, '0'); - } - return formatted; -} - /** * Computes section-aware display page numbers for all pages in a document. * 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 efd57c9b9d..f7068b4de9 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -1,5 +1,9 @@ import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts'; -import { normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts'; +import { + formatPageNumberFieldValue, + normalizeBaselineShift, + resolveBaseFontSizeForVerticalText, +} from '@superdoc/contracts'; import { assertPmPositions } from '../pm-position-validation.js'; import type { FragmentRenderContext } from '../renderer.js'; import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; @@ -151,63 +155,6 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string return run.text ?? ''; }; -const formatPageNumberFieldValue = ( - value: number, - fieldFormat: NonNullable, -): string => { - const num = Math.max(1, Math.trunc(Number.isFinite(value) ? value : 1)); - const format = fieldFormat.format ?? 'decimal'; - const formatted = formatPageNumberByFormat(num, format); - return fieldFormat.zeroPadding && format === 'decimal' ? formatted.padStart(fieldFormat.zeroPadding, '0') : formatted; -}; - -const formatPageNumberByFormat = ( - value: number, - format: NonNullable['format'], -): string => { - switch (format) { - case 'upperRoman': - return toRoman(value); - case 'lowerRoman': - return toRoman(value).toLowerCase(); - case 'upperLetter': - return toLetters(value); - case 'lowerLetter': - return toLetters(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -}; - -const toRoman = (value: number): string => { - if (value < 1 || value > 3999) return String(value); - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - return result; -}; - -const toLetters = (value: number): string => { - let n = Math.max(1, value); - let result = ''; - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - return result; -}; - export const extractLinkData = (run: Run) => { if (run.kind === 'tab' || run.kind === 'image' || run.kind === 'lineBreak' || run.kind === 'math') { return null; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index 2bb6e49e23..ddc6f945dc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -1,3 +1,5 @@ +import { formatPageNumberFieldValue as formatSharedPageNumberFieldValue } from '@superdoc/contracts'; + const GENERAL_FORMATS = new Map([ ['Arabic', 'decimal'], ['roman', 'lowerRoman'], @@ -46,12 +48,10 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs */ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { - const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); - const format = attrs.pageNumberFormat || 'decimal'; - const formatted = formatPageNumberByFormat(value, format); - return attrs.pageNumberZeroPadding && format === 'decimal' - ? formatted.padStart(attrs.pageNumberZeroPadding, '0') - : formatted; + return formatSharedPageNumberFieldValue(pageNumber, { + format: attrs.pageNumberFormat || 'decimal', + zeroPadding: attrs.pageNumberZeroPadding ?? undefined, + }); } /** @@ -60,57 +60,3 @@ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { function unquote(value) { return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; } - -/** - * @param {number} value - * @param {string} format - */ -function formatPageNumberByFormat(value, format) { - switch (format) { - case 'upperRoman': - return toRoman(value); - case 'lowerRoman': - return toRoman(value).toLowerCase(); - case 'upperLetter': - return toLetters(value); - case 'lowerLetter': - return toLetters(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -} - -/** - * @param {number} value - */ -function toRoman(value) { - if (value < 1 || value > 3999) return String(value); - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - return result; -} - -/** - * @param {number} value - */ -function toLetters(value) { - let n = Math.max(1, value); - let result = ''; - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - return result; -} From 29f23630b9221c857f462252f0a2c2d0849e09df Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 09:59:45 -0300 Subject: [PATCH 12/63] refactor(pm-adapter): share page field format extraction --- .../inline-converters/generic-token.ts | 17 +---------------- .../page-number-field-format.test.ts | 18 ++++++++++++++++++ .../page-number-field-format.ts | 17 +++++++++++++++++ .../converters/inline-converters/text-run.ts | 17 +---------------- 4 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts index e90f8ca050..d9e6b3f044 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts @@ -3,6 +3,7 @@ import type { PMMark } from '../../types.js'; import { applyMarksToRun } from '../../marks/index.js'; import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; import { TOKEN_INLINE_TYPES } from '../../constants.js'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; /** * Converts a token PM node (e.g., page-number) to a TextRun with token metadata. @@ -81,19 +82,3 @@ export function tokenNodeToRun({ } return run; } - -function getPageNumberFieldFormat( - attrs: Record | undefined, -): TextRun['pageNumberFieldFormat'] | undefined { - if (!attrs) return undefined; - const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; - const zeroPadding = - typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) - ? attrs.pageNumberZeroPadding - : undefined; - if (!format && !zeroPadding) return undefined; - return { - ...(format ? { format: format as NonNullable['format'] } : {}), - ...(zeroPadding ? { zeroPadding } : {}), - }; -} diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts new file mode 100644 index 0000000000..04e9e519b7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; + +describe('getPageNumberFieldFormat', () => { + it('normalizes PAGE/NUMPAGES format attributes for layout runs', () => { + expect( + getPageNumberFieldFormat({ + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }), + ).toEqual({ format: 'decimal', zeroPadding: 2 }); + }); + + it('ignores invalid format attributes', () => { + expect(getPageNumberFieldFormat(undefined)).toBeUndefined(); + expect(getPageNumberFieldFormat({ pageNumberFormat: 1, pageNumberZeroPadding: Number.NaN })).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts new file mode 100644 index 0000000000..9b1c9e0969 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts @@ -0,0 +1,17 @@ +import type { TextRun } from '@superdoc/contracts'; + +export function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts index f51dedd144..e5cb29b6b0 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts @@ -12,6 +12,7 @@ import type { PMNode, PMMark, PositionMap, HyperlinkConfig, ThemeColorPalette } import { applyMarksToRun } from '../../marks/index.js'; import { DEFAULT_HYPERLINK_CONFIG } from '../../constants.js'; import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; /** * Converts a text PM node to a TextRun. @@ -131,19 +132,3 @@ export function tokenNodeToRun( } return run; } - -function getPageNumberFieldFormat( - attrs: Record | undefined, -): TextRun['pageNumberFieldFormat'] | undefined { - if (!attrs) return undefined; - const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; - const zeroPadding = - typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) - ? attrs.pageNumberZeroPadding - : undefined; - if (!format && !zeroPadding) return undefined; - return { - ...(format ? { format: format as NonNullable['format'] } : {}), - ...(zeroPadding ? { zeroPadding } : {}), - }; -} From 0db3bc16354bbfbbdee73cb913c4b9f3df6b7326 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:02:48 -0300 Subject: [PATCH 13/63] fix(super-editor): pass page field options explicitly --- .../num-pages-preprocessor.js | 21 +++------- .../num-pages-preprocessor.test.js | 39 ++++++++++++++----- .../fld-preprocessors/page-preprocessor.js | 21 +++------- .../page-preprocessor.test.js | 37 +++++++++++++----- .../preProcessNodesForFldChar.js | 9 ++++- .../preProcessPageFieldsOnly.js | 11 ++++-- 6 files changed, 82 insertions(+), 56 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index 376c8ebbff..2698748ef5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -5,23 +5,12 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for NUMPAGES). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction( - nodesToCombine, - instrText = 'NUMPAGES', - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', @@ -49,8 +38,8 @@ export function preProcessNumPagesInstruction( // If no rPr was found in content nodes, use the rPr captured from the field sequence // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - totalPageNumNode.elements = [effectiveFieldRunRPr]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + totalPageNumNode.elements = [fieldRunRPr]; } return [totalPageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index c41e8353c9..3b74113cee 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessNumPagesInstruction', () => { it('should create a sd:totalPageNumber node', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toHaveLength(1); expect(result[0].name).toBe('sd:totalPageNumber'); expect(result[0].type).toBe('element'); @@ -25,7 +25,7 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result[0].elements).toEqual([{ name: 'w:rPr', elements: [{ name: 'w:b' }] }]); }); @@ -40,18 +40,22 @@ describe('preProcessNumPagesInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result[0].elements).toEqual([fieldRunRPr]); }); - it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + it('should use fieldRunRPr from the generic field pipeline options', () => { const nodesToCombine = []; const instruction = 'NUMPAGES \\# "00"'; const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); expect(result[0]).toEqual({ name: 'sd:totalPageNumber', type: 'element', @@ -64,6 +68,21 @@ describe('preProcessNumPagesInstruction', () => { }); }); + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const nodesToCombine = [ @@ -77,7 +96,7 @@ describe('preProcessNumPagesInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result[0].elements).toEqual([contentRPr]); }); @@ -85,7 +104,7 @@ describe('preProcessNumPagesInstruction', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result[0].elements).toBeUndefined(); }); @@ -100,17 +119,17 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, null); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction); expect(result[0].attributes.importedCachedText).toBe('3'); }); it('should not set importedCachedText when no content text exists', () => { - const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); + const result = preProcessNumPagesInstruction([], 'NUMPAGES'); expect(result[0].attributes.importedCachedText).toBeUndefined(); }); it('preserves NUMPAGES zero-padding switches as normalized attributes', () => { - const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"', null); + const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"'); expect(result[0].attributes).toEqual({ instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index 0adf339391..b04639df0b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -5,23 +5,12 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for PAGE). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction( - nodesToCombine, - instrText = 'PAGE', - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', @@ -42,8 +31,8 @@ export function preProcessPageInstruction( // If no rPr was found in content nodes, use the rPr captured from the field sequence // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - pageNumNode.elements = [effectiveFieldRunRPr]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + pageNumNode.elements = [fieldRunRPr]; } return [pageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index 6913d77765..1a1edeb126 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessPageInstruction', () => { it('should create a sd:autoPageNumber node', () => { const nodesToCombine = []; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -28,7 +28,7 @@ describe('preProcessPageInstruction', () => { }, ]; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -51,7 +51,7 @@ describe('preProcessPageInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -61,14 +61,18 @@ describe('preProcessPageInstruction', () => { ]); }); - it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + it('should use fieldRunRPr from the generic field pipeline options', () => { const nodesToCombine = []; const instruction = 'PAGE \\* roman'; const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -82,6 +86,21 @@ describe('preProcessPageInstruction', () => { ]); }); + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; @@ -96,7 +115,7 @@ describe('preProcessPageInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -111,7 +130,7 @@ describe('preProcessPageInstruction', () => { const instruction = 'PAGE'; // Pass something that's not a w:rPr node const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessPageInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -121,7 +140,7 @@ describe('preProcessPageInstruction', () => { }); it('preserves PAGE general format switches as normalized attributes', () => { - const result = preProcessPageInstruction([], 'PAGE \\* roman', null); + const result = preProcessPageInstruction([], 'PAGE \\* roman'); expect(result[0].attributes).toEqual({ instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman', @@ -129,7 +148,7 @@ describe('preProcessPageInstruction', () => { }); it('preserves PAGE ArabicDash switches as normalized attributes', () => { - const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash', null); + const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash'); expect(result[0].attributes).toEqual({ instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 613083e125..ea380efc5d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -9,6 +9,7 @@ import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/im const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); +const isPageNumberFieldInstruction = (instructionType) => instructionType === 'PAGE' || instructionType === 'NUMPAGES'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. @@ -141,7 +142,9 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionType = instr.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - const processed = instructionPreProcessor(node.elements ?? [], instr, docx, null); + const processed = isPageNumberFieldInstruction(instructionType) + ? instructionPreProcessor(node.elements ?? [], instr, { docx }) + : instructionPreProcessor(node.elements ?? [], instr, docx, null); if (collecting) { collectedNodesStack[collectedNodesStack.length - 1].push(...processed); rawCollectedNodesStack[rawCollectedNodesStack.length - 1].push(...processed); @@ -328,7 +331,9 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { - nodes: instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + nodes: isPageNumberFieldInstruction(instructionType) + ? instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }) + : instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), handled: true, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 0367a738ed..6c5cc0cea3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -8,6 +8,7 @@ import { preProcessDocumentStatInstruction } from './fld-preprocessors/document- const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); +const isPageNumberFieldType = (fieldType) => fieldType === 'PAGE' || fieldType === 'NUMPAGES'; /** * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering. @@ -62,7 +63,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = isPageNumberFieldType(fieldType) + ? fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }) + : fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); processedNodes.push(...processedField); i++; continue; @@ -98,7 +101,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) // which is where Word stores the styling for page number fields const contentNodes = fieldInfo.contentNodes; - const processedField = preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); + const processedField = isPageNumberFieldType(fieldInfo.fieldType) + ? preprocessor(contentNodes, fieldInfo.instrText, { fieldRunRPr: fieldInfo.fieldRunRPr }) + : preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); processedNodes.push(...processedField); // Skip past the entire field sequence @@ -127,7 +132,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // to a PAGE field by emitting sd:autoPageNumber. if (node.name === 'w:r' && node.elements?.some((el) => el.name === 'w:pgNum')) { const rPr = node.elements.find((el) => el.name === 'w:rPr') || null; - const processedField = preProcessPageInstruction([], '', rPr); + const processedField = preProcessPageInstruction([], '', { fieldRunRPr: rPr }); processedNodes.push(...processedField); i++; continue; From aa8962738723465a6dc5805a3bfcbd7c91bbf299 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:08:50 -0300 Subject: [PATCH 14/63] refactor(super-editor): use field processor options object --- .../bibliography-preprocessor.js | 7 ++++--- .../citation-preprocessor.js | 6 +++--- .../document-stat-preprocessor.js | 21 +++++-------------- .../document-stat-preprocessor.test.js | 18 ++++++++-------- .../hyperlink-preprocessor.js | 5 +++-- .../hyperlink-preprocessor.test.js | 6 +++--- .../fld-preprocessors/index-preprocessor.js | 6 +++--- .../index-preprocessor.test.js | 4 ++-- .../fld-preprocessors/index.js | 9 +++++++- .../fld-preprocessors/noteref-preprocessor.js | 4 +++- .../page-ref-preprocessor.js | 4 +++- .../page-ref-preprocessor.test.js | 6 ++---- .../fld-preprocessors/ref-preprocessor.js | 4 +++- .../fld-preprocessors/seq-preprocessor.js | 6 +++--- .../styleref-preprocessor.js | 4 +++- .../fld-preprocessors/ta-preprocessor.js | 6 +++--- .../fld-preprocessors/tc-preprocessor.js | 6 +++--- .../fld-preprocessors/toa-preprocessor.js | 6 +++--- .../fld-preprocessors/toc-preprocessor.js | 4 +++- .../fld-preprocessors/xe-preprocessor.js | 6 +++--- .../fld-preprocessors/xe-preprocessor.test.js | 4 ++-- .../preProcessNodesForFldChar.js | 9 ++------ .../preProcessPageFieldsOnly.js | 11 ++++------ 23 files changed, 80 insertions(+), 82 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js index 37b336f579..64e88a2894 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js @@ -7,10 +7,11 @@ import { buildBlockFieldNode } from './build-block-field-node.js'; * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] + * @param {Array<{type: string, text?: string}>} [legacyInstructionTokens] Legacy raw instruction tokens. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessBibliographyInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessBibliographyInstruction(nodesToCombine, instrText, options = {}, legacyInstructionTokens = null) { + const instructionTokens = options?.instructionTokens ?? legacyInstructionTokens; return buildBlockFieldNode('sd:bibliography', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js index dac7ec469b..f9354fff32 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessCitationInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessCitationInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:citation', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js index 041c90d23e..4f3f8ce7c3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js @@ -6,22 +6,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} instrText The full instruction string (e.g. "NUMWORDS" or "NUMCHARS \* MERGEFORMAT"). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] In the generic body pipeline this position still carries `docx`; in header/footer standalone processing it carries the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the generic body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes for complex body fields. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessDocumentStatInstruction( - nodesToCombine, - instrText, - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessDocumentStatInstruction(nodesToCombine, instrText, options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const statFieldNode = { name: 'sd:documentStatField', type: 'element', @@ -44,8 +33,8 @@ export function preProcessDocumentStatInstruction( }); // Priority 2: Use rPr from field sequence if content has none - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - statFieldNode.elements = [effectiveFieldRunRPr, ...nodesToCombine]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + statFieldNode.elements = [fieldRunRPr, ...nodesToCombine]; } return [statFieldNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js index 41e6d7c0f4..2085b22b8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js @@ -41,7 +41,7 @@ describe('document-stat-preprocessor', () => { const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', fieldRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { fieldRunRPr: fieldRPr }); expect(result[0].elements).toContain(fieldRPr); }); @@ -50,7 +50,7 @@ describe('document-stat-preprocessor', () => { const notRPr = { name: 'w:other', elements: [] }; const contentNodes = []; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', notRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { fieldRunRPr: notRPr }); expect(result[0].elements).not.toContain(notRPr); }); @@ -61,22 +61,22 @@ describe('document-stat-preprocessor', () => { expect(result[0].attributes.instruction).toBe('NUMWORDS \\* MERGEFORMAT'); }); - it('uses 5th param fieldRunRPr when 3rd param is docx (body pipeline)', () => { - const docx = { 'word/document.xml': {} }; + it('uses options.fieldRunRPr without depending on docx shape', () => { + const docx = { name: 'w:rPr' }; const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', docx, null, fieldRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { docx, fieldRunRPr: fieldRPr }); expect(result[0].elements[0]).toBe(fieldRPr); }); - it('falls back to 3rd param w:rPr when 5th param is null (header/footer pipeline)', () => { - const rPrFromThirdParam = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + it('uses options.fieldRunRPr for header/footer processing', () => { + const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '5' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS', rPrFromThirdParam, null, null); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS', { fieldRunRPr: fieldRPr }); - expect(result[0].elements[0]).toBe(rPrFromThirdParam); + expect(result[0].elements[0]).toBe(fieldRPr); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js index 47b5ccd581..36e56f8c43 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js @@ -67,11 +67,12 @@ export function resolveHyperlinkAttributes(instruction, docx) { * Processes a HYPERLINK instruction and creates a `w:hyperlink` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instruction The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [docx] - The docx object. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1216 */ -export function preProcessHyperlinkInstruction(nodesToCombine, instruction, docx) { +export function preProcessHyperlinkInstruction(nodesToCombine, instruction, options = {}) { + const docx = options.docx; const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {}; return [ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js index db5401acc8..44bbc626e7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js @@ -31,7 +31,7 @@ describe('preProcessHyperlinkInstruction', () => { }, }; - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'w:hyperlink', @@ -144,7 +144,7 @@ describe('preProcessHyperlinkInstruction', () => { }, }; - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); // The Relationship Id should start with 'rId', not with a digit const relationshipId = mockDocx['word/_rels/document.xml.rels'].elements[0].elements[0].attributes.Id; @@ -161,7 +161,7 @@ describe('preProcessHyperlinkInstruction', () => { 'word/_rels/document.xml.rels': { elements: [] }, // Missing Relationships element }; // Expect it not to crash, but to return w:anchor as before - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'w:hyperlink', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js index 5e0bcbaf1d..fdd61ac303 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js @@ -4,10 +4,10 @@ import { buildBlockFieldNode } from './build-block-field-node.js'; * Processes an INDEX instruction and creates an `sd:index` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessIndexInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessIndexInstruction(nodesToCombine, instrText, options = {}, legacyInstructionTokens = null) { + const instructionTokens = options?.instructionTokens ?? legacyInstructionTokens; return buildBlockFieldNode('sd:index', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js index bd9e33ab49..00557b1f8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js @@ -20,13 +20,13 @@ describe('preProcessIndexInstruction', () => { const instrText = 'INDEX \\e "\t"'; const instructionTokens = [{ type: 'text', text: 'INDEX \\e "' }, { type: 'tab' }, { type: 'text', text: '"' }]; - const result = preProcessIndexInstruction(nodesToCombine, instrText, null, instructionTokens); + const result = preProcessIndexInstruction(nodesToCombine, instrText, { instructionTokens }); expect(result[0].attributes.instructionTokens).toEqual(instructionTokens); }); it('omits instructionTokens when null', () => { - const result = preProcessIndexInstruction([], 'INDEX', null, null); + const result = preProcessIndexInstruction([], 'INDEX', { instructionTokens: null }); expect(result[0].attributes).not.toHaveProperty('instructionTokens'); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index a194d26baf..d637f96126 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -16,11 +16,18 @@ import { preProcessTaInstruction } from './ta-preprocessor.js'; import { preProcessToaInstruction } from './toa-preprocessor.js'; import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; +/** + * @typedef {object} FieldPreprocessorOptions + * @property {import('../../v2/docxHelper').ParsedDocx} [docx] The docx object. + * @property {Array<{type: string, text?: string}> | null} [instructionTokens] Raw instruction tokens. + * @property {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr] The w:rPr node captured from field sequence nodes. + */ + /** * @callback InstructionPreProcessor * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine * @param {string} instruction - * @param {import('../../v2/docxHelper').ParsedDocx} [docx] - The docx object. + * @param {FieldPreprocessorOptions} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js index cee267402f..6d4f20fda5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessNoterefInstruction(nodesToCombine, instrText) { +export function preProcessNoterefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js index a61f24f739..d4e2f07e7c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js @@ -2,10 +2,12 @@ * Processes a PAGEREF instruction and creates a `sd:pageReference` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageRefInstruction(nodesToCombine, instrText) { +export function preProcessPageRefInstruction(nodesToCombine, instrText, options = {}) { + void options; const pageRefNode = { name: 'sd:pageReference', type: 'element', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js index 2d4ae76112..25c4ed076f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js @@ -3,13 +3,11 @@ import { describe, it, expect } from 'vitest'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; describe('preProcessPageRefInstruction', () => { - const mockDocx = {}; - const mockNodesToCombine = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }]; it('should process a page reference instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; - const result = preProcessPageRefInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessPageRefInstruction(mockNodesToCombine, instruction, {}); expect(result).toEqual([ { name: 'sd:pageReference', @@ -25,7 +23,7 @@ describe('preProcessPageRefInstruction', () => { it('should handle no text nodes', () => { const instruction = 'PAGEREF _Toc123456789 h'; const nodesWithoutText = []; - const result = preProcessPageRefInstruction(nodesWithoutText, instruction, mockDocx); + const result = preProcessPageRefInstruction(nodesWithoutText, instruction, {}); expect(result).toEqual([ { name: 'sd:pageReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js index dabe3d4a23..6bfe72c1b2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessRefInstruction(nodesToCombine, instrText) { +export function preProcessRefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js index 5d16712bf0..25cbdf8cc5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessSeqInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessSeqInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:sequenceField', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js index 94256b59ae..adf3e5dfe9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessStylerefInstruction(nodesToCombine, instrText) { +export function preProcessStylerefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js index 9cab61da30..7804be9938 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessTaInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessTaInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:authorityEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js index 54223fcd26..f304ad687f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js @@ -2,11 +2,11 @@ * Processes a TC (table of contents entry) instruction and creates an `sd:tableOfContentsEntry` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessTcInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessTcInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:tableOfContentsEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js index d6548547dd..1cf8d6c1c9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js @@ -7,10 +7,10 @@ import { buildBlockFieldNode } from './build-block-field-node.js'; * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessToaInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessToaInstruction(nodesToCombine, instrText, options = {}, legacyInstructionTokens = null) { + const instructionTokens = options?.instructionTokens ?? legacyInstructionTokens; return buildBlockFieldNode('sd:tableOfAuthorities', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js index 8e49d7c0cf..97380ccb3a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js @@ -2,10 +2,12 @@ * Processes a TOC instruction and creates a `sd:tableOfContents` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1251 */ -export function preProcessTocInstruction(nodesToCombine, instrText) { +export function preProcessTocInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:tableOfContents', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js index b5c979295b..92c77614de 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js @@ -2,11 +2,11 @@ * Processes an XE (index entry) instruction and creates an `sd:indexEntry` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessXeInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessXeInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:indexEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js index b1b9d1a2a5..f03423be56 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js @@ -20,13 +20,13 @@ describe('preProcessXeInstruction', () => { const instrText = 'XE "Term:Subterm"'; const instructionTokens = [{ type: 'text', text: 'XE "Term:Subterm"' }]; - const result = preProcessXeInstruction(nodesToCombine, instrText, null, instructionTokens); + const result = preProcessXeInstruction(nodesToCombine, instrText, { instructionTokens }); expect(result[0].attributes.instructionTokens).toEqual(instructionTokens); }); it('omits instructionTokens when null', () => { - const result = preProcessXeInstruction([], 'XE "Test"', null, null); + const result = preProcessXeInstruction([], 'XE "Test"', { instructionTokens: null }); expect(result[0].attributes).not.toHaveProperty('instructionTokens'); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index ea380efc5d..b6fca74f73 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -9,7 +9,6 @@ import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/im const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); -const isPageNumberFieldInstruction = (instructionType) => instructionType === 'PAGE' || instructionType === 'NUMPAGES'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. @@ -142,9 +141,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionType = instr.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - const processed = isPageNumberFieldInstruction(instructionType) - ? instructionPreProcessor(node.elements ?? [], instr, { docx }) - : instructionPreProcessor(node.elements ?? [], instr, docx, null); + const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); if (collecting) { collectedNodesStack[collectedNodesStack.length - 1].push(...processed); rawCollectedNodesStack[rawCollectedNodesStack.length - 1].push(...processed); @@ -331,9 +328,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { - nodes: isPageNumberFieldInstruction(instructionType) - ? instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }) - : instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + nodes: instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }), handled: true, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 6c5cc0cea3..a72e799b1b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -8,7 +8,6 @@ import { preProcessDocumentStatInstruction } from './fld-preprocessors/document- const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); -const isPageNumberFieldType = (fieldType) => fieldType === 'PAGE' || fieldType === 'NUMPAGES'; /** * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering. @@ -63,9 +62,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = isPageNumberFieldType(fieldType) - ? fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }) - : fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }); processedNodes.push(...processedField); i++; continue; @@ -101,9 +98,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) // which is where Word stores the styling for page number fields const contentNodes = fieldInfo.contentNodes; - const processedField = isPageNumberFieldType(fieldInfo.fieldType) - ? preprocessor(contentNodes, fieldInfo.instrText, { fieldRunRPr: fieldInfo.fieldRunRPr }) - : preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); + const processedField = preprocessor(contentNodes, fieldInfo.instrText, { + fieldRunRPr: fieldInfo.fieldRunRPr, + }); processedNodes.push(...processedField); // Skip past the entire field sequence From f5c65910a8a898f411eb5b4b2ba98d2333e0f294 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:17:59 -0300 Subject: [PATCH 15/63] refactor(contracts): move page number formatting --- packages/layout-engine/contracts/src/index.ts | 78 +++---------------- .../src/page-number-formatting.test.ts | 2 +- .../contracts/src/page-number-formatting.ts | 65 ++++++++++++++++ 3 files changed, 75 insertions(+), 70 deletions(-) create mode 100644 packages/layout-engine/contracts/src/page-number-formatting.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e1619c84e4..adf6c51af4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1,4 +1,5 @@ import type { TabStop } from './engines/tabs.js'; +import type { PageNumberFieldFormat } from './page-number-formatting.js'; export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js'; // Re-export TabStop for external consumers @@ -102,10 +103,10 @@ import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; export { + authorFromTrackedChangeMeta, + authorIdentityKey, composeAuthorColorResolver, fallbackAuthorColor, - authorIdentityKey, - authorFromTrackedChangeMeta, stampTrackedChangeColors, } from './author-colors.js'; export type { AuthorColorsConfig, TrackChangeAuthorColorResolver } from './author-colors.js'; @@ -116,6 +117,12 @@ export { hasExplicitSdtContainerKey, isSdtContainerMetadata, } from './sdt-container.js'; +export { + formatPageNumber, + formatPageNumberFieldValue, + type PageNumberFieldFormat, + type PageNumberFormat, +} from './page-number-formatting.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; @@ -333,73 +340,6 @@ export type FlowRunLink = { export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text'; export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt'; - -export type PageNumberFieldFormat = { - format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; - zeroPadding?: number; -}; - -export type PageNumberFormat = NonNullable; - -function toUpperRoman(value: number): string { - if (value < 1 || value > 3999) return String(value); - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -function toUpperLetter(value: number): string { - let n = Math.max(1, value); - let result = ''; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); - - switch (format) { - case 'upperRoman': - return toUpperRoman(value); - case 'lowerRoman': - return toUpperRoman(value).toLowerCase(); - case 'upperLetter': - return toUpperLetter(value); - case 'lowerLetter': - return toUpperLetter(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -} - -export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { - const format = fieldFormat?.format ?? 'decimal'; - const formatted = formatPageNumber(pageNumber, format); - return fieldFormat?.zeroPadding && format === 'decimal' - ? formatted.padStart(fieldFormat.zeroPadding, '0') - : formatted; -} - /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts index c899f58bd1..529639ec1e 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatPageNumber, formatPageNumberFieldValue } from './index.js'; +import { formatPageNumber, formatPageNumberFieldValue } from './page-number-formatting.js'; describe('page number formatting', () => { it('formats the supported Word page number formats', () => { diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts new file mode 100644 index 0000000000..bf32393cda --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -0,0 +1,65 @@ +export type PageNumberFieldFormat = { + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + zeroPadding?: number; +}; + +export type PageNumberFormat = NonNullable; + +function toUpperRoman(value: number): string { + if (value < 1 || value > 3999) return String(value); + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toUpperLetter(value: number): string { + let n = Math.max(1, value); + let result = ''; + + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + + return result; +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + + switch (format) { + case 'upperRoman': + return toUpperRoman(value); + case 'lowerRoman': + return toUpperRoman(value).toLowerCase(); + case 'upperLetter': + return toUpperLetter(value); + case 'lowerLetter': + return toUpperLetter(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + return fieldFormat?.zeroPadding && format === 'decimal' + ? formatted.padStart(fieldFormat.zeroPadding, '0') + : formatted; +} From cf24abf24f0efb0fc50dfb5c36482037b186a7bc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 11:14:31 -0300 Subject: [PATCH 16/63] fix(super-editor): preserve active header display numbers --- .../HeaderFooterSessionManager.ts | 1 + .../tests/HeaderFooterSessionManager.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) 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 2fb8cc5aa1..19b2db3dbe 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 @@ -2240,6 +2240,7 @@ export class HeaderFooterSessionManager { number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, fragments: page.fragments, })), }; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index d82688658a..30b8a94c60 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -298,6 +298,29 @@ describe('HeaderFooterSessionManager', () => { expect(context?.measures).toEqual([{ id: 'blank-header-measure' }]); }); + it('preserves display page numbers in active per-rId layout contexts', async () => { + await setupWithZoom(1); + + manager.headerLayoutResults = null; + manager.headerLayoutsByRId.set('rId-header-default', { + kind: 'header', + type: 'default', + layout: { + height: 47, + pages: [{ number: 10, numberText: '1', displayNumber: 1, fragments: [] }], + }, + blocks: [], + measures: [], + }); + + const context = manager.getContext(); + expect(context?.layout.pages[0]).toMatchObject({ + number: 10, + numberText: '1', + displayNumber: 1, + }); + }); + it('falls back to zoom=1 when zoom is negative', async () => { await setupWithZoom(-1); From 831588f375b0dc5e196375381d50496f7874632d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 11:48:20 -0300 Subject: [PATCH 17/63] fix(contracts): remove duplicate display number fields --- .../layout-engine/layout-resolved/src/resolveHeaderFooter.ts | 1 - packages/layout-engine/layout-resolved/src/resolveLayout.ts | 1 - .../header-footer/HeaderFooterSessionManager.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index f5c2960c45..48d8f1a22c 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -31,7 +31,6 @@ export function resolveHeaderFooterLayout( number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, 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 cb72521d81..786e293f84 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -332,7 +332,6 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { footnoteReserved: page.footnoteReserved, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, vAlign: page.vAlign, baseMargins: page.baseMargins, sectionIndex: page.sectionIndex, 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 19b2db3dbe..2fb8cc5aa1 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 @@ -2240,7 +2240,6 @@ export class HeaderFooterSessionManager { number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, fragments: page.fragments, })), }; From 0dfe4c4dc1bb4c7f1b703105a32bbe57fa0d6254 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 13:34:35 -0300 Subject: [PATCH 18/63] fix(layout-bridge): bucket zero-padded page numbers --- .../layout-bridge/src/layoutHeaderFooter.ts | 25 ++++++++++++------ .../test/layoutHeaderFooterBucketing.test.ts | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 682cdc0546..814cdb9387 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -121,9 +121,18 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } -function paragraphHasFormattedPageNumberToken(para: ParagraphBlock): boolean { +function isDigitBucketCompatiblePageNumberFormat(format?: string): boolean { + return !format || format === 'decimal' || format === 'numberInDash'; +} + +function paragraphRequiresPerPageLayout(para: ParagraphBlock): boolean { for (const run of para.runs) { - if ('token' in run && run.token === 'pageNumber' && run.pageNumberFieldFormat) { + if ( + 'token' in run && + run.token === 'pageNumber' && + run.pageNumberFieldFormat && + !isDigitBucketCompatiblePageNumberFormat(run.pageNumberFieldFormat.format) + ) { return true; } } @@ -155,10 +164,10 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } -function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { +function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { - if (paragraphHasFormattedPageNumberToken(block as ParagraphBlock)) return true; + if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true; } else if (block.kind === 'table') { const table = block as TableBlock; for (const row of table.rows ?? []) { @@ -168,7 +177,7 @@ function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { : cell.paragraph ? [cell.paragraph] : []; - if (hasFormattedPageNumberTokens(cellBlocks)) return true; + if (hasPageNumberTokensRequiringPerPageLayout(cellBlocks)) return true; } } } @@ -231,7 +240,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache(); * 2. If variant has no tokens: creates one layout reused across all pages (fast path) * 3. For small docs (<100 pages): creates per-page layouts * 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4) - * unless PAGE tokens have explicit field formatting + * unless PAGE tokens use non-decimal field formatting * * @param sections - Header/footer variants (default, first, even, odd) * @param constraints - Layout constraints (width, height, margins) @@ -297,10 +306,10 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - const useBucketingForVariant = useBucketing && !hasFormattedPageNumberTokens(blocks); + const useBucketingForVariant = useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks); if (!useBucketingForVariant) { - // Per-page layout: small docs, disabled bucketing, or explicit PAGE formats. + // Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 36f3441eee..1c6f4a6e4d 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -414,6 +414,32 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(result.default?.layout.pages).toHaveLength(150); expect(measureBlock).toHaveBeenCalledTimes(150); }); + + it('should digit-bucket zero-padded decimal page-number tokens', async () => { + const block = makePageTokenBlock('header-zero-padded-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'decimal', zeroPadding: 3 }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(3); + expect(measureBlock).toHaveBeenCalledTimes(3); + expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005'); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { From 975e586e8f6a6d89cb7fe5597dd3f2f790e17751 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 10:09:34 -0300 Subject: [PATCH 19/63] fix(super-editor): parse numeric page switch casing --- .../shared/page-number-field-switches.js | 8 +++++++- .../shared/page-number-field-switches.test.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index ddc6f945dc..969ea83a9b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -3,12 +3,18 @@ import { formatPageNumberFieldValue as formatSharedPageNumberFieldValue } from ' const GENERAL_FORMATS = new Map([ ['Arabic', 'decimal'], ['roman', 'lowerRoman'], + ['Roman', 'upperRoman'], ['ROMAN', 'upperRoman'], ['alphabetic', 'lowerLetter'], ['ALPHABETIC', 'upperLetter'], ['ArabicDash', 'numberInDash'], ]); +const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ + ['arabic', 'decimal'], + ['arabicdash', 'numberInDash'], +]); + /** * @param {string} instruction * @param {'PAGE' | 'NUMPAGES'} fieldType @@ -24,7 +30,7 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { const rawValue = unquote(match[1]); - const mapped = GENERAL_FORMATS.get(rawValue); + const mapped = GENERAL_FORMATS.get(rawValue) ?? CASE_INSENSITIVE_GENERAL_FORMATS.get(rawValue.toLowerCase()); if (mapped) { result.pageNumberFormat = mapped; break; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js index 8ce9e11d09..3c4a11b96f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -4,15 +4,27 @@ import { parsePageNumberFieldSwitches } from './page-number-field-switches.js'; describe('parsePageNumberFieldSwitches', () => { it.each([ ['PAGE \\* Arabic', { instruction: 'PAGE \\* Arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* arabic', { instruction: 'PAGE \\* arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* ARABIC', { instruction: 'PAGE \\* ARABIC', pageNumberFormat: 'decimal' }], ['PAGE \\* roman', { instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman' }], + ['PAGE \\* Roman', { instruction: 'PAGE \\* Roman', pageNumberFormat: 'upperRoman' }], ['PAGE \\* ROMAN', { instruction: 'PAGE \\* ROMAN', pageNumberFormat: 'upperRoman' }], ['PAGE \\* alphabetic', { instruction: 'PAGE \\* alphabetic', pageNumberFormat: 'lowerLetter' }], ['PAGE \\* ALPHABETIC', { instruction: 'PAGE \\* ALPHABETIC', pageNumberFormat: 'upperLetter' }], ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* arabicdash', { instruction: 'PAGE \\* arabicdash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* ARABICDASH', { instruction: 'PAGE \\* ARABICDASH', pageNumberFormat: 'numberInDash' }], ])('parses general format switch %s', (instruction, expected) => { expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); }); + it.each([['PAGE \\* rOman'], ['PAGE \\* Alphabetic'], ['PAGE \\* aLpHaBeTiC']])( + 'does not case-fold output-case-sensitive switch %s', + (instruction) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual({ instruction }); + }, + ); + it.each([ ['NUMPAGES \\# "00"', { instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', pageNumberZeroPadding: 2 }], ['NUMPAGES \\# 000', { instruction: 'NUMPAGES \\# 000', pageNumberFormat: 'decimal', pageNumberZeroPadding: 3 }], From 9612c6b33b937e91bf07b187655a24639fee32c4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 11:24:31 -0300 Subject: [PATCH 20/63] fix(converter): parse field dispatch whitespace --- .../fld-preprocessors/index.js | 2 +- .../fld-preprocessors/index.test.js | 6 +++ .../preProcessNodesForFldChar.js | 6 +-- .../preProcessNodesForFldChar.test.js | 24 ++++++++++++ .../preProcessPageFieldsOnly.js | 2 +- .../preProcessPageFieldsOnly.test.js | 38 +++++++++++++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index d637f96126..0188dee25f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -37,7 +37,7 @@ import { preProcessDocumentStatInstruction } from './document-stat-preprocessor. * @returns {InstructionPreProcessor | null} The pre-processor function or null if not found. */ export const getInstructionPreProcessor = (instruction) => { - const instructionType = instruction.split(' ')[0]; + const instructionType = instruction.trim().split(/\s+/)[0]; switch (instructionType) { case 'PAGE': return preProcessPageInstruction; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js index 4d11dbb570..0b6c992709 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js @@ -26,6 +26,12 @@ describe('getInstructionPreProcessor', () => { expect(processor).toBe(preProcessNumPagesInstruction); }); + it('should return preProcessNumPagesInstruction when instruction uses non-space whitespace', () => { + const instruction = 'NUMPAGES\t\\# "00"'; + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessNumPagesInstruction); + }); + it('should return preProcessPageRefInstruction for PAGEREF instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; const processor = getInstructionPreProcessor(instruction); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index b6fca74f73..35fe35fba2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -138,7 +138,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { if (node.name === 'w:fldSimple') { const instr = node.attributes?.['w:instr']; if (typeof instr === 'string') { - const instructionType = instr.trim().split(' ')[0]; + const instructionType = instr.trim().split(/\s+/)[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); @@ -324,7 +324,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { * @returns {{ nodes: OpenXmlNode[], handled: boolean }} The processed nodes and whether a preprocessor handled them. */ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, instructionTokens, fieldRunRPr) => { - const instructionType = instrText.trim().split(' ')[0]; + const instructionType = instrText.trim().split(/\s+/)[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { @@ -349,7 +349,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i * @param {ParsedDocx} docx */ const applyConstructiveFieldInterpretation = (rawNodes, instrText, docx) => { - const instructionType = instrText.split(' ')[0]; + const instructionType = instrText.trim().split(/\s+/)[0]; if (instructionType !== 'HYPERLINK') return; const linkAttributes = resolveHyperlinkAttributes(instrText, docx); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 80c551a89f..c8ac7fe719 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -119,6 +119,30 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it('processes PAGE field switches when instruction whitespace is not a literal space', () => { + const nodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'PAGE\t\\* Arabic' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toMatchObject({ + name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* Arabic', + pageNumberFormat: 'decimal', + }, + }); + }); + it('processes TOC fields when begin, instrText, separate, and end share a single run', () => { const nodes = [ { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index a72e799b1b..60df3ca4f6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -206,7 +206,7 @@ function scanFieldSequence(nodes, beginIndex) { return null; // Incomplete field } - const fieldType = instrText.trim().split(' ')[0]; + const fieldType = instrText.trim().split(/\s+/)[0]; return { fieldType, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index 240dbc2453..d97ad0e86b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -63,6 +63,44 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes).toHaveLength(1); expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); }); + + it('should process NUMPAGES switches when field instruction uses newline whitespace', () => { + const nodes = [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'NUMPAGES\n\\# "00"' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '05' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + importedCachedText: '05', + }, + }); + }); }); describe('simple field syntax (w:fldSimple)', () => { From 15c6cf1b5880b8b73502d7a8f68924238ae2fd96 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 12:58:22 -0300 Subject: [PATCH 21/63] fix(header-footer): centralize OOXML ref inheritance for first-page headers (SD-2997) (#3264) * fix(super-editor): honor per-section titlePg when inferring fallback regions When inferring header/footer region variants without explicit instance metadata, the fallback path only consulted the document-level titlePg flag. Multi-section documents that override titlePg per section ended up classifying the first page as 'default' instead of 'first'. Use the multi-section identifier's sectionTitlePg map when available so each section's variant is respected. * refactor(layout-engine): centralize header/footer ref inheritance Extract the OOXML header/footer ref inheritance logic into a shared helper (`resolveInheritedHeaderFooterRef`) in `@superdoc/contracts` and use it from layout-engine, layout-bridge, and HeaderFooterSessionManager. This replaces three near-duplicate copies of the same resolution rules. While unifying the logic, fix inheritance through intermediate sections that omit `first`/`even` refs: previously the resolver only looked at the immediately prior section, so a `first` ref defined in section 0 was lost once section 1 (with only a `default` ref) sat between section 0 and a later section that also lacked an explicit `first` ref. The shared resolver now walks back to the nearest prior section that defines the requested variant. * fix(contracts): preserve header footer fallback refs * fix(layout-engine): use resolved header footer height slot * fix(contracts): ignore later refs for fallback resolution * fix(layout-bridge): render inherited default refs * fix(super-editor): tolerate missing section titlePg map * fix(contracts): inherit converter fallback refs * test(contracts): cover header footer inheritance helper * fix(layout-bridge): drop unused inheritance re-export * test(super-editor): cover section titlePg decoration provider * fix(layout-bridge): skip missing even header refs * fix(header-footer): preserve negative minY from page-relative behindDoc media Stop shifting normal footer/header fragments when the layout's minY is negative purely because of explicit behindDoc anchored drawings/images (e.g. page-relative background shapes). Decoration normalization now computes its own minY that ignores those explicit behindDoc media, so in-flow content stays at its original coordinates while the negative minY is preserved on the payload for downstream painters. * fix(header-footer): honor identifier alternate header state --- .../src/header-footer-inheritance.test.ts | 98 ++++ .../src/header-footer-inheritance.ts | 66 +++ packages/layout-engine/contracts/src/index.ts | 8 + .../layout-bridge/src/headerFooterUtils.ts | 62 +-- .../test/headerFooterUtils.test.ts | 185 ++++++- .../layout-engine/src/index.test.ts | 80 ++++ .../layout-engine/layout-engine/src/index.ts | 96 ++-- .../HeaderFooterSessionManager.ts | 96 ++-- .../tests/HeaderFooterSessionManager.test.ts | 450 +++++++++++++++++- 9 files changed, 1002 insertions(+), 139 deletions(-) create mode 100644 packages/layout-engine/contracts/src/header-footer-inheritance.test.ts create mode 100644 packages/layout-engine/contracts/src/header-footer-inheritance.ts diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts new file mode 100644 index 0000000000..acff874e5c --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, +} from './header-footer-inheritance.js'; + +describe('header/footer inheritance', () => { + it('uses legacy refs when section maps are empty', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + sectionHeaderIds: new Map(), + }, + sectionIndex: 0, + kind: 'header', + variantType: 'default', + }); + + expect(ref).toBe('legacy-default'); + }); + + it('returns null for section zero when no page, section, or legacy refs exist', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { sectionHeaderIds: new Map() }, + sectionIndex: 0, + kind: 'header', + variantType: 'default', + }); + + expect(ref).toBeNull(); + }); + + it('walks back past intermediate sections with no entry', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + sectionHeaderIds: new Map([[0, { first: 'section-0-first' }]]), + }, + sectionIndex: 3, + kind: 'header', + variantType: 'first', + }); + + expect(ref).toBe('section-0-first'); + }); + + it('prefers page refs over section refs', () => { + const resolved = resolveInheritedHeaderFooterRefWithType({ + identifier: { + sectionFooterIds: new Map([[0, { default: 'section-default' }]]), + }, + sectionIndex: 0, + kind: 'footer', + variantType: 'default', + pageRefs: { default: 'page-default' }, + }); + + expect(resolved).toEqual({ ref: 'page-default', variantType: 'default' }); + }); + + it('uses default refs for odd pages and reports the effective variant', () => { + const resolved = resolveInheritedHeaderFooterRefWithType({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'odd', + }); + + expect(resolved).toEqual({ ref: 'legacy-default', variantType: 'default' }); + }); + + it('does not fall back from first to default', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'first', + }); + + expect(ref).toBeNull(); + }); + + it('does not fall back from even to default', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'even', + }); + + expect(ref).toBeNull(); + }); +}); diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts new file mode 100644 index 0000000000..5db15a76bc --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -0,0 +1,66 @@ +import type { HeaderFooterType } from './index.js'; + +export type HeaderFooterRefMap = Partial>; + +export type HeaderFooterRefIdentifier = { + headerIds?: HeaderFooterRefMap; + footerIds?: HeaderFooterRefMap; + sectionCount?: number; + sectionHeaderIds?: Map; + sectionFooterIds?: Map; +}; + +export type ResolveInheritedHeaderFooterRefInput = { + identifier: HeaderFooterRefIdentifier; + sectionIndex: number; + kind: 'header' | 'footer'; + variantType: HeaderFooterType; + pageRefs?: HeaderFooterRefMap; +}; + +export type ResolvedInheritedHeaderFooterRef = { + ref: string; + variantType: HeaderFooterType; +}; + +function resolveVariantRef( + refs: HeaderFooterRefMap | undefined, + variantType: HeaderFooterType, +): ResolvedInheritedHeaderFooterRef | null { + if (!refs) return null; + const direct = refs[variantType]; + if (direct) return { ref: direct, variantType }; + if (variantType === 'odd' && refs.default) return { ref: refs.default, variantType: 'default' }; + return null; +} + +export function resolveInheritedHeaderFooterRefWithType({ + identifier, + sectionIndex, + kind, + variantType, + pageRefs, +}: ResolveInheritedHeaderFooterRefInput): ResolvedInheritedHeaderFooterRef | null { + const fromPage = resolveVariantRef(pageRefs, variantType); + if (fromPage) return fromPage; + + const sectionMap = kind === 'header' ? identifier.sectionHeaderIds : identifier.sectionFooterIds; + const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds; + + const sectionIds = sectionMap?.get(sectionIndex); + const fromSection = resolveVariantRef(sectionIds, variantType); + if (fromSection) return fromSection; + + if (sectionMap) { + for (let index = sectionIndex - 1; index >= 0; index -= 1) { + const inherited = resolveVariantRef(sectionMap.get(index), variantType); + if (inherited) return inherited; + } + } + + return resolveVariantRef(legacyIds, variantType); +} + +export function resolveInheritedHeaderFooterRef(input: ResolveInheritedHeaderFooterRefInput): string | null { + return resolveInheritedHeaderFooterRefWithType(input)?.ref ?? null; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index adf6c51af4..aa76e95ed2 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -117,6 +117,14 @@ export { hasExplicitSdtContainerKey, isSdtContainerMetadata, } from './sdt-container.js'; +export { + resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, + type HeaderFooterRefIdentifier, + type HeaderFooterRefMap, + type ResolvedInheritedHeaderFooterRef, + type ResolveInheritedHeaderFooterRefInput, +} from './header-footer-inheritance.js'; export { formatPageNumber, formatPageNumberFieldValue, diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 384c467e33..bc83e5758e 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -1,4 +1,10 @@ -import type { HeaderFooterType, Layout, SectionMetadata, Page } from '@superdoc/contracts'; +import { + resolveInheritedHeaderFooterRef, + type HeaderFooterType, + type Layout, + type SectionMetadata, + type Page, +} from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; @@ -382,16 +388,33 @@ export function getHeaderFooterTypeForSection( } if (identifier.alternateHeaders) { - // Keep parity-based variant selection even when this section doesn't - // explicitly define that variant. Resolution/inheritance happens later. if (!hasAny) return null; - return parityPageNumber % 2 === 0 ? 'even' : 'odd'; + const parityVariant = parityPageNumber % 2 === 0 ? 'even' : 'odd'; + return resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType: parityVariant, + }) + ? parityVariant + : null; } if (hasDefault) { return 'default'; } + if ( + resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType: 'default', + }) + ) { + return 'default'; + } + return null; } @@ -432,31 +455,14 @@ export function getHeaderFooterIdForPage( }); if (!variantType) return null; - const resolveVariantId = (ids: Partial | undefined): string | null => { - if (!ids) return null; - const direct = ids[variantType]; - if (direct) return direct; - // With w:evenAndOddHeaders enabled, OOXML `default` is the primary/odd - // page slot. It must not be used as a replacement for a missing even ref. - if (variantType === 'odd' && ids.default) return ids.default; - return null; - }; - - // First try to get from page's sectionRefs (most specific, stamped during layout) const pageRefs = kind === 'header' ? page.sectionRefs?.headerRefs : page.sectionRefs?.footerRefs; - const idFromPage = resolveVariantId(pageRefs); - if (idFromPage) return idFromPage; - - // Fall back to identifier's section mappings - const sectionIds = - kind === 'header' ? identifier.sectionHeaderIds.get(sectionIndex) : identifier.sectionFooterIds.get(sectionIndex); - - const idFromSection = resolveVariantId(sectionIds); - if (idFromSection) return idFromSection; - - // Final fallback to legacy identifier fields - const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds; - return legacyIds[variantType] ?? null; + return resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType, + pageRefs, + }); } /** diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 8ce200a8ab..a785892c7b 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -647,6 +647,86 @@ describe('headerFooterUtils', () => { expect(section1FirstPage).toBe('first'); }); + it('resolves first-page header refs through intermediate sections that omit first refs', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default', first: 'h0-first' }, + titlePg: true, + }, + { + sectionIndex: 1, + headerRefs: { default: 'h1-default' }, + titlePg: true, + }, + { + sectionIndex: 2, + headerRefs: { default: 'h2-default' }, + titlePg: true, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + { + number: 3, + fragments: [], + sectionIndex: 2, + sectionRefs: { headerRefs: { default: 'h2-default' } }, + }, + ], + headerFooter: { + first: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 2, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('first'); + expect(resolved?.contentId).toBe('h0-first'); + }); + + it('inherits from the nearest prior section when the current section has no explicit refs map', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default', first: 'h0-first' }, + titlePg: true, + }, + { + sectionIndex: 1, + headerRefs: { default: 'h1-default', first: 'h1-first' }, + titlePg: true, + }, + { + sectionIndex: 2, + titlePg: true, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + { number: 3, fragments: [], sectionIndex: 2 }, + ], + headerFooter: { + first: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 2, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('first'); + expect(resolved?.contentId).toBe('h1-first'); + }); + it('returns even/odd variants for alternate headers even when section defines only default', () => { const sectionMetadata: SectionMetadata[] = [ { @@ -775,7 +855,7 @@ describe('headerFooterUtils', () => { expect(evenPageHeader?.contentId).toBe('h0-even'); }); - it('does not use section default content id for even pages when alternate header even ref is missing', () => { + it('does not resolve a header for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -801,11 +881,10 @@ describe('headerFooterUtils', () => { }; const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); - expect(evenPageHeader?.type).toBe('even'); - expect(evenPageHeader?.contentId).toBeNull(); + expect(evenPageHeader).toBeNull(); }); - it('keeps parity variant but does not infer default content id for missing alternate refs', () => { + it('does not resolve a footer for even pages when alternate footer even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -835,8 +914,7 @@ describe('headerFooterUtils', () => { }; const evenPageFooterId = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'footer' }); - expect(evenPageFooterId?.type).toBe('even'); - expect(evenPageFooterId?.contentId).toBeNull(); + expect(evenPageFooterId).toBeNull(); }); it('keeps inherited parity selection when the current section has no explicit refs', () => { @@ -860,7 +938,7 @@ describe('headerFooterUtils', () => { expect(evenPageType).toBe('even'); }); - it('returns null when a later section has no explicit default ref', () => { + it('returns default when a later section inherits a default ref', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -877,7 +955,98 @@ describe('headerFooterUtils', () => { kind: 'header', sectionPageNumber: 1, }); - expect(inheritedDefaultType).toBeNull(); + expect(inheritedDefaultType).toBe('default'); + }); + + it('uses inherited default refs when alternate headers are disabled', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default' }, + }, + { + sectionIndex: 1, + headerRefs: { even: 'h1-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1, sectionRefs: { headerRefs: { even: 'h1-even' } } }, + ], + headerFooter: { + default: { pages: [{ number: 2, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('h0-default'); + }); + + it('uses converter fallback refs when section metadata has no explicit refs', () => { + const identifier = buildMultiSectionIdentifier([{ sectionIndex: 0 }], undefined, { + headerIds: { default: 'converter-default' }, + }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [{ number: 1, fragments: [], sectionIndex: 0 }], + headerFooter: { + default: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); + }); + + it('uses converter fallback refs when only later sections define refs', () => { + const identifier = buildMultiSectionIdentifier( + [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { default: 'section-1-default' } }], + undefined, + { headerIds: { default: 'converter-default' } }, + ); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [{ number: 1, fragments: [], sectionIndex: 0 }], + headerFooter: { + default: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); + }); + + it('inherits converter fallback refs into later sections with partial refs', () => { + const identifier = buildMultiSectionIdentifier( + [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { even: 'section-1-even' } }], + undefined, + { headerIds: { default: 'converter-default' } }, + ); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1, sectionRefs: { headerRefs: { even: 'section-1-even' } } }, + ], + headerFooter: { + default: { pages: [{ number: 2, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); }); }); }); diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 32c4be6e8b..74020fc2d6 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6090,6 +6090,27 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(p2Fragment!.y).toBeCloseTo(70, 0); }); + it('uses default header height when odd pages resolve through the default ref', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdDefault' } }], + headerContentHeights: { + default: 80, + }, + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages).toHaveLength(1); + + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + expect(p1Fragment).toBeDefined(); + expect(p1Fragment!.y).toBeCloseTo(110, 0); + expect(layout.pages[0].margins.top).toBeCloseTo(110, 0); + }); + it('uses section page-numbering start for odd/even header parity', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -6334,6 +6355,65 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0); }); + it('uses inherited first-page header height through intermediate sections that omit first refs', () => { + const sb1: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb1', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const sb2: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb2', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 1 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const sb3: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb3', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 2 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'rIdS0First', default: 'rIdS0Default' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'rIdS1Default' } }, + { sectionIndex: 2, titlePg: true, headerRefs: { default: 'rIdS2Default' } }, + ], + headerContentHeightsByRId: new Map([ + ['rIdS0First', 100], + ['rIdS2Default', 10], + ]), + }; + + const layout = layoutDocument( + [sb1, tallBlock('p1'), sb2, tallBlock('p2'), sb3, tallBlock('p3')], + [ + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + ], + options, + ); + + expect(layout.pages.length).toBeGreaterThanOrEqual(3); + + const p3Fragment = layout.pages[2]?.fragments.find((fragment) => fragment.blockId === 'p3'); + expect(p3Fragment).toBeDefined(); + expect(p3Fragment!.y).toBeCloseTo(130, 0); + expect(layout.pages[2]?.margins?.top).toBeCloseTo(130, 0); + }); + it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => { // Most realistic mixed case. Section 1 has 3 pages (display numbers 1-3). Section 2 // has titlePg=true and starts with display number 4. diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 68fcc84692..76c7f784bd 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,8 +28,14 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + HeaderFooterRefIdentifier, +} from '@superdoc/contracts'; +import { + buildLayoutSourceIdentityForFragment, + getFragmentZIndex, + normalizeColumnLayout, + resolveInheritedHeaderFooterRefWithType, } from '@superdoc/contracts'; -import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -1458,6 +1464,19 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }; }; const sectionMetadataList = options.sectionMetadata ?? []; + const headerFooterRefIdentifier: HeaderFooterRefIdentifier = { + sectionCount: sectionMetadataList.length, + sectionHeaderIds: new Map(), + sectionFooterIds: new Map(), + }; + for (const metadata of sectionMetadataList) { + if (metadata.headerRefs) { + headerFooterRefIdentifier.sectionHeaderIds?.set(metadata.sectionIndex, metadata.headerRefs); + } + if (metadata.footerRefs) { + headerFooterRefIdentifier.sectionFooterIds?.set(metadata.sectionIndex, metadata.footerRefs); + } + } const initialSectionMetadata = sectionMetadataList[0]; if (initialSectionMetadata?.numbering?.format) { activeNumberFormat = initialSectionMetadata.numbering.format; @@ -1642,62 +1661,31 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options alternateHeaders, }); - // Resolve header/footer refs for margin calculation using OOXML inheritance model. - // This must match the rendering logic in PresentationEditor to ensure margins - // are calculated based on the same header/footer content that will be rendered. - // - // Resolution order: - // 1. Current section's variant ref (e.g., 'first' for first page with titlePg) - // 2. Previous section's same variant ref (inheritance) - // 3. Current section's 'default' ref (final fallback) - let headerRef = activeSectionRefs?.headerRefs?.[variantType]; - let footerRef = activeSectionRefs?.footerRefs?.[variantType]; - let effectiveVariantType = variantType; - - // Step 2: Inherit from previous section if variant not found - if (!headerRef && variantType !== 'default' && activeSectionIndex > 0) { - const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1]; - if (prevSectionMetadata?.headerRefs?.[variantType]) { - headerRef = prevSectionMetadata.headerRefs[variantType]; - layoutLog( - `[Layout] Page ${newPageNumber}: Inheriting header '${variantType}' from section ${activeSectionIndex - 1}: ${headerRef}`, - ); - } - } - if (!footerRef && variantType !== 'default' && activeSectionIndex > 0) { - const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1]; - if (prevSectionMetadata?.footerRefs?.[variantType]) { - footerRef = prevSectionMetadata.footerRefs[variantType]; - layoutLog( - `[Layout] Page ${newPageNumber}: Inheriting footer '${variantType}' from section ${activeSectionIndex - 1}: ${footerRef}`, - ); - } - } - - // Step 3: Fall back to current section's default only when that ref is - // the selected OOXML slot. With even/odd headers enabled, `default` - // represents the odd-page header, not a replacement for a missing even - // header. - const defaultHeaderRef = activeSectionRefs?.headerRefs?.default; - const defaultFooterRef = activeSectionRefs?.footerRefs?.default; - const shouldUseDefaultHeaderRef = - variantType !== 'default' && defaultHeaderRef && (!alternateHeaders || variantType === 'odd'); - const shouldUseDefaultFooterRef = - variantType !== 'default' && defaultFooterRef && (!alternateHeaders || variantType === 'odd'); - - if (!headerRef && shouldUseDefaultHeaderRef) { - headerRef = defaultHeaderRef; - effectiveVariantType = 'default'; - } - if (!footerRef && shouldUseDefaultFooterRef) { - footerRef = defaultFooterRef; - } + const headerResolution = resolveInheritedHeaderFooterRefWithType({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'header', + variantType, + pageRefs: activeSectionRefs?.headerRefs, + }); + const footerResolution = resolveInheritedHeaderFooterRefWithType({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'footer', + variantType, + pageRefs: activeSectionRefs?.footerRefs, + }); + const headerRef = headerResolution?.ref; + const footerRef = footerResolution?.ref; // Calculate the actual header/footer heights for this page's variant - // Use effectiveVariantType for header height lookup to match the fallback - const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef, activeSectionIndex); + const headerHeight = getHeaderHeightForPage( + headerResolution?.variantType ?? variantType, + headerRef, + activeSectionIndex, + ); const footerHeight = getFooterHeightForPage( - variantType !== 'default' && !activeSectionRefs?.footerRefs?.[variantType] ? 'default' : variantType, + footerResolution?.variantType ?? variantType, footerRef, activeSectionIndex, ); 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 2fb8cc5aa1..28a683277e 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 @@ -24,7 +24,7 @@ import type { ResolvedPage, LayoutStoryLocator, } from '@superdoc/contracts'; -import { namedStoryLocator } from '@superdoc/contracts'; +import { namedStoryLocator, resolveInheritedHeaderFooterRef } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; @@ -394,21 +394,43 @@ function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): Reso }; } -function normalizeDecorationFragments(fragments: Fragment[], layoutMinY: number): Fragment[] { - if (layoutMinY >= 0) { +function isExplicitBehindDocMediaFragment(fragment: Fragment): boolean { + return (fragment.kind === 'image' || fragment.kind === 'drawing') && fragment.behindDoc === true; +} + +function getDecorationNormalizationMinY(fragments: Fragment[], layoutMinY: number): number { + if (!Number.isFinite(layoutMinY) || layoutMinY >= 0) { + return 0; + } + + let minY = Infinity; + for (const fragment of fragments) { + if (isExplicitBehindDocMediaFragment(fragment)) { + continue; + } + if (Number.isFinite(fragment.y)) { + minY = Math.min(minY, fragment.y); + } + } + + return minY < 0 ? minY : 0; +} + +function normalizeDecorationFragments(fragments: Fragment[], normalizationMinY: number): Fragment[] { + if (normalizationMinY >= 0) { return fragments; } - const yOffset = -layoutMinY; + const yOffset = -normalizationMinY; return fragments.map((fragment) => ({ ...fragment, y: fragment.y + yOffset })); } -function normalizeDecorationItems(items: ResolvedPaintItem[], layoutMinY: number): ResolvedPaintItem[] { - if (layoutMinY >= 0) { +function normalizeDecorationItems(items: ResolvedPaintItem[], normalizationMinY: number): ResolvedPaintItem[] { + if (normalizationMinY >= 0) { return items; } - const yOffset = -layoutMinY; + const yOffset = -normalizationMinY; return items.map((item) => shiftResolvedPaintItemY(item, yOffset)); } @@ -1728,15 +1750,19 @@ export class HeaderFooterSessionManager { const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const isFirstPageOfSection = firstPageInSection === pageNumber; - // Check for alternateHeaders in converter + // Check for alternateHeaders in converter or the multi-section identifier. const converter = (this.#options.editor as EditorWithConverter).converter; - const hasAlternateHeaders = converter?.pageStyles?.alternateHeaders === true; + const hasAlternateHeaders = + this.#multiSectionIdentifier?.alternateHeaders === true || converter?.pageStyles?.alternateHeaders === true; // Only use 'first' variant when titlePg is enabled (w:titlePg element in OOXML). // Without titlePg, even the first page of a section uses 'default'. const headerIds = converter?.headerIds as { titlePg?: boolean } | undefined; const footerIds = converter?.footerIds as { titlePg?: boolean } | undefined; - const titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; + let titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; + if (this.#multiSectionIdentifier?.sectionTitlePg?.has(sectionIndex)) { + titlePgEnabled = this.#multiSectionIdentifier.sectionTitlePg.get(sectionIndex) === true; + } if (isFirstPageOfSection && titlePgEnabled) { return 'first'; @@ -2386,40 +2412,20 @@ export class HeaderFooterSessionManager { }) : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber }); - // Resolve section-specific rId using Word's OOXML inheritance model - let sectionRId: string | undefined; - if (page?.sectionRefs && kind === 'header') { - sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs]; - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - const shouldUseDefaultHeaderRef = - headerFooterType !== 'default' && - page.sectionRefs.headerRefs?.default && - (!multiSectionId?.alternateHeaders || headerFooterType === 'odd'); - if (!sectionRId && shouldUseDefaultHeaderRef) { - sectionRId = page.sectionRefs.headerRefs?.default; - } - } else if (page?.sectionRefs && kind === 'footer') { - sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs]; - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - const shouldUseDefaultFooterRef = - headerFooterType !== 'default' && - page.sectionRefs.footerRefs?.default && - (!multiSectionId?.alternateHeaders || headerFooterType === 'odd'); - if (!sectionRId && shouldUseDefaultFooterRef) { - sectionRId = page.sectionRefs.footerRefs?.default; - } - } - if (!headerFooterType) { return null; } + const pageRefs = kind === 'header' ? page?.sectionRefs?.headerRefs : page?.sectionRefs?.footerRefs; + const sectionRId = + resolveInheritedHeaderFooterRef({ + identifier: multiSectionId ?? legacyIdentifier, + sectionIndex, + kind, + variantType: headerFooterType, + pageRefs, + }) ?? undefined; + // PRIORITY 1: Try per-rId layout (composite key first for per-section margins, then plain rId) const compositeKey = sectionRId ? `${sectionRId}::s${sectionIndex}` : undefined; const rIdLayoutKey = @@ -2463,8 +2469,9 @@ export class HeaderFooterSessionManager { const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); const layoutMinY = rIdLayout.layout.minY ?? 0; - const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); - const normalizedItems = normalizeDecorationItems(alignedItems, layoutMinY); + const normalizationMinY = getDecorationNormalizationMinY(fragments, layoutMinY); + const normalizedFragments = normalizeDecorationFragments(fragments, normalizationMinY); + const normalizedItems = normalizeDecorationItems(alignedItems, normalizationMinY); const isActiveHeaderFooter = this.#isActiveDecoration(kind, sectionRId, pageNumber); return { @@ -2529,8 +2536,9 @@ export class HeaderFooterSessionManager { const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); const layoutMinY = variant.layout.minY ?? 0; - const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); - const normalizedItems = normalizeDecorationItems(alignedVariantItems, layoutMinY); + const normalizationMinY = getDecorationNormalizationMinY(fragments, layoutMinY); + const normalizedFragments = normalizeDecorationFragments(fragments, normalizationMinY); + const normalizedItems = normalizeDecorationItems(alignedVariantItems, normalizationMinY); const isActiveHeaderFooter = this.#isActiveDecoration(kind, finalHeaderId, pageNumber); return { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 30b8a94c60..aab3f8f2fe 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -22,6 +22,8 @@ import type { ParaFragment, ResolvedLayout, ResolvedPage, + TableFragment, + DrawingFragment, } from '@superdoc/contracts'; import { buildMultiSectionIdentifier, type HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { @@ -587,11 +589,19 @@ describe('HeaderFooterSessionManager', () => { }); describe('createDecorationProvider — resolved items', () => { - function buildHeaderResult(options?: { y?: number; minY?: number }): HeaderFooterLayoutResult { + function buildHeaderResult(options?: { + y?: number; + minY?: number; + blockId?: string; + pageNumber?: number; + type?: HeaderFooterLayoutResult['type']; + }): HeaderFooterLayoutResult { const y = options?.y ?? 10; + const blockId = options?.blockId ?? 'p1'; + const pageNumber = options?.pageNumber ?? 1; const paraFragment: ParaFragment = { kind: 'para', - blockId: 'p1', + blockId, fromLine: 0, toLine: 1, x: 72, @@ -601,9 +611,9 @@ describe('HeaderFooterSessionManager', () => { const layout: HeaderFooterLayout = { height: 50, ...(options?.minY != null ? { minY: options.minY } : {}), - pages: [{ number: 1, fragments: [paraFragment] }], + pages: [{ number: pageNumber, fragments: [paraFragment] }], }; - const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: blockId, runs: [] }]; const measures: Measure[] = [ { kind: 'paragraph', @@ -611,7 +621,7 @@ describe('HeaderFooterSessionManager', () => { totalHeight: 18, }, ]; - return { kind: 'header', type: 'default', layout, blocks, measures }; + return { kind: 'header', type: options?.type ?? 'default', layout, blocks, measures }; } it('delivers items aligned 1:1 with fragments when variant layout is used', () => { @@ -771,6 +781,248 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 }); }); + it('does not shift normal rId footer fragments for negative minY from page-relative behindDoc drawings', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'footer-table', + fromRow: 0, + toRow: 1, + x: 72, + y: 0, + width: 468, + height: 24, + }; + const behindDocFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'footer-bg', + drawingKind: 'vectorShape', + x: 0, + y: -36, + width: 612, + height: 120, + isAnchored: true, + behindDoc: true, + zIndex: 0, + geometry: { width: 612, height: 120 }, + scale: 1, + sourceAnchor: { vRelativeFrom: 'page' }, + } as DrawingFragment; + const footerResult: HeaderFooterLayoutResult = { + kind: 'footer', + type: 'default', + layout: { + height: 48, + minY: -36, + pages: [{ number: 1, fragments: [tableFragment, behindDocFragment] }], + }, + blocks: [ + { kind: 'table', id: 'footer-table', rows: [{ id: 'row-1', cells: [] }] }, + { + kind: 'drawing', + id: 'footer-bg', + drawingKind: 'vectorShape', + anchor: { isAnchored: true, vRelativeFrom: 'page', behindDoc: true }, + geometry: { width: 612, height: 120 }, + }, + ] as FlowBlock[], + measures: [ + { kind: 'table', rowHeights: [24], columnWidths: [468], cells: [], rows: [] }, + { kind: 'drawing', width: 612, height: 120 }, + ] as unknown as Measure[], + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: null, first: null, even: null, odd: null }, + footerIds: { default: 'rId-footer-default', first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.footerLayoutsByRId.set('rId-footer-default', footerResult); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; + const provider = manager.createDecorationProvider('footer', layout as unknown as ResolvedLayout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.minY).toBe(-36); + expect(payload!.fragments).toHaveLength(2); + expect(payload!.fragments[0]).toMatchObject({ kind: 'table', blockId: 'footer-table', y: 0 }); + expect(payload!.fragments[1]).toMatchObject({ kind: 'drawing', blockId: 'footer-bg', y: -36, behindDoc: true }); + expect(payload!.items).toHaveLength(2); + expect(payload!.items![0]).toMatchObject({ fragmentKind: 'table', blockId: 'footer-table', y: 0 }); + expect(payload!.items![1]).toMatchObject({ fragmentKind: 'drawing', blockId: 'footer-bg', y: -36 }); + }); + + it('uses section titlePg state when selecting decoration-provider variants', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { + headerIds: { titlePg: true }, + }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([ + { + sectionIndex: 0, + titlePg: true, + headerRefs: { first: 'rId-section0-first', default: 'rId-section0-default' }, + }, + { + sectionIndex: 1, + titlePg: false, + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + }, + ]), + ); + manager.setLayoutResults( + [ + buildHeaderResult({ type: 'first', blockId: 'first-block', pageNumber: 2 }), + buildHeaderResult({ type: 'default', blockId: 'default-block', pageNumber: 2 }), + ], + null, + ); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }, + { + number: 2, + sectionIndex: 1, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + footerRefs: {}, + }, + }, + ] as never, + } as unknown as Layout; + + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(2, layout.pages[1]!.margins, layout.pages[1] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('default'); + expect(payload!.items![0]!.blockId).toBe('default-block'); + }); + + it('does not render default headers on even pages when alternate headers are enabled', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'rId-header-default' } }], { + alternateHeaders: true, + }), + ); + manager.setLayoutResults([buildHeaderResult({ type: 'even', blockId: 'even-block', pageNumber: 2 })], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }, + { + number: 2, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { default: 'rId-header-default' }, + footerRefs: {}, + }, + }, + ] as never, + } as unknown as Layout; + + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(2, layout.pages[1]!.margins, layout.pages[1] as unknown as ResolvedPage); + + expect(payload).toBeNull(); + }); + it('normalizes resolved items when per-rId layout minY is negative', async () => { mockLayoutPerRIdHeaderFooters.mockImplementation( async ( @@ -1036,6 +1288,79 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.headerFooterRefId).toBe('rId-even'); expect(payload!.fragments[0]!.blockId).toBe('even-header'); }); + + it('inherits first-page header refs through intermediate sections that omit first refs', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 3), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'rId-s0-first', default: 'rId-s0-default' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'rId-s1-default' } }, + { sectionIndex: 2, titlePg: true, headerRefs: { default: 'rId-s2-default' } }, + ]), + ); + manager.headerLayoutsByRId.set('rId-s0-first', buildHeaderResult({ blockId: 's0-first-header' })); + manager.headerLayoutsByRId.set('rId-s2-default', buildHeaderResult({ blockId: 's2-default-header' })); + + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + { + number: 1, + sectionIndex: 0, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { first: 'rId-s0-first', default: 'rId-s0-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + { + number: 2, + sectionIndex: 1, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-s1-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + { + number: 3, + sectionIndex: 2, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-s2-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + ], + }; + + const provider = manager.createDecorationProvider('header', layout); + const page = layout.pages[2]!; + const payload = provider!(page.number, page.margins, page); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('first'); + expect(payload!.headerFooterRefId).toBe('rId-s0-first'); + expect(payload!.fragments[0]!.blockId).toBe('s0-first-header'); + }); }); describe('rebuildRegions — ResolvedLayout entry', () => { @@ -1177,5 +1502,120 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(0)!.sectionType).toBe('even'); expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); }); + + it('uses multi-section alternateHeaders state when inferring fallback region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { pageStyles: { alternateHeaders: false } }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier( + [ + { + sectionIndex: 0, + titlePg: false, + headerRefs: { default: 'rId-default', even: 'rId-even' }, + footerRefs: { default: 'rId-default-footer', even: 'rId-even-footer' }, + }, + ], + { alternateHeaders: true }, + ), + ); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [makePage({ number: 1, displayNumber: 2, height: 792 })], + }); + + expect(manager.headerRegions.get(0)!.sectionType).toBe('even'); + expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); + }); + + it('uses section titlePg state when inferring fallback region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { + headerIds: { titlePg: true }, + footerIds: { titlePg: true }, + pageStyles: { alternateHeaders: false }, + }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier( + [ + { + sectionIndex: 0, + titlePg: true, + headerRefs: { first: 'rId-section0-first', default: 'rId-section0-default' }, + footerRefs: { first: 'rId-section0-first-footer', default: 'rId-section0-default-footer' }, + }, + { + sectionIndex: 1, + titlePg: false, + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + footerRefs: { first: 'rId-section1-first-footer', default: 'rId-section1-default-footer' }, + }, + ], + { alternateHeaders: false }, + ), + ); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + makePage({ number: 1, height: 792, sectionIndex: 0 }), + makePage({ number: 2, height: 792, sectionIndex: 1 }), + ], + }); + + expect(manager.headerRegions.get(0)!.sectionType).toBe('first'); + expect(manager.footerRegions.get(0)!.sectionType).toBe('first'); + expect(manager.headerRegions.get(1)!.sectionType).toBe('default'); + expect(manager.footerRegions.get(1)!.sectionType).toBe('default'); + }); }); }); From 985f1838842b51a8db7c8bb8cdc0b485844bb23b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 13:08:28 -0300 Subject: [PATCH 22/63] refactor(layout): centralize header/footer ref resolution in a shared contract (SD-2989) (#3577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(super-editor): honor per-section titlePg when inferring fallback regions When inferring header/footer region variants without explicit instance metadata, the fallback path only consulted the document-level titlePg flag. Multi-section documents that override titlePg per section ended up classifying the first page as 'default' instead of 'first'. Use the multi-section identifier's sectionTitlePg map when available so each section's variant is respected. * refactor(layout-engine): centralize header/footer ref inheritance Extract the OOXML header/footer ref inheritance logic into a shared helper (`resolveInheritedHeaderFooterRef`) in `@superdoc/contracts` and use it from layout-engine, layout-bridge, and HeaderFooterSessionManager. This replaces three near-duplicate copies of the same resolution rules. While unifying the logic, fix inheritance through intermediate sections that omit `first`/`even` refs: previously the resolver only looked at the immediately prior section, so a `first` ref defined in section 0 was lost once section 1 (with only a `default` ref) sat between section 0 and a later section that also lacked an explicit `first` ref. The shared resolver now walks back to the nearest prior section that defines the requested variant. * fix(contracts): preserve header footer fallback refs * fix(layout-engine): use resolved header footer height slot * fix(contracts): ignore later refs for fallback resolution * fix(layout-bridge): render inherited default refs * fix(super-editor): tolerate missing section titlePg map * fix(contracts): inherit converter fallback refs * test(contracts): cover header footer inheritance helper * fix(layout-bridge): drop unused inheritance re-export * test(super-editor): cover section titlePg decoration provider * fix(layout-bridge): skip missing even header refs * fix(header-footer): preserve negative minY from page-relative behindDoc media Stop shifting normal footer/header fragments when the layout's minY is negative purely because of explicit behindDoc anchored drawings/images (e.g. page-relative background shapes). Decoration normalization now computes its own minY that ignores those explicit behindDoc media, so in-flow content stays at its original coordinates while the negative minY is preserved on the payload for downstream painters. * fix(header-footer): honor identifier alternate header state * refactor(layout): centralize header/footer ref resolution in a shared contract Introduce `selectHeaderFooterVariantForPage` and `resolveEffectiveHeaderFooterRef` in `@superdoc/contracts` as the single source of truth for picking a page's header/footer variant and walking section inheritance to a concrete rId. Replace the four divergent copies of this logic — in layout-bridge (`getHeaderFooterTypeForSection` / `getHeaderFooterIdForPage` / `resolveHeaderFooterForPageAndSection`), the layout-engine margin pass, the PresentationEditor `HeaderFooterSessionManager`, and the document-api `resolveEffectiveRef` helper — with calls into the shared resolver. This corrects the OOXML inheritance model: `first` and `even` variants no longer fall back to a `default` ref (only `odd` may resolve from `default` under `w:evenAndOddHeaders`), and inheritance now walks across all prior sections rather than just the immediately preceding one. Pages with no matching ref now resolve to null/zero height instead of inferring default content, keeping layout margins consistent with rendered output. * fix(layout): preserve converter header refs in section resolver * fix(layout): resolve sparse section metadata by index * refactor(layout): trim header footer resolver surface * chore(editor): remove stale header footer imports * fix(layout): preserve section header ref resolution * fix(layout): preserve converter title page refs * fix(document-api): inherit converter header refs * fix(editor): resolve per-rId header refs for decorations * fix(editor): align header footer fallback variant * fix(layout): stop leaking converter fallback refs into section resolution buildMultiSectionIdentifier previously merged the converter's legacy header/footer refs into section 0's resolution entry. This let a footerless first section inherit a converter-level default that belonged to a later section, painting a footer where the document declares none. Section-aware resolution now reads only per-section refs; converter fallbacks remain on the legacy identifier fields for legacy lookups but are no longer exposed through resolveEffectiveHeaderFooterRef. Guard HeaderFooterSessionManager so it only consults legacy refs when section resolution is unavailable, and skip building resolution sections for an empty identifier. * fix(layout): use effective page number for header parity * feat(page-number): per-field PAGE value-format switches & case-insensitive field dispatch (SD-3006) (#3599) * feat(super-converter): match field dispatch keywords case-insensitively OOXML field type names are case-insensitive, but the field-reference preprocessors dispatched on the raw first token (e.g. only "PAGE", not "page"). A lowercase PAGE/NUMPAGES field in a repeated footer fell through to the cached static text and showed the same number on every page. Add a shared extractFieldKeyword helper that normalizes the dispatch token to upper case while leaving the original instruction text intact for downstream processors, and route fldSimple/fldChar dispatch and the header/footer page-field scan through it. Make the HYPERLINK target regex case-insensitive and anchored. Cover the new behavior with unit tests and a behavior spec asserting a lowercase PAGE footer resolves per page. * test(super-converter): cover field keyword dispatch * fix(super-converter): trust header footer field keyword * feat(page-number): support PAGE field value-format switches Parse the `\*` value-format switches on PAGE field instructions (Arabic, Roman/roman, ALPHABETIC/alphabetic, ArabicDash) into a run-local pageNumberFormat override, and apply it independently of section numbering when resolving page-number tokens. - add parsePageInstruction / pageNumberFormatToInstructionSwitch in a new page-instruction.js; page-preprocessor stores the original instruction and parsed format on sd:autoPageNumber - round-trip instruction + pageNumberFormat through the autoPageNumber translator and the page-number extension node (preserve imported instruction text, synthesize a switch for new formatted nodes) - add pageNumberFormat to TextRun and thread it through layout-bridge, layout-resolved, painters (resolveRunText), and stamp section-aware displayNumber on pages so formatting uses the pre-format numeric value - move formatPageNumber + PageNumberFormat into @superdoc/contracts as the single source of truth; re-export from pageNumbering - include pageNumberFormat in block-version, merge, and hash signatures so format changes invalidate cached layouts upperLetter/lowerLetter now render as repeated letters (AA, BB, CC) to match Word instead of the previous Excel-style sequence (AA, AB). * fix(page-number): render ArabicDash spacing * fix(layout-bridge): hash page number formats * fix(page-number): fall back for unknown formats * test(behavior): cover formatted footer page fields * fix(page-number): address PAGE field review feedback * fix(sequence-field): preserve cached numbering for lowercase seq fields Only dispatch the SEQ pre-processor for uppercase SEQ instructions so lowercase `seq` fields keep their cached visible result runs instead of being re-resolved. Also recurse into run-wrapped content when extracting resolved text so cached numbers nested inside runs are captured. * fix(painter): rebuild drawing page fields on context changes * fix: footnote formatter parity test * fix(layout): remove duplicate displayNumber fields and fix page signature ref Drop the redundant displayNumber declarations from HeaderFooterPage, ResolvedHeaderFooterPage, and the layout-bridge page builder, keeping the section-aware variant. Correct the renderer page context signature to read displayPageNumber instead of the nonexistent pageNumberDisplayNumber. * test(layout): update page-number field expectations Adjust header/footer token and footer rendering expectations to the spaced "- N -" format, and migrate the renderer page-context test to the pageNumberFieldFormat shape. --- .../src/header-footer-resolution.test.ts | 121 ++++++ .../contracts/src/header-footer-resolution.ts | 87 +++++ packages/layout-engine/contracts/src/index.ts | 17 +- .../src/page-number-formatting.test.ts | 9 +- .../contracts/src/page-number-formatting.ts | 16 +- .../contracts/src/resolved-layout.ts | 6 +- .../layout-bridge/src/cacheInvalidation.ts | 1 + .../layout-bridge/src/headerFooterUtils.ts | 231 ++++++------ .../layout-engine/layout-bridge/src/index.ts | 11 + .../layout-bridge/src/layoutHeaderFooter.ts | 22 +- .../test/cacheInvalidation.test.ts | 19 + .../test/headerFooterUtils.test.ts | 177 ++++++++- .../test/layoutHeaderFooterBucketing.test.ts | 45 +++ .../test/resolveHeaderFooterTokens.test.ts | 2 +- .../layout-engine/src/index.test.ts | 245 +++++++++++- .../layout-engine/layout-engine/src/index.ts | 163 ++++---- .../layout-engine/src/pageNumbering.test.ts | 42 ++- .../src/resolvePageTokens.test.ts | 23 ++ .../layout-engine/src/resolvePageTokens.ts | 11 +- .../layout-resolved/src/resolveLayout.ts | 1 + .../layout-resolved/src/versionSignature.ts | 4 + .../painters/dom/src/index.test.ts | 58 ++- .../dom/src/paragraph/block-version.ts | 1 + .../src/renderer-page-context-patch.test.ts | 116 ++++++ .../painters/dom/src/renderer.ts | 80 ++++ .../painters/dom/src/runs/hash.ts | 1 + .../painters/dom/src/runs/text-run.test.ts | 40 ++ .../src/footnote-formatter-parity.test.ts | 33 +- .../inline-converters/generic-token.test.ts | 12 + .../v1/core/layout-adapter/index.test.ts | 25 ++ .../HeaderFooterSessionManager.ts | 64 +++- .../tests/HeaderFooterSessionManager.test.ts | 356 +++++++++++++++++- .../field-references/field-keyword.js | 14 + .../field-references/field-keyword.test.js | 16 + .../hyperlink-preprocessor.js | 2 +- .../fld-preprocessors/index.js | 7 +- .../fld-preprocessors/index.test.js | 38 ++ .../fld-preprocessors/page-instruction.js | 49 +++ .../fld-preprocessors/page-preprocessor.js | 10 +- .../page-preprocessor.test.js | 20 + .../preProcessNodesForFldChar.js | 9 +- .../preProcessNodesForFldChar.test.js | 78 ++++ .../preProcessPageFieldsOnly.js | 5 +- .../preProcessPageFieldsOnly.test.js | 91 +++++ .../autoPageNumber-translator.js | 36 +- .../autoPageNumber-translator.test.js | 35 ++ .../sequence-field-export-routing.test.js | 23 ++ .../sequenceField/sequenceField-translator.js | 15 +- .../header-footers-adapter.ts | 2 +- .../header-footer-refs-mutation.test.ts | 62 +++ .../helpers/header-footer-refs-mutation.ts | 59 ++- .../header-footer-slot-materialization.ts | 3 +- .../header-footer-story-runtime.ts | 10 +- .../v1/extensions/types/node-attributes.ts | 4 +- tests/behavior/helpers/story-fixtures.ts | 61 +++ .../footer-page-keyword-case.spec.ts | 33 ++ 56 files changed, 2362 insertions(+), 359 deletions(-) create mode 100644 packages/layout-engine/contracts/src/header-footer-resolution.test.ts create mode 100644 packages/layout-engine/contracts/src/header-footer-resolution.ts create mode 100644 packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/text-run.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.test.ts create mode 100644 tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts diff --git a/packages/layout-engine/contracts/src/header-footer-resolution.test.ts b/packages/layout-engine/contracts/src/header-footer-resolution.test.ts new file mode 100644 index 0000000000..b181cd9e21 --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-resolution.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage } from './header-footer-resolution.js'; +import type { HeaderFooterResolutionSection } from './header-footer-resolution.js'; + +describe('header/footer effective ref resolution', () => { + it('inherits matching variants across more than one previous section', () => { + const sections: HeaderFooterResolutionSection[] = [ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'h0-first' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'h1-default' } }, + { sectionIndex: 2, titlePg: true, headerRefs: {} }, + ]; + + expect( + resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 2, kind: 'header', variant: 'first' }), + ).toMatchObject({ + refId: 'h0-first', + matchedSectionIndex: 0, + matchedVariant: 'first', + }); + }); + + it('preserves inherited missing variants when a later section partially overrides another variant', () => { + const sections: HeaderFooterResolutionSection[] = [ + { sectionIndex: 0, footerRefs: { default: 'f0-default', even: 'f0-even' } }, + { sectionIndex: 1, footerRefs: { default: 'f1-default' } }, + ]; + + expect( + resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 1, kind: 'footer', variant: 'even' }), + ).toMatchObject({ + refId: 'f0-even', + matchedSectionIndex: 0, + matchedVariant: 'even', + }); + }); + + it('does not let first inherit default when titlePg selects first', () => { + const sections: HeaderFooterResolutionSection[] = [ + { sectionIndex: 0, titlePg: true, headerRefs: { default: 'h0-default' } }, + ]; + + const variant = selectHeaderFooterVariantForPage({ + documentPageNumber: 1, + sectionPageNumber: 1, + titlePg: true, + alternateHeaders: false, + }); + + expect(variant).toBe('first'); + expect(resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'first' })).toBeNull(); + }); + + it('does not let even inherit default when odd/even headers are enabled', () => { + const sections: HeaderFooterResolutionSection[] = [{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }]; + + expect(resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'even' })).toBeNull(); + }); + + it('resolves odd from explicit odd before OOXML default', () => { + const sections: HeaderFooterResolutionSection[] = [ + { sectionIndex: 0, headerRefs: { default: 'h0-default' } }, + { sectionIndex: 1, headerRefs: { odd: 'h1-odd', default: 'h1-default' } }, + ]; + + expect( + resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 1, kind: 'header', variant: 'odd' }), + ).toMatchObject({ + refId: 'h1-odd', + matchedVariant: 'odd', + }); + }); + + it('resolves odd from OOXML default when explicit odd is absent', () => { + const sections: HeaderFooterResolutionSection[] = [{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }]; + + expect( + resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'odd' }), + ).toMatchObject({ + refId: 'h0-default', + matchedVariant: 'default', + }); + }); + + it('uses document page number for even/odd selection', () => { + expect( + selectHeaderFooterVariantForPage({ + documentPageNumber: 4, + sectionPageNumber: 1, + titlePg: false, + alternateHeaders: true, + }), + ).toBe('even'); + }); + + it('returns null for non-positive page numbers', () => { + expect( + selectHeaderFooterVariantForPage({ + documentPageNumber: 0, + sectionPageNumber: 1, + titlePg: false, + alternateHeaders: false, + }), + ).toBeNull(); + expect( + selectHeaderFooterVariantForPage({ + documentPageNumber: 1, + sectionPageNumber: 0, + titlePg: false, + alternateHeaders: false, + }), + ).toBeNull(); + expect( + selectHeaderFooterVariantForPage({ + documentPageNumber: -1, + sectionPageNumber: -1, + titlePg: false, + alternateHeaders: true, + }), + ).toBeNull(); + }); +}); diff --git a/packages/layout-engine/contracts/src/header-footer-resolution.ts b/packages/layout-engine/contracts/src/header-footer-resolution.ts new file mode 100644 index 0000000000..1fce55ffd2 --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-resolution.ts @@ -0,0 +1,87 @@ +export type HeaderFooterKind = 'header' | 'footer'; +export type HeaderFooterVariant = 'default' | 'first' | 'even' | 'odd'; + +export type HeaderFooterSectionRefs = Partial>; + +export type HeaderFooterResolutionSection = { + sectionIndex: number; + titlePg?: boolean; + headerRefs?: HeaderFooterSectionRefs | null; + footerRefs?: HeaderFooterSectionRefs | null; +}; + +export type HeaderFooterVariantSelectionInput = { + documentPageNumber: number; + sectionPageNumber: number; + titlePg?: boolean; + alternateHeaders?: boolean; +}; + +export type HeaderFooterEffectiveRefInput = { + sections: readonly HeaderFooterResolutionSection[]; + sectionIndex: number; + kind: HeaderFooterKind; + variant: HeaderFooterVariant; +}; + +export type HeaderFooterEffectiveRefResult = { + refId: string; + matchedSectionIndex: number; + matchedVariant: HeaderFooterVariant; +}; + +export function selectHeaderFooterVariantForPage({ + documentPageNumber, + sectionPageNumber, + titlePg, + alternateHeaders, +}: HeaderFooterVariantSelectionInput): HeaderFooterVariant | null { + if (documentPageNumber <= 0 || sectionPageNumber <= 0) return null; + if (sectionPageNumber === 1 && titlePg === true) return 'first'; + if (alternateHeaders === true) return documentPageNumber % 2 === 0 ? 'even' : 'odd'; + return 'default'; +} + +function candidateVariantsFor(variant: HeaderFooterVariant): readonly HeaderFooterVariant[] { + return variant === 'odd' ? ['odd', 'default'] : [variant]; +} + +function sectionRefsFor( + section: HeaderFooterResolutionSection | undefined, + kind: HeaderFooterKind, +): HeaderFooterSectionRefs | null | undefined { + return kind === 'header' ? section?.headerRefs : section?.footerRefs; +} + +export function resolveEffectiveHeaderFooterRef({ + sections, + sectionIndex, + kind, + variant, +}: HeaderFooterEffectiveRefInput): HeaderFooterEffectiveRefResult | null { + if (sectionIndex < 0) return null; + + const sectionsByIndex = new Map(); + for (const section of sections) { + sectionsByIndex.set(section.sectionIndex, section); + } + + const candidates = candidateVariantsFor(variant); + for (let currentIndex = sectionIndex; currentIndex >= 0; currentIndex -= 1) { + const refs = sectionRefsFor(sectionsByIndex.get(currentIndex), kind); + if (!refs) continue; + + for (const candidate of candidates) { + const refId = refs[candidate]; + if (refId) { + return { + refId, + matchedSectionIndex: currentIndex, + matchedVariant: candidate, + }; + } + } + } + + return null; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index aa76e95ed2..a12f98efcb 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -35,6 +35,18 @@ export { export { effectiveTableCellSpacing } from './table-cell-spacing.js'; +export { + selectHeaderFooterVariantForPage, + resolveEffectiveHeaderFooterRef, + type HeaderFooterKind, + type HeaderFooterVariant, + type HeaderFooterSectionRefs, + type HeaderFooterResolutionSection, + type HeaderFooterVariantSelectionInput, + type HeaderFooterEffectiveRefInput, + type HeaderFooterEffectiveRefResult, +} from './header-footer-resolution.js'; + // Table column rescaling (moved from layout-engine for cross-stage use) export { rescaleColumnWidths } from './table-column-rescale.js'; @@ -2020,6 +2032,8 @@ export type Page = { /** 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; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; sectionRefs?: { @@ -2246,8 +2260,9 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; export type HeaderFooterPage = { number: number; fragments: Fragment[]; - displayNumber?: number; numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; /** * 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.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts index 529639ec1e..f372797f72 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -7,8 +7,9 @@ describe('page number formatting', () => { expect(formatPageNumber(5, 'upperRoman')).toBe('V'); expect(formatPageNumber(5, 'lowerRoman')).toBe('v'); expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); - expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); - expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); + expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); }); it('normalizes page numbers before formatting', () => { @@ -17,6 +18,10 @@ describe('page number formatting', () => { expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1'); }); + it('falls back to decimal for unsupported runtime formats', () => { + expect(formatPageNumber(5, 'chicago' as never)).toBe('5'); + }); + it('falls back to decimal for roman numerals beyond 3999', () => { expect(formatPageNumber(4000, 'upperRoman')).toBe('4000'); }); diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts index bf32393cda..413e14bb8a 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -24,16 +24,10 @@ function toUpperRoman(value: number): string { } function toUpperLetter(value: number): string { - let n = Math.max(1, value); - let result = ''; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; + const normalized = Math.max(1, value); + const index = (normalized - 1) % 26; + const repeatCount = Math.floor((normalized - 1) / 26) + 1; + return String.fromCharCode(65 + index).repeat(repeatCount); } export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { @@ -49,7 +43,7 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): case 'lowerLetter': return toUpperLetter(value).toLowerCase(); case 'numberInDash': - return `-${value}-`; + return `- ${value} -`; case 'decimal': default: return String(value); diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 70d3f3a924..fdad65d628 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -58,6 +58,8 @@ export type ResolvedPage = { displayNumber?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; + /** Numeric page number after section page numbering settings are applied. */ + effectivePageNumber?: number; /** Vertical alignment of content within this page. */ vAlign?: SectionVerticalAlign; /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ @@ -450,9 +452,9 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved /** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ export type ResolvedHeaderFooterPage = { number: number; - /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ - displayNumber?: number; numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; items: ResolvedPaintItem[]; }; diff --git a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts index 3df39b1783..7bc0bd8ddc 100644 --- a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts +++ b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts @@ -51,6 +51,7 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string { if ('bold' in run && run.bold) parts.push('b'); if ('italic' in run && run.italic) parts.push('i'); if ('token' in run && run.token) parts.push(`token:${run.token}`); + if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`); } } } diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index bc83e5758e..415aa2865b 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -1,10 +1,11 @@ -import { - resolveInheritedHeaderFooterRef, - type HeaderFooterType, - type Layout, - type SectionMetadata, - type Page, +import type { + HeaderFooterType, + Layout, + SectionMetadata, + Page, + HeaderFooterResolutionSection, } from '@superdoc/contracts'; +import { resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage } from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; @@ -162,6 +163,8 @@ export type MultiSectionHeaderFooterIdentifier = { sectionFooterIds: Map; // Per-section titlePg flags (Word allows different first page per section) sectionTitlePg: Map; + // Ordered section metadata used by the shared effective-ref resolver. + sections: HeaderFooterResolutionSection[]; }; /** @@ -176,8 +179,44 @@ export const defaultMultiSectionIdentifier = (): MultiSectionHeaderFooterIdentif sectionHeaderIds: new Map(), sectionFooterIds: new Map(), sectionTitlePg: new Map(), + sections: [], }); +function refreshResolutionSections(identifier: MultiSectionHeaderFooterIdentifier): void { + if ( + identifier.sectionCount === 0 && + identifier.sectionHeaderIds.size === 0 && + identifier.sectionFooterIds.size === 0 && + identifier.sectionTitlePg.size === 0 + ) { + identifier.sections = []; + return; + } + + const maxIndex = Math.max( + identifier.sectionCount - 1, + ...Array.from(identifier.sectionHeaderIds.keys()), + ...Array.from(identifier.sectionFooterIds.keys()), + ...Array.from(identifier.sectionTitlePg.keys()), + ); + + const sections: HeaderFooterResolutionSection[] = []; + for (let sectionIndex = 0; sectionIndex <= maxIndex; sectionIndex += 1) { + sections.push({ + sectionIndex, + titlePg: identifier.sectionTitlePg.get(sectionIndex) ?? false, + headerRefs: identifier.sectionHeaderIds.get(sectionIndex), + footerRefs: identifier.sectionFooterIds.get(sectionIndex), + }); + } + + identifier.sections = sections; +} + +function getSectionTitlePg(identifier: MultiSectionHeaderFooterIdentifier, sectionIndex: number): boolean { + return identifier.sectionTitlePg.get(sectionIndex) ?? false; +} + /** * Builds a multi-section header/footer identifier from section metadata. * @@ -221,8 +260,20 @@ export function buildMultiSectionIdentifier( sectionMetadata: SectionMetadata[], pageStyles?: { alternateHeaders?: boolean }, converterIds?: { - headerIds?: { default?: string | null; first?: string | null; even?: string | null; odd?: string | null }; - footerIds?: { default?: string | null; first?: string | null; even?: string | null; odd?: string | null }; + headerIds?: { + default?: string | null; + first?: string | null; + even?: string | null; + odd?: string | null; + titlePg?: boolean; + }; + footerIds?: { + default?: string | null; + first?: string | null; + even?: string | null; + odd?: string | null; + titlePg?: boolean; + }; }, ): MultiSectionHeaderFooterIdentifier { const identifier = defaultMultiSectionIdentifier(); @@ -254,12 +305,11 @@ export function buildMultiSectionIdentifier( }); } - // Track per-section titlePg from section metadata (w:titlePg element in OOXML) - // Note: The presence of a 'first' header/footer reference does NOT mean titlePg is enabled. - // The w:titlePg element must be present in sectPr to use first page headers/footers. - // Track per-section titlePg from section metadata (w:titlePg element in OOXML) - // Store explicit false so later sections don't inherit section 0's value. - identifier.sectionTitlePg.set(idx, section.titlePg === true); + // Track per-section titlePg from section metadata (w:titlePg element in OOXML). + // The presence of a 'first' header/footer reference does NOT mean titlePg is enabled. + if (Object.prototype.hasOwnProperty.call(section, 'titlePg')) { + identifier.sectionTitlePg.set(idx, section.titlePg === true); + } } // Set legacy fields from section 0 for backward compatibility @@ -277,7 +327,7 @@ export function buildMultiSectionIdentifier( // Only fill in null values - don't override existing refs from section metadata // Also fall back to converter's titlePg if not set from section metadata if (converterIds?.headerIds) { - if (!identifier.titlePg && (converterIds.headerIds as { titlePg?: boolean }).titlePg) { + if (!identifier.titlePg && converterIds.headerIds.titlePg) { identifier.titlePg = true; } identifier.headerIds.default = identifier.headerIds.default ?? converterIds.headerIds.default ?? null; @@ -286,7 +336,7 @@ export function buildMultiSectionIdentifier( identifier.headerIds.odd = identifier.headerIds.odd ?? converterIds.headerIds.odd ?? null; } if (converterIds?.footerIds) { - if (!identifier.titlePg && (converterIds.footerIds as { titlePg?: boolean }).titlePg) { + if (!identifier.titlePg && converterIds.footerIds.titlePg) { identifier.titlePg = true; } identifier.footerIds.default = identifier.footerIds.default ?? converterIds.footerIds.default ?? null; @@ -295,6 +345,8 @@ export function buildMultiSectionIdentifier( identifier.footerIds.odd = identifier.footerIds.odd ?? converterIds.footerIds.odd ?? null; } + refreshResolutionSections(identifier); + return identifier; } @@ -304,7 +356,7 @@ export function buildMultiSectionIdentifier( * This function determines which header/footer variant (default, first, even, odd) * should be used for a given page number within a specific section. It respects: * - Per-section titlePg (first page of section uses 'first' variant) - * - Alternate headers (even/odd pages based on section-aware page numbering) + * - Alternate headers (even/odd pages based on the effective Word page number) * - Fallback to default variant * * **Important**: When `titlePg` is enabled, this function returns 'first' even if the @@ -313,7 +365,7 @@ export function buildMultiSectionIdentifier( * sections. The rendering layer is responsible for resolving the actual content ID * through inheritance fallback logic. * - * @param pageNumber - Physical page number (1-indexed) + * @param pageNumber - Effective Word page number (1-indexed), after section page numbering settings * @param sectionIndex - Index of the section this page belongs to * @param identifier - Multi-section identifier with per-section mappings * @param options - Optional settings (kind, sectionPageNumber, parityPageNumber) @@ -343,79 +395,24 @@ export function getHeaderFooterTypeForSection( const sectionPageNumber = options?.sectionPageNumber ?? pageNumber; const parityPageNumber = options?.parityPageNumber ?? pageNumber; - // Get section-specific IDs, falling back to legacy IDs for backward compatibility - const sectionIds = - kind === 'header' ? identifier.sectionHeaderIds.get(sectionIndex) : identifier.sectionFooterIds.get(sectionIndex); - - // Fallback to legacy fields if section not found (backward compatibility) - const ids = sectionIds ?? (kind === 'header' ? identifier.headerIds : identifier.footerIds); - - const hasFirst = Boolean(ids.first); - const hasEven = Boolean(ids.even); - const hasOdd = Boolean(ids.odd); - const hasDefault = Boolean(ids.default); - const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds; - let hasAny = hasFirst || hasEven || hasOdd || hasDefault; - if (!hasAny) { - for (let index = sectionIndex - 1; index >= 0; index -= 1) { - const inheritedIds = - kind === 'header' ? identifier.sectionHeaderIds.get(index) : identifier.sectionFooterIds.get(index); - if (inheritedIds?.first || inheritedIds?.even || inheritedIds?.odd || inheritedIds?.default) { - hasAny = true; - break; - } - } - } - if (!hasAny) { - hasAny = Boolean(legacyIds.first || legacyIds.even || legacyIds.odd || legacyIds.default); - } - - // Check titlePg for this specific section - const sectionTitlePg = identifier.sectionTitlePg.has(sectionIndex) - ? identifier.sectionTitlePg.get(sectionIndex)! - : identifier.titlePg; - const titlePgEnabled = sectionTitlePg === true; - - // Use the section-relative page number to determine "first page" variants - const isFirstPageOfSection = sectionPageNumber === 1; - if (isFirstPageOfSection && titlePgEnabled) { - // Return 'first' variant type when titlePg is enabled, regardless of whether this section - // has a 'first' header defined. Word inherits headers from previous sections when not defined, - // so we let the rendering layer handle the inheritance/fallback logic. - // Only return null if there's absolutely no header content anywhere. - if (hasAny) return 'first'; - return null; - } - - if (identifier.alternateHeaders) { - if (!hasAny) return null; - const parityVariant = parityPageNumber % 2 === 0 ? 'even' : 'odd'; - return resolveInheritedHeaderFooterRef({ - identifier, - sectionIndex, - kind, - variantType: parityVariant, - }) - ? parityVariant - : null; - } - - if (hasDefault) { - return 'default'; - } - - if ( - resolveInheritedHeaderFooterRef({ - identifier, - sectionIndex, - kind, - variantType: 'default', - }) - ) { - return 'default'; - } + // Check titlePg for this specific section. Omitted section metadata means false; + // legacy converter titlePg is only used by the non-section-aware path. + const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex); + const variant = selectHeaderFooterVariantForPage({ + documentPageNumber: parityPageNumber, + sectionPageNumber, + titlePg: sectionTitlePg, + alternateHeaders: identifier.alternateHeaders, + }); + if (!variant) return null; - return null; + const resolved = resolveEffectiveHeaderFooterRef({ + sections: identifier.sections, + sectionIndex, + kind, + variant, + }); + return resolved ? variant : null; } /** @@ -445,24 +442,24 @@ export function getHeaderFooterIdForPage( const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const sectionPageNumber = options?.sectionPageNumber ?? page.number; - const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? page.number; - - // Determine which variant type to use (default, first, even, odd) - const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, { - kind, + const effectivePageNumber = options?.parityPageNumber ?? page.effectivePageNumber ?? page.displayNumber ?? page.number; + const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex); + const variantType = selectHeaderFooterVariantForPage({ + documentPageNumber: effectivePageNumber, sectionPageNumber, - parityPageNumber, + titlePg: sectionTitlePg, + alternateHeaders: identifier.alternateHeaders, }); if (!variantType) return null; - const pageRefs = kind === 'header' ? page.sectionRefs?.headerRefs : page.sectionRefs?.footerRefs; - return resolveInheritedHeaderFooterRef({ - identifier, - sectionIndex, - kind, - variantType, - pageRefs, - }); + return ( + resolveEffectiveHeaderFooterRef({ + sections: identifier.sections, + sectionIndex, + kind, + variant: variantType, + })?.refId ?? null + ); } /** @@ -508,6 +505,7 @@ export function resolveHeaderFooterForPageAndSection( const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const pageNumber = page.number; + const effectivePageNumber = options?.parityPageNumber ?? page.effectivePageNumber ?? page.displayNumber ?? pageNumber; const sectionFirstPageNumbers = new Map(); for (const layoutPage of layout.pages) { const idx = layoutPage.sectionIndex ?? 0; @@ -517,21 +515,26 @@ export function resolveHeaderFooterForPageAndSection( } const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; - const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? pageNumber; - // Determine variant type for this section - const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { - kind, + const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex); + const type = selectHeaderFooterVariantForPage({ + documentPageNumber: effectivePageNumber, sectionPageNumber, - parityPageNumber, + titlePg: sectionTitlePg, + alternateHeaders: identifier.alternateHeaders, }); if (!type) return null; - // Get content ID for this page/section - const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber, parityPageNumber }); + const resolvedRef = resolveEffectiveHeaderFooterRef({ + sections: identifier.sections, + sectionIndex, + kind, + variant: type, + }); + if (!resolvedRef) return null; - // Look up the header/footer layout slot - const slot = layout.headerFooter?.[type]; + // Look up the concrete slot; odd pages may be backed by OOXML default content. + const slot = layout.headerFooter?.[resolvedRef.matchedVariant] ?? layout.headerFooter?.[type]; if (!slot) return null; // Find the page entry within the header/footer layout @@ -543,6 +546,6 @@ export function resolveHeaderFooterForPageAndSection( layout: slot, page: headerFooterPage, sectionIndex, - contentId, + contentId: resolvedRef.refId, }; } diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 62a30c97aa..e9a4976365 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -30,6 +30,17 @@ import { } from './list-indent-utils.js'; export type { HeaderFooterType } from '@superdoc/contracts'; +export { + selectHeaderFooterVariantForPage, + resolveEffectiveHeaderFooterRef, + type HeaderFooterKind, + type HeaderFooterVariant, + type HeaderFooterSectionRefs, + type HeaderFooterResolutionSection, + type HeaderFooterVariantSelectionInput, + type HeaderFooterEffectiveRefInput, + type HeaderFooterEffectiveRefResult, +} from '@superdoc/contracts'; export { extractIdentifierFromConverter, getHeaderFooterType, diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 814cdb9387..bd81f901e6 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -1,4 +1,11 @@ -import type { FlowBlock, HeaderFooterLayout, Measure, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { + FlowBlock, + HeaderFooterLayout, + ListBlock, + Measure, + ParagraphBlock, + TableBlock, +} from '@superdoc/contracts'; import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine'; import { MeasureCache } from './cache'; import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens'; @@ -143,6 +150,11 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { if (paragraphHasPageToken(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphHasPageToken(item.paragraph)) return true; + } } else if (block.kind === 'table') { // SD-1332: PAGE fields can live inside table cells in headers/footers // (Word's typical layout). Skipping tables here would take the @@ -168,6 +180,11 @@ function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean for (const block of blocks) { if (block.kind === 'paragraph') { if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphRequiresPerPageLayout(item.paragraph)) return true; + } } else if (block.kind === 'table') { const table = block as TableBlock; for (const row of table.rows ?? []) { @@ -332,6 +349,7 @@ export async function layoutHeaderFooterWithCache( blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; + numberText?: string; }> = []; for (const pageNum of pagesToLayout) { @@ -372,6 +390,7 @@ export async function layoutHeaderFooterWithCache( blocks: clonedBlocks, measures, fragments: fragmentsWithLines, + numberText: displayText, }); } @@ -390,6 +409,7 @@ export async function layoutHeaderFooterWithCache( number: p.number, displayNumber: p.displayNumber, fragments: p.fragments, + numberText: p.numberText, blocks: p.blocks, measures: p.measures, })), diff --git a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts index 0d50ca0f35..fb3398face 100644 --- a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts +++ b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts @@ -52,6 +52,25 @@ describe('Cache Invalidation', () => { expect(hash).toContain('token:pageNumber'); }); + it('should include page number token format in hash', () => { + const decimalBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }], + } as ParagraphBlock, + ]; + const romanBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }], + } as ParagraphBlock, + ]; + + expect(computeHeaderFooterContentHash(decimalBlocks)).not.toBe(computeHeaderFooterContentHash(romanBlocks)); + }); + it('should produce different hashes for different content', () => { const blocks1: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index a785892c7b..102a30b58f 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -5,6 +5,7 @@ import { extractIdentifierFromConverter, getHeaderFooterType, getHeaderFooterTypeForSection, + getHeaderFooterIdForPage, resolveHeaderFooterForPage, resolveHeaderFooterForPageAndSection, buildMultiSectionIdentifier, @@ -394,6 +395,95 @@ describe('headerFooterUtils', () => { expect(identifier.footerIds.even).toBe('converter-f-even'); }); + it('keeps converter fallbacks on legacy fields without exposing them through section-aware resolution', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: null }, + footerRefs: { default: null }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + headerIds: { default: 'converter-h-default' }, + footerIds: { default: 'converter-f-default' }, + }); + + expect(identifier.headerIds.default).toBe('converter-h-default'); + expect(identifier.footerIds.default).toBe('converter-f-default'); + expect(getHeaderFooterTypeForSection(1, 0, identifier, { kind: 'header' })).toBeNull(); + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), + ).toBeNull(); + expect(getHeaderFooterTypeForSection(1, 0, identifier, { kind: 'footer' })).toBeNull(); + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'footer' }), + ).toBeNull(); + }); + + it('does not apply a legacy converter default footer to a footerless first section', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + titlePg: false, + }, + { + sectionIndex: 15, + footerRefs: { default: 'rId22' }, + titlePg: false, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + footerIds: { default: 'rId22' }, + }); + + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'footer' }), + ).toBeNull(); + expect( + getHeaderFooterIdForPage({ number: 2, fragments: [], sectionIndex: 15 }, identifier, { kind: 'footer' }), + ).toBe('rId22'); + }); + + it('keeps converter titlePg fallback on legacy fields without exposing first refs through section-aware resolution', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { first: null }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + headerIds: { first: 'converter-h-first', titlePg: true }, + }); + + expect(identifier.titlePg).toBe(true); + expect(identifier.headerIds.first).toBe('converter-h-first'); + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), + ).toBeNull(); + }); + + it('does not apply legacy converter titlePg to section-aware variant selection when titlePg is omitted', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + headerIds: { first: 'legacy-first', titlePg: true }, + }); + + expect(identifier.titlePg).toBe(true); + expect(getHeaderFooterTypeForSection(1, 0, identifier, { kind: 'header', sectionPageNumber: 1 })).toBe('default'); + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), + ).toBe('h0-default'); + }); + it('should NOT override existing section metadata with converter IDs', () => { const sectionMetadata: SectionMetadata[] = [ { @@ -419,6 +509,28 @@ describe('headerFooterUtils', () => { expect(identifier.footerIds.odd).toBe('converter-f-odd'); }); + it('should prefer non-null section refs over converter fallbacks in section-aware resolution', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'section-h-default' }, + footerRefs: { default: 'section-f-default' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + headerIds: { default: 'converter-h-default' }, + footerIds: { default: 'converter-f-default' }, + }); + + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), + ).toBe('section-h-default'); + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'footer' }), + ).toBe('section-f-default'); + }); + it('should handle missing converterIds parameter gracefully', () => { const sectionMetadata: SectionMetadata[] = [ { @@ -602,9 +714,7 @@ describe('headerFooterUtils', () => { expect(firstPage).toBeNull(); }); - it('returns "first" when titlePg enabled and only default header exists', () => { - // Even if only 'default' header exists, return 'first' for first page when titlePg enabled - // This supports inheritance - previous section might have a 'first' header to inherit + it('returns null when titlePg selects first but only default header exists', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -619,8 +729,7 @@ describe('headerFooterUtils', () => { kind: 'header', sectionPageNumber: 1, }); - // Returns 'first' to support inheritance; rendering layer handles the actual rId resolution - expect(firstPage).toBe('first'); + expect(firstPage).toBeNull(); }); it('applies same inheritance logic to footers', () => { @@ -776,7 +885,7 @@ describe('headerFooterUtils', () => { }, ], headerFooter: { - odd: { pages: [{ number: 1, fragments: [] }] }, + default: { pages: [{ number: 1, fragments: [] }] }, }, }; @@ -938,7 +1047,7 @@ describe('headerFooterUtils', () => { expect(evenPageType).toBe('even'); }); - it('returns default when a later section inherits a default ref', () => { + it('inherits default when a later section has no explicit default ref', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -988,7 +1097,7 @@ describe('headerFooterUtils', () => { expect(resolved?.contentId).toBe('h0-default'); }); - it('uses converter fallback refs when section metadata has no explicit refs', () => { + it('does not use converter fallback refs when section metadata has no explicit refs', () => { const identifier = buildMultiSectionIdentifier([{ sectionIndex: 0 }], undefined, { headerIds: { default: 'converter-default' }, }); @@ -1002,11 +1111,10 @@ describe('headerFooterUtils', () => { const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); - expect(resolved?.type).toBe('default'); - expect(resolved?.contentId).toBe('converter-default'); + expect(resolved).toBeNull(); }); - it('uses converter fallback refs when only later sections define refs', () => { + it('does not use converter fallback refs when only later sections define refs', () => { const identifier = buildMultiSectionIdentifier( [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { default: 'section-1-default' } }], undefined, @@ -1022,11 +1130,10 @@ describe('headerFooterUtils', () => { const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); - expect(resolved?.type).toBe('default'); - expect(resolved?.contentId).toBe('converter-default'); + expect(resolved).toBeNull(); }); - it('inherits converter fallback refs into later sections with partial refs', () => { + it('does not inherit converter fallback refs into later sections with partial refs', () => { const identifier = buildMultiSectionIdentifier( [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { even: 'section-1-even' } }], undefined, @@ -1045,8 +1152,46 @@ describe('headerFooterUtils', () => { const resolved = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); - expect(resolved?.type).toBe('default'); - expect(resolved?.contentId).toBe('converter-default'); + expect(resolved).toBeNull(); + }); + + it('gets an inherited first content id from section 0 when section 1 omits it', () => { + const sectionMetadata: SectionMetadata[] = [ + { sectionIndex: 0, headerRefs: { first: 'h0-first' }, titlePg: true }, + { sectionIndex: 1, headerRefs: { default: 'h1-default' }, titlePg: true }, + { sectionIndex: 2, headerRefs: {}, titlePg: true }, + ]; + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const page = { number: 5, fragments: [], sectionIndex: 2 }; + + expect(getHeaderFooterIdForPage(page, identifier, { kind: 'header', sectionPageNumber: 1 })).toBe('h0-first'); + }); + + it('returns no first content when first section has only default and titlePg', () => { + const identifier = buildMultiSectionIdentifier([ + { sectionIndex: 0, headerRefs: { default: 'h0-default' }, titlePg: true }, + ]); + const page = { number: 1, fragments: [], sectionIndex: 0 }; + + expect(getHeaderFooterIdForPage(page, identifier, { kind: 'header', sectionPageNumber: 1 })).toBeNull(); + }); + + it('returns no even content when even page has only default', () => { + const identifier = buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }], { + alternateHeaders: true, + }); + const page = { number: 2, fragments: [], sectionIndex: 0 }; + + expect(getHeaderFooterIdForPage(page, identifier, { kind: 'header', sectionPageNumber: 2 })).toBeNull(); + }); + + it('resolves odd page content from default', () => { + const identifier = buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }], { + alternateHeaders: true, + }); + const page = { number: 3, fragments: [], sectionIndex: 0 }; + + expect(getHeaderFooterIdForPage(page, identifier, { kind: 'header', sectionPageNumber: 3 })).toBe('h0-default'); }); }); }); diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 1c6f4a6e4d..b25c583b9a 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -89,6 +89,23 @@ const makePageTokenBlock = (id: string): FlowBlock => ({ ], }); +const makeFormattedPageTokenBlock = ( + id: string, + pageNumberFieldFormat: NonNullable, +): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}); + describe('getBucketForPageNumber', () => { it('should return d1 for single-digit page numbers (1-9)', () => { expect(getBucketForPageNumber(1)).toBe('d1'); @@ -440,6 +457,34 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(measureBlock).toHaveBeenCalledTimes(3); expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005'); }); + + it.each([ + ['decimal', { format: 'decimal' }], + ['numberInDash', { format: 'numberInDash' }], + ] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFieldFormat) => { + const sections = { + default: [makeFormattedPageTokenBlock(`header-${pageNumberFieldFormat.format}`, pageNumberFieldFormat)], + }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: 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(3); + expect(measureBlock).toHaveBeenCalledTimes(3); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 7598711fd8..35d26065cd 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -81,7 +81,7 @@ describe('resolveHeaderFooterTokens', () => { resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7); const block = blocks[0] as ParagraphBlock; - expect(block.runs[0].text).toBe('-7-'); + expect(block.runs[0].text).toBe('- 7 -'); expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 74020fc2d6..51ab93296b 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6067,6 +6067,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { odd: 'h-odd', even: 'h-even' } }], headerContentHeights: { odd: 80, // Odd pages: header pushes body start down even: 40, // Even pages: smaller header @@ -6143,6 +6144,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, alternateHeaders: false, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'h-default' } }], headerContentHeights: { default: 60, odd: 80, @@ -6167,6 +6169,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, // alternateHeaders not set + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'h-default' } }], headerContentHeights: { default: 60, odd: 80, @@ -6198,7 +6201,9 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, alternateHeaders: true, - sectionMetadata: [{ sectionIndex: 0, titlePg: true }], + sectionMetadata: [ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'h-first', odd: 'h-odd', even: 'h-even' } }, + ], headerContentHeights: { first: 100, // First page: tallest header odd: 80, @@ -6257,7 +6262,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, alternateHeaders: true, - sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1 }], + sectionMetadata: [{ sectionIndex: 0, headerRefs: { odd: 'h-odd', even: 'h-even' } }, { sectionIndex: 1 }], headerContentHeights: { odd: 80, even: 40, @@ -6280,6 +6285,53 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(p4Fragment!.y).toBeCloseTo(70, 0); }); + it('uses restarted section page numbering for even/odd header selection', () => { + const sb1: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb1-restart', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const sb2: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb2-restart', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 1 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [ + { sectionIndex: 0 }, + { sectionIndex: 1, numbering: { start: 2 }, headerRefs: { odd: 'h-odd', even: 'h-even' } }, + ], + headerContentHeightsByRId: new Map([ + ['h-odd', 80], + ['h-even', 40], + ]), + }; + + const layout = layoutDocument( + [sb1, tallBlock('p1'), tallBlock('p2'), sb2, tallBlock('p3')], + [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, { kind: 'sectionBreak' }, tallMeasure], + options, + ); + + expect(layout.pages.length).toBeGreaterThanOrEqual(3); + expect(layout.pages[2].number).toBe(3); + expect(layout.pages[2].effectivePageNumber).toBe(2); + expect(layout.pages[2].numberText).toBe('2'); + + const p3Fragment = layout.pages[2]?.fragments.find((f) => f.blockId === 'p3'); + expect(p3Fragment).toBeDefined(); + expect(p3Fragment!.y).toBeCloseTo(70, 0); + }); + it('selects even/odd footer heights when alternateHeaders is true', () => { // The footer-height path uses the per-rId map + sectionMetadata.footerRefs. // Exposing the variant selection through `footerContentHeights` alone is not @@ -6336,6 +6388,190 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages[1].margins?.top).toBeCloseTo(50, 0); }); + it('uses inherited first and even refs across multiple sections for margin heights', () => { + const sb0: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb0', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + margins: {}, + }; + const sb1: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb1', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 1 }, + margins: {}, + }; + const sb2: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb2', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 2 }, + margins: {}, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'h0-first', even: 'h0-even' } }, + { sectionIndex: 1 }, + { sectionIndex: 2, titlePg: true }, + ], + headerContentHeightsByRId: new Map([ + ['h0-first', 100], + ['h0-even', 80], + ]), + }; + + const layout = layoutDocument( + [sb0, tallBlock('p1'), sb1, tallBlock('p2'), sb2, tallBlock('p3'), tallBlock('p4')], + [ + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + tallMeasure, + ], + options, + ); + + expect(layout.pages[2].fragments.find((f) => f.blockId === 'p3')?.y).toBeCloseTo(130, 0); + expect(layout.pages[3].fragments.find((f) => f.blockId === 'p4')?.y).toBeCloseTo(110, 0); + }); + + it('uses inherited footer refs across sections for margin heights', () => { + const sb0: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb0-footer', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + margins: {}, + }; + const sb1: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb1-footer', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 1 }, + margins: {}, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, footer: 30 }, + sectionMetadata: [{ sectionIndex: 0, footerRefs: { default: 'f0-default' } }, { sectionIndex: 1 }], + footerContentHeightsByRId: new Map([['f0-default', 80]]), + }; + + const layout = layoutDocument( + [sb0, tallBlock('p1-footer'), sb1, tallBlock('p2-footer')], + [{ kind: 'sectionBreak' }, tallMeasure, { kind: 'sectionBreak' }, tallMeasure], + options, + ); + + expect(layout.pages[1].margins?.bottom).toBeCloseTo(110, 0); + }); + + it('uses metadata matched by sparse sectionIndex for title-page header selection', () => { + const sb0: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb0-sparse', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + margins: {}, + }; + const sb2: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb2-sparse', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 2 }, + margins: {}, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 2, titlePg: true, headerRefs: { first: 'h2-first' } }], + headerContentHeightsByRId: new Map([['h2-first', 100]]), + }; + + const layout = layoutDocument( + [sb0, tallBlock('p1-sparse'), sb2, tallBlock('p2-sparse')], + [{ kind: 'sectionBreak' }, tallMeasure, { kind: 'sectionBreak' }, tallMeasure], + options, + ); + + expect(layout.pages[1].fragments.find((f) => f.blockId === 'p2-sparse')?.y).toBeCloseTo(130, 0); + }); + + it('resets to base margin when selected first variant is blank', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [{ sectionIndex: 0, titlePg: true, headerRefs: { default: 'h-default' } }], + headerContentHeightsByRId: new Map([['h-default', 100]]), + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages[0].fragments.find((f) => f.blockId === 'p1')?.y).toBeCloseTo(50, 0); + expect(layout.pages[0].margins?.top).toBeCloseTo(50, 0); + }); + + it('uses default variant height when odd selection is backed by a default ref', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'h-default' } }], + headerContentHeights: { + default: 60, + odd: 140, + }, + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages[0].fragments.find((f) => f.blockId === 'p1')?.y).toBeCloseTo(90, 0); + }); + + it('uses variant header heights when no section refs are available', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + headerContentHeights: { + default: 100, + }, + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages[0].fragments.find((f) => f.blockId === 'p1')?.y).toBeCloseTo(130, 0); + expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0); + }); + + it('prefers runtime section refs over stale metadata for margin heights', () => { + const sb0: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb0-runtime-refs', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + headerRefs: { default: 'h-runtime' }, + margins: {}, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'h-metadata' } }], + headerContentHeightsByRId: new Map([ + ['h-metadata', 20], + ['h-runtime', 100], + ]), + }; + + const layout = layoutDocument([sb0, tallBlock('p1')], [{ kind: 'sectionBreak' }, tallMeasure], options); + + expect(layout.pages[0].fragments.find((f) => f.blockId === 'p1')?.y).toBeCloseTo(130, 0); + expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0); + }); + it('prefers section-aware header heights over the plain rId fallback', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -6443,7 +6679,10 @@ describe('alternateHeaders (odd/even header differentiation)', () => { pageSize: { w: 600, h: 800 }, margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, alternateHeaders: true, - sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1, titlePg: true }], + sectionMetadata: [ + { sectionIndex: 0 }, + { sectionIndex: 1, titlePg: true, headerRefs: { first: 'h-first', odd: 'h-odd', even: 'h-even' } }, + ], headerContentHeights: { first: 100, // section 2 title-page header odd: 80, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 76c7f784bd..434f8033d3 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,13 +28,14 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, - HeaderFooterRefIdentifier, + HeaderFooterResolutionSection, } from '@superdoc/contracts'; import { buildLayoutSourceIdentityForFragment, - getFragmentZIndex, normalizeColumnLayout, - resolveInheritedHeaderFooterRefWithType, + getFragmentZIndex, + resolveEffectiveHeaderFooterRef, + selectHeaderFooterVariantForPage, } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; @@ -723,36 +724,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const footerContentHeightsByRId = options.footerContentHeightsByRId; const footerContentHeightsBySectionRef = options.footerContentHeightsBySectionRef; - /** - * Determines the header/footer variant type for a given page based on section settings. - * - * Takes a params object because the two page-number fields have very similar - * names and types — a positional call site is easy to get wrong. - * - * @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg - * @param parityPageNumber - The section-aware page number used for even/odd - * @param titlePgEnabled - Whether the section has "different first page" enabled - * @param alternateHeaders - Whether the document has odd/even differentiation enabled - * @returns The variant type: 'first', 'even', 'odd', or 'default' - */ - const getVariantTypeForPage = (args: { - sectionPageNumber: number; - parityPageNumber: number; - titlePgEnabled: boolean; - alternateHeaders: boolean; - }): 'default' | 'first' | 'even' | 'odd' => { - // First page of section with titlePg enabled uses 'first' variant - if (args.sectionPageNumber === 1 && args.titlePgEnabled) { - return 'first'; - } - // Alternate headers: even/odd based on the section-aware page number, - // matching ECMA-376 section 17.10.1. - if (args.alternateHeaders) { - return args.parityPageNumber % 2 === 0 ? 'even' : 'odd'; - } - return 'default'; - }; - /** * Gets the header content height for a specific page, considering: * 1. Per-rId heights (highest priority for multi-section documents) @@ -905,11 +876,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Initial effective margins use default variant (will be adjusted per-page) const headerDistance = margins.header ?? margins.top; const footerDistance = margins.footer ?? margins.bottom; - const defaultHeaderHeight = getHeaderHeightForPage('default', undefined, 0); - const defaultFooterHeight = getFooterHeightForPage('default', undefined, 0); + const initialHeaderHeight = 0; + const initialFooterHeight = 0; const effectiveMargins = clampHeaderFooterInflatedMargins( - calculateEffectiveTopMargin(defaultHeaderHeight, headerDistance, margins.top), - calculateEffectiveBottomMargin(defaultFooterHeight, footerDistance, margins.bottom), + calculateEffectiveTopMargin(initialHeaderHeight, headerDistance, margins.top), + calculateEffectiveBottomMargin(initialFooterHeight, footerDistance, margins.bottom), margins.top, margins.bottom, pageSize.h, @@ -1066,7 +1037,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } // Set numbering for first section from metadata const firstSectionMetadata = Number.isFinite(firstMetadataIndex) - ? sectionMetadataList[firstMetadataIndex] + ? getSectionMetadata(firstMetadataIndex) : undefined; if (firstSectionMetadata?.numbering) { if (firstSectionMetadata.numbering.format) activeNumberFormat = firstSectionMetadata.numbering.format; @@ -1138,7 +1109,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options pendingSectionIndex = metadataIndex; } // Get section metadata for numbering if available - const sectionMetadata = Number.isFinite(metadataIndex) ? sectionMetadataList[metadataIndex] : undefined; + const sectionMetadata = Number.isFinite(metadataIndex) ? getSectionMetadata(metadataIndex) : undefined; // Schedule numbering change for next page - prefer metadata over block if (sectionMetadata?.numbering) { pendingNumbering = { ...sectionMetadata.numbering }; @@ -1464,19 +1435,32 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }; }; const sectionMetadataList = options.sectionMetadata ?? []; - const headerFooterRefIdentifier: HeaderFooterRefIdentifier = { - sectionCount: sectionMetadataList.length, - sectionHeaderIds: new Map(), - sectionFooterIds: new Map(), + const getSectionMetadata = (sectionIndex: number) => + sectionMetadataList.find((section, fallbackIndex) => (section.sectionIndex ?? fallbackIndex) === sectionIndex); + const runtimeSectionRefsByIndex = new Map(); + const buildHeaderFooterResolutionSections = (): HeaderFooterResolutionSection[] => { + const sectionIndexes = new Set(); + sectionMetadataList.forEach((section, fallbackIndex) => sectionIndexes.add(section.sectionIndex ?? fallbackIndex)); + runtimeSectionRefsByIndex.forEach((_refs, sectionIndex) => sectionIndexes.add(sectionIndex)); + if (sectionIndexes.size === 0) sectionIndexes.add(0); + + return Array.from(sectionIndexes) + .sort((a, b) => a - b) + .map((sectionIndex) => { + const metadata = getSectionMetadata(sectionIndex); + const runtimeRefs = runtimeSectionRefsByIndex.get(sectionIndex); + return { + sectionIndex, + titlePg: metadata?.titlePg === true, + headerRefs: runtimeRefs?.headerRefs ?? metadata?.headerRefs, + footerRefs: runtimeRefs?.footerRefs ?? metadata?.footerRefs, + }; + }); + }; + const hasAnyHeaderFooterRefs = (sections: HeaderFooterResolutionSection[], kind: 'header' | 'footer'): boolean => { + const refKey = kind === 'header' ? 'headerRefs' : 'footerRefs'; + return sections.some((section) => Object.values(section[refKey] ?? {}).some(Boolean)); }; - for (const metadata of sectionMetadataList) { - if (metadata.headerRefs) { - headerFooterRefIdentifier.sectionHeaderIds?.set(metadata.sectionIndex, metadata.headerRefs); - } - if (metadata.footerRefs) { - headerFooterRefIdentifier.sectionFooterIds?.set(metadata.sectionIndex, metadata.footerRefs); - } - } const initialSectionMetadata = sectionMetadataList[0]; if (initialSectionMetadata?.numbering?.format) { activeNumberFormat = initialSectionMetadata.numbering.format; @@ -1492,6 +1476,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options ...(initialSectionMetadata.headerRefs && { headerRefs: initialSectionMetadata.headerRefs }), ...(initialSectionMetadata.footerRefs && { footerRefs: initialSectionMetadata.footerRefs }), }; + runtimeSectionRefsByIndex.set(initialSectionMetadata.sectionIndex ?? 0, activeSectionRefs); } // Initialize vertical alignment from first section metadata (for page 1) if (initialSectionMetadata?.vAlign) { @@ -1617,6 +1602,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options activeSectionIndex = pendingSectionIndex; pendingSectionIndex = null; } + if (activeSectionRefs) { + runtimeSectionRefsByIndex.set(activeSectionIndex, activeSectionRefs); + } // Apply pending vertical alignment (undefined = no change, null = reset to default) if (pendingVAlign !== undefined) { activeVAlign = pendingVAlign; @@ -1649,46 +1637,48 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const sectionPageNumber = newPageNumber - firstPageInSection + 1; // Get section metadata for titlePg setting - const sectionMetadata = sectionMetadataList[activeSectionIndex]; + const sectionMetadata = getSectionMetadata(activeSectionIndex); const titlePgEnabled = sectionMetadata?.titlePg ?? false; const alternateHeaders = options.alternateHeaders ?? false; - // Determine which header/footer variant applies to this page - const variantType = getVariantTypeForPage({ + // Determine which header/footer variant applies to this page. + const variantType = selectHeaderFooterVariantForPage({ sectionPageNumber, - parityPageNumber: activePageCounter, - titlePgEnabled, + documentPageNumber: activePageCounter, + titlePg: titlePgEnabled, alternateHeaders, }); - const headerResolution = resolveInheritedHeaderFooterRefWithType({ - identifier: headerFooterRefIdentifier, - sectionIndex: activeSectionIndex, - kind: 'header', - variantType, - pageRefs: activeSectionRefs?.headerRefs, - }); - const footerResolution = resolveInheritedHeaderFooterRefWithType({ - identifier: headerFooterRefIdentifier, - sectionIndex: activeSectionIndex, - kind: 'footer', - variantType, - pageRefs: activeSectionRefs?.footerRefs, - }); - const headerRef = headerResolution?.ref; - const footerRef = footerResolution?.ref; - - // Calculate the actual header/footer heights for this page's variant - const headerHeight = getHeaderHeightForPage( - headerResolution?.variantType ?? variantType, - headerRef, - activeSectionIndex, - ); - const footerHeight = getFooterHeightForPage( - footerResolution?.variantType ?? variantType, - footerRef, - activeSectionIndex, - ); + const resolutionSections = buildHeaderFooterResolutionSections(); + const headerResolved = + variantType && + resolveEffectiveHeaderFooterRef({ + sections: resolutionSections, + sectionIndex: activeSectionIndex, + kind: 'header', + variant: variantType, + }); + const footerResolved = + variantType && + resolveEffectiveHeaderFooterRef({ + sections: resolutionSections, + sectionIndex: activeSectionIndex, + kind: 'footer', + variant: variantType, + }); + + const hasHeaderRefs = hasAnyHeaderFooterRefs(resolutionSections, 'header'); + const hasFooterRefs = hasAnyHeaderFooterRefs(resolutionSections, 'footer'); + const headerHeight = headerResolved + ? getHeaderHeightForPage(headerResolved.matchedVariant, headerResolved.refId, activeSectionIndex) + : variantType && !hasHeaderRefs + ? getHeaderHeightForPage(variantType, undefined, activeSectionIndex) + : 0; + const footerHeight = footerResolved + ? getFooterHeightForPage(footerResolved.matchedVariant, footerResolved.refId, activeSectionIndex) + : variantType && !hasFooterRefs + ? getFooterHeightForPage(variantType, undefined, activeSectionIndex) + : 0; // Adjust margins based on the actual header/footer for this page. // Always recalculate to ensure pages without headers reset to base margin @@ -1705,7 +1695,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options activeBottomMargin = adjustedMargins.bottom; layoutLog( - `[Layout] Page ${newPageNumber}: Using variant '${variantType}' - headerHeight: ${headerHeight}, footerHeight: ${footerHeight}`, + `[Layout] Page ${newPageNumber}: Using variant '${variantType ?? 'none'}' - headerHeight: ${headerHeight}, footerHeight: ${footerHeight}`, ); layoutLog( `[Layout] Page ${newPageNumber}: Adjusted margins - top: ${activeTopMargin}, bottom: ${activeBottomMargin} (base: ${activeSectionBaseTopMargin}, ${activeSectionBaseBottomMargin})`, @@ -1718,6 +1708,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (state?.page) { state.page.displayNumber = activePageCounter; state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat); + state.page.effectivePageNumber = activePageCounter; // Stamp section index on the page for section-aware page numbering and header/footer selection state.page.sectionIndex = activeSectionIndex; layoutLog(`[Layout] Page ${state.page.number}: Stamped sectionIndex:`, activeSectionIndex); @@ -2240,7 +2231,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } // Get section metadata for numbering if available - const sectionMetadata = Number.isFinite(metadataIndex) ? sectionMetadataList[metadataIndex] : undefined; + const sectionMetadata = Number.isFinite(metadataIndex) ? getSectionMetadata(metadataIndex) : undefined; if (sectionMetadata?.numbering) { if (isFirstSection) { // First section: apply immediately diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index 87ef3473ea..777f2ee828 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -31,16 +31,20 @@ describe('formatPageNumber', () => { it('should truncate fractional numbers before formatting', () => { expect(formatPageNumber(4.9, 'decimal')).toBe('4'); }); + + it('should fall back to decimal for unsupported runtime formats', () => { + expect(formatPageNumber(5, 'chicago' as never)).toBe('5'); + }); }); describe('numberInDash format', () => { it('should wrap numbers in dashes', () => { - expect(formatPageNumber(1, 'numberInDash')).toBe('-1-'); - expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + expect(formatPageNumber(1, 'numberInDash')).toBe('- 1 -'); + expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); }); it('should clamp zero to 1', () => { - expect(formatPageNumber(0, 'numberInDash')).toBe('-1-'); + expect(formatPageNumber(0, 'numberInDash')).toBe('- 1 -'); }); }); @@ -128,19 +132,19 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'upperLetter')).toBe('Z'); }); - it('should format numbers > 26 as AA, AB, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); - expect(formatPageNumber(28, 'upperLetter')).toBe('AB'); - expect(formatPageNumber(52, 'upperLetter')).toBe('AZ'); - expect(formatPageNumber(53, 'upperLetter')).toBe('BA'); - expect(formatPageNumber(78, 'upperLetter')).toBe('BZ'); - expect(formatPageNumber(79, 'upperLetter')).toBe('CA'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(52, 'upperLetter')).toBe('ZZ'); + expect(formatPageNumber(53, 'upperLetter')).toBe('AAA'); + expect(formatPageNumber(78, 'upperLetter')).toBe('ZZZ'); + expect(formatPageNumber(79, 'upperLetter')).toBe('AAAA'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'upperLetter')).toBe('ZZ'); - expect(formatPageNumber(703, 'upperLetter')).toBe('AAA'); - expect(formatPageNumber(704, 'upperLetter')).toBe('AAB'); + expect(formatPageNumber(702, 'upperLetter')).toBe('Z'.repeat(27)); + expect(formatPageNumber(703, 'upperLetter')).toBe('A'.repeat(28)); + expect(formatPageNumber(704, 'upperLetter')).toBe('B'.repeat(28)); }); it('should clamp zero and negative to A', () => { @@ -158,16 +162,16 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'lowerLetter')).toBe('z'); }); - it('should format numbers > 26 as aa, ab, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'lowerLetter')).toBe('aa'); - expect(formatPageNumber(28, 'lowerLetter')).toBe('ab'); - expect(formatPageNumber(52, 'lowerLetter')).toBe('az'); - expect(formatPageNumber(53, 'lowerLetter')).toBe('ba'); + expect(formatPageNumber(28, 'lowerLetter')).toBe('bb'); + expect(formatPageNumber(52, 'lowerLetter')).toBe('zz'); + expect(formatPageNumber(53, 'lowerLetter')).toBe('aaa'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'lowerLetter')).toBe('zz'); - expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(702, 'lowerLetter')).toBe('z'.repeat(27)); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); }); it('should clamp zero and negative to a', () => { @@ -434,7 +438,7 @@ describe('computeDisplayPageNumber', () => { expect(result[24].displayText).toBe('Y'); expect(result[25].displayText).toBe('Z'); expect(result[26].displayText).toBe('AA'); - expect(result[27].displayText).toBe('AB'); + expect(result[27].displayText).toBe('BB'); }); it('should handle large page numbers in roman numerals', () => { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts index 8857cf8cf4..c06f7c47eb 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts @@ -276,4 +276,27 @@ describe('resolveTokensInBlock', () => { expect((block.runs[0] as { pmStart?: number }).pmStart).toBe(10); expect((block.runs[0] as { pmEnd?: number }).pmEnd).toBe(11); }); + + it('should apply run-local page number format when resolving tokens', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-local-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + }; + + const wasModified = resolveTokensInBlock(block, 5, 10); + + expect(wasModified).toBe(true); + expect((block.runs[0] as TextRun).text).toBe('V'); + expect((block.runs[0] as TextRun).token).toBeUndefined(); + expect((block.runs[0] as TextRun).pageNumberFieldFormat).toBeUndefined(); + }); }); diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index b8880041ba..0b3c01e2e3 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -208,11 +208,11 @@ function cloneBlockWithResolvedTokens( if ('token' in run && run.token) { if (run.token === 'pageNumber') { // Clone the run and resolve the token - const { token: _token, ...runWithoutToken } = run; + const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run; return { ...runWithoutToken, - text: run.pageNumberFieldFormat - ? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat) + text: pageNumberFieldFormat + ? formatPageNumberFieldValue(displayPageInfo.displayNumber, pageNumberFieldFormat) : displayPageInfo.displayText, }; } else if (run.token === 'totalPageCount') { @@ -284,9 +284,12 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, if ('token' in run && run.token) { if (run.token === 'pageNumber') { // Replace placeholder text with actual page number - run.text = pageNumberStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(pageNumber, run.pageNumberFieldFormat) + : pageNumberStr; // Clear token metadata to treat as normal text after resolution delete run.token; + delete run.pageNumberFieldFormat; blockModified = true; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 786e293f84..1d62e074f5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -332,6 +332,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { footnoteReserved: page.footnoteReserved, displayNumber: page.displayNumber, numberText: page.numberText, + effectivePageNumber: page.effectivePageNumber, vAlign: page.vAlign, baseMargins: page.baseMargins, sectionIndex: page.sectionIndex, diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 89d56326f8..c75e9ba493 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -355,6 +355,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { textRun.vertAlign ?? '', textRun.baselineShift != null ? textRun.baselineShift : '', textRun.token ?? '', + textRun.pageNumberFieldFormat ? JSON.stringify(textRun.pageNumberFieldFormat) : '', trackedVersion, textRun.comments?.length ?? 0, // SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it. @@ -539,6 +540,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => { hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); hash = hashString(hash, getRunStringProp(run, 'vertAlign')); hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + hash = hashString(hash, getRunStringProp(run, 'token')); + const pageNumberFieldFormat = (run as { pageNumberFieldFormat?: unknown }).pageNumberFieldFormat; + hash = hashString(hash, pageNumberFieldFormat ? JSON.stringify(pageNumberFieldFormat) : ''); // SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash. const bidi = (run as { bidi?: unknown }).bidi; hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 76c62c6cc5..aea53d5eb2 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5763,7 +5763,7 @@ describe('DomPainter', () => { const footerEl = mount.querySelector('.superdoc-page-footer'); expect(footerEl).toBeTruthy(); - expect(footerEl?.textContent).toBe('-4-'); + expect(footerEl?.textContent).toBe('- 4 -'); }); it('bottom-aligns footer content within the footer box', () => { @@ -6472,6 +6472,62 @@ describe('DomPainter', () => { expect(svgEl?.style.transform).toBe(''); }); + it('rebuilds drawing text with PAGE fields when page context changes during patch rendering', () => { + const vectorShapeBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-page-field', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + textContent: { + parts: [ + { text: 'Page ', formatting: { fontFamily: 'Arial', fontSize: 18 } }, + { text: '', fieldType: 'PAGE', formatting: { fontFamily: 'Arial', fontSize: 18 } }, + ], + }, + textAlign: 'center', + }; + + const vectorShapeMeasure: Measure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 100, + height: 50, + scale: 1, + naturalWidth: 100, + naturalHeight: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + }; + + const drawingFragment = { + kind: 'drawing' as const, + drawingKind: 'vectorShape' as const, + blockId: 'drawing-page-field', + x: 30, + y: 40, + width: 100, + height: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + scale: 1, + }; + + const painter = createTestPainter({ blocks: [vectorShapeBlock], measures: [vectorShapeMeasure] }); + const firstLayout: Layout = { + pageSize: layout.pageSize, + pages: [{ number: 1, numberText: '1', fragments: [drawingFragment] }], + }; + const secondLayout: Layout = { + pageSize: layout.pageSize, + pages: [{ number: 2, numberText: '2', fragments: [drawingFragment] }], + }; + + painter.paint(firstLayout, mount); + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 1'); + + painter.paint(secondLayout, mount); + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 2'); + }); + describe('resolved paragraph rendering', () => { it('renders resolved paragraph lines with precomputed indent styles', () => { const paragraphBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts index 212210b2a3..d4f730c418 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -162,6 +162,7 @@ export const deriveParagraphBlockVersion = ( textRun.vertAlign ?? '', textRun.baselineShift != null ? textRun.baselineShift : '', textRun.token ?? '', + textRun.pageNumberFieldFormat ? JSON.stringify(textRun.pageNumberFieldFormat) : '', trackedVersion, textRun.comments?.length ?? 0, ].join(','); diff --git a/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts new file mode 100644 index 0000000000..606c2760a3 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, Layout, Measure, TextRun } from '@superdoc/contracts'; +import { createTestPainter } from './_test-utils.js'; + +const pageNumberBlock: FlowBlock = { + kind: 'paragraph', + id: 'page-number-block', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}; + +const pageNumberMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 10, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +const staticBlock: FlowBlock = { + kind: 'paragraph', + id: 'static-block', + runs: [ + { + text: 'Static', + fontFamily: 'Arial', + fontSize: 12, + }, + ], +}; + +const staticMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 40, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +function makeLayout(displayNumber: number): Layout { + return { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + displayNumber, + fragments: [ + { + kind: 'para', + blockId: 'page-number-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }, + { + kind: 'para', + blockId: 'static-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 20, + width: 200, + }, + ], + }, + ], + }; +} + +describe('DomPainter page-number context patching', () => { + it('rebuilds token fragments when display page number changes during incremental patch', () => { + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const painter = createTestPainter({ + blocks: [pageNumberBlock, staticBlock], + measures: [pageNumberMeasure, staticMeasure], + }); + + painter.paint(makeLayout(5), mount); + expect(mount.textContent).toContain('V'); + const staticFragment = mount.querySelector('[data-block-id="static-block"]'); + expect(staticFragment).toBeTruthy(); + + painter.paint(makeLayout(8), mount); + expect(mount.textContent).toContain('VIII'); + expect(mount.querySelector('[data-block-id="static-block"]')).toBe(staticFragment); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 18390c37e8..b1c9f832bf 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -38,6 +38,7 @@ import type { ResolvedDrawingItem, LayoutSourceIdentity, LayoutStoryLocator, + ListBlock, } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, @@ -258,6 +259,83 @@ type PageDomState = { fragments: FragmentDomState[]; }; +function pageContextSignature(context: FragmentRenderContext): string { + return [context.pageNumber, context.totalPages, context.pageNumberText ?? '', context.displayPageNumber ?? ''].join( + '|', + ); +} + +function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { + return ( + Array.isArray(textContent?.parts) && + textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES') + ); +} + +function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | undefined): boolean { + return ( + Array.isArray(shapes) && + shapes.some((shape) => { + if (shape.shapeType !== 'vectorShape') { + return false; + } + return hasPageContextTokenInShapeText(shape.attrs.textContent); + }) + ); +} + +function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { + if (!block) return false; + if (block.kind === 'paragraph') { + for (const run of (block as ParagraphBlock).runs) { + if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) { + return true; + } + } + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (hasPageContextTokenInBlock(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 (cellBlocks.some(hasPageContextTokenInBlock)) { + return true; + } + } + } + } else if (block.kind === 'drawing') { + const drawing = block as DrawingBlock; + if (drawing.drawingKind === 'vectorShape') { + return hasPageContextTokenInShapeText(drawing.textContent); + } + if (drawing.drawingKind === 'shapeGroup') { + return hasPageContextTokenInShapeGroup(drawing.shapes); + } + } + return false; +} + +function needsRebuildForPageContext( + currentContext: FragmentRenderContext, + nextContext: FragmentRenderContext, + resolvedItem: ResolvedPaintItem | undefined, +): boolean { + const block = resolvedItem?.kind === 'fragment' && 'block' in resolvedItem ? resolvedItem.block : undefined; + return ( + pageContextSignature(currentContext) !== pageContextSignature(nextContext) && hasPageContextTokenInBlock(block) + ); +} + /** * Rendering context passed to fragment renderers containing page metadata. * Provides information about the current page position and section for dynamic content like page numbers. @@ -2297,6 +2375,7 @@ export class DomPainter { (current.element.dataset.betweenBorder === 'true') !== (betweenInfo?.showBetweenBorder ?? false) || (current.element.dataset.suppressTopBorder === 'true') !== (betweenInfo?.suppressTopBorder ?? false) || (current.element.dataset.gapBelow ?? '') !== (betweenInfo?.gapBelow ? String(betweenInfo.gapBelow) : ''); + const pageContextChanged = needsRebuildForPageContext(current.context, contextBase, resolvedItem); // Verify the position mapping is reliable: if mapping the old pmStart doesn't produce // the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and // we must rebuild to get correct span position attributes. @@ -2312,6 +2391,7 @@ export class DomPainter { current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || + pageContextChanged || mappingUnreliable; if (needsRebuild) { diff --git a/packages/layout-engine/painters/dom/src/runs/hash.ts b/packages/layout-engine/painters/dom/src/runs/hash.ts index 94002063a2..ad14859059 100644 --- a/packages/layout-engine/painters/dom/src/runs/hash.ts +++ b/packages/layout-engine/painters/dom/src/runs/hash.ts @@ -160,6 +160,7 @@ export const textRunMergeSignature = (run: TextRun): string => highlight: run.highlight ?? null, textTransform: run.textTransform ?? null, token: run.token ?? null, + pageNumberFieldFormat: run.pageNumberFieldFormat ?? null, pageRefMetadata: run.pageRefMetadata ?? null, trackedChange: run.trackedChange ?? null, trackedChanges: run.trackedChanges ?? null, 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 new file mode 100644 index 0000000000..0647645ef8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { FragmentRenderContext } from '../renderer.js'; +import { textRunMergeSignature } from './hash.js'; +import { resolveRunText } from './text-run.js'; + +describe('resolveRunText', () => { + const context: FragmentRenderContext = { + pageNumber: 1, + displayPageNumber: 5, + pageNumberText: 'v', + totalPages: 10, + section: 'body', + }; + + it('uses section-formatted page number text without a local format', () => { + const run: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; + + expect(resolveRunText(run, context)).toBe('v'); + }); + + it('uses run-local page number format when present', () => { + const run: TextRun = { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + }; + + expect(resolveRunText(run, context)).toBe('V'); + }); + + it('changes merge signature when pageNumberFieldFormat changes', () => { + const baseRun: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; + const formattedRun: TextRun = { ...baseRun, pageNumberFieldFormat: { format: 'upperRoman' } }; + + expect(textRunMergeSignature(baseRun)).not.toBe(textRunMergeSignature(formattedRun)); + }); +}); diff --git a/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts b/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts index 05630084c2..d69b592f83 100644 --- a/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts +++ b/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts @@ -4,21 +4,22 @@ * `v1 layout-adapter/footnote-formatting.ts` deliberately inlines its number-format * switch instead of reusing layout-engine's `formatPageNumber` — the package * graph forbids the adapter from importing layout-engine at runtime (Guard C in - * `architecture-boundaries.test.ts`). To keep the two implementations in sync - * we assert here that they agree on every supported format for cardinals 1..100. + * `architecture-boundaries.test.ts`). To keep the shared semantics in sync we + * assert here that they agree on formats with the same expected rendering. * - * If you add a new format to one helper, this test will fail until you add the - * matching case in the other helper. That is the intended behavior. + * If you add a new shared-semantics format to one helper, this test should fail + * until you add the matching case in the other helper. Helper-specific formats + * are pinned by direct-string assertions below. */ import { describe, it, expect } from 'vitest'; import { formatPageNumber } from '@superdoc/layout-engine'; import { formatFootnoteCardinal } from '@core/layout-adapter/footnote-formatting.js'; -const FORMATS = ['decimal', 'upperRoman', 'lowerRoman', 'upperLetter', 'lowerLetter', 'numberInDash'] as const; +const SHARED_FORMATS = ['decimal', 'upperRoman', 'lowerRoman'] as const; describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { - for (const fmt of FORMATS) { + for (const fmt of SHARED_FORMATS) { it(`agrees with formatPageNumber for ${fmt} on 1..100`, () => { for (let n = 1; n <= 100; n += 1) { expect(formatFootnoteCardinal(n, fmt)).toBe(formatPageNumber(n, fmt)); @@ -36,15 +37,10 @@ describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { expect(formatFootnoteCardinal(-3, 'upperRoman')).toBe(formatPageNumber(-3, 'upperRoman')); }); - // Direct-string assertions: parity-only tests close the loop only if both - // helpers are correct. Pin the expected output for the less-obvious formats - // so a regression in BOTH helpers (e.g. someone "fixing" the inlined - // numberInDash to ` ${num} ` style) fails here rather than silently passing. - it('formats numberInDash as -n- in both helpers', () => { + it('formats numberInDash according to each helper contract', () => { for (const n of [1, 5, 12, 99]) { - const expected = `-${n}-`; - expect(formatFootnoteCardinal(n, 'numberInDash')).toBe(expected); - expect(formatPageNumber(n, 'numberInDash')).toBe(expected); + expect(formatFootnoteCardinal(n, 'numberInDash')).toBe(`-${n}-`); + expect(formatPageNumber(n, 'numberInDash')).toBe(`- ${n} -`); } }); @@ -71,18 +67,25 @@ describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { expect(formatPageNumber(9, 'lowerRoman')).toBe('ix'); }); - it('formats upperLetter / lowerLetter using base-26 cycle (a, b, ..., z, aa)', () => { + it('formats footnote upperLetter / lowerLetter using spreadsheet-style letters', () => { expect(formatFootnoteCardinal(1, 'upperLetter')).toBe('A'); expect(formatFootnoteCardinal(26, 'upperLetter')).toBe('Z'); expect(formatFootnoteCardinal(27, 'upperLetter')).toBe('AA'); + expect(formatFootnoteCardinal(28, 'upperLetter')).toBe('AB'); expect(formatFootnoteCardinal(1, 'lowerLetter')).toBe('a'); expect(formatFootnoteCardinal(26, 'lowerLetter')).toBe('z'); expect(formatFootnoteCardinal(27, 'lowerLetter')).toBe('aa'); + expect(formatFootnoteCardinal(28, 'lowerLetter')).toBe('ab'); + }); + + it('formats page upperLetter / lowerLetter using repeated letters', () => { expect(formatPageNumber(1, 'upperLetter')).toBe('A'); expect(formatPageNumber(26, 'upperLetter')).toBe('Z'); expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); expect(formatPageNumber(1, 'lowerLetter')).toBe('a'); expect(formatPageNumber(26, 'lowerLetter')).toBe('z'); expect(formatPageNumber(27, 'lowerLetter')).toBe('aa'); + expect(formatPageNumber(28, 'lowerLetter')).toBe('bb'); }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts index 0798dce8af..d59a31cb37 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts @@ -46,6 +46,18 @@ describe('tokenNodeToRun', () => { expect(result.token).toBe('totalPageCount'); }); + it('carries PAGE field-local page number format', () => { + const tokenNode: PMNode = { + type: 'page-number', + attrs: { pageNumberFormat: 'lowerRoman' }, + }; + const positions: PositionMap = new WeakMap(); + + const result = tokenNodeToRun(tokenNode, positions, 'Arial', 16, [], 'pageNumber'); + + expect(result.pageNumberFieldFormat).toEqual({ format: 'lowerRoman' }); + }); + it('attaches PM position tracking when position exists', () => { const tokenNode: PMNode = { type: 'page-number', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts index 6fe7e37f45..9bdcc3e5b6 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts @@ -678,6 +678,31 @@ describe('toFlowBlocks', () => { }); }); + it('preserves PAGE field-local page number format on token runs', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'page-number', + attrs: { pageNumberFormat: 'upperRoman' }, + }, + ], + }, + ], + }; + + const { + blocks: [block], + } = toFlowBlocks(pmDoc); + expect(block.runs[0]).toMatchObject({ + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + }); + }); + it('preserves bold formatting on page number token', () => { const pmDoc = { type: 'doc', 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 28a683277e..1b53997e9d 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 @@ -24,7 +24,7 @@ import type { ResolvedPage, LayoutStoryLocator, } from '@superdoc/contracts'; -import { namedStoryLocator, resolveInheritedHeaderFooterRef } from '@superdoc/contracts'; +import { namedStoryLocator } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; @@ -51,6 +51,7 @@ import { extractIdentifierFromConverter, getHeaderFooterType, getHeaderFooterTypeForSection, + resolveEffectiveHeaderFooterRef, getBucketForPageNumber, getBucketRepresentative, buildSectionAwareHeaderFooterLayoutKey, @@ -76,6 +77,14 @@ type SurfacePmEntry = { el: HTMLElement; }; +function hasSectionRefsForKind( + identifier: MultiSectionHeaderFooterIdentifier | null | undefined, + kind: 'header' | 'footer', +): identifier is MultiSectionHeaderFooterIdentifier { + const refKey = kind === 'header' ? 'headerRefs' : 'footerRefs'; + return Boolean(identifier?.sections?.some((section) => section[refKey] !== undefined)); +} + // AIDEV-NOTE: compat-fallback - header/footer session interaction still keys // off `data-pm-*` (prep-002). DomPainter also stamps the parallel neutral // dataset (`data-layout-fragment-id` etc.) which a future v2 consumer can @@ -374,6 +383,15 @@ function storyIdFromHeaderFooterLayoutKey(key: string): string { return key.replace(/::s\d+$/, ''); } +function refForVariant( + refs: Partial> | undefined, + variant: 'default' | 'first' | 'even' | 'odd', +): { refId: string; matchedVariant: 'default' | 'first' | 'even' | 'odd' } | undefined { + const ref = refs?.[variant]; + if (ref) return { refId: ref, matchedVariant: variant }; + return variant === 'odd' && refs?.default ? { refId: refs.default, matchedVariant: 'default' } : undefined; +} + function resolveResult(result: HeaderFooterLayoutResult, storyId?: string | null): ResolvedHeaderFooterLayout { const story = buildHeaderFooterStory(result.kind, storyId ?? String(result.type)); return resolveHeaderFooterLayout(result.layout, result.blocks, result.measures, story); @@ -1746,6 +1764,7 @@ export class HeaderFooterSessionManager { sectionFirstPageNumbers: Map, ): string { const pageNumber = page.number; + const effectivePageNumber = page.effectivePageNumber ?? page.displayNumber ?? pageNumber; const sectionIndex = page.sectionIndex ?? 0; const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const isFirstPageOfSection = firstPageInSection === pageNumber; @@ -1768,8 +1787,7 @@ export class HeaderFooterSessionManager { return 'first'; } if (hasAlternateHeaders) { - const parityPageNumber = page.displayNumber ?? page.number; - return parityPageNumber % 2 === 0 ? 'even' : 'odd'; + return effectivePageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; } @@ -2397,34 +2415,42 @@ export class HeaderFooterSessionManager { sectionFirstPageNumbers.set(idx, p.number); } } + const hasSectionResolution = hasSectionRefsForKind(multiSectionId, kind); return (pageNumber, pageMargins, page) => { const sectionIndex = page?.sectionIndex ?? 0; + const effectivePageNumber = page?.effectivePageNumber ?? page?.displayNumber ?? pageNumber; const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; - const parityPageNumber = page?.displayNumber ?? pageNumber; - const headerFooterType = multiSectionId - ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { + const headerFooterType = hasSectionResolution + ? getHeaderFooterTypeForSection(effectivePageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber, - parityPageNumber, + parityPageNumber: effectivePageNumber, }) - : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber }); + : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber: effectivePageNumber }); if (!headerFooterType) { return null; } - const pageRefs = kind === 'header' ? page?.sectionRefs?.headerRefs : page?.sectionRefs?.footerRefs; - const sectionRId = - resolveInheritedHeaderFooterRef({ - identifier: multiSectionId ?? legacyIdentifier, - sectionIndex, - kind, - variantType: headerFooterType, - pageRefs, - }) ?? undefined; + const pageSectionRefs = kind === 'header' ? page?.sectionRefs?.headerRefs : page?.sectionRefs?.footerRefs; + const sectionResolvedRef = hasSectionResolution + ? resolveEffectiveHeaderFooterRef({ + sections: multiSectionId.sections, + sectionIndex, + kind, + variant: headerFooterType, + }) + : null; + const legacyRefs = kind === 'header' ? legacyIdentifier.headerIds : legacyIdentifier.footerIds; + const resolvedRef = + refForVariant(pageSectionRefs, headerFooterType) ?? + sectionResolvedRef ?? + (!hasSectionResolution ? refForVariant(legacyRefs, headerFooterType) : undefined); + const sectionRId = resolvedRef?.refId; + const layoutVariantType = resolvedRef?.matchedVariant ?? headerFooterType; // PRIORITY 1: Try per-rId layout (composite key first for per-section margins, then plain rId) const compositeKey = sectionRId ? `${sectionRId}::s${sectionIndex}` : undefined; @@ -2498,7 +2524,7 @@ export class HeaderFooterSessionManager { return null; } - const variantIndex = results.findIndex((entry) => entry.type === headerFooterType); + const variantIndex = results.findIndex((entry) => entry.type === layoutVariantType); const variant = variantIndex >= 0 ? results[variantIndex] : undefined; if (!variant || !variant.layout?.pages?.length) { return null; @@ -2518,7 +2544,7 @@ export class HeaderFooterSessionManager { slotPage.number, variant, resolvedVariant, - `variant '${headerFooterType}' page ${pageNumber}`, + `variant '${layoutVariantType}' page ${pageNumber}`, finalHeaderId ?? headerFooterType, ); if (!alignedVariantItems) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index aab3f8f2fe..a2927a999a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -673,6 +673,337 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items![0]!.blockId).toBe('p1'); }); + it('uses legacy converter-backed selection when the multi-section identifier has no sections', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.multiSectionIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + sectionCount: 0, + sectionHeaderIds: new Map(), + sectionFooterIds: new Map(), + sectionTitlePg: new Map(), + sections: [], + }; + manager.setLayoutResults([buildHeaderResult()], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + expect(provider).toBeDefined(); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.headerFooterRefId).toBe('rId-header-default'); + expect(payload!.sectionType).toBe('default'); + }); + + it('uses legacy header selection when section resolution only has footer refs', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.multiSectionIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + sectionCount: 1, + sectionHeaderIds: new Map(), + sectionFooterIds: new Map([[0, { default: 'rId-footer-default', first: null, even: null, odd: null }]]), + sectionTitlePg: new Map(), + sections: [ + { + sectionIndex: 0, + titlePg: false, + footerRefs: { default: 'rId-footer-default', first: null, even: null, odd: null }, + }, + ], + }; + manager.setLayoutResults([buildHeaderResult()], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + } as never, + ], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + expect(provider).toBeDefined(); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.headerFooterRefId).toBe('rId-header-default'); + expect(payload!.sectionType).toBe('default'); + }); + + it('does not use legacy header selection when section resolution has explicit empty header refs', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.multiSectionIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + sectionCount: 1, + sectionHeaderIds: new Map([[0, { default: null, first: null, even: null, odd: null }]]), + sectionFooterIds: new Map(), + sectionTitlePg: new Map(), + sections: [ + { + sectionIndex: 0, + titlePg: false, + headerRefs: { default: null, first: null, even: null, odd: null }, + }, + ], + }; + manager.setLayoutResults([buildHeaderResult()], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + } as never, + ], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + expect(provider).toBeDefined(); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).toBeNull(); + }); + + it('uses the default variant layout when odd ref lookup falls back to default', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: null, first: null, even: null, odd: 'rId-header-odd' }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: true, + }; + manager.setLayoutResults([buildHeaderResult()], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionRefs: { headerRefs: { default: 'rId-header-default' }, footerRefs: {} }, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + } as never, + ], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.headerFooterRefId).toBe('rId-header-default'); + expect(payload!.sectionType).toBe('odd'); + expect(payload!.items?.[0]?.blockId).toBe('p1'); + }); + + it('uses the effective Word page number for section odd/even selection', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 3), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.headerFooterIdentifier = { + headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: true, + }; + manager.multiSectionIdentifier = { + headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: true, + sectionCount: 2, + sectionHeaderIds: new Map([ + [1, { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }], + ]), + sectionFooterIds: new Map(), + sectionTitlePg: new Map(), + sections: [ + { sectionIndex: 0, titlePg: false }, + { + sectionIndex: 1, + titlePg: false, + headerRefs: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }, + }, + ], + }; + manager.setLayoutResults([{ ...buildHeaderResult(), type: 'even' }], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { number: 1, sectionIndex: 0 } as never, + { number: 2, sectionIndex: 0 } as never, + { + number: 3, + effectivePageNumber: 2, + sectionIndex: 1, + sectionRefs: { headerRefs: { even: 'rId-header-even', odd: 'rId-header-odd' }, footerRefs: {} }, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + } as never, + ], + } as unknown as Layout; + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(3, layout.pages[2]!.margins, layout.pages[2] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.headerFooterRefId).toBe('rId-header-even'); + expect(payload!.sectionType).toBe('even'); + expect(payload!.items?.[0]?.blockId).toBe('p1'); + }); + it('recomputes variant items when cached resolved items become misaligned', () => { const deps: SessionManagerDependencies = { getLayoutOptions: vi.fn(() => ({})), @@ -1364,7 +1695,7 @@ describe('HeaderFooterSessionManager', () => { }); describe('rebuildRegions — ResolvedLayout entry', () => { - function buildManager(): HeaderFooterSessionManager { + function buildManager(editor: Editor = createMainEditorStub()): HeaderFooterSessionManager { const deps: SessionManagerDependencies = { getLayoutOptions: vi.fn(() => ({})), getPageElement: vi.fn(() => null), @@ -1383,7 +1714,7 @@ describe('HeaderFooterSessionManager', () => { painterHost, visibleHost, selectionOverlay, - editor: createMainEditorStub(), + editor, defaultPageSize: { w: 612, h: 792 }, defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, }); @@ -1617,5 +1948,26 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(1)!.sectionType).toBe('default'); expect(manager.footerRegions.get(1)!.sectionType).toBe('default'); }); + it('uses effective Word page number for fallback odd/even region type', () => { + manager = buildManager({ + ...createMainEditorStub(), + converter: { pageStyles: { alternateHeaders: true } }, + } as unknown as Editor); + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + makePage({ number: 1, height: 792, sectionIndex: 0 }), + makePage({ number: 2, height: 792, sectionIndex: 0 }), + makePage({ number: 3, effectivePageNumber: 2, height: 792, sectionIndex: 1 }), + ], + }; + + manager.rebuildRegions(layout); + + expect(manager.headerRegions.get(2)!.sectionType).toBe('even'); + expect(manager.footerRegions.get(2)!.sectionType).toBe('even'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js new file mode 100644 index 0000000000..4b81b09341 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js @@ -0,0 +1,14 @@ +/** + * Extracts the field dispatch keyword from an instruction string. + * Field type names are case-insensitive in OOXML; only normalize the dispatch + * token so downstream processors still receive the original instruction text. + * + * @param {string} instruction + * @returns {string} + */ +export function extractFieldKeyword(instruction) { + return String(instruction ?? '') + .trim() + .split(/\s+/)[0] + .toUpperCase(); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js new file mode 100644 index 0000000000..40eccdd407 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js @@ -0,0 +1,16 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { extractFieldKeyword } from './field-keyword.js'; + +describe('extractFieldKeyword', () => { + it.each([ + [null, ''], + [undefined, ''], + ['', ''], + [' ', ''], + [' page \\* arabic ', 'PAGE'], + ['toc \\o "1-3"', 'TOC'], + ])('extracts the uppercase dispatch keyword from %s', (instruction, expected) => { + expect(extractFieldKeyword(instruction)).toBe(expected); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js index 36e56f8c43..c7d9943867 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js @@ -14,7 +14,7 @@ import { generateDocxRandomId } from '@helpers/generateDocxRandomId.js'; * when the instruction has no recognisable target. */ export function resolveHyperlinkAttributes(instruction, docx) { - const urlMatch = instruction.match(/HYPERLINK\s+"([^"]+)"/); + const urlMatch = instruction.match(/^\s*HYPERLINK\s+"([^"]+)"/i); if (urlMatch && urlMatch.length >= 2) { const url = urlMatch[1]; const rels = docx?.['word/_rels/document.xml.rels']; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index 0188dee25f..f1b5ff97fb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -15,6 +15,7 @@ import { preProcessBibliographyInstruction } from './bibliography-preprocessor.j import { preProcessTaInstruction } from './ta-preprocessor.js'; import { preProcessToaInstruction } from './toa-preprocessor.js'; import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; +import { extractFieldKeyword } from '../field-keyword.js'; /** * @typedef {object} FieldPreprocessorOptions @@ -37,7 +38,10 @@ import { preProcessDocumentStatInstruction } from './document-stat-preprocessor. * @returns {InstructionPreProcessor | null} The pre-processor function or null if not found. */ export const getInstructionPreProcessor = (instruction) => { - const instructionType = instruction.trim().split(/\s+/)[0]; + const rawInstructionType = String(instruction ?? '') + .trim() + .split(/\s+/)[0]; + const instructionType = extractFieldKeyword(instruction); switch (instructionType) { case 'PAGE': return preProcessPageInstruction; @@ -65,6 +69,7 @@ export const getInstructionPreProcessor = (instruction) => { case 'STYLEREF': return preProcessStylerefInstruction; case 'SEQ': + if (rawInstructionType !== 'SEQ') return null; return preProcessSeqInstruction; case 'CITATION': return preProcessCitationInstruction; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js index 0b6c992709..227be4e206 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js @@ -6,6 +6,8 @@ import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js'; import { preProcessTocInstruction } from './toc-preprocessor.js'; +import { preProcessRefInstruction } from './ref-preprocessor.js'; +import { preProcessSeqInstruction } from './seq-preprocessor.js'; describe('getInstructionPreProcessor', () => { const mockDocx = { @@ -20,6 +22,14 @@ describe('getInstructionPreProcessor', () => { expect(processor).toBe(preProcessPageInstruction); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should return preProcessPageInstruction for case-insensitive PAGE instruction %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessPageInstruction); + }, + ); + it('should return preProcessNumPagesInstruction for NUMPAGES instruction', () => { const instruction = 'NUMPAGES'; const processor = getInstructionPreProcessor(instruction); @@ -32,6 +42,14 @@ describe('getInstructionPreProcessor', () => { expect(processor).toBe(preProcessNumPagesInstruction); }); + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should return preProcessNumPagesInstruction for case-insensitive NUMPAGES instruction %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessNumPagesInstruction); + }, + ); + it('should return preProcessPageRefInstruction for PAGEREF instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; const processor = getInstructionPreProcessor(instruction); @@ -46,6 +64,26 @@ describe('getInstructionPreProcessor', () => { expect(processor([], instruction, mockDocx)).toBeDefined(); }); + it.each([ + ['pageref _Toc123456789 h', preProcessPageRefInstruction], + ['hyperlink "http://example.com"', preProcessHyperlinkInstruction], + ['toc \\o "1-3" \\h \\z \\u', preProcessTocInstruction], + ['ref BookmarkName \\h', preProcessRefInstruction], + ])('should dispatch non-page field instruction case-insensitively: %s', (instruction, expectedProcessor) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(expectedProcessor); + }); + + it('should dispatch uppercase SEQ fields', () => { + const processor = getInstructionPreProcessor('SEQ Figure \\* ARABIC'); + expect(processor).toBe(preProcessSeqInstruction); + }); + + it('should leave lowercase seq fields unprocessed to preserve cached numbering results', () => { + const processor = getInstructionPreProcessor('seq level2 \\*arabic'); + expect(processor).toBeNull(); + }); + it('should return preProcessTocInstruction for TOC instruction', () => { const instruction = 'TOC \\o "1-3" \\h \\z \\u'; const processor = getInstructionPreProcessor(instruction); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js new file mode 100644 index 0000000000..b2e6789979 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js @@ -0,0 +1,49 @@ +const PAGE_VALUE_FORMAT_SWITCHES = { + Arabic: 'decimal', + Roman: 'upperRoman', + ROMAN: 'upperRoman', + roman: 'lowerRoman', + ALPHABETIC: 'upperLetter', + alphabetic: 'lowerLetter', + ArabicDash: 'numberInDash', +}; + +/** + * Parses the supported PAGE value-format switches from an OOXML field instruction. + * Field dispatch is case-insensitive; value-format switches preserve ECMA casing. + * + * @param {string} instruction + * @returns {{ instruction: string, pageNumberFormat?: string }} + */ +export function parsePageInstruction(instruction) { + const rawInstruction = String(instruction ?? '').trim(); + const tokens = rawInstruction.match(/"[^"]*"|'[^']*'|\\\*|\\[^\s]+|[^\s]+/g) ?? []; + const keyword = tokens[0]?.toUpperCase(); + if (keyword !== 'PAGE') { + return { instruction: rawInstruction }; + } + + for (let i = 1; i < tokens.length - 1; i += 1) { + if (tokens[i] !== '\\*') continue; + const switchName = tokens[i + 1]; + const pageNumberFormat = PAGE_VALUE_FORMAT_SWITCHES[switchName]; + if (pageNumberFormat) { + return { instruction: rawInstruction, pageNumberFormat }; + } + } + + return { instruction: rawInstruction }; +} + +/** + * @param {string} pageNumberFormat + * @returns {string | undefined} + */ +export function pageNumberFormatToInstructionSwitch(pageNumberFormat) { + for (const [switchName, format] of Object.entries(PAGE_VALUE_FORMAT_SWITCHES)) { + if (format === pageNumberFormat) { + return switchName; + } + } + return undefined; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index b04639df0b..2f85247bc9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -4,18 +4,22 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * Processes a PAGE instruction and creates a `sd:autoPageNumber` node. * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. - * @param {string} [_instrText] The instruction text (unused for PAGE). + * @param {string} [instrText] The PAGE instruction text. * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { const fieldRunRPr = options.fieldRunRPr ?? null; - const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); + const normalizedInstruction = typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'PAGE'; + const fieldAttrs = { + instruction: normalizedInstruction, + ...parsePageNumberFieldSwitches(normalizedInstruction, 'PAGE'), + }; const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', - ...(Object.keys(fieldAttrs).length > 0 ? { attributes: fieldAttrs } : {}), + attributes: fieldAttrs, }; // First, try to get rPr from content nodes (between separate and end) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index 1a1edeb126..4ec52f6f1a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -13,10 +13,26 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); + it.each([ + ['PAGE', undefined], + ['PAGE \\* roman', 'lowerRoman'], + ['PAGE \\* Roman \\* MERGEFORMAT', 'upperRoman'], + ['PAGE \\* ROMAN', 'upperRoman'], + ['page \\* Arabic', 'decimal'], + ['PAGE \\* Unsupported \\* MERGEFORMAT', undefined], + ])('preserves PAGE instruction and parses supported value format: %s', (instruction, pageNumberFormat) => { + const result = preProcessPageInstruction([], instruction, mockDocx); + expect(result[0].attributes).toEqual({ + instruction, + ...(pageNumberFormat ? { pageNumberFormat } : {}), + }); + }); + it('should extract rPr from nodes', () => { const nodesToCombine = [ { @@ -33,6 +49,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], }, ]); @@ -56,6 +73,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [fieldRunRPr], }, ]); @@ -120,6 +138,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [contentRPr], }, ]); @@ -135,6 +154,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 35fe35fba2..c3e0e7b766 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -3,6 +3,7 @@ */ import { getInstructionPreProcessor } from './fld-preprocessors'; import { resolveHyperlinkAttributes } from './fld-preprocessors/hyperlink-preprocessor.js'; +import { extractFieldKeyword } from './field-keyword.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/importer/trackChangeElements.js'; @@ -138,8 +139,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { if (node.name === 'w:fldSimple') { const instr = node.attributes?.['w:instr']; if (typeof instr === 'string') { - const instructionType = instr.trim().split(/\s+/)[0]; - const instructionPreProcessor = getInstructionPreProcessor(instructionType); + const instructionPreProcessor = getInstructionPreProcessor(instr); if (instructionPreProcessor) { const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); if (collecting) { @@ -324,8 +324,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { * @returns {{ nodes: OpenXmlNode[], handled: boolean }} The processed nodes and whether a preprocessor handled them. */ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, instructionTokens, fieldRunRPr) => { - const instructionType = instrText.trim().split(/\s+/)[0]; - const instructionPreProcessor = getInstructionPreProcessor(instructionType); + const instructionPreProcessor = getInstructionPreProcessor(instrText); if (instructionPreProcessor) { return { nodes: instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }), @@ -349,7 +348,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i * @param {ParsedDocx} docx */ const applyConstructiveFieldInterpretation = (rawNodes, instrText, docx) => { - const instructionType = instrText.trim().split(/\s+/)[0]; + const instructionType = extractFieldKeyword(instrText); if (instructionType !== 'HYPERLINK') return; const linkAttributes = resolveHyperlinkAttributes(instrText, docx); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index c8ac7fe719..88ee37cf9f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -19,6 +19,19 @@ describe('preProcessNodesForFldChar', () => { }, }; + function complexFieldNodes(instruction, cachedText = '1') { + return [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: instruction }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]; + } + it('should process a simple hyperlink field', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, @@ -53,6 +66,71 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should process PAGE field instructions case-insensitively: %s', + (instruction) => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes(instruction), mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should process NUMPAGES field instructions case-insensitively: %s', + (instruction) => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes(instruction, '5'), mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); + + it('should process non-page field instructions case-insensitively', () => { + const docx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + + const { processedNodes } = preProcessNodesForFldChar( + complexFieldNodes('hyperlink "http://example.com"', 'link text'), + docx, + ); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toEqual({ + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }], + }); + expect(processedNodes[0].elements[0].elements[0].elements[0].text).toBe('link text'); + expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([ + { + type: 'element', + name: 'Relationship', + attributes: { + Id: 'rIdabc12345', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: 'http://example.com', + TargetMode: 'External', + }, + }, + ]); + }); + + it('should preserve cached visible result runs for lowercase seq fields', () => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes('seq level2 \\*arabic', '1'), mockDocx); + + expect(processedNodes).toHaveLength(5); + expect(processedNodes.some((node) => node.name === 'sd:sequenceField')).toBe(false); + expect(processedNodes[3]).toEqual({ + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }], + }); + }); + it('should handle nested fields (PAGEREF within HYPERLINK)', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 60df3ca4f6..08c7117bf7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -4,6 +4,7 @@ import { preProcessPageInstruction } from './fld-preprocessors/page-preprocessor.js'; import { preProcessNumPagesInstruction } from './fld-preprocessors/num-pages-preprocessor.js'; import { preProcessDocumentStatInstruction } from './fld-preprocessors/document-stat-preprocessor.js'; +import { extractFieldKeyword } from './field-keyword.js'; const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); @@ -47,7 +48,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // fldSimple has the instruction in an attribute, not nested elements if (node.name === 'w:fldSimple') { const instrAttr = node.attributes?.['w:instr'] || ''; - const fieldType = instrAttr.trim().split(/\s+/)[0]; + const fieldType = extractFieldKeyword(instrAttr); const fldSimplePreprocessor = getHeaderFooterFieldPreprocessor(fieldType); if (fldSimplePreprocessor) { @@ -206,7 +207,7 @@ function scanFieldSequence(nodes, beginIndex) { return null; // Incomplete field } - const fieldType = instrText.trim().split(/\s+/)[0]; + const fieldType = extractFieldKeyword(instrText); return { fieldType, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index d97ad0e86b..63fd22720a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -3,6 +3,31 @@ import { describe, it, expect } from 'vitest'; import { preProcessPageFieldsOnly } from './preProcessPageFieldsOnly.js'; describe('preProcessPageFieldsOnly', () => { + function complexFieldNodes(instruction, cachedText = '1') { + return [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: instruction }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + } + describe('complex field syntax (w:fldChar)', () => { it('should process PAGE field with fldChar syntax', () => { const nodes = [ @@ -34,6 +59,16 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); }); + it.each([' page \\* arabic ', ' Page ', ' PAGE '])( + 'should process PAGE field case-insensitively with fldChar syntax: %s', + (instruction) => { + const result = preProcessPageFieldsOnly(complexFieldNodes(instruction)); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + it('should process NUMPAGES field with fldChar syntax', () => { const nodes = [ { @@ -101,6 +136,16 @@ describe('preProcessPageFieldsOnly', () => { }, }); }); + + it.each([' numpages ', ' NumPages ', ' NUMPAGES '])( + 'should process NUMPAGES field case-insensitively with fldChar syntax: %s', + (instruction) => { + const result = preProcessPageFieldsOnly(complexFieldNodes(instruction, '5')); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); }); describe('simple field syntax (w:fldSimple)', () => { @@ -124,6 +169,29 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should process PAGE field case-insensitively with fldSimple syntax: %s', + (instruction) => { + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': instruction }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }], + }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + it('should process NUMPAGES field with fldSimple syntax', () => { const nodes = [ { @@ -147,6 +215,29 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); }); + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should process NUMPAGES field case-insensitively with fldSimple syntax: %s', + (instruction) => { + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': instruction }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '5' }] }], + }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); + it('should preserve rPr styling from fldSimple content', () => { const nodes = [ { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index 9f4fc1bf21..306c32c2d8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -2,6 +2,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; +import { pageNumberFormatToInstructionSwitch } from '../../../../field-references/fld-preprocessors/page-instruction.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:autoPageNumber'; @@ -27,6 +28,12 @@ const encode = (params) => { ...getPageNumberFieldAttrs(node), }, }; + if (typeof node.attributes?.instruction === 'string') { + processedNode.attrs.instruction = node.attributes.instruction; + } + if (typeof node.attributes?.pageNumberFormat === 'string') { + processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat; + } return processedNode; }; @@ -40,7 +47,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); - const instruction = node.attrs?.instruction || 'PAGE'; + const instruction = getPageInstructionText(node.attrs); const translated = [ { name: 'w:r', @@ -121,6 +128,33 @@ function getPageNumberFieldAttrs(node) { return attrs; } +/** + * @param {Record | undefined} attrs + * @returns {string} + */ +function getPageInstructionText(attrs = {}) { + if (typeof attrs.instruction === 'string' && attrs.instruction.trim()) { + return attrs.instruction.trim(); + } + + if (typeof attrs.pageNumberFormat === 'string') { + const instructionSwitch = pageNumberFormatToInstructionSwitch(attrs.pageNumberFormat); + if (instructionSwitch) { + const numericPicture = + typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0 + ? ` \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}` + : ''; + return `PAGE \\* ${instructionSwitch}${numericPicture}`; + } + } + + if (typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0) { + return `PAGE \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}`; + } + + return 'PAGE'; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js index 5de2b2e30c..903dd8eb48 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js @@ -37,6 +37,10 @@ describe('sd:autoPageNumber translator', () => { nodes: [ { name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, elements: [ { name: 'w:rPr', @@ -59,6 +63,8 @@ describe('sd:autoPageNumber translator', () => { type: 'page-number', attrs: { marksAsAttrs: marks, + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', }, }); }); @@ -211,5 +217,34 @@ describe('sd:autoPageNumber translator', () => { expect(result[1].elements[1].elements[0].text).toBe(' PAGE \\* ArabicDash'); }); + + it('preserves imported PAGE instruction on export', () => { + const result = config.decode({ + node: { + type: 'page-number', + attrs: { + instruction: 'PAGE \\* Roman \\* MERGEFORMAT', + pageNumberFormat: 'upperRoman', + }, + }, + }); + + const instrRun = result[1]; + expect(instrRun.elements[1].elements[0].text).toBe(' PAGE \\* Roman \\* MERGEFORMAT'); + }); + + it('synthesizes a PAGE switch for new formatted page-number nodes', () => { + const result = config.decode({ + node: { + type: 'page-number', + attrs: { + pageNumberFormat: 'lowerRoman', + }, + }, + }); + + const instrRun = result[1]; + expect(instrRun.elements[1].elements[0].text).toBe(' PAGE \\* roman'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js index d0d615cfb2..a6d1971268 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { exportSchemaToJson } from '../../../../exporter.js'; import { translator as runTranslator } from '../../w/r/r-translator.js'; +import { translator as sequenceFieldTranslator } from './sequenceField-translator.js'; const SEQUENCE_FIELD_INSTRUCTION = 'SEQ Figure \\* ARABIC'; @@ -30,6 +31,28 @@ function hasFieldCharType(node, fieldType) { } describe('sequenceField export routing', () => { + it('extracts cached result text from run-wrapped field content', () => { + const encoded = sequenceFieldTranslator.encode({ + nodes: [ + { + name: 'sd:sequenceField', + attributes: { instruction: 'seq level2 \\*arabic' }, + elements: [ + { + type: 'run', + content: [{ type: 'text', text: '1', marks: [] }], + }, + ], + }, + ], + nodeListHandler: { + handler: () => [{ type: 'run', content: [{ type: 'text', text: '1', marks: [] }] }], + }, + }); + + expect(encoded.attrs.resolvedNumber).toBe('1'); + }); + it('exports sequenceField nodes as fldChar + instrText runs', () => { const exported = exportSchemaToJson({ node: buildSequenceFieldNode(), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js index b3e8d8dc94..26c1605078 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js @@ -113,10 +113,17 @@ function parseSeqInstruction(instruction) { */ function extractResolvedText(content) { if (!Array.isArray(content)) return ''; - return content - .filter((n) => n.type === 'text') - .map((n) => n.text || '') - .join(''); + let text = ''; + for (const node of content) { + if (!node) continue; + if (node.type === 'text') { + text += node.text || ''; + } + if (Array.isArray(node.content)) { + text += extractResolvedText(node.content); + } + } + return text; } /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts index ecb12915f7..895e40bd88 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts @@ -198,7 +198,7 @@ export function headerFootersResolveAdapter( } // Walk previous sections via shared resolver - const resolved = resolveEffectiveRef(editor, sections, projection.range.sectionIndex, headerFooterKind, variant); + const resolved = resolveEffectiveRef(sections, projection.range.sectionIndex, headerFooterKind, variant); if (resolved) { return { status: 'inherited', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.test.ts new file mode 100644 index 0000000000..8b161ad427 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { resolveEffectiveRef } from './header-footer-refs-mutation.js'; +import type { SectionProjection } from './sections-resolver.js'; + +function projection( + sectionIndex: number, + refs: SectionProjection['range']['headerRefs'], + domainRefs?: SectionProjection['domain']['headerRefs'], +): SectionProjection { + return { + sectionId: `section-${sectionIndex}`, + address: { kind: 'section', sectionId: `section-${sectionIndex}` }, + range: { + sectionIndex, + headerRefs: refs, + } as SectionProjection['range'], + target: { kind: 'body' }, + domain: { + ...(domainRefs && { headerRefs: domainRefs }), + }, + }; +} + +describe('resolveEffectiveRef', () => { + it('does not inherit default for first variants', () => { + const sections = [projection(0, { default: 'h0-default' }), projection(1, undefined)]; + + expect(resolveEffectiveRef(sections, 1, 'header', 'first')).toBeNull(); + }); + + it('does not inherit default for even variants', () => { + const sections = [projection(0, { default: 'h0-default' }), projection(1, undefined)]; + + expect(resolveEffectiveRef(sections, 1, 'header', 'even')).toBeNull(); + }); + + it('inherits default for default variants', () => { + const sections = [projection(0, { default: 'h0-default' }), projection(1, undefined)]; + + expect(resolveEffectiveRef(sections, 1, 'header', 'default')).toMatchObject({ + refId: 'h0-default', + resolvedFromSection: { kind: 'section', sectionId: 'section-0' }, + resolvedVariant: 'default', + }); + }); + + it('inherits converter-preserved refs exposed through section domain metadata', () => { + const sections = [projection(0, undefined, { default: 'h0-domain-default' }), projection(1, undefined)]; + + expect(resolveEffectiveRef(sections, 1, 'header', 'default')).toMatchObject({ + refId: 'h0-domain-default', + resolvedFromSection: { kind: 'section', sectionId: 'section-0' }, + resolvedVariant: 'default', + }); + }); + + it('returns null when resolving before the first section', () => { + const sections = [projection(0, { default: 'h0-default' })]; + + expect(resolveEffectiveRef(sections, 0, 'header', 'default')).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.ts index 172b09ae3e..51768dd705 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-refs-mutation.ts @@ -4,13 +4,13 @@ import type { SectionAddress, SectionMutationResult, } from '@superdoc/document-api'; +import { resolveEffectiveHeaderFooterRef } from '@superdoc/contracts'; import type { Editor } from '../../core/Editor.js'; import type { SectionProjection } from './sections-resolver.js'; import { getSectPrHeaderFooterRef, setSectPrHeaderFooterRef, clearSectPrHeaderFooterRef, - readSectPrHeaderFooterRefs, type XmlElement, } from './sections-xml.js'; import { @@ -18,7 +18,8 @@ import { hasHeaderFooterRelationship, type ConverterWithHeaderFooterParts, } from './header-footer-parts.js'; -import { readTargetSectPr } from './section-projection-access.js'; + +type HeaderFooterRefs = Partial>; // --------------------------------------------------------------------------- // Shared resolver @@ -30,43 +31,37 @@ import { readTargetSectPr } from './section-projection-access.js'; * Returns null if no ref found in any section. */ export function resolveEffectiveRef( - editor: Editor, sections: SectionProjection[], startSectionIndex: number, kind: HeaderFooterKind, variant: HeaderFooterVariant, ): { refId: string; resolvedFromSection: SectionAddress; resolvedVariant: HeaderFooterVariant } | null { - // Walk previous sections in descending index order (toward section 0) - for (let i = startSectionIndex - 1; i >= 0; i--) { - const section = sections.find((s) => s.range.sectionIndex === i); - if (!section) continue; - - const sectPr = readTargetSectPr(editor, section); - if (!sectPr) continue; - - const refs = readSectPrHeaderFooterRefs(sectPr, kind); - if (!refs) continue; + const refsFor = (section: SectionProjection, refKind: HeaderFooterKind): HeaderFooterRefs | undefined => + refKind === 'header' + ? ((section.range.headerRefs ?? section.domain.headerRefs) as HeaderFooterRefs | undefined) + : ((section.range.footerRefs ?? section.domain.footerRefs) as HeaderFooterRefs | undefined); - // Try exact variant first - if (refs[variant]) { - return { - refId: refs[variant]!, - resolvedFromSection: section.address, - resolvedVariant: variant, - }; - } + const resolved = resolveEffectiveHeaderFooterRef({ + sections: sections.map((section) => ({ + sectionIndex: section.range.sectionIndex, + titlePg: section.range.titlePg, + headerRefs: refsFor(section, 'header'), + footerRefs: refsFor(section, 'footer'), + })), + sectionIndex: startSectionIndex - 1, + kind, + variant, + }); + if (!resolved) return null; - // Fall back to 'default' (only for non-default requests) - if (variant !== 'default' && refs.default) { - return { - refId: refs.default, - resolvedFromSection: section.address, - resolvedVariant: 'default', - }; - } - } + const resolvedSection = sections.find((section) => section.range.sectionIndex === resolved.matchedSectionIndex); + if (!resolvedSection) return null; - return null; + return { + refId: resolved.refId, + resolvedFromSection: resolvedSection.address, + resolvedVariant: resolved.matchedVariant as HeaderFooterVariant, + }; } // --------------------------------------------------------------------------- @@ -199,7 +194,7 @@ export function setLinkedToPreviousMutation( } // Walk the full chain to find effective source - const resolved = resolveEffectiveRef(editor, sections, projection.range.sectionIndex, kind, variant); + const resolved = resolveEffectiveRef(sections, projection.range.sectionIndex, kind, variant); // During dry-run, skip part allocation if (dryRun) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts index 70e128df98..c8ccd6db4d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts @@ -13,7 +13,6 @@ import type { SectionHeaderFooterKind, SectionHeaderFooterVariant } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import { getWordPartRelsPath } from '../../core/helpers/word-part-path.js'; -import type { SectionProjection } from './sections-resolver.js'; import { resolveSectionProjections } from './sections-resolver.js'; import { readTargetSectPr } from './section-projection-access.js'; import { ensureSectPrElement, setSectPrHeaderFooterRef, readSectPrHeaderFooterRefs } from './sections-xml.js'; @@ -122,7 +121,7 @@ export function ensureExplicitHeaderFooterSlot( // Step 4: Resolve inherited effective ref for potential cloning. const sectionIndex = sections.indexOf(projection); - const inheritedRef = resolveEffectiveRef(editor, sections, sectionIndex, kind, variant); + const inheritedRef = resolveEffectiveRef(sections, sectionIndex, kind, variant); const effectiveSourceRefId = sourceRefId ?? inheritedRef?.refId ?? undefined; // Step 5–11: Create part + update sectPr, wrapped in compoundMutation diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts index 953d86e870..4bc86f9272 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/story-runtime/header-footer-story-runtime.ts @@ -13,7 +13,7 @@ import { createStoryEditor } from '../../core/story-editor-factory.js'; import { DocumentApiAdapterError } from '../errors.js'; import { resolveSectionProjections } from '../helpers/sections-resolver.js'; import { readTargetSectPr } from '../helpers/section-projection-access.js'; -import { readSectPrHeaderFooterRefs, type XmlElement } from '../helpers/sections-xml.js'; +import { readSectPrHeaderFooterRefs } from '../helpers/sections-xml.js'; import { resolveEffectiveRef } from '../helpers/header-footer-refs-mutation.js'; import { exportSubEditorToPart } from '../../core/parts/adapters/header-footer-sync.js'; import { ensureExplicitHeaderFooterSlot } from '../helpers/header-footer-slot-materialization.js'; @@ -103,13 +103,7 @@ export function resolveHeaderFooterSlotRuntime( } // For 'effective' resolution, walk the section chain backward - const resolved = resolveEffectiveRef( - hostEditor, - sections, - projection.range.sectionIndex, - headerFooterKind, - variant, - ); + const resolved = resolveEffectiveRef(sections, projection.range.sectionIndex, headerFooterKind, variant); effectiveRefId = resolved?.refId ?? null; } diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index dcd23a2cc2..6dcf619ecd 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -14,7 +14,7 @@ import type { InlineNodeAttributes, ShapeNodeAttributes, } from '../../core/types/NodeCategories.js'; -import type { ImageHyperlink, StructuredContentLockMode } from '@superdoc/contracts'; +import type { ImageHyperlink, PageNumberFormat, StructuredContentLockMode } from '@superdoc/contracts'; // ============================================ // SHARED TYPES @@ -951,7 +951,7 @@ export interface PageNumberAttrs extends InlineNodeAttributes { /** @internal Original PAGE field instruction when switched */ instruction?: string | null; /** @internal Normalized field switch format */ - pageNumberFormat?: string | null; + pageNumberFormat?: PageNumberFormat | null; /** @internal Zero-padding width from numeric picture switch */ pageNumberZeroPadding?: number | null; } diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts index 7df11f1c74..378ad22308 100644 --- a/tests/behavior/helpers/story-fixtures.ts +++ b/tests/behavior/helpers/story-fixtures.ts @@ -479,6 +479,51 @@ function inlinePageFieldFooterXml(): string { `; } +function lowercasePageFieldFooterXml(): string { + return ` + + + + + + + Case footer + + page \\* arabic + + 1 + + + +`; +} + +function formattedPageFieldFooterXml(): string { + const pageField = (instruction: string, cachedText: string) => ` + + ${instruction} + + ${cachedText} + `; + + return ` + + + + + + + Formats + ${pageField('PAGE \\* Roman', 'I')} + + ${pageField('PAGE \\* ALPHABETIC', 'A')} + + ${pageField('PAGE \\* ArabicDash', '- 1 -')} + + +`; +} + function inlinePageFieldSingleRunFooterXml(): string { return ` @@ -631,6 +676,22 @@ export const FOOTER_INLINE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( 'word/footer2.xml': inlinePageFieldFooterXml(), }, ); +export const FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( + 'footer-lowercase-page-field.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + 'word/footer2.xml': lowercasePageFieldFooterXml(), + }, +); +export const FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( + 'footer-formatted-page-field.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + 'word/footer2.xml': formattedPageFieldFooterXml(), + }, +); export const FOOTER_SIMPLE_TEXT_WITH_TABLE_AND_FOOTNOTE_DOC_PATH = ensureGeneratedFixture( 'footer-simple-text-with-table-and-footnote.docx', 'h_f-normal.docx', diff --git a/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts new file mode 100644 index 0000000000..44b9aeb158 --- /dev/null +++ b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH, + FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test('lowercase PAGE field in repeated footer resolves per page instead of using cached text', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH); + await superdoc.waitForStable(); + + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const secondPageFooter = superdoc.page.locator('.superdoc-page-footer').nth(1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + + await expect(secondPageFooter).toContainText(/Case footer\s*2/); + await expect(secondPageFooter).not.toContainText(/Case footer\s*1/); +}); + +test('formatted PAGE fields in repeated footer resolve per page', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH); + await superdoc.waitForStable(); + + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const secondPageFooter = superdoc.page.locator('.superdoc-page-footer').nth(1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + + await expect(secondPageFooter).toContainText(/Formats\s*II\s*B\s*-\s*2\s*-/); + await expect(secondPageFooter).not.toContainText(/Formats\s*I\s*A\s*-\s*1\s*-/); +}); From c557c8f9c4a40bc537c84f5ffebce21d0b2f49d3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 14:56:48 -0300 Subject: [PATCH 23/63] refactor(layout-engine): centralize header/footer ref inheritance Extract the OOXML header/footer ref inheritance logic into a shared helper (`resolveInheritedHeaderFooterRef`) in `@superdoc/contracts` and use it from layout-engine, layout-bridge, and HeaderFooterSessionManager. This replaces three near-duplicate copies of the same resolution rules. While unifying the logic, fix inheritance through intermediate sections that omit `first`/`even` refs: previously the resolver only looked at the immediately prior section, so a `first` ref defined in section 0 was lost once section 1 (with only a `default` ref) sat between section 0 and a later section that also lacked an explicit `first` ref. The shared resolver now walks back to the nearest prior section that defines the requested variant. --- .../contracts/src/header-footer-inheritance.ts | 9 ++++++++- .../layout-engine/layout-bridge/src/headerFooterUtils.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 5db15a76bc..31ae6b28e0 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -51,11 +51,18 @@ export function resolveInheritedHeaderFooterRefWithType({ const fromSection = resolveVariantRef(sectionIds, variantType); if (fromSection) return fromSection; - if (sectionMap) { + if (sectionMap && sectionMap.size > 0) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; } + return null; + } + + const hasSectionAwareRefs = + sectionMap != null && (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); + if (hasSectionAwareRefs) { + return null; } return resolveVariantRef(legacyIds, variantType); diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 415aa2865b..10084ff344 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -6,6 +6,7 @@ import type { HeaderFooterResolutionSection, } from '@superdoc/contracts'; import { resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage } from '@superdoc/contracts'; +export { resolveInheritedHeaderFooterRef, type HeaderFooterRefIdentifier } from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; From 392ec7d0654610d5978adc0c1204b2103bfe2143 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:37:16 -0300 Subject: [PATCH 24/63] fix(contracts): preserve header footer fallback refs --- .../contracts/src/header-footer-inheritance.ts | 6 ------ .../layout-bridge/test/headerFooterUtils.test.ts | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 31ae6b28e0..13fac4ecee 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -59,12 +59,6 @@ export function resolveInheritedHeaderFooterRefWithType({ return null; } - const hasSectionAwareRefs = - sectionMap != null && (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); - if (hasSectionAwareRefs) { - return null; - } - return resolveVariantRef(legacyIds, variantType); } diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 102a30b58f..6bb20b0046 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -1193,5 +1193,6 @@ describe('headerFooterUtils', () => { expect(getHeaderFooterIdForPage(page, identifier, { kind: 'header', sectionPageNumber: 3 })).toBe('h0-default'); }); + }); }); From aa1880c72135d472cb6cdc54dcb114a2a685c3ad Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:46:08 -0300 Subject: [PATCH 25/63] fix(contracts): ignore later refs for fallback resolution --- .../contracts/src/header-footer-inheritance.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 13fac4ecee..26ee204638 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -51,7 +51,17 @@ export function resolveInheritedHeaderFooterRefWithType({ const fromSection = resolveVariantRef(sectionIds, variantType); if (fromSection) return fromSection; - if (sectionMap && sectionMap.size > 0) { + let hasPriorSectionRefs = false; + if (sectionMap) { + for (const index of sectionMap.keys()) { + if (index < sectionIndex) { + hasPriorSectionRefs = true; + break; + } + } + } + const hasSectionAwareRefs = sectionMap != null && (sectionMap.has(sectionIndex) || hasPriorSectionRefs); + if (hasSectionAwareRefs) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; From c2ae9cff4b17f4c0b102a53d3bee2a55026a573a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 16:23:12 -0300 Subject: [PATCH 26/63] fix(contracts): inherit converter fallback refs --- .../layout-engine/contracts/src/header-footer-inheritance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 26ee204638..83c3885b06 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -66,7 +66,6 @@ export function resolveInheritedHeaderFooterRefWithType({ const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; } - return null; } return resolveVariantRef(legacyIds, variantType); From e900a4da88a0001c8a0be99b1fe425c494e39df5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:33:55 -0300 Subject: [PATCH 27/63] test(contracts): cover header footer inheritance helper --- .../contracts/src/header-footer-inheritance.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 83c3885b06..5db15a76bc 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -51,17 +51,7 @@ export function resolveInheritedHeaderFooterRefWithType({ const fromSection = resolveVariantRef(sectionIds, variantType); if (fromSection) return fromSection; - let hasPriorSectionRefs = false; if (sectionMap) { - for (const index of sectionMap.keys()) { - if (index < sectionIndex) { - hasPriorSectionRefs = true; - break; - } - } - } - const hasSectionAwareRefs = sectionMap != null && (sectionMap.has(sectionIndex) || hasPriorSectionRefs); - if (hasSectionAwareRefs) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; From bca3a17677ff2e855ab796133a3bd5ff670feb80 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:34:30 -0300 Subject: [PATCH 28/63] fix(layout-bridge): drop unused inheritance re-export --- packages/layout-engine/layout-bridge/src/headerFooterUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 10084ff344..415aa2865b 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -6,7 +6,6 @@ import type { HeaderFooterResolutionSection, } from '@superdoc/contracts'; import { resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage } from '@superdoc/contracts'; -export { resolveInheritedHeaderFooterRef, type HeaderFooterRefIdentifier } from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; From bfe39fee26cb8f9917d5894d54eb52514bae9abc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 10:08:11 -0300 Subject: [PATCH 29/63] fix(layout): preserve converter title page refs --- .../test/headerFooterUtils.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 6bb20b0046..69c73ef1ab 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -484,6 +484,23 @@ describe('headerFooterUtils', () => { ).toBe('h0-default'); }); + it('should preserve converter titlePg fallback for section 0 variant selection', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { first: null }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { + headerIds: { first: 'converter-h-first', titlePg: true }, + }); + + expect( + getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), + ).toBe('converter-h-first'); + }); + it('should NOT override existing section metadata with converter IDs', () => { const sectionMetadata: SectionMetadata[] = [ { From 1648f3b4522100e64caad78bd0cb82ddce84bd2d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 14:50:42 -0300 Subject: [PATCH 30/63] fix(layout): stop leaking converter fallback refs into section resolution buildMultiSectionIdentifier previously merged the converter's legacy header/footer refs into section 0's resolution entry. This let a footerless first section inherit a converter-level default that belonged to a later section, painting a footer where the document declares none. Section-aware resolution now reads only per-section refs; converter fallbacks remain on the legacy identifier fields for legacy lookups but are no longer exposed through resolveEffectiveHeaderFooterRef. Guard HeaderFooterSessionManager so it only consults legacy refs when section resolution is unavailable, and skip building resolution sections for an empty identifier. --- .../test/headerFooterUtils.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 69c73ef1ab..6bb20b0046 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -484,23 +484,6 @@ describe('headerFooterUtils', () => { ).toBe('h0-default'); }); - it('should preserve converter titlePg fallback for section 0 variant selection', () => { - const sectionMetadata: SectionMetadata[] = [ - { - sectionIndex: 0, - headerRefs: { first: null }, - }, - ]; - - const identifier = buildMultiSectionIdentifier(sectionMetadata, undefined, { - headerIds: { first: 'converter-h-first', titlePg: true }, - }); - - expect( - getHeaderFooterIdForPage({ number: 1, fragments: [], sectionIndex: 0 }, identifier, { kind: 'header' }), - ).toBe('converter-h-first'); - }); - it('should NOT override existing section metadata with converter IDs', () => { const sectionMetadata: SectionMetadata[] = [ { From efede9d4dbb17d7e1719e7b47b9a39e25a67999e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 14:56:15 -0300 Subject: [PATCH 31/63] feat(super-converter): match field dispatch keywords case-insensitively OOXML field type names are case-insensitive, but the field-reference preprocessors dispatched on the raw first token (e.g. only "PAGE", not "page"). A lowercase PAGE/NUMPAGES field in a repeated footer fell through to the cached static text and showed the same number on every page. Add a shared extractFieldKeyword helper that normalizes the dispatch token to upper case while leaving the original instruction text intact for downstream processors, and route fldSimple/fldChar dispatch and the header/footer page-field scan through it. Make the HYPERLINK target regex case-insensitive and anchored. Cover the new behavior with unit tests and a behavior spec asserting a lowercase PAGE footer resolves per page. --- .../field-references/preProcessPageFieldsOnly.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 08c7117bf7..dafa8e9de0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -226,7 +226,7 @@ function scanFieldSequence(nodes, beginIndex) { * @returns {Function | null} */ function getHeaderFooterFieldPreprocessor(fieldType) { - switch (fieldType) { + switch (extractFieldKeyword(fieldType)) { case 'PAGE': return preProcessPageInstruction; case 'NUMPAGES': From adfbeb9e74b1be5da8779be109eaecb8aa28a2bb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 15:11:52 -0300 Subject: [PATCH 32/63] fix(super-converter): trust header footer field keyword --- .../field-references/preProcessPageFieldsOnly.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index dafa8e9de0..08c7117bf7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -226,7 +226,7 @@ function scanFieldSequence(nodes, beginIndex) { * @returns {Function | null} */ function getHeaderFooterFieldPreprocessor(fieldType) { - switch (extractFieldKeyword(fieldType)) { + switch (fieldType) { case 'PAGE': return preProcessPageInstruction; case 'NUMPAGES': From 30a7c9b64ada8feff05d2d7556b8208efc337a35 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 17:41:25 -0300 Subject: [PATCH 33/63] fix(painter): rebuild drawing page fields on context changes --- .../painters/dom/src/renderer.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b1c9f832bf..68302d972a 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -284,6 +284,25 @@ function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | un ); } +function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { + return ( + Array.isArray(textContent?.parts) && + textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES') + ); +} + +function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | undefined): boolean { + return ( + Array.isArray(shapes) && + shapes.some((shape) => { + if (shape.shapeType !== 'vectorShape') { + return false; + } + return hasPageContextTokenInShapeText(shape.attrs.textContent); + }) + ); +} + function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { if (!block) return false; if (block.kind === 'paragraph') { From eb74b1e821406c9f474a79c9259eb37adb5fcc6e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 10:25:57 -0300 Subject: [PATCH 34/63] fix(layout): remove duplicate displayNumber fields and fix page signature ref Drop the redundant displayNumber declarations from HeaderFooterPage, ResolvedHeaderFooterPage, and the layout-bridge page builder, keeping the section-aware variant. Correct the renderer page context signature to read displayPageNumber instead of the nonexistent pageNumberDisplayNumber. --- .../painters/dom/src/renderer.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 68302d972a..b1c9f832bf 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -284,25 +284,6 @@ function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | un ); } -function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { - return ( - Array.isArray(textContent?.parts) && - textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES') - ); -} - -function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | undefined): boolean { - return ( - Array.isArray(shapes) && - shapes.some((shape) => { - if (shape.shapeType !== 'vectorShape') { - return false; - } - return hasPageContextTokenInShapeText(shape.attrs.textContent); - }) - ); -} - function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { if (!block) return false; if (block.kind === 'paragraph') { From 94f1b752eb202e65ec167262cd698f662c8c721d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 10:40:03 -0300 Subject: [PATCH 35/63] feat(page-number): add SECTIONPAGES field support Implement end-to-end handling for the SECTIONPAGES field, resolving the per-section page count as a new `sd:sectionPageCount` node. - Import: parse SECTIONPAGES fields via a dedicated preprocessor that captures the instruction, page number format, and cached display text - Convert: add the `sectionPageCount` token through the layout-adapter, contracts, and page-token resolution in the layout engine - Layout: thread sectionPageCount through the header/footer page resolver and disable bucketing when section tokens are present so each page resolves its own count - Render: support the SECTIONPAGES field in textbox/shape SVG rendering and the DOM painter, with page-number-format-aware value formatting - Export: round-trip the node back to OOXML complex field runs via a new sectionPageCount translator - Editor: register the SectionPageCount inline node and its node view Also fix the malformed parseDOM selectors for the auto page-number and total-page-count nodes (missing closing bracket). --- packages/layout-engine/contracts/src/index.ts | 10 +- .../layout-bridge/src/cacheInvalidation.ts | 4 +- .../layout-bridge/src/incrementalLayout.ts | 5 +- .../layout-bridge/src/layoutHeaderFooter.ts | 52 +++++++- .../src/resolveHeaderFooterTokens.ts | 26 +++- .../layout-engine/src/pageNumbering.test.ts | 12 ++ .../layout-engine/src/pageNumbering.ts | 9 ++ .../src/resolvePageNumberTokens.test.ts | 64 ++++++++++ .../layout-engine/src/resolvePageTokens.ts | 35 +++++- .../painters/dom/src/renderer.ts | 47 ++++++- .../painters/dom/src/runs/text-run.ts | 7 ++ .../v1/assets/styles/elements/page-number.css | 9 +- .../HeaderFooterPerRidLayout.test.ts | 1 + .../header-footer/HeaderFooterPerRidLayout.ts | 8 +- .../header-footer/HeaderFooterRegistry.ts | 44 ++++++- .../editors/v1/core/header-footer/types.ts | 3 + .../v1/core/layout-adapter/constants.ts | 4 +- .../presentation-editor/PresentationEditor.ts | 2 + .../HeaderFooterSessionManager.ts | 6 + .../story-session/types.ts | 1 + .../editors/v1/core/story-editor-factory.ts | 9 ++ .../v1/core/super-converter/exporter.js | 2 + .../fld-preprocessors/index.js | 3 + .../fld-preprocessors/index.test.js | 9 ++ .../fld-preprocessors/page-instruction.js | 7 +- .../section-pages-preprocessor.js | 58 +++++++++ .../section-pages-preprocessor.test.js | 50 ++++++++ .../preProcessPageFieldsOnly.js | 6 +- .../preProcessPageFieldsOnly.test.js | 39 ++++++ .../v2/importer/autoPageNumberImporter.js | 6 + .../v2/importer/docxImporter.js | 4 +- .../v3/handlers/helpers/is-inline-node.js | 1 + .../core/super-converter/v3/handlers/index.js | 2 + .../v3/handlers/sd/sectionPageCount/index.js | 1 + .../sectionPageCount-translator.js | 112 +++++++++++++++++ .../sectionPageCount-translator.test.js | 103 ++++++++++++++++ .../wp/helpers/encode-image-node-helpers.js | 21 +++- .../src/editors/v1/core/types/EditorConfig.ts | 9 ++ .../helpers/field-resolver.test.ts | 67 ++++++++++ .../helpers/field-resolver.ts | 15 ++- .../src/editors/v1/extensions/index.js | 3 +- .../v1/extensions/page-number/page-number.js | 115 +++++++++++++++++- .../page-number/page-number.test.js | 59 ++++++++- .../pagination/pagination-helpers.js | 9 ++ .../extensions/shape-group/ShapeGroupView.js | 6 + .../editors/v1/extensions/shared/svg-utils.js | 12 +- .../types/miscellaneous-commands.ts | 6 + .../v1/extensions/types/node-attributes.ts | 15 +++ .../vector-shape/VectorShapeView.js | 2 + 49 files changed, 1051 insertions(+), 49 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index a12f98efcb..24d0db6862 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1,5 +1,5 @@ import type { TabStop } from './engines/tabs.js'; -import type { PageNumberFieldFormat } from './page-number-formatting.js'; +import type { PageNumberFieldFormat, PageNumberFormat } from './page-number-formatting.js'; export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js'; // Re-export TabStop for external consumers @@ -416,7 +416,7 @@ export type TextRun = RunMarks & { visualPlaceholder?: SdtVisualPlaceholder; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ - token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; + token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount'; /** Explicit formatting requested by PAGE/NUMPAGES field switches. */ pageNumberFieldFormat?: PageNumberFieldFormat; /** Absolute ProseMirror position (inclusive) of first character in this run. */ @@ -949,8 +949,10 @@ export type TextFormatting = { export type TextPart = { text: string; formatting?: TextFormatting; - /** Optional field token (e.g., PAGE/NUMPAGES) resolved at render time. */ - fieldType?: 'PAGE' | 'NUMPAGES'; + /** Optional field token (e.g., PAGE/NUMPAGES/SECTIONPAGES) resolved at render time. */ + fieldType?: 'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'; + /** PAGE/SECTIONPAGES field-local value formatting override. */ + pageNumberFormat?: PageNumberFormat; /** Indicates this part represents a line break between paragraphs. */ isLineBreak?: boolean; /** Indicates this line break follows an empty paragraph (creates extra spacing). */ diff --git a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts index 7bc0bd8ddc..7fc1deb9cc 100644 --- a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts +++ b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts @@ -51,7 +51,9 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string { if ('bold' in run && run.bold) parts.push('b'); if ('italic' in run && run.italic) parts.push('i'); if ('token' in run && run.token) parts.push(`token:${run.token}`); - if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`); + if ('pageNumberFieldFormat' in run && run.pageNumberFieldFormat) { + parts.push(`pnf:${JSON.stringify(run.pageNumberFieldFormat)}`); + } } } } diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index c867d56d06..024916ed62 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -2700,13 +2700,16 @@ export async function incrementalLayout( // Create page resolver for section-aware header/footer numbering // Only use page resolver if feature flag is enabled const pageResolver = FeatureFlags.HEADER_FOOTER_PAGE_TOKENS - ? (pageNumber: number): { displayText: string; displayNumber: number; totalPages: number } => { + ? ( + pageNumber: number, + ): { displayText: string; displayNumber: number; totalPages: number; sectionPageCount: number } => { const pageIndex = pageNumber - 1; const displayInfo = numberingCtx.displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages: numberingCtx.totalPages, + sectionPageCount: displayInfo?.sectionPageCount ?? numberingCtx.totalPages ?? 1, }; } : undefined; diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index bd81f901e6..1e8993a448 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -33,6 +33,7 @@ export type PageResolver = (pageNumber: number) => { displayText: string; displayNumber?: number; totalPages: number; + sectionPageCount: number; }; /** @@ -117,11 +118,23 @@ export function getBucketRepresentative(bucket: DigitBucket): number { * for header/footer variants that don't contain page tokens. * * @param blocks - FlowBlocks to check for tokens - * @returns True if any block contains pageNumber or totalPageCount tokens + * @returns True if any block contains pageNumber, totalPageCount, or sectionPageCount tokens */ function paragraphHasPageToken(para: ParagraphBlock): boolean { for (const run of para.runs) { - if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) { + if ( + 'token' in run && + (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount') + ) { + return true; + } + } + return false; +} + +function paragraphHasSectionPageCountToken(para: ParagraphBlock): boolean { + for (const run of para.runs) { + if ('token' in run && run.token === 'sectionPageCount') { return true; } } @@ -176,6 +189,32 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } +function hasSectionPageCountTokens(blocks: FlowBlock[]): boolean { + for (const block of blocks) { + if (block.kind === 'paragraph') { + if (paragraphHasSectionPageCountToken(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphHasSectionPageCountToken(item.paragraph)) return true; + } + } else if (block.kind === 'table') { + const table = block as TableBlock; + for (const row of table.rows ?? []) { + for (const cell of row.cells ?? []) { + const cellBlocks: FlowBlock[] = cell.blocks + ? (cell.blocks as FlowBlock[]) + : cell.paragraph + ? [cell.paragraph] + : []; + if (hasSectionPageCountTokens(cellBlocks)) return true; + } + } + } + } + return false; +} + function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { @@ -323,10 +362,11 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - const useBucketingForVariant = useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks); + const useBucketingForVariant = + useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks) && !hasSectionPageCountTokens(blocks); if (!useBucketingForVariant) { - // Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats. + // Per-page layout: small docs, disabled bucketing, SECTIONPAGES, or non-digit-bucket-compatible PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { @@ -357,9 +397,9 @@ export async function layoutHeaderFooterWithCache( const clonedBlocks = cloneHeaderFooterBlocks(blocks); // Resolve page number tokens for this specific page - const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum); + const { displayText, displayNumber, totalPages: totalPagesForPage, sectionPageCount } = pageResolver(pageNum); - resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber); + resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber, sectionPageCount); // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); diff --git a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts index 2ecd495f9a..91792f869a 100644 --- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts +++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts @@ -10,7 +10,7 @@ * page number is used when calculating dimensions and caching layouts. */ -import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { FlowBlock, ListBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; import { formatPageNumberFieldValue } from '@superdoc/layout-engine'; /** @@ -23,6 +23,11 @@ function forEachParagraphBlock(blocks: FlowBlock[], visit: (para: ParagraphBlock for (const block of blocks) { if (block.kind === 'paragraph') { visit(block as ParagraphBlock); + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + forEachParagraphBlock([item.paragraph], visit); + } } else if (block.kind === 'table') { const table = block as TableBlock; for (const row of table.rows ?? []) { @@ -42,7 +47,7 @@ function forEachParagraphBlock(blocks: FlowBlock[], visit: (para: ParagraphBlock * Resolves page number tokens in a batch of header or footer blocks. * * Headers and footers can contain the same token types as body content - * (pageNumber, totalPageCount), but they need to be resolved to the specific + * (pageNumber, totalPageCount, sectionPageCount), but they need to be resolved to the specific * page where the header/footer will appear. This function mutates the blocks * in-place to replace token placeholders with actual values. * @@ -74,6 +79,7 @@ export function resolveHeaderFooterTokens( totalPages: number, pageNumberText?: string, displayPageNumber?: number, + sectionPageCount?: number, ): void { // Validate inputs if (!blocks || blocks.length === 0) { @@ -93,6 +99,8 @@ export function resolveHeaderFooterTokens( const pageNumberStr = pageNumberText ?? String(pageNumber); const totalPagesStr = String(totalPages); const displayNumber = displayPageNumber ?? pageNumber; + const sectionPageCountNumber = sectionPageCount || totalPages || 1; + const sectionPageCountStr = String(sectionPageCountNumber); // Process every paragraph block, including those nested in table cells // (SD-1332). The page-number field can live in `tableCell > paragraph > @@ -116,6 +124,10 @@ export function resolveHeaderFooterTokens( run.text = run.pageNumberFieldFormat ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) : totalPagesStr; + } else if (run.token === 'sectionPageCount') { + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(sectionPageCountNumber, run.pageNumberFieldFormat) + : sectionPageCountStr; } // Note: pageReference tokens should not appear in headers/footers typically, // but if they do, they'll be handled by the PAGEREF resolution logic @@ -183,6 +195,16 @@ function cloneHeaderFooterBlock(block: FlowBlock): FlowBlock { })), } as TableBlock; } + if (block.kind === 'list') { + const list = block as ListBlock; + return { + ...list, + items: (list.items ?? []).map((item) => ({ + ...item, + paragraph: cloneHeaderFooterBlock(item.paragraph) as ParagraphBlock, + })), + } as ListBlock; + } // For other block types, shallow copy is sufficient (they don't contain tokens) return { ...block }; } diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index 777f2ee828..0cd6282ecb 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -200,6 +200,7 @@ describe('computeDisplayPageNumber', () => { displayNumber: 1, displayText: '1', sectionIndex: 0, + sectionPageCount: 1, }); }); @@ -219,18 +220,21 @@ describe('computeDisplayPageNumber', () => { displayNumber: 1, displayText: '1', sectionIndex: 0, + sectionPageCount: 3, }); expect(result[1]).toEqual({ physicalPage: 2, displayNumber: 2, displayText: '2', sectionIndex: 0, + sectionPageCount: 3, }); expect(result[2]).toEqual({ physicalPage: 3, displayNumber: 3, displayText: '3', sectionIndex: 0, + sectionPageCount: 3, }); }); @@ -281,18 +285,21 @@ describe('computeDisplayPageNumber', () => { displayNumber: 5, displayText: '5', sectionIndex: 0, + sectionPageCount: 3, }); expect(result[1]).toEqual({ physicalPage: 2, displayNumber: 6, displayText: '6', sectionIndex: 0, + sectionPageCount: 3, }); expect(result[2]).toEqual({ physicalPage: 3, displayNumber: 7, displayText: '7', sectionIndex: 0, + sectionPageCount: 3, }); }); }); @@ -530,12 +537,14 @@ describe('computeDisplayPageNumber', () => { displayNumber: 1, displayText: 'i', sectionIndex: 0, + sectionPageCount: 2, }); expect(result[1]).toEqual({ physicalPage: 2, displayNumber: 2, displayText: 'ii', sectionIndex: 0, + sectionPageCount: 2, }); // Section 1: pages 3-4 in decimal (restarted at 1) expect(result[2]).toEqual({ @@ -543,12 +552,14 @@ describe('computeDisplayPageNumber', () => { displayNumber: 1, displayText: '1', sectionIndex: 1, + sectionPageCount: 2, }); expect(result[3]).toEqual({ physicalPage: 4, displayNumber: 2, displayText: '2', sectionIndex: 1, + sectionPageCount: 2, }); // Section 2: page 5 in upperLetter (restarted at 1) expect(result[4]).toEqual({ @@ -556,6 +567,7 @@ describe('computeDisplayPageNumber', () => { displayNumber: 1, displayText: 'A', sectionIndex: 2, + sectionPageCount: 1, }); }); diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index ce3ed67c62..0dd25c98e1 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -36,6 +36,8 @@ export interface DisplayPageInfo { displayText: string; /** Index of the section this page belongs to */ sectionIndex: number; + /** Physical page count in the current section */ + sectionPageCount: number; } /** @@ -92,6 +94,12 @@ export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadat sectionMap.set(section.sectionIndex, section); } + const sectionPageCounts = new Map(); + for (const page of pages) { + const sectionIndex = page.sectionIndex ?? 0; + sectionPageCounts.set(sectionIndex, (sectionPageCounts.get(sectionIndex) ?? 0) + 1); + } + // Track running page counter across sections let runningCounter = 1; let currentSectionIndex = -1; @@ -136,6 +144,7 @@ export function computeDisplayPageNumber(pages: Page[], sections: SectionMetadat displayNumber, displayText, sectionIndex: pageSectionIndex, + sectionPageCount: sectionPageCounts.get(pageSectionIndex) ?? pages.length, }); // Increment counters diff --git a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts index 7a378f43eb..7f2433abc6 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts @@ -188,6 +188,70 @@ describe('resolvePageNumberTokens', () => { expect(updatedBlock.runs[1].token).toBeUndefined(); }); + it('should resolve formatted sectionPageCount tokens', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'para-1', + runs: [ + { + text: 'Section pages: ', + fontFamily: 'Arial', + fontSize: 12, + }, + { + text: '0', + token: 'sectionPageCount', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'para-1', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }, + ], + }, + ], + }; + const numberingCtx: NumberingContext = { + totalPages: 9, + displayPages: [ + { + physicalPage: 1, + displayNumber: 1, + displayText: '1', + sectionIndex: 0, + sectionPageCount: 4, + }, + ], + }; + + const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx); + const updatedBlock = result.updatedBlocks.get('para-1') as ParagraphBlock; + + expect(result.affectedBlockIds.has('para-1')).toBe(true); + expect(updatedBlock.runs[1].text).toBe('IV'); + expect(updatedBlock.runs[1].token).toBeUndefined(); + expect(updatedBlock.runs[1].pageNumberFieldFormat).toBeUndefined(); + }); + it('should resolve both pageNumber and totalPageCount in same paragraph', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 0b3c01e2e3..10ba0bc520 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -1,7 +1,7 @@ /** * Page Number Token Resolution Module * - * Resolves dynamic page number tokens (pageNumber, totalPageCount) in layout fragments. + * Resolves dynamic page number tokens (pageNumber, totalPageCount, sectionPageCount) in layout fragments. * This module follows the same pattern as resolvePageRefs.ts for PAGEREF token resolution. * * Tokens are created during PM-to-FlowBlock conversion with placeholder text ('0'), @@ -118,6 +118,7 @@ export function resolvePageNumberTokens( continue; } + const sectionPageCount = displayPageInfo.sectionPageCount || numberingCtx.totalPages || 1; for (const fragment of page.fragments) { // Paragraph fragments — original behaviour. if (fragment.kind === 'para') { @@ -140,6 +141,7 @@ export function resolvePageNumberTokens( displayPageInfo, totalPagesStr, numberingCtx.totalPages, + sectionPageCount, ); updatedBlocks.set(blockId, clonedBlock); affectedBlockIds.add(blockId); @@ -174,11 +176,14 @@ export function resolvePageNumberTokens( * Checks if a paragraph block contains any page number tokens. * * @param block - Paragraph block to check - * @returns True if block contains pageNumber or totalPageCount tokens + * @returns True if block contains pageNumber, totalPageCount, or sectionPageCount tokens */ function hasPageTokens(block: ParagraphBlock): boolean { for (const run of block.runs) { - if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) { + if ( + 'token' in run && + (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount') + ) { return true; } } @@ -201,6 +206,7 @@ function cloneBlockWithResolvedTokens( displayPageInfo: DisplayPageInfo, totalPagesStr: string, totalPages: number, + sectionPageCount: number, ): ParagraphBlock { // Clone the runs array and resolve tokens const clonedRuns = block.runs.map((run) => { @@ -224,6 +230,14 @@ function cloneBlockWithResolvedTokens( ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) : totalPagesStr, }; + } else if (run.token === 'sectionPageCount') { + const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run; + return { + ...runWithoutToken, + text: pageNumberFieldFormat + ? formatPageNumberFieldValue(sectionPageCount, pageNumberFieldFormat) + : String(sectionPageCount), + }; } } @@ -258,7 +272,12 @@ function cloneBlockWithResolvedTokens( * } * ``` */ -export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, totalPages: number): boolean { +export function resolveTokensInBlock( + block: ParagraphBlock, + pageNumber: number, + totalPages: number, + sectionPageCount: number = totalPages, +): boolean { if (block.kind !== 'paragraph') { return false; } @@ -276,6 +295,7 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, const pageNumberStr = String(pageNumber); const totalPagesStr = String(totalPages); + const sectionPageCountStr = String(sectionPageCount); let blockModified = false; // Iterate through runs in the paragraph @@ -297,6 +317,13 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, // Clear token metadata to treat as normal text after resolution delete run.token; blockModified = true; + } else if (run.token === 'sectionPageCount') { + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat) + : sectionPageCountStr; + delete run.token; + delete run.pageNumberFieldFormat; + blockModified = true; } // Note: pageReference tokens are handled by resolvePageRefs.ts } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b1c9f832bf..7ef79178e9 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -44,6 +44,7 @@ import { LAYOUT_BOUNDARY_SCHEMA, buildLayoutSourceIdentityForFragment, expandRunsForInlineNewlines, + formatPageNumber, getCellSpacingPx, normalizeColumnLayout, } from '@superdoc/contracts'; @@ -260,15 +261,21 @@ type PageDomState = { }; function pageContextSignature(context: FragmentRenderContext): string { - return [context.pageNumber, context.totalPages, context.pageNumberText ?? '', context.displayPageNumber ?? ''].join( - '|', - ); + return [ + context.pageNumber, + context.totalPages, + context.sectionPageCount ?? '', + context.pageNumberText ?? '', + context.displayPageNumber ?? '', + ].join('|'); } function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { return ( Array.isArray(textContent?.parts) && - textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES') + textContent.parts.some( + (part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES' || part.fieldType === 'SECTIONPAGES', + ) ); } @@ -288,7 +295,10 @@ function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { if (!block) return false; if (block.kind === 'paragraph') { for (const run of (block as ParagraphBlock).runs) { - if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) { + if ( + 'token' in run && + (run.token === 'pageNumber' || run.token === 'totalPageCount' || run.token === 'sectionPageCount') + ) { return true; } } @@ -346,6 +356,7 @@ function needsRebuildForPageContext( * @property {'body'|'header'|'footer'} section - Document section being rendered * @property {string} [pageNumberText] - Optional formatted page number text (e.g., "Page 1 of 10") * @property {number} [displayPageNumber] - Section-aware numeric page value before formatting + * @property {number} [sectionPageCount] - Physical page count in the current section */ export type FragmentRenderContext = { pageNumber: number; @@ -354,9 +365,19 @@ export type FragmentRenderContext = { story?: LayoutStoryLocator; pageNumberText?: string; displayPageNumber?: number; + sectionPageCount?: number; pageIndex?: number; }; +function buildSectionPageCounts(pages: ResolvedPage[]): Map { + const counts = new Map(); + for (const page of pages) { + const sectionIndex = page.sectionIndex ?? 0; + counts.set(sectionIndex, (counts.get(sectionIndex) ?? 0) + 1); + } + return counts; +} + export type PaintSnapshotLineStyle = { paddingLeftPx?: number; paddingRightPx?: number; @@ -855,6 +876,7 @@ export class DomPainter { private headerProvider?: PageDecorationProvider; private footerProvider?: PageDecorationProvider; private totalPages = 0; + private sectionPageCounts = new Map(); private linkIdCounter = 0; // Counter for generating unique link IDs private sdtLabelsRendered = new Set(); // Tracks SDT labels rendered across pages @@ -1281,6 +1303,7 @@ export class DomPainter { this.beginPaintSnapshot(resolvedLayout); this.totalPages = resolvedLayout.pages.length; + this.sectionPageCounts = buildSectionPageCounts(resolvedLayout.pages); if (this.isSemanticFlow) { // Semantic mode always renders as a single continuous surface. applyStyles(mount, containerStyles); @@ -1795,6 +1818,7 @@ export class DomPainter { section: 'body', pageNumberText: page.numberText, displayPageNumber: page.displayNumber, + sectionPageCount: this.getSectionPageCount(page), pageIndex, }; @@ -2150,6 +2174,7 @@ export class DomPainter { story: resolveDecorationStory(kind, data), pageNumberText: page.numberText, displayPageNumber: page.displayNumber, + sectionPageCount: this.getSectionPageCount(page), pageIndex, }; @@ -2292,6 +2317,10 @@ export class DomPainter { this.mountedPageIndices = []; } + private getSectionPageCount(page: ResolvedPage): number { + return this.sectionPageCounts.get(page.sectionIndex ?? 0) ?? this.totalPages ?? 1; + } + private fullRender(layout: ResolvedLayout): void { if (!this.mount || !this.doc) return; this.mount.innerHTML = ''; @@ -2354,6 +2383,7 @@ export class DomPainter { section: 'body', pageNumberText: page.numberText, displayPageNumber: page.displayNumber, + sectionPageCount: this.getSectionPageCount(page), pageIndex, }; @@ -2517,6 +2547,7 @@ export class DomPainter { section: 'body', pageNumberText: page.numberText, displayPageNumber: page.displayNumber, + sectionPageCount: this.getSectionPageCount(page), pageIndex, }; @@ -3090,6 +3121,12 @@ export class DomPainter { if (part.fieldType === 'NUMPAGES') { return String(context?.totalPages ?? 1); } + if (part.fieldType === 'SECTIONPAGES') { + const sectionPageCount = context?.sectionPageCount ?? context?.totalPages ?? 1; + return part.pageNumberFormat + ? formatPageNumber(sectionPageCount, part.pageNumberFormat) + : String(sectionPageCount); + } return part.text; } 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 f7068b4de9..f45cbf9ea3 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -152,6 +152,13 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string } return context.totalPages ? String(context.totalPages) : (run.text ?? ''); } + if (runToken === 'sectionPageCount') { + const sectionPageCount = context.sectionPageCount ?? context.totalPages ?? 1; + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat); + } + return String(sectionPageCount); + } return run.text ?? ''; }; diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css b/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css index 7ae846bd60..8322c7388a 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css +++ b/packages/super-editor/src/editors/v1/assets/styles/elements/page-number.css @@ -1,12 +1,14 @@ .super-editor .sd-editor-auto-page-number, -.super-editor .sd-editor-auto-total-pages { +.super-editor .sd-editor-auto-total-pages, +.super-editor .sd-editor-auto-section-pages { transition: all 250ms ease; border-bottom: 1px solid #9a9a9a; cursor: not-allowed; } .super-editor .sd-editor-auto-page-number:hover, -.super-editor .sd-editor-auto-total-pages:hover { +.super-editor .sd-editor-auto-total-pages:hover, +.super-editor .sd-editor-auto-section-pages:hover { border-bottom-color: #4f4f4f; } @@ -16,7 +18,8 @@ .super-editor .ProseMirror.view-mode { .sd-editor-auto-page-number, - .sd-editor-auto-total-pages { + .sd-editor-auto-total-pages, + .sd-editor-auto-section-pages { border: none; } } diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 98f0581207..a6e549822b 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -187,6 +187,7 @@ describe('layoutPerRIdHeaderFooters', () => { displayText: 'i', displayNumber: 1, totalPages: 10, + sectionPageCount: 10, }); }); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index a64adc00b0..8c85a021af 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 @@ -20,7 +20,12 @@ export type HeaderFooterPerRidLayoutInput = { }; type Constraints = HeaderFooterConstraints; -type PageResolver = (pageNumber: number) => { displayText: string; displayNumber: number; totalPages: number }; +type PageResolver = (pageNumber: number) => { + displayText: string; + displayNumber: number; + totalPages: number; + sectionPageCount: number; +}; /** * Layout header/footer blocks per rId, respecting per-section margins. @@ -56,6 +61,7 @@ export async function layoutPerRIdHeaderFooters( displayText: displayInfo?.displayText ?? String(pageNumber), displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages, + sectionPageCount: displayInfo?.sectionPageCount ?? totalPages ?? 1, }; }; diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index edfb9ad782..cb3b3264f8 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,6 @@ import { toFlowBlocks } from '@core/layout-adapter'; import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js'; -import type { FlowBlock, TrackedChangesMode } from '@superdoc/contracts'; +import { formatPageNumber, type FlowBlock, 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'; @@ -264,6 +264,7 @@ export class HeaderFooterEditorManager extends EventEmitter { * @param options.availableHeight - The height of the editing region in pixels. Must be a positive number if provided. * @param options.currentPageNumber - The current page number for PAGE field resolution. Must be a positive integer if provided. * @param options.totalPageCount - The total page count for NUMPAGES field resolution. Must be a positive integer if provided. + * @param options.sectionPageCount - The current section page count for SECTIONPAGES field resolution. Must be a positive integer if provided. * @returns The editor instance, or null if creation failed * * @throws Never throws - errors are logged and emitted as events. Invalid parameters return null with error logged. @@ -276,6 +277,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; totalPageCount?: number; + sectionPageCount?: number; }, ): Promise { if (!descriptor?.id) return null; @@ -352,6 +354,21 @@ export class HeaderFooterEditorManager extends EventEmitter { return null; } } + + if (options.sectionPageCount !== undefined) { + if ( + typeof options.sectionPageCount !== 'number' || + !Number.isInteger(options.sectionPageCount) || + options.sectionPageCount < 1 + ) { + console.error('[HeaderFooterEditorManager] sectionPageCount must be a positive integer'); + this.emit('error', { + descriptor, + error: new TypeError('sectionPageCount must be a positive integer'), + }); + return null; + } + } } const existing = this.#editorEntries.get(descriptor.id); @@ -426,6 +443,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; totalPageCount?: number; + sectionPageCount?: number; }, ): Editor | null { if (!descriptor?.id) return null; @@ -461,9 +479,11 @@ export class HeaderFooterEditorManager extends EventEmitter { const currentPage = String(opts.currentPageNumber || '1'); const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); + const sectionPages = Number(opts.sectionPageCount || opts.totalPageCount || parentEditor?.currentTotalPages || 1); const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]'); const totalPagesEls = container.querySelectorAll('[data-id="auto-total-pages"]'); + const sectionPagesEls = container.querySelectorAll('[data-id="auto-section-pages"]'); pageNumberEls.forEach((el) => { if (el.textContent !== currentPage) el.textContent = currentPage; @@ -471,6 +491,22 @@ export class HeaderFooterEditorManager extends EventEmitter { totalPagesEls.forEach((el) => { if (el.textContent !== totalPages) el.textContent = totalPages; }); + sectionPagesEls.forEach((el) => { + const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el); + const text = pageNumberFormat ? formatPageNumber(sectionPages, pageNumberFormat) : String(sectionPages); + if (el.textContent !== text) el.textContent = text; + }); + } + + #getPageNumberFormatForDomNode(editor: Editor, el: Element): PageNumberFormat | null { + try { + const pos = editor.view.posAtDOM(el, 0); + const node = editor.state.doc.nodeAt(pos); + const format = node?.attrs?.pageNumberFormat; + return typeof format === 'string' ? (format as PageNumberFormat) : null; + } catch { + return null; + } } /** @@ -731,6 +767,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; totalPageCount?: number; + sectionPageCount?: number; }, ): HeaderFooterEditorEntry | null { const json = this.getDocumentJson(descriptor); @@ -751,6 +788,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight: options?.availableHeight ?? DEFAULT_HEADER_FOOTER_HEIGHT, currentPageNumber: options?.currentPageNumber ?? 1, totalPageCount: options?.totalPageCount ?? 1, + sectionPageCount: options?.sectionPageCount ?? options?.totalPageCount ?? 1, }) as Editor; } catch (error) { console.error('[HeaderFooterEditorManager] Editor creation failed:', error); @@ -867,6 +905,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; totalPageCount?: number; + sectionPageCount?: number; }, ): void { if (entry.container && options?.editorHost && entry.container.parentElement !== options.editorHost) { @@ -884,6 +923,9 @@ export class HeaderFooterEditorManager extends EventEmitter { if (options.totalPageCount !== undefined) { updateOptions.totalPageCount = options.totalPageCount; } + if (options.sectionPageCount !== undefined) { + updateOptions.sectionPageCount = options.sectionPageCount; + } if (options.availableWidth !== undefined) { updateOptions.availableWidth = options.availableWidth; } diff --git a/packages/super-editor/src/editors/v1/core/header-footer/types.ts b/packages/super-editor/src/editors/v1/core/header-footer/types.ts index 561cd6c02d..ad54d1a61e 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/types.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/types.ts @@ -38,6 +38,9 @@ export type HeaderFooterRegion = { /** Section-aware display page number (e.g. "7" when physical page is 10 due to section numbering) */ displayPageNumber?: string; + /** Physical page count in this region's section */ + sectionPageCount?: number; + /** X coordinate relative to page */ localX: number; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts index 91e46f4f01..e33ff3240a 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/constants.ts @@ -86,7 +86,7 @@ export const DEFAULT_HYPERLINK_CONFIG: HyperlinkConfig = { * Node types with atom: true: * - image: Inline images * - hardBreak, lineBreak: Line breaks - * - page-number, total-page-number: Document tokens + * - page-number, total-page-number, section-page-count: Document tokens * - indexEntry: Index entry field markers (see index-entry.js) * - tab: Tab stops (see tab.js) * - noBreakHyphen: Non-breaking hyphen (U+2011 from ; see no-break-hyphen.js) @@ -102,6 +102,7 @@ export const ATOMIC_INLINE_TYPES = new Set([ 'lineBreak', 'page-number', 'total-page-number', + 'section-page-count', 'indexEntry', 'tab', 'noBreakHyphen', @@ -119,4 +120,5 @@ export const ATOMIC_INLINE_TYPES = new Set([ export const TOKEN_INLINE_TYPES = new Map([ ['page-number', 'pageNumber'], ['total-page-number', 'totalPageCount'], + ['section-page-count', 'sectionPageCount'], ]); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index dad4441c10..b848a8facb 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 @@ -5679,6 +5679,7 @@ export class PresentationEditor extends EventEmitter { availableHeight: editorContext.availableHeight, currentPageNumber: editorContext.currentPageNumber, totalPageCount: editorContext.totalPageCount, + sectionPageCount: editorContext.sectionPageCount, }) ?? null) : null; @@ -5710,6 +5711,7 @@ export class PresentationEditor extends EventEmitter { element: hostElement, currentPageNumber: editorContext.currentPageNumber, totalPageCount: editorContext.totalPageCount, + sectionPageCount: editorContext.sectionPageCount, editorOptions: headerFooterRefId ? { headerFooterRefId } : undefined, }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 1b53997e9d..a5ff3baa94 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -806,11 +806,13 @@ export class HeaderFooterSessionManager { // Build section first page numbers map const sectionFirstPageNumbers = new Map(); + const sectionPageCounts = new Map(); for (const p of resolvedLayout.pages) { const idx = p.sectionIndex ?? 0; if (!sectionFirstPageNumbers.has(idx)) { sectionFirstPageNumbers.set(idx, p.number); } + sectionPageCounts.set(idx, (sectionPageCounts.get(idx) ?? 0) + 1); } // Resolve section projections to map sectionIndex → sectionId @@ -823,6 +825,7 @@ export class HeaderFooterSessionManager { const actualPageHeight = page.height ?? fallbackPageHeight; const sectionIndex = page.sectionIndex ?? 0; const sectionId = sectionIdBySectionIndex.get(sectionIndex) ?? `section-${sectionIndex}`; + const sectionPageCount = sectionPageCounts.get(sectionIndex) ?? resolvedLayout.pages.length ?? 1; // Header region const headerPayload = this.#headerDecorationProvider?.(page.number, margins, page); @@ -839,6 +842,7 @@ export class HeaderFooterSessionManager { pageIndex, pageNumber: page.number, displayPageNumber, + sectionPageCount, localX: headerPayload?.hitRegion?.x ?? headerBox.x, localY: headerPayload?.hitRegion?.y ?? headerBox.offset, width: headerPayload?.hitRegion?.width ?? headerBox.width, @@ -859,6 +863,7 @@ export class HeaderFooterSessionManager { pageIndex, pageNumber: page.number, displayPageNumber, + sectionPageCount, localX: footerPayload?.hitRegion?.x ?? footerBox.x, localY: footerPayload?.hitRegion?.y ?? footerBox.offset, width: footerPayload?.hitRegion?.width ?? footerBox.width, @@ -1092,6 +1097,7 @@ export class HeaderFooterSessionManager { availableHeight: Math.max(1, region.height), currentPageNumber: Math.max(1, region.pageNumber ?? 1), totalPageCount: Math.max(1, bodyPageCount), + sectionPageCount: Math.max(1, region.sectionPageCount ?? bodyPageCount), surfaceKind: region.kind, }, }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts index 37f7ad0ab6..30d4dc36f0 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 @@ -119,6 +119,7 @@ export interface ActivateStorySessionOptions { availableHeight?: number; currentPageNumber?: number; 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 afb6b173f0..87a829a588 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 @@ -41,6 +41,13 @@ export interface StoryEditorOptions { */ totalPageCount?: number; + /** + * The current section's physical page count for SECTIONPAGES field resolution. + * Must be a positive integer. + * @default 1 + */ + sectionPageCount?: number; + /** * The container element to mount the editor into. * Required for non-headless mode; ignored when headless. @@ -115,6 +122,7 @@ export function createStoryEditor( headless, currentPageNumber = 1, totalPageCount = 1, + sectionPageCount = 1, element = null, editorOptions = {}, } = options; @@ -156,6 +164,7 @@ export function createStoryEditor( annotations: true, currentPageNumber, totalPageCount, + sectionPageCount, editable: false, documentMode: 'viewing', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index a7a3882f9b..38714dacbc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -35,6 +35,7 @@ import { translator as sdIndexTranslator } from '@converter/v3/handlers/sd/index import { translator as sdIndexEntryTranslator } from '@converter/v3/handlers/sd/indexEntry'; import { translator as sdAutoPageNumberTranslator } from '@converter/v3/handlers/sd/autoPageNumber'; import { translator as sdTotalPageNumberTranslator } from '@converter/v3/handlers/sd/totalPageNumber'; +import { translator as sdSectionPageCountTranslator } from '@converter/v3/handlers/sd/sectionPageCount'; import { translator as sdDocumentStatFieldTranslator } from '@converter/v3/handlers/sd/documentStatField/documentStatField-translator.js'; import { translator as pictTranslator } from './v3/handlers/w/pict/pict-translator'; import { translateVectorShape, translateShapeGroup } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers'; @@ -242,6 +243,7 @@ export function exportSchemaToJson(params) { documentSection: wSdtNodeTranslator, 'page-number': sdAutoPageNumberTranslator, 'total-page-number': sdTotalPageNumberTranslator, + 'section-page-count': sdSectionPageCountTranslator, pageReference: sdPageReferenceTranslator, crossReference: sdCrossReferenceTranslator, citation: sdCitationTranslator, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index f1b5ff97fb..f0b46891d0 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -1,5 +1,6 @@ import { preProcessPageInstruction } from './page-preprocessor.js'; import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js'; +import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js'; import { preProcessTocInstruction } from './toc-preprocessor.js'; @@ -47,6 +48,8 @@ export const getInstructionPreProcessor = (instruction) => { return preProcessPageInstruction; case 'NUMPAGES': return preProcessNumPagesInstruction; + case 'SECTIONPAGES': + return preProcessSectionPagesInstruction; case 'NUMWORDS': case 'NUMCHARS': return preProcessDocumentStatInstruction; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js index 227be4e206..894e77c2c6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { getInstructionPreProcessor } from './index.js'; import { preProcessPageInstruction } from './page-preprocessor.js'; import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js'; +import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js'; import { preProcessTocInstruction } from './toc-preprocessor.js'; @@ -50,6 +51,14 @@ describe('getInstructionPreProcessor', () => { }, ); + it.each(['sectionpages', 'SectionPages', 'SECTIONPAGES \\* roman'])( + 'should return preProcessSectionPagesInstruction for case-insensitive SECTIONPAGES instruction %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessSectionPagesInstruction); + }, + ); + it('should return preProcessPageRefInstruction for PAGEREF instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; const processor = getInstructionPreProcessor(instruction); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js index b2e6789979..80be6e1517 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js @@ -9,17 +9,18 @@ const PAGE_VALUE_FORMAT_SWITCHES = { }; /** - * Parses the supported PAGE value-format switches from an OOXML field instruction. + * Parses the supported PAGE/SECTIONPAGES value-format switches from an OOXML field instruction. * Field dispatch is case-insensitive; value-format switches preserve ECMA casing. * * @param {string} instruction + * @param {string} [expectedKeyword='PAGE'] * @returns {{ instruction: string, pageNumberFormat?: string }} */ -export function parsePageInstruction(instruction) { +export function parsePageInstruction(instruction, expectedKeyword = 'PAGE') { const rawInstruction = String(instruction ?? '').trim(); const tokens = rawInstruction.match(/"[^"]*"|'[^']*'|\\\*|\\[^\s]+|[^\s]+/g) ?? []; const keyword = tokens[0]?.toUpperCase(); - if (keyword !== 'PAGE') { + if (keyword !== expectedKeyword.toUpperCase()) { return { instruction: rawInstruction }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js new file mode 100644 index 0000000000..5dba86c15a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js @@ -0,0 +1,58 @@ +import { parsePageInstruction } from './page-instruction.js'; + +/** + * Processes a SECTIONPAGES instruction and creates a `sd:sectionPageCount` node. + * + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. + * @param {string} [instrText] The SECTIONPAGES instruction text. + * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. + * @returns {import('../../v2/types/index.js').OpenXmlNode[]} + */ +export function preProcessSectionPagesInstruction(nodesToCombine, instrText = '', fieldRunRPr = null) { + const parsedInstruction = parsePageInstruction(instrText, 'SECTIONPAGES'); + const sectionPageCountNode = { + name: 'sd:sectionPageCount', + type: 'element', + attributes: { + instruction: parsedInstruction.instruction, + ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}), + }, + }; + + const cachedText = extractCachedText(nodesToCombine); + if (cachedText) { + sectionPageCountNode.attributes.importedCachedText = cachedText; + } + + let foundContentRPr = false; + nodesToCombine.forEach((n) => { + const rPrNode = n.elements?.find((el) => el.name === 'w:rPr'); + if (rPrNode) { + sectionPageCountNode.elements = [rPrNode]; + foundContentRPr = true; + } + }); + + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + sectionPageCountNode.elements = [fieldRunRPr]; + } + + return [sectionPageCountNode]; +} + +/** + * Extracts cached display text from content runs (between separate and end). + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodes + * @returns {string} + */ +function extractCachedText(nodes) { + const texts = []; + for (const node of nodes) { + const textEl = node.elements?.find((el) => el.name === 'w:t'); + if (textEl) { + const text = textEl.elements?.[0]?.text ?? ''; + if (text) texts.push(text); + } + } + return texts.join(''); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js new file mode 100644 index 0000000000..94220d1a92 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js @@ -0,0 +1,50 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { preProcessSectionPagesInstruction } from './section-pages-preprocessor.js'; + +describe('preProcessSectionPagesInstruction', () => { + it.each([ + ['SECTIONPAGES', undefined], + ['sectionpages', undefined], + ['SectionPages', undefined], + ['SECTIONPAGES \\* roman', 'lowerRoman'], + ['SECTIONPAGES \\* Roman \\* MERGEFORMAT', 'upperRoman'], + ['SECTIONPAGES \\* Unsupported \\* MERGEFORMAT', undefined], + ])('creates sd:sectionPageCount and parses supported value format: %s', (instruction, pageNumberFormat) => { + const result = preProcessSectionPagesInstruction([], instruction, null); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'sd:sectionPageCount', + type: 'element', + attributes: { + instruction, + ...(pageNumberFormat ? { pageNumberFormat } : {}), + }, + }); + }); + + it('preserves cached text and content run styling', () => { + const rPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; + const result = preProcessSectionPagesInstruction( + [ + { + name: 'w:r', + elements: [rPr, { name: 'w:t', elements: [{ type: 'text', text: '4' }] }], + }, + ], + 'SECTIONPAGES', + null, + ); + + expect(result[0].attributes.importedCachedText).toBe('4'); + expect(result[0].elements).toEqual([rPr]); + }); + + it('uses fieldRunRPr when cached content has no run properties', () => { + const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + const result = preProcessSectionPagesInstruction([], 'SECTIONPAGES', fieldRunRPr); + + expect(result[0].elements).toEqual([fieldRunRPr]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 08c7117bf7..2208479796 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -3,6 +3,7 @@ */ import { preProcessPageInstruction } from './fld-preprocessors/page-preprocessor.js'; import { preProcessNumPagesInstruction } from './fld-preprocessors/num-pages-preprocessor.js'; +import { preProcessSectionPagesInstruction } from './fld-preprocessors/section-pages-preprocessor.js'; import { preProcessDocumentStatInstruction } from './fld-preprocessors/document-stat-preprocessor.js'; import { extractFieldKeyword } from './field-keyword.js'; @@ -11,7 +12,7 @@ const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); /** - * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering. + * Pre-processes nodes to convert PAGE, NUMPAGES, and SECTIONPAGES field codes for header/footer rendering. * * NOTE: This function is used exclusively when constructing a standalone header/footer * editor for on-screen display/editing. It is NOT part of the DOCX import pipeline. @@ -20,6 +21,7 @@ const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has * This function specifically handles: * - PAGE fields → sd:autoPageNumber (displays current page number) * - NUMPAGES fields → sd:totalPageNumber (displays total page count) + * - SECTIONPAGES fields → sd:sectionPageCount (displays current section page count) * - Unhandled fldSimple fields (FILENAME, DOCPROPERTY, etc.) → unwrapped to their * cached display text (the value Word rendered when the document was last saved), * so the header renders meaningful content rather than an empty box. @@ -231,6 +233,8 @@ function getHeaderFooterFieldPreprocessor(fieldType) { return preProcessPageInstruction; case 'NUMPAGES': return preProcessNumPagesInstruction; + case 'SECTIONPAGES': + return preProcessSectionPagesInstruction; case 'NUMWORDS': case 'NUMCHARS': return preProcessDocumentStatInstruction; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index 63fd22720a..fdaf2a628b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -146,6 +146,17 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); }, ); + + it.each([' sectionpages ', ' SectionPages ', ' SECTIONPAGES \\* roman '])( + 'should process SECTIONPAGES field case-insensitively with fldChar syntax: %s', + (instruction) => { + const result = preProcessPageFieldsOnly(complexFieldNodes(instruction, '4')); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:sectionPageCount'); + expect(result.processedNodes[0].attributes.importedCachedText).toBe('4'); + }, + ); }); describe('simple field syntax (w:fldSimple)', () => { @@ -238,6 +249,34 @@ describe('preProcessPageFieldsOnly', () => { }, ); + it('should process SECTIONPAGES field with fldSimple syntax and preserve format', () => { + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': ' SECTIONPAGES \\* roman \\* MERGEFORMAT ' }, + elements: [ + { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:noProof' }] }, + { name: 'w:t', elements: [{ type: 'text', text: 'iv' }] }, + ], + }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:sectionPageCount'); + expect(result.processedNodes[0].attributes).toMatchObject({ + instruction: 'SECTIONPAGES \\* roman \\* MERGEFORMAT', + pageNumberFormat: 'lowerRoman', + importedCachedText: 'iv', + }); + }); + it('should preserve rPr styling from fldSimple content', () => { const nodes = [ { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js index 017eef54e4..ea3aa1bd9b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/autoPageNumberImporter.js @@ -1,6 +1,7 @@ import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; import { translator as autoPageNumberTranslator } from '../../v3/handlers/sd/autoPageNumber/index.js'; import { translator as totalPageNumberTranslator } from '../../v3/handlers/sd/totalPageNumber/index.js'; +import { translator as sectionPageCountTranslator } from '../../v3/handlers/sd/sectionPageCount/index.js'; /** * @type {import("docxImporter").NodeHandlerEntry} @@ -11,3 +12,8 @@ export const autoPageHandlerEntity = generateV2HandlerEntity('autoPageNumberHand * @type {import("docxImporter").NodeHandlerEntry} */ export const autoTotalPageCountEntity = generateV2HandlerEntity('autoTotalPageCountEntity', totalPageNumberTranslator); + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const sectionPageCountEntity = generateV2HandlerEntity('sectionPageCountEntity', sectionPageCountTranslator); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 239825d532..d7b4603d24 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -14,7 +14,7 @@ import { lineBreakNodeHandlerEntity } from './lineBreakImporter.js'; import { bookmarkStartNodeHandlerEntity } from './bookmarkStartImporter.js'; import { bookmarkEndNodeHandlerEntity } from './bookmarkEndImporter.js'; import { alternateChoiceHandler } from './alternateChoiceImporter.js'; -import { autoPageHandlerEntity, autoTotalPageCountEntity } from './autoPageNumberImporter.js'; +import { autoPageHandlerEntity, autoTotalPageCountEntity, sectionPageCountEntity } from './autoPageNumberImporter.js'; import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; import { crossReferenceEntity } from './crossReferenceImporter.js'; @@ -349,6 +349,7 @@ export const defaultNodeListHandler = () => { indexEntryHandlerEntity, autoPageHandlerEntity, autoTotalPageCountEntity, + sectionPageCountEntity, documentStatFieldHandlerEntity, pageReferenceEntity, crossReferenceEntity, @@ -928,6 +929,7 @@ export function filterOutRootInlineNodes(content = []) { 'hardBreak', 'pageNumber', 'totalPageCount', + 'sectionPageCount', 'runItem', 'image', 'tab', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js index 32f754de9b..f1bc7b52e4 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -28,6 +28,7 @@ const INLINE_FALLBACK_TYPES = new Set([ 'passthroughInline', 'page-number', 'total-page-number', + 'section-page-count', 'pageReference', 'crossReference', 'citation', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js index 544e950224..a0c721ac78 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js @@ -13,6 +13,7 @@ import { translator as sd_authorityEntry_translator } from './sd/authorityEntry/ import { translator as sd_tableOfAuthorities_translator } from './sd/tableOfAuthorities/tableOfAuthorities-translator.js'; import { translator as sd_autoPageNumber_translator } from './sd/autoPageNumber/autoPageNumber-translator.js'; import { translator as sd_totalPageNumber_translator } from './sd/totalPageNumber/totalPageNumber-translator.js'; +import { translator as sd_sectionPageCount_translator } from './sd/sectionPageCount/sectionPageCount-translator.js'; import { translator as sd_documentStatField_translator } from './sd/documentStatField/documentStatField-translator.js'; import { translator as w_abstractNum_translator } from './w/abstractNum/abstractNum-translator.js'; import { translator as w_abstractNumId_translator } from './w/abstractNumId/abstractNumId-translator.js'; @@ -226,6 +227,7 @@ const translatorList = Array.from( sd_tableOfAuthorities_translator, sd_autoPageNumber_translator, sd_totalPageNumber_translator, + sd_sectionPageCount_translator, sd_documentStatField_translator, w_abstractNum_translator, w_abstractNumId_translator, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js new file mode 100644 index 0000000000..433f64c730 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/index.js @@ -0,0 +1 @@ +export * from './sectionPageCount-translator.js'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js new file mode 100644 index 0000000000..ad15497113 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js @@ -0,0 +1,112 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { processOutputMarks } from '../../../../exporter.js'; +import { parseMarks } from './../../../../v2/importer/markImporter.js'; +import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; +import { pageNumberFormatToInstructionSwitch } from '../../../../field-references/fld-preprocessors/page-instruction.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'sd:sectionPageCount'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'section-page-count'; + +/** + * Encode a node as a SuperDoc section-page-count node. + * @param {import('@translator').SCEncoderConfig} [params] + * @returns {import('@translator').SCEncoderResult} + */ +const encode = (params) => { + const { nodes = [] } = params || {}; + const node = nodes[0]; + + const rPr = node.elements?.find((el) => el.name === 'w:rPr'); + const marks = parseMarks(rPr || { elements: [] }); + const processedNode = { + type: 'section-page-count', + attrs: { + marksAsAttrs: marks, + }, + }; + + if (typeof node.attributes?.instruction === 'string') { + processedNode.attrs.instruction = node.attributes.instruction; + } + if (typeof node.attributes?.pageNumberFormat === 'string') { + processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat; + } + if (node.attributes?.importedCachedText) { + processedNode.attrs.importedCachedText = node.attributes.importedCachedText; + } + + return processedNode; +}; + +/** + * Decode the section-page-count node back into OOXML structure. + * @param {import('@translator').SCDecoderConfig} params + * @returns {import('@translator').SCDecoderResult[]} + */ +const decode = (params) => { + const { node } = params; + + const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const instruction = getSectionPagesInstructionText(node.attrs); + const cachedText = resolveCachedSectionPageCount(node); + + return buildComplexFieldRuns({ instruction, cachedText, outputMarks, dirty: true }); +}; + +/** + * @param {Record | undefined} attrs + * @returns {string} + */ +function getSectionPagesInstructionText(attrs = {}) { + if (typeof attrs.instruction === 'string' && attrs.instruction.trim()) { + return attrs.instruction.trim(); + } + + if (typeof attrs.pageNumberFormat === 'string') { + const instructionSwitch = pageNumberFormatToInstructionSwitch(attrs.pageNumberFormat); + if (instructionSwitch) { + return `SECTIONPAGES \\* ${instructionSwitch}`; + } + } + + return 'SECTIONPAGES'; +} + +/** + * Priority: resolvedText, importedCachedText, then node text content. + * @param {{ attrs?: Record, content?: Array<{ type?: string, text?: string }> }} node + */ +function resolveCachedSectionPageCount(node) { + if (node.attrs?.resolvedText) { + return String(node.attrs.resolvedText); + } + + if (node.attrs?.importedCachedText) { + return String(node.attrs.importedCachedText); + } + + const textContent = node.content + ?.filter((n) => n.type === 'text') + .map((n) => n.text || '') + .join(''); + return textContent || ''; +} + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, +}; + +/** + * The NodeTranslator instance. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from(config); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js new file mode 100644 index 0000000000..0787a0d07b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js @@ -0,0 +1,103 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { config, translator } from './sectionPageCount-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; +import { processOutputMarks } from '../../../../exporter.js'; +import { parseMarks } from './../../../../v2/importer/markImporter.js'; + +vi.mock('../../../../exporter.js', () => ({ + processOutputMarks: vi.fn(() => []), +})); + +vi.mock('./../../../../v2/importer/markImporter.js', () => ({ + parseMarks: vi.fn(() => []), +})); + +describe('sd:sectionPageCount translator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes correct config meta', () => { + expect(config.xmlName).toBe('sd:sectionPageCount'); + expect(config.sdNodeOrKeyName).toBe('section-page-count'); + expect(config.type).toBe(NodeTranslator.translatorTypes.NODE); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + + it('encodes sd:sectionPageCount with marks, instruction, cached text, and pageNumberFormat', () => { + const marks = [{ type: 'textStyle', attrs: { fontSize: '12pt' } }]; + vi.mocked(parseMarks).mockReturnValue(marks); + + const result = config.encode({ + nodes: [ + { + name: 'sd:sectionPageCount', + attributes: { + instruction: 'SECTIONPAGES \\* roman', + pageNumberFormat: 'lowerRoman', + importedCachedText: 'iv', + }, + elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], + }, + ], + }); + + expect(result).toEqual({ + type: 'section-page-count', + attrs: { + marksAsAttrs: marks, + instruction: 'SECTIONPAGES \\* roman', + pageNumberFormat: 'lowerRoman', + importedCachedText: 'iv', + }, + }); + }); + + it('preserves imported instruction and marks SECTIONPAGES dirty on export', () => { + vi.mocked(processOutputMarks).mockReturnValue([{ name: 'w:b' }]); + + const result = config.decode({ + node: { + type: 'section-page-count', + attrs: { + marksAsAttrs: [{ type: 'bold' }], + instruction: 'SECTIONPAGES \\* Roman \\* MERGEFORMAT', + importedCachedText: 'IV', + }, + }, + }); + + expect(result[0].elements[1].attributes).toEqual({ 'w:fldCharType': 'begin', 'w:dirty': 'true' }); + expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* Roman \\* MERGEFORMAT'); + expect(result[3].elements[1].elements[0].text).toBe('IV'); + }); + + it('synthesizes SECTIONPAGES switch when only pageNumberFormat is present', () => { + const result = config.decode({ + node: { + type: 'section-page-count', + attrs: { + pageNumberFormat: 'lowerRoman', + resolvedText: 'iii', + }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* roman'); + expect(result[3].elements[1].elements[0].text).toBe('iii'); + }); + + it('falls back to plain SECTIONPAGES without instruction or supported format', () => { + const result = config.decode({ + node: { + type: 'section-page-count', + attrs: {}, + content: [{ type: 'text', text: '2' }], + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES'); + expect(result[3].elements[1].elements[0].text).toBe('2'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 1079a5b816..b4a5009a35 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -998,7 +998,8 @@ const handleChartDrawing = (params, node, graphicData, size, padding, marginOffs * parts: Array<{ * text: string, * formatting?: { bold?: boolean, italic?: boolean, color?: string, fontSize?: number, fontFamily?: string }, - * fieldType?: 'PAGE' | 'NUMPAGES', + * fieldType?: 'PAGE' | 'NUMPAGES' | 'SECTIONPAGES', + * pageNumberFormat?: string, * isLineBreak?: boolean, * isEmptyParagraph?: boolean * }>, @@ -1017,15 +1018,20 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) { let horizontalAlign = null; /** - * Appends a field part (PAGE or NUMPAGES) to textParts with formatting. - * @param {'PAGE' | 'NUMPAGES'} fieldType - The field type + * Appends a field part (PAGE, NUMPAGES, or SECTIONPAGES) to textParts with formatting. + * @param {'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'} fieldType - The field type * @param {Object} node - The field node element * @param {Object} paragraphProperties - Resolved paragraph properties */ const appendFieldPart = (fieldType, node, paragraphProperties) => { const rPr = node?.elements?.find((el) => el.name === 'w:rPr'); const formatting = extractRunFormatting(rPr, paragraphProperties, params); - textParts.push({ text: '', formatting, fieldType }); + textParts.push({ + text: '', + formatting, + fieldType, + ...(node?.attributes?.pageNumberFormat ? { pageNumberFormat: node.attributes.pageNumberFormat } : {}), + }); }; /** @@ -1061,6 +1067,9 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) { } else if (el.name === 'sd:totalPageNumber') { hasText = true; appendFieldPart('NUMPAGES', el, paragraphProperties); + } else if (el.name === 'sd:sectionPageCount') { + hasText = true; + appendFieldPart('SECTIONPAGES', el, paragraphProperties); } else if (el.name === 'w:drawing') { // SD-2804 / ECMA-376 §20.4.2.38: a textbox can hold body-level // content, including runs with inline w:drawing images. Defer to @@ -1117,6 +1126,10 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) { appendFieldPart('NUMPAGES', el, paragraphProperties); return true; } + if (el.name === 'sd:sectionPageCount') { + appendFieldPart('SECTIONPAGES', el, paragraphProperties); + return true; + } if ((el.name === 'w:hyperlink' || el.name === 'sd:pageReference') && Array.isArray(el.elements)) { let hasText = false; el.elements.forEach((child) => { diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 4487329bfd..d993cb32a1 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -453,6 +453,15 @@ export interface EditorOptions { /** OOXML relationship id for this header/footer part (e.g. `rId7`). */ headerFooterRefId?: string; + /** Current page number for PAGE field rendering in story editors */ + currentPageNumber?: number; + + /** Total document page count for NUMPAGES field rendering in story editors */ + totalPageCount?: number; + + /** Current section page count for SECTIONPAGES field rendering in story editors */ + sectionPageCount?: number; + /** Optional pagination metadata */ lastSelection?: unknown | null; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts new file mode 100644 index 0000000000..205dc72810 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model'; +import { findAllFields } from './field-resolver.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { sdBlockId: { default: null } }, + }, + text: { group: 'inline' }, + 'section-page-count': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + }, + }, + }, +}); + +function createDocWithSectionPageCount(attrs: Record, text?: string): ProseMirrorNode { + const content = text ? schema.text(text) : undefined; + const field = schema.nodes['section-page-count'].create(attrs, content); + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); + return schema.nodes.doc.create(null, paragraph); +} + +describe('field-resolver synthetic section page count fields', () => { + it('discovers section-page-count as SECTIONPAGES with imported instruction', () => { + const doc = createDocWithSectionPageCount({ instruction: 'SECTIONPAGES \\* roman' }, 'iii'); + + expect(findAllFields(doc)).toEqual([ + { + pos: 1, + blockId: 'block-1', + occurrenceIndex: 0, + nestingDepth: 0, + instruction: 'SECTIONPAGES \\* roman', + fieldType: 'SECTIONPAGES', + resolvedText: 'iii', + }, + ]); + }); + + it('falls back to plain SECTIONPAGES and imported cached text', () => { + const doc = createDocWithSectionPageCount({ importedCachedText: '4' }); + + expect(findAllFields(doc)).toEqual([ + { + pos: 1, + blockId: 'block-1', + occurrenceIndex: 0, + nestingDepth: 0, + instruction: 'SECTIONPAGES', + fieldType: 'SECTIONPAGES', + resolvedText: '4', + }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts index d05c1a8344..60939eb5a4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts @@ -52,8 +52,19 @@ const FIELD_NODE_TYPES = new Set([ * Node types that represent fields but derive their instruction synthetically * rather than from an `instruction` attribute. */ -const SYNTHETIC_FIELD_NODE_TYPES: Record = { +const SYNTHETIC_FIELD_NODE_TYPES: Record< + string, + { fieldType: string; instruction: string; resolveInstruction?: (node: ProseMirrorNode) => string } +> = { 'total-page-number': { fieldType: 'NUMPAGES', instruction: 'NUMPAGES' }, + 'section-page-count': { + fieldType: 'SECTIONPAGES', + instruction: 'SECTIONPAGES', + resolveInstruction: (node) => + typeof node.attrs?.instruction === 'string' && node.attrs.instruction.trim() + ? node.attrs.instruction + : 'SECTIONPAGES', + }, }; export function findAllFields(doc: ProseMirrorNode): ResolvedField[] { @@ -82,7 +93,7 @@ export function findAllFields(doc: ProseMirrorNode): ResolvedField[] { blockId, occurrenceIndex: counter, nestingDepth: 0, - instruction: synthetic.instruction, + instruction: synthetic.resolveInstruction?.(node) ?? synthetic.instruction, fieldType: synthetic.fieldType, resolvedText, }); diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index a5e16e8548..dcbc408aaa 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -41,7 +41,7 @@ import { Image } from './image/index.js'; import { BookmarkStart, BookmarkEnd } from './bookmarks/index.js'; import { SmartTag } from './smart-tag/index.js'; import { Mention } from './mention/index.js'; -import { PageNumber, TotalPageCount } from './page-number/index.js'; +import { PageNumber, TotalPageCount, SectionPageCount } from './page-number/index.js'; import { PageReference } from './page-reference/index.js'; import { ShapeContainer } from './shape-container/index.js'; import { ShapeTextbox } from './shape-textbox/index.js'; @@ -203,6 +203,7 @@ const getStarterExtensions = () => { AiLoaderNode, PageNumber, TotalPageCount, + SectionPageCount, PageReference, IndexEntry, TableOfContentsEntry, diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 6b5dc1cb9e..e3f0abc98e 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -1,6 +1,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { isHeadless } from '@utils/headless-helpers.js'; +import { formatPageNumber } from '@superdoc/layout-engine'; /** * Configuration options for PageNumber * @typedef {Object} PageNumberOptions @@ -74,7 +75,7 @@ export const PageNumber = Node.create({ }, parseDOM() { - return [{ tag: 'span[data-id="auto-page-number"' }]; + return [{ tag: 'span[data-id="auto-page-number"]' }]; }, renderDOM({ htmlAttributes }) { @@ -215,7 +216,7 @@ export const TotalPageCount = Node.create({ }, parseDOM() { - return [{ tag: 'span[data-id="auto-total-pages"' }]; + return [{ tag: 'span[data-id="auto-total-pages"]' }]; }, renderDOM({ htmlAttributes }) { @@ -263,7 +264,99 @@ export const TotalPageCount = Node.create({ }, }); -const getNodeAttributes = (nodeName, editor) => { +/** + * @module SectionPageCount + * @sidebarTitle Section Page Count + */ +export const SectionPageCount = Node.create({ + name: 'section-page-count', + group: 'inline', + inline: true, + atom: true, + draggable: false, + selectable: false, + + content: 'text*', + + addOptions() { + return { + htmlAttributes: { + contenteditable: false, + 'data-id': 'auto-section-pages', + 'aria-label': 'Section page count node', + class: 'sd-editor-auto-section-pages', + }, + }; + }, + + addAttributes() { + return { + marksAsAttrs: { + default: null, + rendered: false, + }, + importedCachedText: { + default: null, + rendered: false, + }, + resolvedText: { + default: null, + rendered: false, + }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + }; + }, + + addNodeView() { + return ({ node, editor, getPos, decorations }) => { + const htmlAttributes = this.options.htmlAttributes; + return new AutoPageNumberNodeView(node, getPos, decorations, editor, htmlAttributes); + }; + }, + + parseDOM() { + return [{ tag: 'span[data-id="auto-section-pages"]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; + }, + + addCommands() { + return { + addSectionPageCount: + () => + ({ tr, dispatch, state, editor }) => { + const { options } = editor; + if (!options.isHeaderOrFooter) return false; + + const { schema } = state; + const sectionPageCountType = schema.nodes?.['section-page-count']; + if (!sectionPageCountType) return false; + + const sectionPageCount = editor?.options?.sectionPageCount || 1; + const sectionPageCountNode = { + type: 'section-page-count', + content: [{ type: 'text', text: String(sectionPageCount) }], + }; + const pageNode = schema.nodeFromJSON(sectionPageCountNode); + if (dispatch) { + tr.replaceSelectionWith(pageNode, false); + } + return true; + }, + }; + }, +}); + +const getNodeAttributes = (nodeName, editor, node = null) => { switch (nodeName) { case 'page-number': return { @@ -279,6 +372,18 @@ const getNodeAttributes = (nodeName, editor) => { dataId: 'auto-total-pages', ariaLabel: 'Total page count node', }; + case 'section-page-count': { + const sectionPageCount = editor.options.sectionPageCount || editor.options.totalPageCount || '1'; + const text = node?.attrs?.pageNumberFormat + ? formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat) + : sectionPageCount; + return { + text, + className: 'sd-editor-auto-section-pages', + dataId: 'auto-section-pages', + ariaLabel: 'Section page count node', + }; + } default: return {}; } @@ -296,7 +401,7 @@ export class AutoPageNumberNodeView { } #renderDom(node, htmlAttributes) { - const attrs = getNodeAttributes(this.node.type.name, this.editor); + const attrs = getNodeAttributes(this.node.type.name, this.editor, this.node); const content = document.createTextNode(String(attrs.text)); const nodeContent = document.createElement('span'); @@ -354,7 +459,7 @@ export class AutoPageNumberNodeView { this.node = node; // Refresh displayed text when editor options change (e.g. currentPageNumber) - const attrs = getNodeAttributes(this.node.type.name, this.editor); + const attrs = getNodeAttributes(this.node.type.name, this.editor, this.node); const newText = String(attrs.text); if (this.dom.textContent !== newText) { this.dom.textContent = newText; diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js index 6ad214e92e..68b2e2ff7d 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { PageNumber, TotalPageCount, AutoPageNumberNodeView } from './page-number.js'; +import { PageNumber, TotalPageCount, SectionPageCount, AutoPageNumberNodeView } from './page-number.js'; describe('PageNumber commands', () => { it('addAutoPageNumber aborts when not in header/footer', () => { @@ -69,6 +69,42 @@ describe('PageNumber commands', () => { false, ); }); + + it('addSectionPageCount inserts section pages only in header/footer contexts', () => { + const commands = SectionPageCount.config.addCommands(); + expect( + commands.addSectionPageCount()({ + editor: { options: { isHeaderOrFooter: false } }, + state: { schema: {} }, + }), + ).toBe(false); + + const replaceSelectionWith = vi.fn(); + const schema = { + nodes: { 'section-page-count': {} }, + nodeFromJSON: vi.fn().mockImplementation((json) => json), + }; + + const result = commands.addSectionPageCount()({ + editor: { options: { isHeaderOrFooter: true, sectionPageCount: 4 } }, + tr: { replaceSelectionWith }, + dispatch: vi.fn(), + state: { schema }, + }); + + expect(result).toBe(true); + expect(schema.nodeFromJSON).toHaveBeenCalledWith({ + type: 'section-page-count', + content: [{ type: 'text', text: '4' }], + }); + expect(replaceSelectionWith).toHaveBeenCalledWith( + { + type: 'section-page-count', + content: [{ type: 'text', text: '4' }], + }, + false, + ); + }); }); describe('AutoPageNumberNodeView', () => { @@ -199,4 +235,25 @@ describe('AutoPageNumberNodeView', () => { expect(nodeView.dom.className).toBe('sd-editor-auto-total-pages'); expect(nodeView.dom.getAttribute('data-id')).toBe('auto-total-pages'); }); + + it('renders formatted section page count node', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { sectionPageCount: 4, totalPageCount: 9 }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'section-page-count' }, attrs: { pageNumberFormat: 'upperRoman' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('IV'); + expect(nodeView.dom.className).toBe('sd-editor-auto-section-pages'); + expect(nodeView.dom.getAttribute('data-id')).toBe('auto-section-pages'); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js index 29b4217b72..4ceab3ded6 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 @@ -97,6 +97,7 @@ const getSectionHeight = async (editor, data) => { * @param {number} [params.availableHeight] - The height of the editing region in pixels. Must be positive. * @param {number} [params.currentPageNumber] - The current page number for PAGE field resolution. Must be a positive integer. * @param {number} [params.totalPageCount] - The total page count for NUMPAGES field resolution. Must be a positive integer. + * @param {number} [params.sectionPageCount] - The current section page count for SECTIONPAGES field resolution. Must be a positive integer. * @returns {Editor} The created header/footer editor instance * * @throws {TypeError} If required parameters are missing or have invalid types @@ -113,6 +114,7 @@ export const createHeaderFooterEditor = ({ availableHeight, currentPageNumber, totalPageCount, + sectionPageCount, }) => { // Validate required parameters if (!editor) { @@ -160,6 +162,12 @@ export const createHeaderFooterEditor = ({ } } + if (sectionPageCount !== undefined) { + if (typeof sectionPageCount !== 'number' || !Number.isInteger(sectionPageCount) || sectionPageCount < 1) { + throw new RangeError('sectionPageCount must be a positive integer'); + } + } + // --- DOM layout & styling (UI-only concerns) --- const parentStyles = editor.converter.getDocumentDefaultStyles(); @@ -206,6 +214,7 @@ export const createHeaderFooterEditor = ({ isHeaderOrFooter: true, currentPageNumber, totalPageCount, + sectionPageCount, element: editorContainer, editorOptions: { headerFooterRefId, diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 0b85757946..55557442f5 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -283,11 +283,13 @@ export class ShapeGroupView { if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; const totalPages = this.editor?.options?.totalPageCount; + const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, totalPages, + sectionPageCount, }); if (textGroup) { g.appendChild(textGroup); @@ -358,11 +360,13 @@ export class ShapeGroupView { if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; const totalPages = this.editor?.options?.totalPageCount; + const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, totalPages, + sectionPageCount, }); if (textGroup) { g.appendChild(textGroup); @@ -484,11 +488,13 @@ export class ShapeGroupView { if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; const totalPages = this.editor?.options?.totalPageCount; + const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, totalPages, + sectionPageCount, }); if (textGroup) { g.appendChild(textGroup); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index dc8e250654..2910803480 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -1,3 +1,5 @@ +import { formatPageNumber } from '@superdoc/layout-engine'; + /** * Shared utility functions for SVG shape rendering * Used by VectorShapeView and ShapeGroupView @@ -67,7 +69,8 @@ export function createGradient(gradientData, gradientId) { * @param {Array} textContent.parts - Array of text parts with formatting * @param {string} textContent.parts[].text - The text content * @param {Object} [textContent.parts[].formatting] - Formatting options (bold, italic, color, fontSize, fontFamily) - * @param {'PAGE'|'NUMPAGES'} [textContent.parts[].fieldType] - Field type for dynamic content resolution + * @param {'PAGE'|'NUMPAGES'|'SECTIONPAGES'} [textContent.parts[].fieldType] - Field type for dynamic content resolution + * @param {string} [textContent.parts[].pageNumberFormat] - PAGE/SECTIONPAGES field-local value formatting override * @param {boolean} [textContent.parts[].isLineBreak] - Whether this part represents a line break * @param {boolean} [textContent.parts[].isEmptyParagraph] - Whether this line break follows an empty paragraph * @param {string} textAlign - Text alignment ('left', 'center', 'right', 'r') @@ -78,10 +81,11 @@ export function createGradient(gradientData, gradientId) { * @param {'top'|'center'|'bottom'} [options.textVerticalAlign] - Vertical alignment of text content * @param {number} [options.pageNumber] - Current page number for PAGE field resolution * @param {number} [options.totalPages] - Total page count for NUMPAGES field resolution + * @param {number} [options.sectionPageCount] - Current section page count for SECTIONPAGES field resolution * @returns {SVGForeignObjectElement} The created foreignObject element containing the formatted text */ export function createTextElement(textContent, textAlign, width, height, options = {}) { - const { textInsets, textVerticalAlign, pageNumber, totalPages } = options; + const { textInsets, textVerticalAlign, pageNumber, totalPages, sectionPageCount } = options; // Use foreignObject with HTML for proper text wrapping const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); foreignObject.setAttribute('x', '0'); @@ -135,6 +139,10 @@ export function createTextElement(textContent, textAlign, width, height, options if (part.fieldType === 'NUMPAGES') { return totalPages != null ? String(totalPages) : '1'; } + if (part.fieldType === 'SECTIONPAGES') { + const count = sectionPageCount ?? totalPages ?? 1; + return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); + } return part.text; }; diff --git a/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts index ad92705465..c2f7b2ac64 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/miscellaneous-commands.ts @@ -150,6 +150,12 @@ export interface MiscellaneousCommands { */ addTotalPageCount: () => boolean; + /** + * Insert section page count at the current position + * @note Only works in header/footer contexts + */ + addSectionPageCount: () => boolean; + // ============================================ // LINKED STYLES COMMANDS // ============================================ diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 6dcf619ecd..632651d83d 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -968,6 +968,20 @@ export interface TotalPageCountAttrs extends InlineNodeAttributes { pageNumberZeroPadding?: number | null; } +/** Section page count node attributes */ +export interface SectionPageCountAttrs extends InlineNodeAttributes { + /** @internal Marks stored as attributes */ + marksAsAttrs?: unknown[] | null; + /** Imported cached field result */ + importedCachedText?: string | null; + /** Cached display value set by an explicit field update */ + resolvedText?: string | null; + /** Imported or synthesized SECTIONPAGES field instruction */ + instruction?: string | null; + /** PAGE/SECTIONPAGES field-local value formatting override */ + pageNumberFormat?: PageNumberFormat | null; +} + // ============================================ // FIELD ANNOTATION // ============================================ @@ -1291,6 +1305,7 @@ declare module '../../core/types/NodeAttributesMap.js' { pageReference: PageReferenceAttrs; 'page-number': PageNumberAttrs; 'total-page-number': TotalPageCountAttrs; + 'section-page-count': SectionPageCountAttrs; // Field annotations fieldAnnotation: FieldAnnotationAttrs; diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 417c98a514..fba2b207c0 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -164,11 +164,13 @@ export class VectorShapeView { if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; const totalPages = this.editor?.options?.totalPageCount; + const sectionPageCount = this.editor?.options?.sectionPageCount; const textElement = this.createTextElement(attrs.textContent, attrs.textAlign, attrs.width, attrs.height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, totalPages, + sectionPageCount, }); if (textElement) { svg.appendChild(textElement); From 1d3bd2851c1abbec126139c8fde265a8ae10bbb3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 10:48:45 -0300 Subject: [PATCH 36/63] fix(super-converter): preserve SECTIONPAGES field styling --- .../section-pages-preprocessor.js | 10 +++++++- .../section-pages-preprocessor.test.js | 2 +- .../preProcessNodesForFldChar.test.js | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js index 5dba86c15a..9bc0c90093 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js @@ -5,10 +5,18 @@ import { parsePageInstruction } from './page-instruction.js'; * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [instrText] The SECTIONPAGES instruction text. + * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object. + * @param {Array<{type: string, text?: string}> | null} [_instructionTokens] Raw instruction tokens. * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessSectionPagesInstruction(nodesToCombine, instrText = '', fieldRunRPr = null) { +export function preProcessSectionPagesInstruction( + nodesToCombine, + instrText = '', + _docx, + _instructionTokens, + fieldRunRPr = null, +) { const parsedInstruction = parsePageInstruction(instrText, 'SECTIONPAGES'); const sectionPageCountNode = { name: 'sd:sectionPageCount', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js index 94220d1a92..f8b25569db 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js @@ -43,7 +43,7 @@ describe('preProcessSectionPagesInstruction', () => { it('uses fieldRunRPr when cached content has no run properties', () => { const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; - const result = preProcessSectionPagesInstruction([], 'SECTIONPAGES', fieldRunRPr); + const result = preProcessSectionPagesInstruction([], 'SECTIONPAGES', undefined, null, fieldRunRPr); expect(result[0].elements).toEqual([fieldRunRPr]); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 88ee37cf9f..32cd709e3c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -86,6 +86,30 @@ describe('preProcessNodesForFldChar', () => { }, ); + it('preserves SECTIONPAGES field run properties when cached result has no run properties', () => { + const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + const { processedNodes } = preProcessNodesForFldChar( + [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [fieldRunRPr, { name: 'w:instrText', elements: [{ type: 'text', text: 'SECTIONPAGES' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '4' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ], + mockDocx, + ); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toMatchObject({ + name: 'sd:sectionPageCount', + attributes: { importedCachedText: '4' }, + elements: [fieldRunRPr], + }); + }); + it('should process non-page field instructions case-insensitively', () => { const docx = { 'word/_rels/document.xml.rels': { From ba142fc18910e60296bd2e337c86c5f9dd1abc1c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 10:51:13 -0300 Subject: [PATCH 37/63] fix(layout-bridge): keep page resolver section count optional --- packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 1e8993a448..f4e9e34242 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -33,7 +33,7 @@ export type PageResolver = (pageNumber: number) => { displayText: string; displayNumber?: number; totalPages: number; - sectionPageCount: number; + sectionPageCount?: number; }; /** From f9da5bba1e80477d156ef4928ff77d69e84f0c53 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 10:56:09 -0300 Subject: [PATCH 38/63] fix(super-converter): preserve header SECTIONPAGES styling --- .../section-pages-preprocessor.js | 9 ++--- .../preProcessPageFieldsOnly.test.js | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js index 9bc0c90093..681649e142 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js @@ -5,7 +5,7 @@ import { parsePageInstruction } from './page-instruction.js'; * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [instrText] The SECTIONPAGES instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object. + * @param {unknown} [_docxOrFieldRunRPr] The parsed docx in the main import path, or w:rPr in header/footer-only preprocessing. * @param {Array<{type: string, text?: string}> | null} [_instructionTokens] Raw instruction tokens. * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} @@ -13,10 +13,11 @@ import { parsePageInstruction } from './page-instruction.js'; export function preProcessSectionPagesInstruction( nodesToCombine, instrText = '', - _docx, + _docxOrFieldRunRPr = null, _instructionTokens, fieldRunRPr = null, ) { + const effectiveFieldRunRPr = fieldRunRPr ?? (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); const parsedInstruction = parsePageInstruction(instrText, 'SECTIONPAGES'); const sectionPageCountNode = { name: 'sd:sectionPageCount', @@ -41,8 +42,8 @@ export function preProcessSectionPagesInstruction( } }); - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - sectionPageCountNode.elements = [fieldRunRPr]; + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + sectionPageCountNode.elements = [effectiveFieldRunRPr]; } return [sectionPageCountNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index fdaf2a628b..20724f17ab 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -157,6 +157,41 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].attributes.importedCachedText).toBe('4'); }, ); + + it('should preserve SECTIONPAGES field sequence styling when cached result has no rPr', () => { + const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + const nodes = [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [fieldRunRPr, { name: 'w:instrText', elements: [{ type: 'text', text: ' SECTIONPAGES ' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '4' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:sectionPageCount', + attributes: { importedCachedText: '4' }, + elements: [fieldRunRPr], + }); + }); }); describe('simple field syntax (w:fldSimple)', () => { From 82b3e17daa85fa5fb216acae6338b22b3b7363da Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 10:56:50 -0300 Subject: [PATCH 39/63] fix(super-editor): import page formatter from contracts --- .../src/editors/v1/extensions/page-number/page-number.js | 2 +- .../super-editor/src/editors/v1/extensions/shared/svg-utils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 e3f0abc98e..2e623b27d4 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/layout-engine'; +import { formatPageNumber } from '@superdoc/contracts'; /** * Configuration options for PageNumber * @typedef {Object} PageNumberOptions diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index 2910803480..cdb3c995a0 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -1,4 +1,4 @@ -import { formatPageNumber } from '@superdoc/layout-engine'; +import { formatPageNumber } from '@superdoc/contracts'; /** * Shared utility functions for SVG shape rendering From b1bfa4933b142eacdd76c5ed2125616455557430 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:04:55 -0300 Subject: [PATCH 40/63] fix(painter): format PAGE fields in drawing text --- .../painters/dom/src/index.test.ts | 63 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 3 + .../editors/v1/extensions/shared/svg-utils.js | 3 +- .../v1/extensions/shared/svg-utils.test.js | 12 ++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index aea53d5eb2..096cd8baba 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6528,6 +6528,69 @@ describe('DomPainter', () => { expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 2'); }); + it('renders formatted PAGE fields in drawing text', () => { + const vectorShapeBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-formatted-page-field', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + textContent: { + parts: [ + { text: 'Page ', formatting: { fontFamily: 'Arial', fontSize: 18 } }, + { + text: '', + fieldType: 'PAGE', + pageNumberFormat: 'upperRoman', + formatting: { fontFamily: 'Arial', fontSize: 18 }, + }, + ], + }, + textAlign: 'center', + }; + + const vectorShapeMeasure: Measure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 100, + height: 50, + scale: 1, + naturalWidth: 100, + naturalHeight: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + }; + + const painter = createTestPainter({ blocks: [vectorShapeBlock], measures: [vectorShapeMeasure] }); + painter.paint( + { + pageSize: layout.pageSize, + pages: [ + { + number: 7, + displayNumber: 5, + numberText: '5', + fragments: [ + { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'drawing-formatted-page-field', + x: 30, + y: 40, + width: 100, + height: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + scale: 1, + }, + ], + }, + ], + }, + mount, + ); + + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page V'); + }); + describe('resolved paragraph rendering', () => { it('renders resolved paragraph lines with precomputed indent styles', () => { const paragraphBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7ef79178e9..3ad808b973 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3116,6 +3116,9 @@ export class DomPainter { private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string { if (part.fieldType === 'PAGE') { + if (part.pageNumberFormat) { + return formatPageNumber(context?.pageNumberDisplayNumber ?? context?.pageNumber ?? 1, part.pageNumberFormat); + } return context?.pageNumberText ?? String(context?.pageNumber ?? 1); } if (part.fieldType === 'NUMPAGES') { diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index cdb3c995a0..7e9e127318 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -134,7 +134,8 @@ export function createTextElement(textContent, textAlign, width, height, options const resolveFieldText = (part) => { if (part.fieldType === 'PAGE') { - return pageNumber != null ? String(pageNumber) : '1'; + const count = pageNumber ?? 1; + return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); } if (part.fieldType === 'NUMPAGES') { return totalPages != null ? String(totalPages) : '1'; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index c144fbd1f6..69a1ec86fe 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -134,6 +134,18 @@ describe('svg-utils', () => { expect(span.textContent).toBe('5'); }); + it('should apply pageNumberFormat to PAGE field type', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 5, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('V'); + }); + it('should resolve NUMPAGES field type to totalPages', () => { const textContent = { parts: [{ text: '', fieldType: 'NUMPAGES', formatting: {} }], From 19f79e47f34cb6458d1e45acab1cdf7ce13148e0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:14:16 -0300 Subject: [PATCH 41/63] fix(fields): refresh SECTIONPAGES cached values --- .../field-wrappers.section-pages.test.ts | 71 +++++++++++++++++++ .../plan-engine/field-wrappers.ts | 50 +++++++++++++ .../extensions/field-update/field-update.js | 24 +++++-- .../field-update/field-update.test.js | 51 +++++++++++++ 4 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts new file mode 100644 index 0000000000..a11eb1abf3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts @@ -0,0 +1,71 @@ +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { fieldsRebuildWrapper } from './field-wrappers.js'; + +registerBuiltInExecutors(); + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { sdBlockId: { default: null } }, + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, + 'section-page-count': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + pageNumberFormat: { default: null }, + }, + toDOM: () => ['span', 0], + }, + }, +}); + +function createEditorWithSectionPageCount(sectionPageCount: number): Editor { + const field = schema.nodes['section-page-count'].create( + { instruction: 'SECTIONPAGES', resolvedText: '1' }, + schema.text('1'), + ); + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); + const doc = schema.nodes.doc.create(null, paragraph); + + const editor = { + schema, + state: EditorState.create({ schema, doc }), + options: { sectionPageCount }, + view: { dispatch: () => {} }, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; + + return editor as unknown as Editor; +} + +describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { + it('updates section-page-count text content and resolvedText from editor section page count', () => { + const editor = createEditorWithSectionPageCount(4); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('section-page-count'); + expect(updatedField?.attrs.resolvedText).toBe('4'); + expect(updatedField?.textContent).toBe('4'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts index 8367870665..063190a0dd 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts @@ -3,6 +3,7 @@ */ import type { Editor } from '../../core/Editor.js'; +import { formatPageNumber, type PageNumberFormat } from '@superdoc/contracts'; import type { FieldListInput, FieldGetInput, @@ -259,6 +260,10 @@ export function fieldsRebuildWrapper( return rebuildTotalPageNumber(editor, resolved, address, options); } + if (node.type.name === 'section-page-count') { + return rebuildSectionPageCount(editor, resolved, address, options); + } + // Default: clear resolvedNumber to force re-evaluation (sequence fields, etc.) const receipt = executeDomainCommand( editor, @@ -362,6 +367,51 @@ function rebuildTotalPageNumber( return fieldSuccess(address); } +/** + * Rebuilds a section-page-count field by writing the current section page count + * into both resolvedText and the node's text content. + */ +function rebuildSectionPageCount( + editor: Editor, + resolved: { pos: number }, + address: FieldAddress, + options?: MutationOptions, +): FieldMutationResult { + const node = editor.state.doc.nodeAt(resolved.pos); + if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found.'); + + const freshValue = resolveSectionPageCountFieldValue(editor, node); + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + const currentNode = tr.doc.nodeAt(resolved.pos); + if (!currentNode) return false; + + const textChild = freshValue ? editor.schema.text(freshValue) : null; + const newNode = currentNode.type.create({ ...currentNode.attrs, resolvedText: freshValue }, textChild); + tr.replaceWith(resolved.pos, resolved.pos + currentNode.nodeSize, newNode); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Rebuild produced no change.'); + return fieldSuccess(address); +} + +function resolveSectionPageCountFieldValue(editor: Editor, node: { attrs?: Record }): string { + const sectionPageCount = editor.options?.sectionPageCount ?? editor.options?.totalPageCount ?? 1; + const pageNumberFormat = node.attrs?.pageNumberFormat; + if (typeof pageNumberFormat === 'string' && pageNumberFormat) { + return formatPageNumber(Number(sectionPageCount) || 1, pageNumberFormat as PageNumberFormat); + } + return String(sectionPageCount); +} + export function fieldsRemoveWrapper( editor: Editor, input: FieldRemoveInput, diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js index 2c05458f75..f6608ae743 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js @@ -6,9 +6,10 @@ import { resolveDocumentStatFieldValue, resolveMainBodyEditor, } from '../../document-api-adapters/helpers/word-statistics.js'; +import { formatPageNumber } from '@superdoc/contracts'; /** Stat-field types refreshed by F9 when the doc has no TOCs. */ -const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES']); +const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES', 'SECTIONPAGES']); /** * @module FieldUpdate @@ -99,14 +100,17 @@ export const FieldUpdate = Extension.create({ const sorted = [...updatable].sort((a, b) => b.pos - a.pos); for (const field of sorted) { - const freshValue = resolveDocumentStatFieldValue(field.fieldType, stats); - if (freshValue == null) continue; - const node = tr.doc.nodeAt(field.pos); if (!node) continue; - if (node.type.name === 'total-page-number') { - // total-page-number stores its display value as a text child, + const freshValue = + field.fieldType === 'SECTIONPAGES' + ? resolveSectionPageCountFieldValue(editor, node) + : resolveDocumentStatFieldValue(field.fieldType, stats); + if (freshValue == null) continue; + + if (node.type.name === 'total-page-number' || node.type.name === 'section-page-count') { + // Page-count fields store their display value as a text child, // not just an attr. Replace the entire node so both the text // content and resolvedText stay in sync. const textChild = freshValue ? state.schema.text(freshValue) : null; @@ -138,3 +142,11 @@ export const FieldUpdate = Extension.create({ }; }, }); + +function resolveSectionPageCountFieldValue(editor, node) { + const sectionPageCount = editor?.options?.sectionPageCount ?? editor?.options?.totalPageCount ?? 1; + if (node?.attrs?.pageNumberFormat) { + return formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat); + } + return String(sectionPageCount); +} diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js index b65bcf32d6..85c901fc92 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js @@ -294,6 +294,19 @@ const mixedSchema = new Schema({ }, toDOM: (n) => ['span', n.attrs.resolvedText ?? ''], }, + 'section-page-count': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + pageNumberFormat: { default: null }, + }, + toDOM: () => ['span', 0], + }, text: { group: 'inline' }, }, }); @@ -338,6 +351,44 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)', expect(dispatch).toHaveBeenCalledTimes(1); expect(result).toBe(true); }); + + it('updates SECTIONPAGES fields from the current header/footer section page count', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const sectionPageCountField = mixedSchema.nodes['section-page-count'].create( + { + instruction: 'SECTIONPAGES', + resolvedText: '1', + }, + mixedSchema.text('1'), + ); + const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]); + const editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + options: { sectionPageCount: 4 }, + state: editorState, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const outerTr = editorState.tr; + const dispatch = vi.fn(); + const state = { + doc, + selection: { from: 0, to: doc.content.size }, + schema: mixedSchema, + tr: outerTr, + }; + + const result = command({ editor, state, tr: outerTr, dispatch }); + + expect(result).toBe(true); + expect(dispatch).toHaveBeenCalledTimes(1); + const updatedDoc = dispatch.mock.calls[0][0].doc; + const updatedField = updatedDoc.nodeAt(1); + expect(updatedField.type.name).toBe('section-page-count'); + expect(updatedField.attrs.resolvedText).toBe('4'); + expect(updatedField.textContent).toBe('4'); + }); }); describe('FieldUpdate extension shortcuts', () => { From 933bbbb31232f5a437ec06b578abcc41d72ba483 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:22:02 -0300 Subject: [PATCH 42/63] fix(fields): preserve SECTIONPAGES without context --- .../field-wrappers.section-pages.test.ts | 23 +++++++++--- .../plan-engine/field-wrappers.ts | 7 ++-- .../extensions/field-update/field-update.js | 4 ++- .../field-update/field-update.test.js | 36 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts index a11eb1abf3..a488405163 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts @@ -33,18 +33,19 @@ const schema = new Schema({ }, }); -function createEditorWithSectionPageCount(sectionPageCount: number): Editor { +function createEditorWithSectionPageCount(sectionPageCount?: number, initialValue = '1'): Editor { const field = schema.nodes['section-page-count'].create( - { instruction: 'SECTIONPAGES', resolvedText: '1' }, - schema.text('1'), + { instruction: 'SECTIONPAGES', resolvedText: initialValue }, + schema.text(initialValue), ); const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); const doc = schema.nodes.doc.create(null, paragraph); + const options = sectionPageCount == null ? {} : { sectionPageCount }; const editor = { schema, state: EditorState.create({ schema, doc }), - options: { sectionPageCount }, + options, view: { dispatch: () => {} }, dispatch(tr) { this.state = this.state.apply(tr); @@ -68,4 +69,18 @@ describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { expect(updatedField?.attrs.resolvedText).toBe('4'); expect(updatedField?.textContent).toBe('4'); }); + + it('preserves existing section-page-count text when section page context is unavailable', () => { + const editor = createEditorWithSectionPageCount(undefined, '3'); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('section-page-count'); + expect(updatedField?.attrs.resolvedText).toBe('3'); + expect(updatedField?.textContent).toBe('3'); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts index 063190a0dd..a98d150276 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts @@ -381,6 +381,7 @@ function rebuildSectionPageCount( if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found.'); const freshValue = resolveSectionPageCountFieldValue(editor, node); + if (freshValue == null) return fieldSuccess(address); const receipt = executeDomainCommand( editor, @@ -403,8 +404,10 @@ function rebuildSectionPageCount( return fieldSuccess(address); } -function resolveSectionPageCountFieldValue(editor: Editor, node: { attrs?: Record }): string { - const sectionPageCount = editor.options?.sectionPageCount ?? editor.options?.totalPageCount ?? 1; +function resolveSectionPageCountFieldValue(editor: Editor, node: { attrs?: Record }): string | null { + const sectionPageCount = editor.options?.sectionPageCount; + if (sectionPageCount == null) return null; + const pageNumberFormat = node.attrs?.pageNumberFormat; if (typeof pageNumberFormat === 'string' && pageNumberFormat) { return formatPageNumber(Number(sectionPageCount) || 1, pageNumberFormat as PageNumberFormat); diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js index f6608ae743..b0313af365 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js @@ -144,7 +144,9 @@ export const FieldUpdate = Extension.create({ }); function resolveSectionPageCountFieldValue(editor, node) { - const sectionPageCount = editor?.options?.sectionPageCount ?? editor?.options?.totalPageCount ?? 1; + const sectionPageCount = editor?.options?.sectionPageCount; + if (sectionPageCount == null) return null; + if (node?.attrs?.pageNumberFormat) { return formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat); } diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js index 85c901fc92..c3e0d76552 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js @@ -389,6 +389,42 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)', expect(updatedField.attrs.resolvedText).toBe('4'); expect(updatedField.textContent).toBe('4'); }); + + it('leaves SECTIONPAGES fields unchanged when section page context is unavailable', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const sectionPageCountField = mixedSchema.nodes['section-page-count'].create( + { + instruction: 'SECTIONPAGES', + resolvedText: '3', + }, + mixedSchema.text('3'), + ); + const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]); + const editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + options: {}, + state: editorState, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const outerTr = editorState.tr; + const dispatch = vi.fn(); + const state = { + doc, + selection: { from: 0, to: doc.content.size }, + schema: mixedSchema, + tr: outerTr, + }; + + const result = command({ editor, state, tr: outerTr, dispatch }); + + expect(result).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + const unchangedField = editorState.doc.nodeAt(1); + expect(unchangedField.attrs.resolvedText).toBe('3'); + expect(unchangedField.textContent).toBe('3'); + }); }); describe('FieldUpdate extension shortcuts', () => { From 4469aaa9b0d63bc9b321bcd74f5d0c63506a6876 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:24:30 -0300 Subject: [PATCH 43/63] fix(page-number): show section PAGE text in edit mode --- .../header-footer/HeaderFooterRegistry.ts | 11 +++++- .../presentation-editor/PresentationEditor.ts | 2 + .../HeaderFooterSessionManager.ts | 1 + .../story-session/types.ts | 1 + .../tests/HeaderFooterSessionManager.test.ts | 2 + .../editors/v1/core/story-editor-factory.ts | 7 ++++ .../src/editors/v1/core/types/EditorConfig.ts | 3 ++ .../v1/extensions/page-number/page-number.js | 11 +++++- .../page-number/page-number.test.js | 38 +++++++++++++++++++ .../pagination/pagination-helpers.js | 3 ++ 10 files changed, 76 insertions(+), 3 deletions(-) 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 cb3b3264f8..df71275052 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 @@ -263,6 +263,7 @@ export class HeaderFooterEditorManager extends EventEmitter { * @param options.availableWidth - The width of the editing region in pixels. Must be a positive number if provided. * @param options.availableHeight - The height of the editing region in pixels. Must be a positive number if provided. * @param options.currentPageNumber - The current page number for PAGE field resolution. Must be a positive integer if provided. + * @param options.currentPageNumberText - The current formatted PAGE field display text if provided. * @param options.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 @@ -276,6 +277,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableWidth?: number; availableHeight?: number; currentPageNumber?: number; + currentPageNumberText?: string; totalPageCount?: number; sectionPageCount?: number; }, @@ -442,6 +444,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableWidth?: number; availableHeight?: number; currentPageNumber?: number; + currentPageNumberText?: string; totalPageCount?: number; sectionPageCount?: number; }, @@ -477,7 +480,7 @@ export class HeaderFooterEditorManager extends EventEmitter { const opts = editor.options as Record; const parentEditor = opts.parentEditor as Record | undefined; - const currentPage = String(opts.currentPageNumber || '1'); + const currentPage = String(opts.currentPageNumberText || opts.currentPageNumber || '1'); const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); const sectionPages = Number(opts.sectionPageCount || opts.totalPageCount || parentEditor?.currentTotalPages || 1); @@ -766,6 +769,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableWidth?: number; availableHeight?: number; currentPageNumber?: number; + currentPageNumberText?: string; totalPageCount?: number; sectionPageCount?: number; }, @@ -787,6 +791,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableWidth: options?.availableWidth, availableHeight: options?.availableHeight ?? DEFAULT_HEADER_FOOTER_HEIGHT, currentPageNumber: options?.currentPageNumber ?? 1, + currentPageNumberText: options?.currentPageNumberText, totalPageCount: options?.totalPageCount ?? 1, sectionPageCount: options?.sectionPageCount ?? options?.totalPageCount ?? 1, }) as Editor; @@ -904,6 +909,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableWidth?: number; availableHeight?: number; currentPageNumber?: number; + currentPageNumberText?: string; totalPageCount?: number; sectionPageCount?: number; }, @@ -920,6 +926,9 @@ export class HeaderFooterEditorManager extends EventEmitter { if (options.currentPageNumber !== undefined) { updateOptions.currentPageNumber = options.currentPageNumber; } + if (options.currentPageNumberText !== undefined) { + updateOptions.currentPageNumberText = options.currentPageNumberText; + } if (options.totalPageCount !== undefined) { updateOptions.totalPageCount = options.totalPageCount; } 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 b848a8facb..464f4fe996 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 @@ -5678,6 +5678,7 @@ export class PresentationEditor extends EventEmitter { availableWidth: editorContext.availableWidth, availableHeight: editorContext.availableHeight, currentPageNumber: editorContext.currentPageNumber, + currentPageNumberText: editorContext.currentPageNumberText, totalPageCount: editorContext.totalPageCount, sectionPageCount: editorContext.sectionPageCount, }) ?? null) @@ -5710,6 +5711,7 @@ export class PresentationEditor extends EventEmitter { headless: false, element: hostElement, currentPageNumber: editorContext.currentPageNumber, + currentPageNumberText: editorContext.currentPageNumberText, 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 a5ff3baa94..3abca8309e 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 @@ -1096,6 +1096,7 @@ export class HeaderFooterSessionManager { availableWidth: Math.max(1, region.width), availableHeight: Math.max(1, region.height), currentPageNumber: Math.max(1, region.pageNumber ?? 1), + currentPageNumberText: region.displayPageNumber, 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 30d4dc36f0..f78117ef87 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/story-session/types.ts @@ -118,6 +118,7 @@ export interface ActivateStorySessionOptions { availableWidth?: number; availableHeight?: number; currentPageNumber?: number; + currentPageNumberText?: string; totalPageCount?: number; sectionPageCount?: number; surfaceKind?: 'header' | 'footer' | 'note' | 'endnote'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index a2927a999a..2b2eb44880 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -403,6 +403,7 @@ describe('HeaderFooterSessionManager', () => { sectionIndex: 0, pageIndex: 0, pageNumber: 1, + displayPageNumber: 'i', localX: 36, localY: 24, width: 480, @@ -430,6 +431,7 @@ describe('HeaderFooterSessionManager', () => { availableWidth: 480, availableHeight: 72, currentPageNumber: 1, + currentPageNumberText: 'i', totalPageCount: 3, surfaceKind: 'header', }), 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 87a829a588..6310a4cb3e 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts @@ -34,6 +34,11 @@ export interface StoryEditorOptions { */ currentPageNumber?: number; + /** + * The current formatted PAGE field display text for section-aware story editors. + */ + currentPageNumberText?: string; + /** * The total page count for NUMPAGES field resolution. * Must be a positive integer. @@ -121,6 +126,7 @@ export function createStoryEditor( isHeaderOrFooter = true, headless, currentPageNumber = 1, + currentPageNumberText, totalPageCount = 1, sectionPageCount = 1, element = null, @@ -163,6 +169,7 @@ export function createStoryEditor( pagination: false, annotations: true, currentPageNumber, + currentPageNumberText, 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 d993cb32a1..559208ea51 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -456,6 +456,9 @@ export interface EditorOptions { /** Current page number for PAGE field rendering in story editors */ currentPageNumber?: number; + /** Current formatted PAGE display text for story editors */ + currentPageNumberText?: string; + /** Total document page count for NUMPAGES field rendering in story editors */ totalPageCount?: number; 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 2e623b27d4..13834a0837 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 @@ -358,13 +358,20 @@ export const SectionPageCount = Node.create({ const getNodeAttributes = (nodeName, editor, node = null) => { switch (nodeName) { - case 'page-number': + case 'page-number': { + const currentPageNumber = editor.options.currentPageNumber || 1; + const text = + editor.options.currentPageNumberText || + (node?.attrs?.pageNumberFormat + ? formatPageNumber(Number(currentPageNumber) || 1, node.attrs.pageNumberFormat) + : currentPageNumber); return { - text: editor.options.currentPageNumber || '1', + text, className: 'sd-editor-auto-page-number', dataId: 'auto-page-number', ariaLabel: 'Page number node', }; + } case 'total-page-number': return { text: editor.options.totalPageCount || editor.options.parentEditor?.currentTotalPages || '1', 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 68b2e2ff7d..e0e83fe816 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 @@ -215,6 +215,44 @@ describe('AutoPageNumberNodeView', () => { expect(nodeView.update({ type: { name: 'total-page-number' } })).toBe(false); }); + it('renders page number node with section-aware display text when provided', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { currentPageNumber: 7, currentPageNumberText: 'iii' }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'page-number' }, attrs: { pageNumberFormat: 'lowerRoman' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('iii'); + }); + + it('formats page number node from current page number when display text is unavailable', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { currentPageNumber: 4 }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'page-number' }, attrs: { pageNumberFormat: 'upperRoman' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('IV'); + }); + it('renders total page count node with parent editor value', () => { const doc = { resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), 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 4ceab3ded6..c200e65272 100644 --- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js @@ -96,6 +96,7 @@ const getSectionHeight = async (editor, data) => { * @param {number} [params.availableWidth] - The width of the editing region in pixels. Must be positive. * @param {number} [params.availableHeight] - The height of the editing region in pixels. Must be positive. * @param {number} [params.currentPageNumber] - The current page number for PAGE field resolution. Must be a positive integer. + * @param {string} [params.currentPageNumberText] - The formatted PAGE field display text. * @param {number} [params.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 @@ -113,6 +114,7 @@ export const createHeaderFooterEditor = ({ availableWidth, availableHeight, currentPageNumber, + currentPageNumberText, totalPageCount, sectionPageCount, }) => { @@ -213,6 +215,7 @@ export const createHeaderFooterEditor = ({ documentId: headerFooterRefId || 'headerFooterRefId', isHeaderOrFooter: true, currentPageNumber, + currentPageNumberText, totalPageCount, sectionPageCount, element: editorContainer, From 2fd95a2ed6b2efd0790a5c30324f7debb904eac7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:25:14 -0300 Subject: [PATCH 44/63] fix(super-converter): filter root SECTIONPAGES nodes --- .../editors/v1/core/super-converter/v2/importer/docxImporter.js | 1 + .../v1/core/super-converter/v2/importer/docxImporter.test.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index d7b4603d24..c420abfdea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -929,6 +929,7 @@ export function filterOutRootInlineNodes(content = []) { 'hardBreak', 'pageNumber', 'totalPageCount', + 'section-page-count', 'sectionPageCount', 'runItem', 'image', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js index d5f8d93449..8fd53598ce 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js @@ -108,6 +108,7 @@ describe('filterOutRootInlineNodes', () => { n('table'), n('pageNumber'), n('totalPageCount'), + n('section-page-count'), n('runItem'), n('image'), n('tab'), From 79ec44dbe7eb42f34ec7e6a2b783a001390def32 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:31:10 -0300 Subject: [PATCH 45/63] fix(page-number): format PAGE from display number --- .../header-footer/HeaderFooterRegistry.ts | 14 +++++++++- .../editors/v1/core/header-footer/types.ts | 3 ++ .../presentation-editor/PresentationEditor.ts | 2 ++ .../HeaderFooterSessionManager.ts | 4 +++ .../story-session/types.ts | 1 + .../tests/HeaderFooterSessionManager.test.ts | 26 +++++++++++++++++ .../editors/v1/core/story-editor-factory.ts | 7 +++++ .../src/editors/v1/core/types/EditorConfig.ts | 3 ++ .../v1/extensions/page-number/page-number.js | 9 +++--- .../page-number/page-number.test.js | 21 +++++++++++++- .../pagination/pagination-helpers.js | 3 ++ .../extensions/shape-group/ShapeGroupView.js | 12 ++++++++ .../editors/v1/extensions/shared/svg-utils.js | 16 +++++++++-- .../v1/extensions/shared/svg-utils.test.js | 28 +++++++++++++++++++ .../vector-shape/VectorShapeView.js | 4 +++ 15 files changed, 143 insertions(+), 10 deletions(-) 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 df71275052..ab2a685b55 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 @@ -264,6 +264,7 @@ export class HeaderFooterEditorManager extends EventEmitter { * @param options.availableHeight - The height of the editing region in pixels. Must be a positive number if provided. * @param options.currentPageNumber - The current page number for PAGE field resolution. Must be a positive integer if provided. * @param options.currentPageNumberText - The current formatted PAGE field display text if provided. + * @param options.currentPageDisplayNumber - The current numeric PAGE display value for local field formatting. * @param options.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 @@ -278,6 +279,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; currentPageNumberText?: string; + currentPageDisplayNumber?: number; totalPageCount?: number; sectionPageCount?: number; }, @@ -445,6 +447,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; currentPageNumberText?: string; + currentPageDisplayNumber?: number; totalPageCount?: number; sectionPageCount?: number; }, @@ -481,6 +484,7 @@ export class HeaderFooterEditorManager extends EventEmitter { const parentEditor = opts.parentEditor as Record | undefined; const currentPage = String(opts.currentPageNumberText || opts.currentPageNumber || '1'); + const currentPageNumber = Number(opts.currentPageDisplayNumber || opts.currentPageNumber || 1); const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); const sectionPages = Number(opts.sectionPageCount || opts.totalPageCount || parentEditor?.currentTotalPages || 1); @@ -489,7 +493,9 @@ export class HeaderFooterEditorManager extends EventEmitter { const sectionPagesEls = container.querySelectorAll('[data-id="auto-section-pages"]'); pageNumberEls.forEach((el) => { - if (el.textContent !== currentPage) el.textContent = currentPage; + const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el); + const text = pageNumberFormat ? formatPageNumber(currentPageNumber, pageNumberFormat) : currentPage; + if (el.textContent !== text) el.textContent = text; }); totalPagesEls.forEach((el) => { if (el.textContent !== totalPages) el.textContent = totalPages; @@ -770,6 +776,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; currentPageNumberText?: string; + currentPageDisplayNumber?: number; totalPageCount?: number; sectionPageCount?: number; }, @@ -792,6 +799,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight: options?.availableHeight ?? DEFAULT_HEADER_FOOTER_HEIGHT, currentPageNumber: options?.currentPageNumber ?? 1, currentPageNumberText: options?.currentPageNumberText, + currentPageDisplayNumber: options?.currentPageDisplayNumber, totalPageCount: options?.totalPageCount ?? 1, sectionPageCount: options?.sectionPageCount ?? options?.totalPageCount ?? 1, }) as Editor; @@ -910,6 +918,7 @@ export class HeaderFooterEditorManager extends EventEmitter { availableHeight?: number; currentPageNumber?: number; currentPageNumberText?: string; + currentPageDisplayNumber?: number; totalPageCount?: number; sectionPageCount?: number; }, @@ -929,6 +938,9 @@ export class HeaderFooterEditorManager extends EventEmitter { if (options.currentPageNumberText !== undefined) { updateOptions.currentPageNumberText = options.currentPageNumberText; } + if (options.currentPageDisplayNumber !== undefined) { + updateOptions.currentPageDisplayNumber = options.currentPageDisplayNumber; + } 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 ad54d1a61e..f2623b758a 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/types.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/types.ts @@ -38,6 +38,9 @@ export type HeaderFooterRegion = { /** Section-aware display page number (e.g. "7" when physical page is 10 due to section numbering) */ displayPageNumber?: string; + /** Numeric section-aware display page number before PAGE field-local formatting */ + displayPageNumberValue?: number; + /** Physical page count in this region's section */ sectionPageCount?: number; 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 464f4fe996..c5e8720252 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 @@ -5679,6 +5679,7 @@ export class PresentationEditor extends EventEmitter { availableHeight: editorContext.availableHeight, currentPageNumber: editorContext.currentPageNumber, currentPageNumberText: editorContext.currentPageNumberText, + currentPageDisplayNumber: editorContext.currentPageDisplayNumber, totalPageCount: editorContext.totalPageCount, sectionPageCount: editorContext.sectionPageCount, }) ?? null) @@ -5712,6 +5713,7 @@ export class PresentationEditor extends EventEmitter { element: hostElement, currentPageNumber: editorContext.currentPageNumber, currentPageNumberText: editorContext.currentPageNumberText, + currentPageDisplayNumber: editorContext.currentPageDisplayNumber, 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 3abca8309e..adc51050bf 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 @@ -831,6 +831,7 @@ export class HeaderFooterSessionManager { const headerPayload = this.#headerDecorationProvider?.(page.number, margins, page); const headerBox = this.#computeDecorationBox('header', margins, actualPageHeight); const displayPageNumber = page.numberText ?? String(page.number); + const displayPageNumberValue = page.displayNumber ?? page.number; this.#headerRegions.set(pageIndex, { kind: 'header', @@ -842,6 +843,7 @@ export class HeaderFooterSessionManager { pageIndex, pageNumber: page.number, displayPageNumber, + displayPageNumberValue, sectionPageCount, localX: headerPayload?.hitRegion?.x ?? headerBox.x, localY: headerPayload?.hitRegion?.y ?? headerBox.offset, @@ -863,6 +865,7 @@ export class HeaderFooterSessionManager { pageIndex, pageNumber: page.number, displayPageNumber, + displayPageNumberValue, sectionPageCount, localX: footerPayload?.hitRegion?.x ?? footerBox.x, localY: footerPayload?.hitRegion?.y ?? footerBox.offset, @@ -1097,6 +1100,7 @@ export class HeaderFooterSessionManager { availableHeight: Math.max(1, region.height), currentPageNumber: Math.max(1, region.pageNumber ?? 1), currentPageNumberText: region.displayPageNumber, + currentPageDisplayNumber: Math.max(1, region.displayPageNumberValue ?? region.pageNumber ?? 1), 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 f78117ef87..d8330e8b89 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 @@ -119,6 +119,7 @@ export interface ActivateStorySessionOptions { availableHeight?: number; currentPageNumber?: number; currentPageNumberText?: string; + currentPageDisplayNumber?: number; totalPageCount?: number; sectionPageCount?: number; surfaceKind?: 'header' | 'footer' | 'note' | 'endnote'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 2b2eb44880..69337fc99b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -404,6 +404,7 @@ describe('HeaderFooterSessionManager', () => { pageIndex: 0, pageNumber: 1, displayPageNumber: 'i', + displayPageNumberValue: 1, localX: 36, localY: 24, width: 480, @@ -432,6 +433,7 @@ describe('HeaderFooterSessionManager', () => { availableHeight: 72, currentPageNumber: 1, currentPageNumberText: 'i', + currentPageDisplayNumber: 1, totalPageCount: 3, surfaceKind: 'header', }), @@ -1971,5 +1973,29 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(2)!.sectionType).toBe('even'); expect(manager.footerRegions.get(2)!.sectionType).toBe('even'); }); + + it('propagates section-aware page display values onto built regions', () => { + manager = buildManager(); + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + makePage({ + number: 7, + height: 792, + numberText: 'iii', + displayNumber: 3, + }), + ], + }; + + manager.rebuildRegions(layout); + + expect(manager.headerRegions.get(0)!.displayPageNumber).toBe('iii'); + expect(manager.headerRegions.get(0)!.displayPageNumberValue).toBe(3); + expect(manager.footerRegions.get(0)!.displayPageNumber).toBe('iii'); + expect(manager.footerRegions.get(0)!.displayPageNumberValue).toBe(3); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index 6310a4cb3e..be87358dbd 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 @@ -39,6 +39,11 @@ export interface StoryEditorOptions { */ currentPageNumberText?: string; + /** + * The current numeric PAGE display value for field-local formatting. + */ + currentPageDisplayNumber?: number; + /** * The total page count for NUMPAGES field resolution. * Must be a positive integer. @@ -127,6 +132,7 @@ export function createStoryEditor( headless, currentPageNumber = 1, currentPageNumberText, + currentPageDisplayNumber, totalPageCount = 1, sectionPageCount = 1, element = null, @@ -170,6 +176,7 @@ export function createStoryEditor( annotations: true, currentPageNumber, currentPageNumberText, + currentPageDisplayNumber, 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 559208ea51..46d8879d34 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -459,6 +459,9 @@ export interface EditorOptions { /** Current formatted PAGE display text for story editors */ currentPageNumberText?: string; + /** Current numeric PAGE display value for story editor field-local formatting */ + currentPageDisplayNumber?: number; + /** Total document page count for NUMPAGES field rendering in story editors */ totalPageCount?: number; 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 13834a0837..bb5e8b2cf7 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 @@ -360,11 +360,10 @@ const getNodeAttributes = (nodeName, editor, node = null) => { switch (nodeName) { case 'page-number': { const currentPageNumber = editor.options.currentPageNumber || 1; - const text = - editor.options.currentPageNumberText || - (node?.attrs?.pageNumberFormat - ? formatPageNumber(Number(currentPageNumber) || 1, node.attrs.pageNumberFormat) - : currentPageNumber); + const currentPageDisplayNumber = editor.options.currentPageDisplayNumber || currentPageNumber; + const text = node?.attrs?.pageNumberFormat + ? formatPageNumber(Number(currentPageDisplayNumber) || 1, node.attrs.pageNumberFormat) + : editor.options.currentPageNumberText || currentPageNumber; return { text, className: 'sd-editor-auto-page-number', 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 e0e83fe816..6b9facc5cf 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 @@ -228,12 +228,31 @@ describe('AutoPageNumberNodeView', () => { view: { state, dispatch: vi.fn() }, }; - const node = { type: { name: 'page-number' }, attrs: { pageNumberFormat: 'lowerRoman' } }; + const node = { type: { name: 'page-number' }, attrs: {} }; const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); expect(nodeView.dom.textContent).toBe('iii'); }); + it('formats page number node from section-aware display number when provided', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { currentPageNumber: 7, currentPageNumberText: '3', currentPageDisplayNumber: 3 }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'page-number' }, attrs: { pageNumberFormat: 'upperRoman' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('III'); + }); + it('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 c200e65272..e5f110c1fa 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 @@ -97,6 +97,7 @@ const getSectionHeight = async (editor, data) => { * @param {number} [params.availableHeight] - The height of the editing region in pixels. Must be positive. * @param {number} [params.currentPageNumber] - The current page number for PAGE field resolution. Must be a positive integer. * @param {string} [params.currentPageNumberText] - The formatted PAGE field display text. + * @param {number} [params.currentPageDisplayNumber] - The numeric PAGE display value for local field formatting. * @param {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 @@ -115,6 +116,7 @@ export const createHeaderFooterEditor = ({ availableHeight, currentPageNumber, currentPageNumberText, + currentPageDisplayNumber, totalPageCount, sectionPageCount, }) => { @@ -216,6 +218,7 @@ export const createHeaderFooterEditor = ({ isHeaderOrFooter: true, currentPageNumber, currentPageNumberText, + currentPageDisplayNumber, totalPageCount, sectionPageCount, element: editorContainer, diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 55557442f5..13a2f26d5b 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -282,12 +282,16 @@ export class ShapeGroupView { // Add text content if present if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; + const pageNumberText = this.editor?.options?.currentPageNumberText; + const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, + pageNumberText, + pageNumberDisplayNumber, totalPages, sectionPageCount, }); @@ -359,12 +363,16 @@ export class ShapeGroupView { // Add text content if present if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; + const pageNumberText = this.editor?.options?.currentPageNumberText; + const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, + pageNumberText, + pageNumberDisplayNumber, totalPages, sectionPageCount, }); @@ -487,12 +495,16 @@ export class ShapeGroupView { // Add text content if present if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; + const pageNumberText = this.editor?.options?.currentPageNumberText; + const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, + pageNumberText, + pageNumberDisplayNumber, totalPages, sectionPageCount, }); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index 7e9e127318..abe37474b5 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -80,12 +80,22 @@ export function createGradient(gradientData, gradientId) { * @param {{ top: number, right: number, bottom: number, left: number }} [options.textInsets] - Text padding insets in pixels * @param {'top'|'center'|'bottom'} [options.textVerticalAlign] - Vertical alignment of text content * @param {number} [options.pageNumber] - Current page number for PAGE field resolution + * @param {string} [options.pageNumberText] - Current formatted PAGE display text + * @param {number} [options.pageNumberDisplayNumber] - Current numeric PAGE display value for local field formatting * @param {number} [options.totalPages] - Total page count for NUMPAGES field resolution * @param {number} [options.sectionPageCount] - Current section page count for SECTIONPAGES field resolution * @returns {SVGForeignObjectElement} The created foreignObject element containing the formatted text */ export function createTextElement(textContent, textAlign, width, height, options = {}) { - const { textInsets, textVerticalAlign, pageNumber, totalPages, sectionPageCount } = options; + const { + textInsets, + textVerticalAlign, + pageNumber, + pageNumberText, + pageNumberDisplayNumber, + totalPages, + sectionPageCount, + } = options; // Use foreignObject with HTML for proper text wrapping const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); foreignObject.setAttribute('x', '0'); @@ -134,8 +144,8 @@ export function createTextElement(textContent, textAlign, width, height, options const resolveFieldText = (part) => { if (part.fieldType === 'PAGE') { - const count = pageNumber ?? 1; - return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); + const count = pageNumberDisplayNumber ?? pageNumber ?? 1; + return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : (pageNumberText ?? String(count)); } if (part.fieldType === 'NUMPAGES') { return totalPages != null ? String(totalPages) : '1'; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index 69a1ec86fe..4e3e9257cb 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -146,6 +146,34 @@ describe('svg-utils', () => { expect(span.textContent).toBe('V'); }); + it('should resolve PAGE field type to section-aware display text when provided', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3', + pageNumberDisplayNumber: 3, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3'); + }); + + it('should apply pageNumberFormat to section-aware PAGE display number when provided', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3', + pageNumberDisplayNumber: 3, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('III'); + }); + it('should resolve NUMPAGES field type to totalPages', () => { const textContent = { parts: [{ text: '', fieldType: 'NUMPAGES', formatting: {} }], diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index fba2b207c0..82621b9976 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -163,12 +163,16 @@ export class VectorShapeView { // Add text content if present if (attrs.textContent && attrs.textContent.parts) { const pageNumber = this.editor?.options?.currentPageNumber; + const pageNumberText = this.editor?.options?.currentPageNumberText; + const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textElement = this.createTextElement(attrs.textContent, attrs.textAlign, attrs.width, attrs.height, { textVerticalAlign: attrs.textVerticalAlign, textInsets: attrs.textInsets, pageNumber, + pageNumberText, + pageNumberDisplayNumber, totalPages, sectionPageCount, }); From ec45b0d7f8abaaae7b1d3f115257894d1e13b4fd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:34:14 -0300 Subject: [PATCH 46/63] fix(page-number): preserve SECTIONPAGES cached display --- .../v1/extensions/page-number/page-number.js | 12 ++++-- .../page-number/page-number.test.js | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) 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 bb5e8b2cf7..eb54125186 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 @@ -379,10 +379,14 @@ const getNodeAttributes = (nodeName, editor, node = null) => { ariaLabel: 'Total page count node', }; case 'section-page-count': { - const sectionPageCount = editor.options.sectionPageCount || editor.options.totalPageCount || '1'; - const text = node?.attrs?.pageNumberFormat - ? formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat) - : sectionPageCount; + const sectionPageCount = editor.options.sectionPageCount; + const cachedText = node?.attrs?.resolvedText ?? node?.attrs?.importedCachedText ?? node?.textContent ?? '1'; + const text = + sectionPageCount != null + ? node?.attrs?.pageNumberFormat + ? formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat) + : sectionPageCount + : cachedText; return { text, className: 'sd-editor-auto-section-pages', 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 6b9facc5cf..3fc0444980 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 @@ -313,4 +313,45 @@ describe('AutoPageNumberNodeView', () => { expect(nodeView.dom.className).toBe('sd-editor-auto-section-pages'); expect(nodeView.dom.getAttribute('data-id')).toBe('auto-section-pages'); }); + + it('renders imported SECTIONPAGES cached text when section page context is unavailable', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: {}, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'section-page-count' }, attrs: { importedCachedText: '3' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('3'); + }); + + it('renders resolved SECTIONPAGES text before imported cached text when section context is unavailable', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: {}, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { + type: { name: 'section-page-count' }, + attrs: { resolvedText: '4', importedCachedText: '3' }, + }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('4'); + }); }); From 3c1f179f083a9f1d5e74a86552eefa8bc1ba0649 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:38:50 -0300 Subject: [PATCH 47/63] fix(story-editor): keep section count context optional --- .../v1/core/story-editor-factory.test.ts | 38 +++++++++++++++++++ .../editors/v1/core/story-editor-factory.ts | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts index dc102ef0cd..4f5c3c4716 100644 --- a/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts +++ b/packages/super-editor/src/editors/v1/core/story-editor-factory.test.ts @@ -119,6 +119,44 @@ describe('createStoryEditor', () => { expect(note.options.telemetry).toEqual({ enabled: false }); }); + it('does not synthesize sectionPageCount when the caller lacks section context', () => { + const parent = trackEditor( + initTestEditor({ + mode: 'text', + content: '

parent

', + }).editor as Editor, + ); + + const child = trackEditor( + createStoryEditor( + parent, + { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h/f' }] }] }, + { documentId: 'hf:part:rId1', isHeaderOrFooter: true, headless: true }, + ), + ); + + expect(child.options.sectionPageCount).toBeUndefined(); + }); + + it('preserves explicit sectionPageCount when provided by the caller', () => { + const parent = trackEditor( + initTestEditor({ + mode: 'text', + content: '

parent

', + }).editor as Editor, + ); + + const child = trackEditor( + createStoryEditor( + parent, + { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'h/f' }] }] }, + { documentId: 'hf:part:rId1', isHeaderOrFooter: true, headless: true, sectionPageCount: 4 }, + ), + ); + + expect(child.options.sectionPageCount).toBe(4); + }); + it('keeps telemetry disabled even when a caller passes telemetry overrides', () => { const parent = trackEditor( initTestEditor({ diff --git a/packages/super-editor/src/editors/v1/core/story-editor-factory.ts b/packages/super-editor/src/editors/v1/core/story-editor-factory.ts index be87358dbd..d31c0336c7 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 @@ -134,7 +134,7 @@ export function createStoryEditor( currentPageNumberText, currentPageDisplayNumber, totalPageCount = 1, - sectionPageCount = 1, + sectionPageCount, element = null, editorOptions = {}, } = options; From e30090df4e2519b39e45c7d773c898f3dba6703d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:46:03 -0300 Subject: [PATCH 48/63] fix(header-footer): preserve SECTIONPAGES display on refresh --- .../HeaderFooterRegistry.test.ts | 22 +++++++++++++++++++ .../header-footer/HeaderFooterRegistry.ts | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index d5959c3dd8..8dc781806a 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -11,6 +11,7 @@ type MockEditorEmitter = { type MockSectionEditor = MockEditorEmitter & { destroy: ReturnType; + setOptions: ReturnType; view: { dom: HTMLDivElement; focus: ReturnType; @@ -58,6 +59,9 @@ const { mockCreateHeaderFooterEditor, mockOnHeaderFooterDataUpdate, mockToFlowBl once: emitter.once, emit: emitter.emit, destroy: vi.fn(), + setOptions: vi.fn((options: Record) => { + Object.assign(editorStub.options, options); + }), view: { dom: document.createElement('div'), focus: vi.fn(), @@ -233,6 +237,24 @@ describe('HeaderFooterEditorManager', () => { expect(secondHost.children).toHaveLength(1); }); + it('preserves section page count DOM text when refreshed without section context', () => { + const editor = createMockEditor(); + const manager = new HeaderFooterEditorManager(editor); + const descriptor = { id: 'rId-header-default', kind: 'header' } as const; + const host = document.createElement('div'); + + const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host }); + expect(sectionEditor).toBeDefined(); + const sectionPages = document.createElement('span'); + sectionPages.dataset.id = 'auto-section-pages'; + sectionPages.textContent = '3'; + sectionEditor!.view.dom.appendChild(sectionPages); + + manager.ensureEditorSync(descriptor, { editorHost: host, totalPageCount: 9 }); + + expect(sectionPages.textContent).toBe('3'); + }); + it('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 ab2a685b55..8153735847 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 @@ -486,7 +486,7 @@ export class HeaderFooterEditorManager extends EventEmitter { const currentPage = String(opts.currentPageNumberText || opts.currentPageNumber || '1'); const currentPageNumber = Number(opts.currentPageDisplayNumber || opts.currentPageNumber || 1); const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); - const sectionPages = Number(opts.sectionPageCount || opts.totalPageCount || parentEditor?.currentTotalPages || 1); + const sectionPages = opts.sectionPageCount; const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]'); const totalPagesEls = container.querySelectorAll('[data-id="auto-total-pages"]'); @@ -501,8 +501,10 @@ export class HeaderFooterEditorManager extends EventEmitter { if (el.textContent !== totalPages) el.textContent = totalPages; }); sectionPagesEls.forEach((el) => { + if (sectionPages == null) return; const pageNumberFormat = this.#getPageNumberFormatForDomNode(editor, el); - const text = pageNumberFormat ? formatPageNumber(sectionPages, pageNumberFormat) : String(sectionPages); + const sectionPageCount = Number(sectionPages) || 1; + const text = pageNumberFormat ? formatPageNumber(sectionPageCount, pageNumberFormat) : String(sectionPageCount); if (el.textContent !== text) el.textContent = text; }); } From 92304796d6f961fa50b3f30cf80742a1b8948516 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 11:48:22 -0300 Subject: [PATCH 49/63] fix(shape-text): preserve SECTIONPAGES cached text --- .../wp/helpers/encode-image-node-helpers.js | 6 ++++- .../helpers/encode-image-node-helpers.test.js | 27 +++++++++++++++++++ .../editors/v1/extensions/shared/svg-utils.js | 3 ++- .../v1/extensions/shared/svg-utils.test.js | 12 +++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index b4a5009a35..2c702b0910 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -1026,8 +1026,12 @@ function extractTextFromTextBox(textBoxContent, bodyPr, params = {}) { const appendFieldPart = (fieldType, node, paragraphProperties) => { const rPr = node?.elements?.find((el) => el.name === 'w:rPr'); const formatting = extractRunFormatting(rPr, paragraphProperties, params); + const cachedText = + fieldType === 'SECTIONPAGES' + ? (node?.attributes?.resolvedText ?? node?.attributes?.importedCachedText ?? '') + : ''; textParts.push({ - text: '', + text: cachedText, formatting, fieldType, ...(node?.attributes?.pageNumberFormat ? { pageNumberFormat: node.attributes.pageNumberFormat } : {}), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index d704216d24..3ef9df4015 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -1760,6 +1760,33 @@ describe('getVectorShape', () => { expect(result.attrs.textContent.parts[0].text).toBe('Hello World'); }); + it('preserves cached SECTIONPAGES text in textbox field parts', () => { + const graphicData = makeGraphicDataWithTextbox(''); + const paragraph = graphicData.elements[0].elements.find((el) => el.name === 'wps:txbx').elements[0].elements[0]; + paragraph.elements = [ + { + name: 'sd:sectionPageCount', + attributes: { + importedCachedText: '3', + resolvedText: '4', + }, + }, + ]; + + const result = getVectorShape({ + params: makeParams(), + node: {}, + graphicData, + size: { width: 100, height: 100 }, + }); + + expect(result.attrs.textContent.parts).toHaveLength(1); + expect(result.attrs.textContent.parts[0]).toMatchObject({ + text: '4', + fieldType: 'SECTIONPAGES', + }); + }); + it('handles [[sdspace]] at the beginning of text', () => { const graphicData = makeGraphicDataWithTextbox('[[sdspace]]Hello'); const result = getVectorShape({ diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index abe37474b5..b1e0faf936 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -151,7 +151,8 @@ export function createTextElement(textContent, textAlign, width, height, options return totalPages != null ? String(totalPages) : '1'; } if (part.fieldType === 'SECTIONPAGES') { - const count = sectionPageCount ?? totalPages ?? 1; + if (sectionPageCount == null) return part.text ?? '1'; + const count = sectionPageCount; return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); } return part.text; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index 4e3e9257cb..e1fbccbdbe 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -174,6 +174,18 @@ describe('svg-utils', () => { expect(span.textContent).toBe('III'); }); + it('should preserve SECTIONPAGES cached text when section page count is unavailable', () => { + const textContent = { + parts: [{ text: '3', fieldType: 'SECTIONPAGES', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + totalPages: 9, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3'); + }); + it('should resolve NUMPAGES field type to totalPages', () => { const textContent = { parts: [{ text: '', fieldType: 'NUMPAGES', formatting: {} }], From af3df9ffc0d1030531acf84afb4a53d7c28d6f00 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:01:29 -0300 Subject: [PATCH 50/63] fix(header-footer): avoid synthetic section count --- .../header-footer/HeaderFooterRegistry.test.ts | 16 ++++++++++++++++ .../core/header-footer/HeaderFooterRegistry.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) 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 8dc781806a..a8547737b1 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 @@ -219,6 +219,22 @@ describe('HeaderFooterEditorManager', () => { expect(host.children).toHaveLength(1); }); + it('does not synthesize section page count when creating a header/footer editor without section context', () => { + const editor = createMockEditor(); + const manager = new HeaderFooterEditorManager(editor); + const descriptor = { id: 'rId-header-default', kind: 'header' } as const; + const host = document.createElement('div'); + + const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host, totalPageCount: 9 }); + + expect(sectionEditor).toBeDefined(); + expect(mockCreateHeaderFooterEditor).toHaveBeenCalledTimes(1); + expect(mockCreateHeaderFooterEditor.mock.calls[0][0]).toMatchObject({ + totalPageCount: 9, + sectionPageCount: undefined, + }); + }); + it('ensureEditorSync reattaches the cached editor container to a new host', () => { const editor = createMockEditor(); const manager = new HeaderFooterEditorManager(editor); 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 8153735847..d5c35011ad 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 @@ -803,7 +803,7 @@ export class HeaderFooterEditorManager extends EventEmitter { currentPageNumberText: options?.currentPageNumberText, currentPageDisplayNumber: options?.currentPageDisplayNumber, totalPageCount: options?.totalPageCount ?? 1, - sectionPageCount: options?.sectionPageCount ?? options?.totalPageCount ?? 1, + sectionPageCount: options?.sectionPageCount, }) as Editor; } catch (error) { console.error('[HeaderFooterEditorManager] Editor creation failed:', error); From c66d87d56c0f18bbb7d9d3fef24b443d647a723f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:02:50 -0300 Subject: [PATCH 51/63] fix(layout-bridge): aggregate header metrics --- .../layout-bridge/src/layoutHeaderFooter.ts | 21 ++++++---- .../test/headerFooterLayout.test.ts | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index f4e9e34242..13e3e6cba8 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -389,6 +389,7 @@ export async function layoutHeaderFooterWithCache( blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; + layout: HeaderFooterLayout; numberText?: string; }> = []; @@ -430,21 +431,25 @@ export async function layoutHeaderFooterWithCache( blocks: clonedBlocks, measures, fragments: fragmentsWithLines, + layout: pageLayout, numberText: displayText, }); } // Construct final HeaderFooterLayout with all pages - // Use the first page's measurements for overall dimensions - const firstPageLayout = pages[0] - ? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints, kind) - : { height: 0, pages: [] }; + // Use the widest visual/measurement bounds from the page-specific layouts. + const pageLayouts = pages.map((page) => page.layout); + const minYValues = pageLayouts.map((layout) => layout.minY).filter((value): value is number => value !== undefined); + const maxYValues = pageLayouts.map((layout) => layout.maxY).filter((value): value is number => value !== undefined); + const minY = minYValues.length > 0 ? Math.min(...minYValues) : undefined; + const maxY = maxYValues.length > 0 ? Math.max(...maxYValues) : undefined; + const renderHeight = minY !== undefined && maxY !== undefined ? maxY - minY : undefined; const finalLayout: HeaderFooterLayout = { - height: firstPageLayout.height, - minY: firstPageLayout.minY, - maxY: firstPageLayout.maxY, - renderHeight: firstPageLayout.renderHeight, + height: pageLayouts.reduce((maxHeight, layout) => Math.max(maxHeight, layout.height), 0), + minY, + maxY, + renderHeight, pages: pages.map((p) => ({ number: p.number, displayNumber: p.displayNumber, diff --git a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts index 690f251187..997bdfe8f6 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts @@ -113,6 +113,45 @@ describe('layoutHeaderFooterWithCache', () => { expect(result.default?.layout.pages[1].measures).toHaveLength(1); }); + it('uses the largest page-specific metrics when section page counts change layout height', async () => { + const sections = { + default: [ + { + kind: 'paragraph', + id: 'section-pages-footer', + runs: [ + { text: 'Section pages ', fontFamily: 'Arial', fontSize: 16 }, + { text: '0', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 16 }, + ], + } satisfies FlowBlock, + ], + }; + const measureBlock = vi.fn(async (block: FlowBlock) => { + const sectionPageText = block.kind === 'paragraph' ? block.runs[1]?.text : undefined; + return makeMeasure(sectionPageText === '100' ? 36 : 12); + }); + + const result = await layoutHeaderFooterWithCache( + sections, + { width: 300, height: 80 }, + measureBlock, + undefined, + undefined, + (pageNumber) => ({ + displayText: String(pageNumber), + totalPages: 2, + sectionPageCount: pageNumber === 1 ? 1 : 100, + }), + 'footer', + ); + + expect(result.default?.layout.pages).toHaveLength(2); + expect(result.default?.layout.pages[0].measures?.[0]?.totalHeight).toBe(12); + expect(result.default?.layout.pages[1].measures?.[0]?.totalHeight).toBe(36); + expect(result.default?.layout.height).toBe(36); + expect(result.default?.layout.renderHeight).toBe(36); + }); + describe('integration test', () => { it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => { // 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter) From 2cbacf779cdb9cc579bbcc2b501b993dc076aaed Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:25:34 -0300 Subject: [PATCH 52/63] fix(super-converter): drop dead section alias --- .../editors/v1/core/super-converter/v2/importer/docxImporter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index c420abfdea..c402c61cf4 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -930,7 +930,6 @@ export function filterOutRootInlineNodes(content = []) { 'pageNumber', 'totalPageCount', 'section-page-count', - 'sectionPageCount', 'runItem', 'image', 'tab', From 15734ae1de53bf6bf60c72ce36469abb901418cb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:27:11 -0300 Subject: [PATCH 53/63] fix(painter): preserve section pages shape text --- .../painters/dom/src/index.test.ts | 16 ++++++++++++++++ .../layout-engine/painters/dom/src/renderer.ts | 3 ++- .../painters/dom/src/runs/text-run.test.ts | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 096cd8baba..8a84ef1a7e 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6591,6 +6591,22 @@ describe('DomPainter', () => { expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page V'); }); + it('preserves cached SECTIONPAGES drawing text when section context is unavailable', () => { + const painter = new DomPainter(); + const resolvePartText = ( + painter as unknown as { + resolveShapeTextPartText: ( + part: { text: string; fieldType: string; pageNumberFormat?: string }, + context: { pageNumber: number; totalPages: number; section: 'body' }, + ) => string; + } + ).resolveShapeTextPartText.bind(painter); + + expect( + resolvePartText({ text: '3', fieldType: 'SECTIONPAGES' }, { pageNumber: 1, totalPages: 9, section: 'body' }), + ).toBe('3'); + }); + describe('resolved paragraph rendering', () => { it('renders resolved paragraph lines with precomputed indent styles', () => { const paragraphBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 3ad808b973..41a4f0a693 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3125,7 +3125,8 @@ export class DomPainter { return String(context?.totalPages ?? 1); } if (part.fieldType === 'SECTIONPAGES') { - const sectionPageCount = context?.sectionPageCount ?? context?.totalPages ?? 1; + if (context?.sectionPageCount == null) return part.text ?? '1'; + const sectionPageCount = context.sectionPageCount; return part.pageNumberFormat ? formatPageNumber(sectionPageCount, part.pageNumberFormat) : String(sectionPageCount); diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts index 0647645ef8..edf03636bf 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,24 @@ describe('resolveRunText', () => { expect(resolveRunText(run, context)).toBe('V'); }); + it('uses section page count context for SECTIONPAGES tokens', () => { + const run: TextRun = { text: '0', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 12 }; + + expect(resolveRunText(run, { ...context, sectionPageCount: 7 })).toBe('7'); + }); + + it('formats SECTIONPAGES tokens with run-local page number format', () => { + const run: TextRun = { + text: '0', + token: 'sectionPageCount', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + }; + + expect(resolveRunText(run, { ...context, sectionPageCount: 7 })).toBe('VII'); + }); + it('changes merge signature when pageNumberFieldFormat changes', () => { const baseRun: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; const formattedRun: TextRun = { ...baseRun, pageNumberFieldFormat: { format: 'upperRoman' } }; From cb3994eb6fe903dc5b8cb9f88fd2c3292d18ec15 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:28:09 -0300 Subject: [PATCH 54/63] fix(fields): share section pages resolver --- .../helpers/section-page-count.ts | 16 ++++++++++++++ .../field-wrappers.section-pages.test.ts | 22 +++++++++++++++++-- .../plan-engine/field-wrappers.ts | 13 +---------- .../extensions/field-update/field-update.js | 12 +--------- 4 files changed, 38 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts new file mode 100644 index 0000000000..4f170dd700 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts @@ -0,0 +1,16 @@ +import { formatPageNumber, type PageNumberFormat } from '@superdoc/contracts'; +import type { Editor } from '../../core/Editor.js'; + +export function resolveSectionPageCountFieldValue( + editor: Editor, + node: { attrs?: Record }, +): string | null { + const sectionPageCount = editor.options?.sectionPageCount; + if (sectionPageCount == null) return null; + + const pageNumberFormat = node.attrs?.pageNumberFormat; + if (typeof pageNumberFormat === 'string' && pageNumberFormat) { + return formatPageNumber(Number(sectionPageCount) || 1, pageNumberFormat as PageNumberFormat); + } + return String(sectionPageCount); +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts index a488405163..2439c065a5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts @@ -33,9 +33,13 @@ const schema = new Schema({ }, }); -function createEditorWithSectionPageCount(sectionPageCount?: number, initialValue = '1'): Editor { +function createEditorWithSectionPageCount( + sectionPageCount?: number, + initialValue = '1', + pageNumberFormat?: string, +): Editor { const field = schema.nodes['section-page-count'].create( - { instruction: 'SECTIONPAGES', resolvedText: initialValue }, + { instruction: 'SECTIONPAGES', resolvedText: initialValue, pageNumberFormat }, schema.text(initialValue), ); const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); @@ -70,6 +74,20 @@ describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { expect(updatedField?.textContent).toBe('4'); }); + it('formats rebuilt section-page-count values with pageNumberFormat', () => { + const editor = createEditorWithSectionPageCount(4, '1', 'upperRoman'); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('section-page-count'); + expect(updatedField?.attrs.resolvedText).toBe('IV'); + expect(updatedField?.textContent).toBe('IV'); + }); + it('preserves existing section-page-count text when section page context is unavailable', () => { const editor = createEditorWithSectionPageCount(undefined, '3'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts index a98d150276..a782d90178 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts @@ -3,7 +3,6 @@ */ import type { Editor } from '../../core/Editor.js'; -import { formatPageNumber, type PageNumberFormat } from '@superdoc/contracts'; import type { FieldListInput, FieldGetInput, @@ -30,6 +29,7 @@ import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { DocumentApiAdapterError } from '../errors.js'; import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from '../helpers/word-statistics.js'; +import { resolveSectionPageCountFieldValue } from '../helpers/section-page-count.js'; // --------------------------------------------------------------------------- // Result helpers @@ -404,17 +404,6 @@ function rebuildSectionPageCount( return fieldSuccess(address); } -function resolveSectionPageCountFieldValue(editor: Editor, node: { attrs?: Record }): string | null { - const sectionPageCount = editor.options?.sectionPageCount; - if (sectionPageCount == null) return null; - - const pageNumberFormat = node.attrs?.pageNumberFormat; - if (typeof pageNumberFormat === 'string' && pageNumberFormat) { - return formatPageNumber(Number(sectionPageCount) || 1, pageNumberFormat as PageNumberFormat); - } - return String(sectionPageCount); -} - export function fieldsRemoveWrapper( editor: Editor, input: FieldRemoveInput, diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js index b0313af365..f2a1882981 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js @@ -6,7 +6,7 @@ import { resolveDocumentStatFieldValue, resolveMainBodyEditor, } from '../../document-api-adapters/helpers/word-statistics.js'; -import { formatPageNumber } from '@superdoc/contracts'; +import { resolveSectionPageCountFieldValue } from '../../document-api-adapters/helpers/section-page-count.js'; /** Stat-field types refreshed by F9 when the doc has no TOCs. */ const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES', 'SECTIONPAGES']); @@ -142,13 +142,3 @@ export const FieldUpdate = Extension.create({ }; }, }); - -function resolveSectionPageCountFieldValue(editor, node) { - const sectionPageCount = editor?.options?.sectionPageCount; - if (sectionPageCount == null) return null; - - if (node?.attrs?.pageNumberFormat) { - return formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat); - } - return String(sectionPageCount); -} From 1a3c0ce9851a3a79eb80cf37ac348527fd793866 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:28:53 -0300 Subject: [PATCH 55/63] fix(layout): trim dead section token branch --- .../test/resolveHeaderFooterTokens.test.ts | 29 +++++++++++++++++++ .../layout-engine/src/resolvePageTokens.ts | 15 +--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 35d26065cd..de12506771 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -138,6 +138,35 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[0] as TextRun).token).toBe('totalPageCount'); }); + it('should resolve formatted sectionPageCount token from section context', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'footer-section-pages', + runs: [ + { + text: 'Section pages: ', + fontFamily: 'Arial', + fontSize: 12, + }, + { + text: '0', + token: 'sectionPageCount', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 1, 99, '1', 1, 4); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[1].text).toBe('IV'); + expect((block.runs[1] as TextRun).token).toBe('sectionPageCount'); + }); + it('should resolve both tokens in the same block', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 10ba0bc520..d6b75ef7b8 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -272,12 +272,7 @@ function cloneBlockWithResolvedTokens( * } * ``` */ -export function resolveTokensInBlock( - block: ParagraphBlock, - pageNumber: number, - totalPages: number, - sectionPageCount: number = totalPages, -): boolean { +export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, totalPages: number): boolean { if (block.kind !== 'paragraph') { return false; } @@ -295,7 +290,6 @@ export function resolveTokensInBlock( const pageNumberStr = String(pageNumber); const totalPagesStr = String(totalPages); - const sectionPageCountStr = String(sectionPageCount); let blockModified = false; // Iterate through runs in the paragraph @@ -317,13 +311,6 @@ export function resolveTokensInBlock( // Clear token metadata to treat as normal text after resolution delete run.token; blockModified = true; - } else if (run.token === 'sectionPageCount') { - run.text = run.pageNumberFieldFormat - ? formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat) - : sectionPageCountStr; - delete run.token; - delete run.pageNumberFieldFormat; - blockModified = true; } // Note: pageReference tokens are handled by resolvePageRefs.ts } From ae9193678a36a738f308ae2b2353e826baa88379 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 12:29:34 -0300 Subject: [PATCH 56/63] test(header-footer): cover section count refresh --- .../HeaderFooterRegistry.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 a8547737b1..62f3c10be4 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 @@ -271,6 +271,46 @@ describe('HeaderFooterEditorManager', () => { expect(sectionPages.textContent).toBe('3'); }); + it('refreshes section page count DOM text when section context is available', () => { + const editor = createMockEditor(); + const manager = new HeaderFooterEditorManager(editor); + const descriptor = { id: 'rId-header-default', kind: 'header' } as const; + const host = document.createElement('div'); + + const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host }); + expect(sectionEditor).toBeDefined(); + const sectionPages = document.createElement('span'); + sectionPages.dataset.id = 'auto-section-pages'; + sectionPages.textContent = '3'; + sectionEditor!.view.dom.appendChild(sectionPages); + + manager.ensureEditorSync(descriptor, { editorHost: host, sectionPageCount: 5 }); + + expect(sectionPages.textContent).toBe('5'); + }); + + it('refreshes section page count DOM text with node pageNumberFormat', () => { + const editor = createMockEditor(); + const manager = new HeaderFooterEditorManager(editor); + const descriptor = { id: 'rId-header-default', kind: 'header' } as const; + const host = document.createElement('div'); + + const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host }); + expect(sectionEditor).toBeDefined(); + const sectionPages = document.createElement('span'); + sectionPages.dataset.id = 'auto-section-pages'; + sectionPages.textContent = '3'; + sectionEditor!.view.dom.appendChild(sectionPages); + (sectionEditor!.view as unknown as { posAtDOM: ReturnType }).posAtDOM = vi.fn(() => 0); + (sectionEditor as unknown as { state: { doc: { nodeAt: ReturnType } } }).state = { + doc: { nodeAt: vi.fn(() => ({ attrs: { pageNumberFormat: 'upperRoman' } })) }, + }; + + manager.ensureEditorSync(descriptor, { editorHost: host, sectionPageCount: 4 }); + + expect(sectionPages.textContent).toBe('IV'); + }); + it('emits contentChanged and syncs converter/Yjs data when section editor updates', async () => { const editor = createMockEditor(); const manager = new HeaderFooterEditorManager(editor); From 4e7a4fe87d7407ff3e336d6e1d4114ffc101d91f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 14:07:56 -0300 Subject: [PATCH 57/63] chore(contracts): bump layout contracts version --- packages/layout-engine/contracts/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 24d0db6862..118679ae36 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -241,7 +241,7 @@ export type SdtMetadata = | DocumentSectionMetadata | DocPartMetadata; -export const CONTRACTS_VERSION = '1.0.0'; +export const CONTRACTS_VERSION = '1.1.0'; /** Unique identifier for a block in the document. Format: `${pos}-${type}`. */ export type BlockId = string; From 49f556998797ecce7c575e9e24039334fa1d985b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 14:10:00 -0300 Subject: [PATCH 58/63] fix(painter): preserve cached SECTIONPAGES fallback --- .../layout-engine/painters/dom/src/runs/text-run.test.ts | 6 ++++++ packages/layout-engine/painters/dom/src/runs/text-run.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 edf03636bf..f55d47fd2b 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 @@ -37,6 +37,12 @@ describe('resolveRunText', () => { expect(resolveRunText(run, { ...context, sectionPageCount: 7 })).toBe('7'); }); + it('preserves cached SECTIONPAGES text when section page count context is missing', () => { + const run: TextRun = { text: '42', token: 'sectionPageCount', fontFamily: 'Arial', fontSize: 12 }; + + expect(resolveRunText(run, context)).toBe('42'); + }); + it('formats SECTIONPAGES tokens with run-local page number format', () => { const run: TextRun = { text: '0', 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 f45cbf9ea3..4faea2a5c4 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -153,7 +153,10 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string return context.totalPages ? String(context.totalPages) : (run.text ?? ''); } if (runToken === 'sectionPageCount') { - const sectionPageCount = context.sectionPageCount ?? context.totalPages ?? 1; + const sectionPageCount = context.sectionPageCount; + if (sectionPageCount == null) { + return run.text ?? ''; + } if (run.pageNumberFieldFormat) { return formatPageNumberFieldValue(sectionPageCount, run.pageNumberFieldFormat); } From a99587802a4ade1d1a76ffd602d09c3ac12ce265 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 10:43:46 -0300 Subject: [PATCH 59/63] fix(painter): use correct display page number for shape PAGE fields --- packages/layout-engine/painters/dom/src/renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 41a4f0a693..fecbfeed79 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3117,7 +3117,7 @@ export class DomPainter { private resolveShapeTextPartText(part: ShapeTextContent['parts'][number], context?: FragmentRenderContext): string { if (part.fieldType === 'PAGE') { if (part.pageNumberFormat) { - return formatPageNumber(context?.pageNumberDisplayNumber ?? context?.pageNumber ?? 1, part.pageNumberFormat); + return formatPageNumber(context?.displayPageNumber ?? context?.pageNumber ?? 1, part.pageNumberFormat); } return context?.pageNumberText ?? String(context?.pageNumber ?? 1); } From e2a88d6e32919049ab999ff7e6d2e02a5d365ef4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 10:50:19 -0300 Subject: [PATCH 60/63] refactor(fields): accept generic options in SECTIONPAGES preprocessor Replace the third positional `_docxOrFieldRunRPr` argument with a generic field-preprocessing `options` object (docx, instructionTokens, fieldRunRPr), keeping backward-compat with the legacy positional w:rPr. Update cache-invalidation test fixtures to the `pageNumberFieldFormat` run shape. --- .../layout-bridge/test/cacheInvalidation.test.ts | 4 ++-- .../fld-preprocessors/section-pages-preprocessor.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts index fb3398face..72d8eb5f2a 100644 --- a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts +++ b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts @@ -57,14 +57,14 @@ describe('Cache Invalidation', () => { { kind: 'paragraph', id: 'p1', - runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }], + runs: [{ text: '0', token: 'pageNumber', pageNumberFieldFormat: { format: 'decimal' } }], } as ParagraphBlock, ]; const romanBlocks: FlowBlock[] = [ { kind: 'paragraph', id: 'p1', - runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }], + runs: [{ text: '0', token: 'pageNumber', pageNumberFieldFormat: { format: 'upperRoman' } }], } as ParagraphBlock, ]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js index 681649e142..316e537fdd 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js @@ -5,7 +5,7 @@ import { parsePageInstruction } from './page-instruction.js'; * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [instrText] The SECTIONPAGES instruction text. - * @param {unknown} [_docxOrFieldRunRPr] The parsed docx in the main import path, or w:rPr in header/footer-only preprocessing. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null } | import('../../v2/types/index.js').OpenXmlNode | null} [options] Generic field preprocessing options, or legacy positional w:rPr. * @param {Array<{type: string, text?: string}> | null} [_instructionTokens] Raw instruction tokens. * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} @@ -13,11 +13,11 @@ import { parsePageInstruction } from './page-instruction.js'; export function preProcessSectionPagesInstruction( nodesToCombine, instrText = '', - _docxOrFieldRunRPr = null, + options = null, _instructionTokens, fieldRunRPr = null, ) { - const effectiveFieldRunRPr = fieldRunRPr ?? (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); + const effectiveFieldRunRPr = fieldRunRPr ?? options?.fieldRunRPr ?? (options?.name === 'w:rPr' ? options : null); const parsedInstruction = parsePageInstruction(instrText, 'SECTIONPAGES'); const sectionPageCountNode = { name: 'sd:sectionPageCount', From 1de645bf2f3fa9509c932ca48bc193c938b2481d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 21:58:03 -0300 Subject: [PATCH 61/63] feat(page-number): chapter-prefixed section page numbering (SD-3029) (#3620) * feat(sections): add chapter numbering to section page numbering Extend section page numbering with `chapterStyle` (w:chapStyle) and `chapterSeparator` (w:chapSep) support across the stack: - document-api: add fields to the setPageNumbering contract (anyOf now accepts chapterStyle/chapterSeparator), schemas, types, and validation - layout contracts: extend SectionNumbering with chapter fields and reuse PageNumberFormat/PageNumberChapterSeparator types - layout-adapter: extract w:chapStyle/w:chapSep from pgNumType, validate positive integers and known separators, and compare them in section signatures - sections-xml helpers: read/write chapter attributes on w:pgNumType - regenerate Document API reference docs * feat(layout): render chapter-prefixed page numbers Resolve and paint chapter number prefixes (e.g. "1-1", "3:V") for sections that enable chapter numbering, deriving the chapter from the nearest numbered Heading N marker. - contracts: add formatSectionPageNumberText helper, chapter/format fields on Page and HeaderFooterPage, and headingLevel/listLevelOrdinal paragraph attrs - pageNumbering: add buildChapterContextByPage to track the active chapter per physical page, normalizeChapterMarkerText to accept only clean single-token markers, and thread chapter context through computeDisplayPageNumber - layout-bridge: build/cache chapter context across PAGE-token convergence, apply it to body pages, and disable header/footer digit bucketing when chapter prefixes can vary the rendered width - resolvePageTokens / text-run / renderer: preserve the chapter prefix when a run-local PAGE format switch applies - layout-adapter: resolve built-in heading level from style metadata (incl. localized names) and expose the structured list ordinal * fix(page-number): fall back chapter headings * fix(page-number): use no-break chapter hyphen * fix(page-number): clear stale chapter children * fix(layout): stamp section page format * fix(page-number): accept nested chapter markers * fix(layout): converge page tokens by output * fix(header-footer): disable buckets for restarts * fix(page-number): preserve body token metadata * fix(header-footer): pass per-rid chapter context * fix(layout): key chapter cache by fragments * fix(page-number): allow safe chapter separators * fix(header-footer): measure chapter tokens in prelayout * fix(header-footer): preserve chapter page prefix in editor * fix(header-footer): clear stale chapter editor context * fix(header-footer): prelayout resolved headings * fix(header-footer): prelayout heading ordinal fallback * fix(header-footer): prelayout two digit page tokens * fix(layout-bridge): keep numpages headers bucketed --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- .../document-api/reference/sections/get.mdx | 16 + .../document-api/reference/sections/list.mdx | 14 + .../reference/sections/set-page-numbering.mdx | 52 ++- .../src/contract/operation-definitions.ts | 4 +- packages/document-api/src/contract/schemas.ts | 11 +- packages/document-api/src/index.ts | 1 + .../src/sections/sections.test.ts | 52 ++- .../document-api/src/sections/sections.ts | 21 +- .../src/sections/sections.types.ts | 6 + .../document-api/src/types/sd-sections.ts | 2 + packages/layout-engine/contracts/src/index.ts | 36 +- .../contracts/src/page-number-formatting.ts | 42 +++ .../contracts/src/resolved-layout.ts | 14 + .../layout-bridge/src/incrementalLayout.ts | 325 ++++++++++++++-- .../layout-bridge/src/layoutHeaderFooter.ts | 113 +++++- .../src/resolveHeaderFooterTokens.ts | 32 +- .../test/headerFooterPrelayout.test.ts | 208 ++++++++++ .../incrementalLayout.semanticFlow.test.ts | 29 ++ .../test/layoutHeaderFooterBucketing.test.ts | 85 +++++ .../test/pageTokenConvergence.test.ts | 139 +++++++ .../test/resolveHeaderFooterTokens.test.ts | 24 ++ .../layout-engine/layout-engine/src/index.ts | 11 +- .../layout-engine/src/pageNumbering.test.ts | 356 +++++++++++++++++- .../layout-engine/src/pageNumbering.ts | 231 +++++++++++- .../src/resolvePageNumberTokens.test.ts | 46 ++- .../layout-engine/src/resolvePageTokens.ts | 79 ++-- .../src/resolveHeaderFooter.ts | 3 + .../layout-resolved/src/resolveLayout.ts | 3 + .../painters/dom/src/renderer.ts | 30 +- .../painters/dom/src/runs/text-run.test.ts | 20 + .../painters/dom/src/runs/text-run.ts | 16 +- .../HeaderFooterPerRidLayout.test.ts | 55 +++ .../header-footer/HeaderFooterPerRidLayout.ts | 23 +- .../HeaderFooterRegistry.test.ts | 61 +++ .../header-footer/HeaderFooterRegistry.ts | 50 ++- .../editors/v1/core/header-footer/types.ts | 6 + .../attributes/paragraph.test.ts | 47 +++ .../layout-adapter/attributes/paragraph.ts | 44 +++ .../layout-adapter/sections/analysis.test.ts | 4 +- .../layout-adapter/sections/breaks.test.ts | 40 ++ .../v1/core/layout-adapter/sections/breaks.ts | 4 +- .../sections/extraction.test.ts | 65 ++++ .../layout-adapter/sections/extraction.ts | 41 +- .../v1/core/layout-adapter/sections/types.ts | 12 +- .../presentation-editor/PresentationEditor.ts | 4 + .../HeaderFooterSessionManager.ts | 8 + .../story-session/types.ts | 2 + .../editors/v1/core/story-editor-factory.ts | 14 + .../src/editors/v1/core/types/EditorConfig.ts | 6 + .../helpers/sections-xml.test.ts | 70 ++++ .../helpers/sections-xml.ts | 28 +- .../v1/extensions/page-number/page-number.js | 13 +- .../page-number/page-number.test.js | 25 ++ .../pagination/pagination-helpers.js | 6 + 56 files changed, 2516 insertions(+), 137 deletions(-) create mode 100644 packages/layout-engine/layout-bridge/test/headerFooterPrelayout.test.ts create mode 100644 packages/layout-engine/layout-bridge/test/pageTokenConvergence.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/sections-xml.test.ts 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, From 0a2aea61eeef19d73ef8b8b003b7c240494a9755 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 3 Jun 2026 19:28:11 -0700 Subject: [PATCH 62/63] chore: fixes --- .../layout-bridge/src/layoutHeaderFooter.ts | 7 +- .../test/layoutHeaderFooterBucketing.test.ts | 30 +++++++ .../section-pages-preprocessor.js | 11 ++- .../section-pages-preprocessor.test.js | 41 +++++----- .../shared/page-number-field-switches.js | 2 +- .../shared/page-number-field-switches.test.js | 8 ++ .../sectionPageCount-translator.js | 13 ++- .../sectionPageCount-translator.test.js | 28 +++++-- .../helpers/section-page-count.ts | 19 ++++- .../field-wrappers.section-pages.test.ts | 79 ++++++++++++++++++- .../plan-engine/field-wrappers.ts | 54 +++++++++++++ .../field-update/field-update.test.js | 39 +++++++++ .../v1/extensions/page-number/page-number.js | 19 ++++- .../page-number/page-number.test.js | 19 +++++ .../extensions/shape-group/ShapeGroupView.js | 12 +++ .../editors/v1/extensions/shared/svg-utils.js | 15 +++- .../v1/extensions/shared/svg-utils.test.js | 16 ++++ .../vector-shape/VectorShapeView.js | 4 + 18 files changed, 377 insertions(+), 39 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 1f973c2982..46e06efa13 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -10,6 +10,7 @@ import type { TableBlock, TextRun, } from '@superdoc/contracts'; +import { formatChapterPageNumberText } from '@superdoc/contracts'; import { formatPageNumberFieldValue, layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine'; import { MeasureCache } from './cache'; import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens'; @@ -217,7 +218,11 @@ function canUseDigitBucketingForVariant( const renderedText = strategy.kind === 'fieldFormat' ? Number.isFinite(pageInfo.displayNumber) - ? formatPageNumberFieldValue(pageInfo.displayNumber ?? pageNumber, strategy.fieldFormat) + ? formatChapterPageNumberText({ + pageComponent: formatPageNumberFieldValue(pageInfo.displayNumber ?? pageNumber, strategy.fieldFormat), + chapterNumberText: pageInfo.chapterNumberText, + chapterSeparator: pageInfo.chapterSeparator, + }) : null : pageInfo.displayText; return renderedText ? getBucketForRenderedPageNumberText(renderedText) : null; diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 881fd5c93c..f0541a8582 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -569,6 +569,36 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(result.default?.layout.pages).toHaveLength(3); expect(measureBlock).toHaveBeenCalledTimes(3); }); + + it('should disable bucketing for chapter-prefixed run-local page number formats', async () => { + const sections = { + default: [makeFormattedPageTokenBlock('header-decimal-chapter', { format: 'decimal' })], + }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: pageNum < 75 ? `1-${pageNum}` : `12-${pageNum}`, + displayNumber: pageNum, + totalPages: 150, + pageFormat: 'decimal', + chapterNumberText: pageNum < 75 ? '1' : '12', + chapterSeparator: 'hyphen', + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + sections, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(150); + expect(measureBlock).toHaveBeenCalledTimes(150); + expect(result.default?.layout.pages[0].numberText).toBe('1-1'); + expect(result.default?.layout.pages[100].numberText).toBe('12-101'); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js index 316e537fdd..adbeee7d83 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.js @@ -1,4 +1,4 @@ -import { parsePageInstruction } from './page-instruction.js'; +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; /** * Processes a SECTIONPAGES instruction and creates a `sd:sectionPageCount` node. @@ -18,13 +18,18 @@ export function preProcessSectionPagesInstruction( fieldRunRPr = null, ) { const effectiveFieldRunRPr = fieldRunRPr ?? options?.fieldRunRPr ?? (options?.name === 'w:rPr' ? options : null); - const parsedInstruction = parsePageInstruction(instrText, 'SECTIONPAGES'); + const normalizedInstruction = + typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'SECTIONPAGES'; + const parsedInstruction = parsePageNumberFieldSwitches(normalizedInstruction, 'SECTIONPAGES'); const sectionPageCountNode = { name: 'sd:sectionPageCount', type: 'element', attributes: { - instruction: parsedInstruction.instruction, + instruction: normalizedInstruction, ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}), + ...(parsedInstruction.pageNumberZeroPadding != null + ? { pageNumberZeroPadding: parsedInstruction.pageNumberZeroPadding } + : {}), }, }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js index f8b25569db..610f319444 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/section-pages-preprocessor.test.js @@ -4,25 +4,30 @@ import { preProcessSectionPagesInstruction } from './section-pages-preprocessor. describe('preProcessSectionPagesInstruction', () => { it.each([ - ['SECTIONPAGES', undefined], - ['sectionpages', undefined], - ['SectionPages', undefined], - ['SECTIONPAGES \\* roman', 'lowerRoman'], - ['SECTIONPAGES \\* Roman \\* MERGEFORMAT', 'upperRoman'], - ['SECTIONPAGES \\* Unsupported \\* MERGEFORMAT', undefined], - ])('creates sd:sectionPageCount and parses supported value format: %s', (instruction, pageNumberFormat) => { - const result = preProcessSectionPagesInstruction([], instruction, null); + ['SECTIONPAGES', undefined, undefined], + ['sectionpages', undefined, undefined], + ['SectionPages', undefined, undefined], + ['SECTIONPAGES \\* roman', 'lowerRoman', undefined], + ['SECTIONPAGES \\* Roman \\* MERGEFORMAT', 'upperRoman', undefined], + ['SECTIONPAGES \\# "000"', 'decimal', 3], + ['SECTIONPAGES \\* Unsupported \\* MERGEFORMAT', undefined, undefined], + ])( + 'creates sd:sectionPageCount and parses supported value format: %s', + (instruction, pageNumberFormat, pageNumberZeroPadding) => { + const result = preProcessSectionPagesInstruction([], instruction, null); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: 'sd:sectionPageCount', - type: 'element', - attributes: { - instruction, - ...(pageNumberFormat ? { pageNumberFormat } : {}), - }, - }); - }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'sd:sectionPageCount', + type: 'element', + attributes: { + instruction: instruction.trim().replace(/\s+/g, ' '), + ...(pageNumberFormat ? { pageNumberFormat } : {}), + ...(pageNumberZeroPadding != null ? { pageNumberZeroPadding } : {}), + }, + }); + }, + ); it('preserves cached text and content run styling', () => { const rPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index 969ea83a9b..c006d15e9c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -17,7 +17,7 @@ const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ /** * @param {string} instruction - * @param {'PAGE' | 'NUMPAGES'} fieldType + * @param {'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'} fieldType * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }} */ export function parsePageNumberFieldSwitches(instruction, fieldType) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js index 3c4a11b96f..287723b79f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -32,6 +32,14 @@ describe('parsePageNumberFieldSwitches', () => { expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); }); + it('parses SECTIONPAGES zero-padding picture switches', () => { + expect(parsePageNumberFieldSwitches('SECTIONPAGES \\# "000"', 'SECTIONPAGES')).toEqual({ + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + }); + }); + it('preserves unsupported switched instructions without format metadata', () => { expect(parsePageNumberFieldSwitches('PAGE \\* OrdText', 'PAGE')).toEqual({ instruction: 'PAGE \\* OrdText' }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js index ad15497113..756e0a28e7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.js @@ -35,6 +35,9 @@ const encode = (params) => { if (typeof node.attributes?.pageNumberFormat === 'string') { processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat; } + if (node.attributes?.pageNumberZeroPadding != null) { + processedNode.attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } if (node.attributes?.importedCachedText) { processedNode.attrs.importedCachedText = node.attributes.importedCachedText; } @@ -69,10 +72,18 @@ function getSectionPagesInstructionText(attrs = {}) { if (typeof attrs.pageNumberFormat === 'string') { const instructionSwitch = pageNumberFormatToInstructionSwitch(attrs.pageNumberFormat); if (instructionSwitch) { - return `SECTIONPAGES \\* ${instructionSwitch}`; + const numericPicture = + typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0 + ? ` \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}` + : ''; + return `SECTIONPAGES \\* ${instructionSwitch}${numericPicture}`; } } + if (typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0) { + return `SECTIONPAGES \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}`; + } + return 'SECTIONPAGES'; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js index 0787a0d07b..b2861ceaf2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sectionPageCount/sectionPageCount-translator.test.js @@ -25,7 +25,7 @@ describe('sd:sectionPageCount translator', () => { expect(translator).toBeInstanceOf(NodeTranslator); }); - it('encodes sd:sectionPageCount with marks, instruction, cached text, and pageNumberFormat', () => { + it('encodes sd:sectionPageCount with marks, instruction, cached text, and page-number formatting attrs', () => { const marks = [{ type: 'textStyle', attrs: { fontSize: '12pt' } }]; vi.mocked(parseMarks).mockReturnValue(marks); @@ -34,8 +34,9 @@ describe('sd:sectionPageCount translator', () => { { name: 'sd:sectionPageCount', attributes: { - instruction: 'SECTIONPAGES \\* roman', - pageNumberFormat: 'lowerRoman', + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, importedCachedText: 'iv', }, elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], @@ -47,8 +48,9 @@ describe('sd:sectionPageCount translator', () => { type: 'section-page-count', attrs: { marksAsAttrs: marks, - instruction: 'SECTIONPAGES \\* roman', - pageNumberFormat: 'lowerRoman', + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, importedCachedText: 'iv', }, }); @@ -88,6 +90,22 @@ describe('sd:sectionPageCount translator', () => { expect(result[3].elements[1].elements[0].text).toBe('iii'); }); + it('synthesizes SECTIONPAGES numeric picture switches when only zero-padding attrs are present', () => { + const result = config.decode({ + node: { + type: 'section-page-count', + attrs: { + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + resolvedText: '007', + }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' SECTIONPAGES \\* Arabic \\# 000'); + expect(result[3].elements[1].elements[0].text).toBe('007'); + }); + it('falls back to plain SECTIONPAGES without instruction or supported format', () => { const result = config.decode({ node: { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts index 4f170dd700..98c8ee7c9b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/section-page-count.ts @@ -1,4 +1,4 @@ -import { formatPageNumber, type PageNumberFormat } from '@superdoc/contracts'; +import { formatPageNumberFieldValue, type PageNumberFieldFormat } from '@superdoc/contracts'; import type { Editor } from '../../core/Editor.js'; export function resolveSectionPageCountFieldValue( @@ -8,9 +8,20 @@ export function resolveSectionPageCountFieldValue( const sectionPageCount = editor.options?.sectionPageCount; if (sectionPageCount == null) return null; - const pageNumberFormat = node.attrs?.pageNumberFormat; - if (typeof pageNumberFormat === 'string' && pageNumberFormat) { - return formatPageNumber(Number(sectionPageCount) || 1, pageNumberFormat as PageNumberFormat); + const pageNumberFormat = + typeof node.attrs?.pageNumberFormat === 'string' && node.attrs.pageNumberFormat + ? node.attrs.pageNumberFormat + : undefined; + const pageNumberZeroPadding = + typeof node.attrs?.pageNumberZeroPadding === 'number' && Number.isFinite(node.attrs.pageNumberZeroPadding) + ? node.attrs.pageNumberZeroPadding + : undefined; + + if (pageNumberFormat || pageNumberZeroPadding != null) { + return formatPageNumberFieldValue(Number(sectionPageCount) || 1, { + ...(pageNumberFormat ? { format: pageNumberFormat as PageNumberFieldFormat['format'] } : {}), + ...(pageNumberZeroPadding != null ? { zeroPadding: pageNumberZeroPadding } : {}), + }); } return String(sectionPageCount); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts index 2439c065a5..15c8f01e17 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts @@ -3,7 +3,7 @@ import { EditorState } from 'prosemirror-state'; import { describe, expect, it } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { registerBuiltInExecutors } from './register-executors.js'; -import { fieldsRebuildWrapper } from './field-wrappers.js'; +import { fieldsInsertWrapper, fieldsRebuildWrapper } from './field-wrappers.js'; registerBuiltInExecutors(); @@ -17,6 +17,19 @@ const schema = new Schema({ toDOM: () => ['p', 0], }, text: { group: 'inline' }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: null }, + identifier: { default: null }, + format: { default: null }, + resolvedNumber: { default: null }, + sdBlockId: { default: null }, + }, + toDOM: () => ['span', 0], + }, 'section-page-count': { group: 'inline', inline: true, @@ -27,6 +40,7 @@ const schema = new Schema({ importedCachedText: { default: null }, resolvedText: { default: null }, pageNumberFormat: { default: null }, + pageNumberZeroPadding: { default: null }, }, toDOM: () => ['span', 0], }, @@ -59,7 +73,46 @@ function createEditorWithSectionPageCount( return editor as unknown as Editor; } +function createEditorForInsert(sectionPageCount?: number): Editor { + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, schema.text('x')); + const doc = schema.nodes.doc.create(null, paragraph); + const options = sectionPageCount == null ? {} : { sectionPageCount }; + + const editor = { + schema, + state: EditorState.create({ schema, doc }), + options, + view: { dispatch: () => {} }, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; + + return editor as unknown as Editor; +} + describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { + it('inserts SECTIONPAGES as a section-page-count node with parsed formatting attrs', () => { + const editor = createEditorForInsert(7); + + const result = fieldsInsertWrapper(editor, { + mode: 'raw', + instruction: 'SECTIONPAGES \\# "000"', + at: { kind: 'text', segments: [{ blockId: 'block-1', range: { start: 0, end: 0 } }] }, + }); + + expect(result.success).toBe(true); + const insertedField = editor.state.doc.nodeAt(1); + expect(insertedField?.type.name).toBe('section-page-count'); + expect(insertedField?.attrs).toMatchObject({ + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + resolvedText: '007', + }); + expect(insertedField?.textContent).toBe('007'); + }); + it('updates section-page-count text content and resolvedText from editor section page count', () => { const editor = createEditorWithSectionPageCount(4); @@ -88,6 +141,30 @@ describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { expect(updatedField?.textContent).toBe('IV'); }); + it('formats rebuilt section-page-count values with zero-padding picture switches', () => { + const editor = createEditorWithSectionPageCount(4, '1'); + const field = editor.state.doc.nodeAt(1); + const currentAttrs = field?.attrs ?? {}; + const { tr } = editor.state; + tr.setNodeMarkup(1, undefined, { + ...currentAttrs, + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + }); + editor.dispatch(tr); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('section-page-count'); + expect(updatedField?.attrs.resolvedText).toBe('004'); + expect(updatedField?.textContent).toBe('004'); + }); + it('preserves existing section-page-count text when section page context is unavailable', () => { const editor = createEditorWithSectionPageCount(undefined, '3'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts index a782d90178..4f597a432f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts @@ -30,6 +30,7 @@ import { clearIndexCache } from '../helpers/index-cache.js'; import { DocumentApiAdapterError } from '../errors.js'; import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from '../helpers/word-statistics.js'; import { resolveSectionPageCountFieldValue } from '../helpers/section-page-count.js'; +import { parsePageNumberFieldSwitches } from '../../core/super-converter/field-references/shared/page-number-field-switches.js'; // --------------------------------------------------------------------------- // Result helpers @@ -112,6 +113,10 @@ export function fieldsInsertWrapper( return insertNumPagesField(editor, resolved, options); } + if (fieldType === 'SECTIONPAGES') { + return insertSectionPagesField(editor, input, resolved, options); + } + return insertRawField(editor, input, resolved, options); } @@ -193,6 +198,55 @@ function insertNumPagesField( return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from)); } +function insertSectionPagesField( + editor: Editor, + input: FieldInsertInput, + resolved: { from: number }, + options?: MutationOptions, +): FieldMutationResult { + const nodeType = editor.schema.nodes['section-page-count']; + if (!nodeType) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'fields.insert: section-page-count node type not in schema.', + ); + } + + const normalizedInstruction = input.instruction.trim().replace(/\s+/g, ' '); + const parsedInstruction = parsePageNumberFieldSwitches(normalizedInstruction, 'SECTIONPAGES'); + const initialAttrs = { + instruction: normalizedInstruction, + ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}), + ...(parsedInstruction.pageNumberZeroPadding != null + ? { pageNumberZeroPadding: parsedInstruction.pageNumberZeroPadding } + : {}), + }; + const initialValue = resolveSectionPageCountFieldValue(editor, { attrs: initialAttrs }) ?? ''; + + const receipt = executeDomainCommand( + editor, + (): boolean => { + const textChild = initialValue ? editor.schema.text(initialValue) : null; + const node = nodeType.create( + { + ...initialAttrs, + ...(initialValue ? { resolvedText: initialValue } : {}), + }, + textChild, + ); + const { tr } = editor.state; + tr.insert(resolved.from, node); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (!receiptApplied(receipt)) return fieldFailure('NO_OP', 'Insert produced no change.'); + return fieldSuccess(computeFieldAddress(editor.state.doc, resolved.from)); +} + function insertRawField( editor: Editor, input: FieldInsertInput, diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js index c3e0d76552..cdda0f7eea 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js @@ -304,6 +304,7 @@ const mixedSchema = new Schema({ importedCachedText: { default: null }, resolvedText: { default: null }, pageNumberFormat: { default: null }, + pageNumberZeroPadding: { default: null }, }, toDOM: () => ['span', 0], }, @@ -390,6 +391,44 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)', expect(updatedField.textContent).toBe('4'); }); + it('updates SECTIONPAGES zero-padded fields from the current header/footer section page count', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const sectionPageCountField = mixedSchema.nodes['section-page-count'].create( + { + instruction: 'SECTIONPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + resolvedText: '001', + }, + mixedSchema.text('001'), + ); + const doc = mixedSchema.nodes.doc.create({}, [para([sectionPageCountField])]); + const editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + options: { sectionPageCount: 4 }, + state: editorState, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const outerTr = editorState.tr; + const dispatch = vi.fn(); + const state = { + doc, + selection: { from: 0, to: doc.content.size }, + schema: mixedSchema, + tr: outerTr, + }; + + const result = command({ editor, state, tr: outerTr, dispatch }); + + expect(result).toBe(true); + const updatedDoc = dispatch.mock.calls[0][0].doc; + const updatedField = updatedDoc.nodeAt(1); + expect(updatedField.attrs.resolvedText).toBe('004'); + expect(updatedField.textContent).toBe('004'); + }); + it('leaves SECTIONPAGES fields unchanged when section page context is unavailable', () => { const para = (children) => mixedSchema.nodes.paragraph.create({}, children); const sectionPageCountField = mixedSchema.nodes['section-page-count'].create( 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 aa194e3c25..0641287272 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -1,7 +1,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { isHeadless } from '@utils/headless-helpers.js'; -import { formatPageNumber, formatSectionPageNumberText } from '@superdoc/contracts'; +import { formatPageNumberFieldValue, formatSectionPageNumberText } from '@superdoc/contracts'; /** * Configuration options for PageNumber * @typedef {Object} PageNumberOptions @@ -311,6 +311,10 @@ export const SectionPageCount = Node.create({ default: null, rendered: false, }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, }; }, @@ -390,10 +394,19 @@ const getNodeAttributes = (nodeName, editor, node = null) => { case 'section-page-count': { const sectionPageCount = editor.options.sectionPageCount; const cachedText = node?.attrs?.resolvedText ?? node?.attrs?.importedCachedText ?? node?.textContent ?? '1'; + const pageNumberFormat = + typeof node?.attrs?.pageNumberFormat === 'string' ? node.attrs.pageNumberFormat : undefined; + const pageNumberZeroPadding = + typeof node?.attrs?.pageNumberZeroPadding === 'number' && Number.isFinite(node.attrs.pageNumberZeroPadding) + ? node.attrs.pageNumberZeroPadding + : undefined; const text = sectionPageCount != null - ? node?.attrs?.pageNumberFormat - ? formatPageNumber(Number(sectionPageCount) || 1, node.attrs.pageNumberFormat) + ? pageNumberFormat || pageNumberZeroPadding != null + ? formatPageNumberFieldValue(Number(sectionPageCount) || 1, { + ...(pageNumberFormat ? { format: pageNumberFormat } : {}), + ...(pageNumberZeroPadding != null ? { zeroPadding: pageNumberZeroPadding } : {}), + }) : sectionPageCount : cachedText; return { 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 60d0c41a8a..bd2ea400ef 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js @@ -339,6 +339,25 @@ describe('AutoPageNumberNodeView', () => { expect(nodeView.dom.getAttribute('data-id')).toBe('auto-section-pages'); }); + it('renders zero-padded section page count node', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { sectionPageCount: 4, totalPageCount: 9 }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'section-page-count' }, attrs: { pageNumberZeroPadding: 3 } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('004'); + }); + it('renders imported SECTIONPAGES cached text when section page context is unavailable', () => { const doc = { resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), diff --git a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js index 13a2f26d5b..6cf05348f0 100644 --- a/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/editors/v1/extensions/shape-group/ShapeGroupView.js @@ -284,6 +284,8 @@ export class ShapeGroupView { const pageNumber = this.editor?.options?.currentPageNumber; const pageNumberText = this.editor?.options?.currentPageNumberText; const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; + const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText; + const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { @@ -292,6 +294,8 @@ export class ShapeGroupView { pageNumber, pageNumberText, pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, sectionPageCount, }); @@ -365,6 +369,8 @@ export class ShapeGroupView { const pageNumber = this.editor?.options?.currentPageNumber; const pageNumberText = this.editor?.options?.currentPageNumberText; const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; + const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText; + const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { @@ -373,6 +379,8 @@ export class ShapeGroupView { pageNumber, pageNumberText, pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, sectionPageCount, }); @@ -497,6 +505,8 @@ export class ShapeGroupView { const pageNumber = this.editor?.options?.currentPageNumber; const pageNumberText = this.editor?.options?.currentPageNumberText; const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; + const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText; + const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textGroup = this.createTextElement(attrs.textContent, attrs.textAlign, width, height, { @@ -505,6 +515,8 @@ export class ShapeGroupView { pageNumber, pageNumberText, pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, sectionPageCount, }); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index b1e0faf936..8ed9faf3a8 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -1,4 +1,4 @@ -import { formatPageNumber } from '@superdoc/contracts'; +import { formatChapterPageNumberText, formatPageNumber } from '@superdoc/contracts'; /** * Shared utility functions for SVG shape rendering @@ -82,6 +82,8 @@ export function createGradient(gradientData, gradientId) { * @param {number} [options.pageNumber] - Current page number for PAGE field resolution * @param {string} [options.pageNumberText] - Current formatted PAGE display text * @param {number} [options.pageNumberDisplayNumber] - Current numeric PAGE display value for local field formatting + * @param {string} [options.pageNumberChapterText] - Current chapter prefix text for section-aware PAGE display + * @param {string} [options.pageNumberChapterSeparator] - Current chapter separator for section-aware PAGE display * @param {number} [options.totalPages] - Total page count for NUMPAGES field resolution * @param {number} [options.sectionPageCount] - Current section page count for SECTIONPAGES field resolution * @returns {SVGForeignObjectElement} The created foreignObject element containing the formatted text @@ -93,6 +95,8 @@ export function createTextElement(textContent, textAlign, width, height, options pageNumber, pageNumberText, pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, sectionPageCount, } = options; @@ -145,7 +149,14 @@ export function createTextElement(textContent, textAlign, width, height, options const resolveFieldText = (part) => { if (part.fieldType === 'PAGE') { const count = pageNumberDisplayNumber ?? pageNumber ?? 1; - return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : (pageNumberText ?? String(count)); + if (!part.pageNumberFormat) { + return pageNumberText ?? String(count); + } + return formatChapterPageNumberText({ + pageComponent: formatPageNumber(count, part.pageNumberFormat), + chapterNumberText: pageNumberChapterText, + chapterSeparator: pageNumberChapterSeparator, + }); } if (part.fieldType === 'NUMPAGES') { return totalPages != null ? String(totalPages) : '1'; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index e1fbccbdbe..641cf0ce48 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -174,6 +174,22 @@ describe('svg-utils', () => { expect(span.textContent).toBe('III'); }); + it('should preserve chapter prefix when applying pageNumberFormat to section-aware PAGE display number', () => { + const textContent = { + parts: [{ text: '', fieldType: 'PAGE', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + pageNumber: 7, + pageNumberText: '3\u2011III', + pageNumberDisplayNumber: 3, + pageNumberChapterText: '3', + pageNumberChapterSeparator: 'hyphen', + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('3\u2011III'); + }); + it('should preserve SECTIONPAGES cached text when section page count is unavailable', () => { const textContent = { parts: [{ text: '3', fieldType: 'SECTIONPAGES', formatting: {} }], diff --git a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js index 82621b9976..8aa2ad7c6b 100644 --- a/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/editors/v1/extensions/vector-shape/VectorShapeView.js @@ -165,6 +165,8 @@ export class VectorShapeView { const pageNumber = this.editor?.options?.currentPageNumber; const pageNumberText = this.editor?.options?.currentPageNumberText; const pageNumberDisplayNumber = this.editor?.options?.currentPageDisplayNumber; + const pageNumberChapterText = this.editor?.options?.currentPageChapterNumberText; + const pageNumberChapterSeparator = this.editor?.options?.currentPageChapterSeparator; const totalPages = this.editor?.options?.totalPageCount; const sectionPageCount = this.editor?.options?.sectionPageCount; const textElement = this.createTextElement(attrs.textContent, attrs.textAlign, attrs.width, attrs.height, { @@ -173,6 +175,8 @@ export class VectorShapeView { pageNumber, pageNumberText, pageNumberDisplayNumber, + pageNumberChapterText, + pageNumberChapterSeparator, totalPages, sectionPageCount, }); From bc297aeb100835e50754390db7590a202bc0daf7 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 3 Jun 2026 20:12:40 -0700 Subject: [PATCH 63/63] chore: ci fixes --- .../field-references/preProcessPageFieldsOnly.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index ff4be0590f..3e1f00541b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -284,11 +284,12 @@ describe('preProcessPageFieldsOnly', () => { }, ); - it('should process SECTIONPAGES field with fldSimple syntax and preserve format', () => { + it('should process SECTIONPAGES field with fldSimple syntax and preserve parsed format', () => { + const instruction = ' SECTIONPAGES \\* roman \\* MERGEFORMAT '; const nodes = [ { name: 'w:fldSimple', - attributes: { 'w:instr': ' SECTIONPAGES \\* roman \\* MERGEFORMAT ' }, + attributes: { 'w:instr': instruction }, elements: [ { name: 'w:r', @@ -306,7 +307,7 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes).toHaveLength(1); expect(result.processedNodes[0].name).toBe('sd:sectionPageCount'); expect(result.processedNodes[0].attributes).toMatchObject({ - instruction: 'SECTIONPAGES \\* roman \\* MERGEFORMAT', + instruction: instruction.trim().replace(/\s+/g, ' '), pageNumberFormat: 'lowerRoman', importedCachedText: 'iv', });