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*-/); +});