diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e12ab7ea53..01300f0c05 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -391,6 +391,8 @@ export type TextRun = RunMarks & { link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; + /** PAGE field-local value formatting override. Only used when token === 'pageNumber'. */ + pageNumberFormat?: PageNumberFormat; /** Absolute ProseMirror position (inclusive) of first character in this run. */ pmStart?: number; /** Absolute ProseMirror position (exclusive) after the last character. */ @@ -419,6 +421,64 @@ export type TextRun = RunMarks & { script?: RunScriptContext; }; +/** + * Page number format types supported by the layout pipeline. + * These mirror the common OOXML page number formats used by section numbering + * and PAGE field value-format switches. + */ +export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + +function toUpperRoman(num: number): string { + if (num < 1 || num > 3999) { + return String(num); + } + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let result = ''; + let remaining = num; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toRepeatedLetter(num: number, baseCodePoint: number): string { + if (num < 1) { + return String.fromCharCode(baseCodePoint); + } + + const index = (num - 1) % 26; + const repeatCount = Math.floor((num - 1) / 26) + 1; + return String.fromCharCode(baseCodePoint + index).repeat(repeatCount); +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const num = Math.max(1, pageNumber); + + switch (format) { + case 'decimal': + return String(num); + case 'upperRoman': + return toUpperRoman(num); + case 'lowerRoman': + return toUpperRoman(num).toLowerCase(); + case 'upperLetter': + return toRepeatedLetter(num, 65); + case 'lowerLetter': + return toRepeatedLetter(num, 97); + case 'numberInDash': + return `- ${num} -`; + default: + return String(num); + } +} + export type TabRun = RunMarks & { kind: 'tab'; text: '\t'; @@ -2002,6 +2062,8 @@ export type Page = { */ footnoteLedger?: FootnotePageLedger; numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; sectionRefs?: { @@ -2229,6 +2291,8 @@ export type HeaderFooterPage = { number: number; fragments: Fragment[]; 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/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 97e9b6c21d..af03a6f203 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -56,6 +56,8 @@ export type ResolvedPage = { footnoteReserved?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; /** Vertical alignment of content within this page. */ vAlign?: SectionVerticalAlign; /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ @@ -449,6 +451,8 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved export type ResolvedHeaderFooterPage = { number: 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/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index d035f504f8..c867d56d06 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -2700,11 +2700,12 @@ export async function incrementalLayout( // Create page resolver for section-aware header/footer numbering // Only use page resolver if feature flag is enabled const pageResolver = FeatureFlags.HEADER_FOOTER_PAGE_TOKENS - ? (pageNumber: number): { displayText: string; totalPages: number } => { + ? (pageNumber: number): { displayText: string; displayNumber: number; totalPages: number } => { const pageIndex = pageNumber - 1; const displayInfo = numberingCtx.displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), + displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages: numberingCtx.totalPages, }; } diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 6385a3065f..cee6dbe8c1 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'; @@ -24,6 +31,7 @@ export type HeaderFooterBatchResult = Partial< */ export type PageResolver = (pageNumber: number) => { displayText: string; + displayNumber?: number; totalPages: number; }; @@ -120,10 +128,31 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } +function paragraphHasVariableWidthRunLocalPageFormat(para: ParagraphBlock): boolean { + for (const run of para.runs) { + if ( + 'token' in run && + run.token === 'pageNumber' && + 'pageNumberFormat' in run && + run.pageNumberFormat && + run.pageNumberFormat !== 'decimal' && + run.pageNumberFormat !== 'numberInDash' + ) { + return true; + } + } + return false; +} + 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 @@ -145,6 +174,32 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } +function hasVariableWidthRunLocalPageFormat(blocks: FlowBlock[]): boolean { + for (const block of blocks) { + if (block.kind === 'paragraph') { + if (paragraphHasVariableWidthRunLocalPageFormat(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphHasVariableWidthRunLocalPageFormat(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 (hasVariableWidthRunLocalPageFormat(cellBlocks)) return true; + } + } + } + } + return false; +} + export class HeaderFooterLayoutCache { private readonly cache = new MeasureCache(); @@ -265,7 +320,7 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - if (!useBucketing) { + if (!useBucketing || hasVariableWidthRunLocalPageFormat(blocks)) { // Small doc: create layout for every page pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); @@ -288,6 +343,8 @@ export async function layoutHeaderFooterWithCache( blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; + numberText?: string; + displayNumber?: number; }> = []; for (const pageNum of pagesToLayout) { @@ -295,9 +352,9 @@ export async function layoutHeaderFooterWithCache( const clonedBlocks = cloneHeaderFooterBlocks(blocks); // Resolve page number tokens for this specific page - const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum); + const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum); - resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText); + resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber); // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); @@ -327,6 +384,8 @@ export async function layoutHeaderFooterWithCache( blocks: clonedBlocks, measures, fragments: fragmentsWithLines, + numberText: displayText, + displayNumber, }); } @@ -344,6 +403,8 @@ export async function layoutHeaderFooterWithCache( pages: pages.map((p) => ({ number: p.number, fragments: p.fragments, + numberText: p.numberText, + displayNumber: p.displayNumber, blocks: p.blocks, measures: p.measures, })), diff --git a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts index d7eeaade60..1095de5066 100644 --- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts +++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts @@ -10,7 +10,7 @@ * page number is used when calculating dimensions and caching layouts. */ -import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import { formatPageNumber, type FlowBlock, type ParagraphBlock, type TableBlock } from '@superdoc/contracts'; /** * Walk every paragraph block reachable through `blocks`, including those @@ -72,6 +72,7 @@ export function resolveHeaderFooterTokens( pageNumber: number, totalPages: number, pageNumberText?: string, + displayNumber?: number, ): void { // Validate inputs if (!blocks || blocks.length === 0) { @@ -89,6 +90,7 @@ export function resolveHeaderFooterTokens( } const pageNumberStr = pageNumberText ?? String(pageNumber); + const pageNumberDisplayNumber = displayNumber ?? pageNumber; const totalPagesStr = String(totalPages); // Process every paragraph block, including those nested in table cells @@ -104,7 +106,9 @@ export function resolveHeaderFooterTokens( // IMPORTANT: Do NOT delete run.token - the painter needs it to // re-resolve the correct page number at render time for each page. // The text here is for measurement purposes (digit width). - run.text = pageNumberStr; + run.text = run.pageNumberFormat + ? formatPageNumber(pageNumberDisplayNumber, run.pageNumberFormat) + : pageNumberStr; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count for measurement. // IMPORTANT: Keep token for painter to re-resolve if needed. diff --git a/packages/layout-engine/layout-bridge/test/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/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 92a8c36d9f..5f16bc03a5 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -89,6 +89,20 @@ const makePageTokenBlock = (id: string): FlowBlock => ({ ], }); +const makeFormattedPageTokenBlock = (id: string, pageNumberFormat: TextRun['pageNumberFormat']): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFormat, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}); + describe('getBucketForPageNumber', () => { it('should return d1 for single-digit page numbers (1-9)', () => { expect(getBucketForPageNumber(1)).toBe('d1'); @@ -389,6 +403,59 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(pageNumbers).toContain(500); // d3 expect(pageNumbers).not.toContain(5000); // d4 not needed }); + + it('should disable bucketing for variable-width run-local page number formats', async () => { + const sections = { + default: [makeFormattedPageTokenBlock('header-roman', 'upperRoman')], + }; + + 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(150); + expect(measureBlock).toHaveBeenCalledTimes(150); + }); + + it.each([ + ['decimal', 'decimal'], + ['numberInDash', 'numberInDash'], + ] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFormat) => { + const sections = { + default: [makeFormattedPageTokenBlock(`header-${pageNumberFormat}`, pageNumberFormat)], + }; + + 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 8bf4a48bf9..87cb9dd622 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -61,6 +61,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); + it('should let run-local pageNumberFormat override provided section text', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'header-local-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFormat: 'upperRoman', + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 1, 10, 'i', 5); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('V'); + expect((block.runs[0] as TextRun).token).toBe('pageNumber'); + }); + it('should resolve totalPageCount token in footer blocks', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 00cb67ea06..d3a7e884f1 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1729,6 +1729,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // second callback: after page creation -> stamp display number, section refs, section index, and advance counter if (state?.page) { state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat); + state.page.displayNumber = 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); diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index d550dab76f..0d9b22e4d4 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -27,16 +27,20 @@ describe('formatPageNumber', () => { expect(formatPageNumber(-1, 'decimal')).toBe('1'); expect(formatPageNumber(-100, 'decimal')).toBe('1'); }); + + 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 -'); }); }); @@ -124,19 +128,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', () => { @@ -154,16 +158,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', () => { @@ -430,7 +434,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/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index fa544310b4..d0b3fd3c1d 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,13 +13,8 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, SectionMetadata } from '@superdoc/contracts'; - -/** - * Page number format types supported by the layout engine. - * These match MS Word's page numbering format options. - */ -export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; +import { formatPageNumber, type Page, type PageNumberFormat, type SectionMetadata } from '@superdoc/contracts'; +export { formatPageNumber, type PageNumberFormat } from '@superdoc/contracts'; /** * Display page information for a single page in the document. @@ -36,165 +31,6 @@ export interface DisplayPageInfo { sectionIndex: number; } -/** - * Converts a decimal number to uppercase Roman numeral format. - * - * Supports numbers from 1 to 3999. Uses standard Roman numeral rules - * including subtractive notation (IV, IX, XL, XC, CD, CM). - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in uppercase - * - * @example - * ```typescript - * toUpperRoman(1); // "I" - * toUpperRoman(4); // "IV" - * toUpperRoman(49); // "XLIX" - * toUpperRoman(1994); // "MCMXCIV" - * ``` - */ -function toUpperRoman(num: number): string { - if (num < 1 || num > 3999) { - // For numbers outside valid range, fall back to decimal - return String(num); - } - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - - let result = ''; - let remaining = num; - - for (let i = 0; i < values.length; i++) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -/** - * Converts a decimal number to lowercase Roman numeral format. - * - * Same conversion logic as uppercase Roman numerals, but returns - * lowercase characters. - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in lowercase - * - * @example - * ```typescript - * toLowerRoman(1); // "i" - * toLowerRoman(4); // "iv" - * toLowerRoman(49); // "xlix" - * ``` - */ -function toLowerRoman(num: number): string { - return toUpperRoman(num).toLowerCase(); -} - -/** - * Converts a decimal number to uppercase letter format (A-Z, AA-ZZ, etc.). - * - * Uses Excel-style column naming: A, B, ..., Z, AA, AB, ..., AZ, BA, ... - * This provides an alphabetical sequence that continues beyond 26. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in uppercase - * - * @example - * ```typescript - * toUpperLetter(1); // "A" - * toUpperLetter(26); // "Z" - * toUpperLetter(27); // "AA" - * toUpperLetter(52); // "AZ" - * ``` - */ -function toUpperLetter(num: number): string { - if (num < 1) { - return 'A'; - } - - let result = ''; - let n = num; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -/** - * Converts a decimal number to lowercase letter format (a-z, aa-zz, etc.). - * - * Same conversion logic as uppercase letters, but returns lowercase characters. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in lowercase - * - * @example - * ```typescript - * toLowerLetter(1); // "a" - * toLowerLetter(26); // "z" - * toLowerLetter(27); // "aa" - * ``` - */ -function toLowerLetter(num: number): string { - return toUpperLetter(num).toLowerCase(); -} - -/** - * Formats a page number according to the specified format. - * - * This function provides MS Word-compatible page number formatting. - * Edge cases are handled as follows: - * - Numbers <= 0 are clamped to 1 - * - Roman numerals outside 1-3999 fall back to decimal - * - All formats handle arbitrarily large positive numbers - * - * @param pageNumber - Page number to format (will be clamped to minimum 1) - * @param format - Desired output format - * @returns Formatted page number string - * - * @example - * ```typescript - * formatPageNumber(5, 'decimal'); // "5" - * formatPageNumber(5, 'upperRoman'); // "V" - * formatPageNumber(5, 'lowerRoman'); // "v" - * formatPageNumber(5, 'upperLetter'); // "E" - * formatPageNumber(5, 'lowerLetter'); // "e" - * formatPageNumber(0, 'decimal'); // "1" (clamped) - * formatPageNumber(-5, 'decimal'); // "1" (clamped) - * ``` - */ -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - // Clamp to minimum of 1 for edge cases - const num = Math.max(1, pageNumber); - - switch (format) { - case 'decimal': - return String(num); - case 'upperRoman': - return toUpperRoman(num); - case 'lowerRoman': - return toLowerRoman(num); - case 'upperLetter': - return toUpperLetter(num); - case 'lowerLetter': - return toLowerLetter(num); - case 'numberInDash': - return `-${num}-`; - default: - // TypeScript exhaustiveness check - should never reach here - return String(num); - } -} - /** * Computes section-aware display page numbers for all pages in a document. * diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts index 8857cf8cf4..ddf5bb973e 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', + pageNumberFormat: '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).pageNumberFormat).toBeUndefined(); + }); }); diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 779d218d70..cff1dded1d 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -14,7 +14,7 @@ * - Integrates with two-pass convergence loop in incrementalLayout */ -import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts'; +import { formatPageNumber, type Layout, type FlowBlock, type ParagraphBlock, type Measure } from '@superdoc/contracts'; import type { DisplayPageInfo } from './pageNumbering'; /** @@ -137,7 +137,12 @@ export function resolvePageNumberTokens( continue; } - const clonedBlock = cloneBlockWithResolvedTokens(block, displayPageText, totalPagesStr); + const clonedBlock = cloneBlockWithResolvedTokens( + block, + displayPageText, + displayPageInfo.displayNumber, + totalPagesStr, + ); updatedBlocks.set(blockId, clonedBlock); affectedBlockIds.add(blockId); processedBlocks.add(blockId); @@ -196,6 +201,7 @@ function hasPageTokens(block: ParagraphBlock): boolean { function cloneBlockWithResolvedTokens( block: ParagraphBlock, displayPageText: string, + displayPageNumber: number, totalPagesStr: string, ): ParagraphBlock { // Clone the runs array and resolve tokens @@ -204,10 +210,10 @@ 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, pageNumberFormat, ...runWithoutToken } = run; return { ...runWithoutToken, - text: displayPageText, + text: pageNumberFormat ? formatPageNumber(displayPageNumber, pageNumberFormat) : displayPageText, }; } else if (run.token === 'totalPageCount') { // Clone the run and resolve the token @@ -276,9 +282,10 @@ 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.pageNumberFormat ? formatPageNumber(pageNumber, run.pageNumberFormat) : pageNumberStr; // Clear token metadata to treat as normal text after resolution delete run.token; + delete run.pageNumberFormat; blockModified = true; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 944c0b138a..a5a81bf4c5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -30,6 +30,7 @@ export function resolveHeaderFooterLayout( return { number: page.number, numberText: page.numberText, + displayNumber: page.displayNumber, items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache, story), ), diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 6e1a8dd4d5..3f3faa775e 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -331,6 +331,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { margins: page.margins, footnoteReserved: page.footnoteReserved, numberText: page.numberText, + displayNumber: page.displayNumber, 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..402062cf79 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.pageNumberFormat ?? '', trackedVersion, textRun.comments?.length ?? 0, // SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it. @@ -539,6 +540,8 @@ 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')); + hash = hashString(hash, getRunStringProp(run, 'pageNumberFormat')); // 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 de1a88bfda..0b4c8b5491 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6416,6 +6416,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..4297b9a7bf 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.pageNumberFormat ?? '', 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..5756cc288f --- /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', + pageNumberFormat: '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 910e320008..972cfa054a 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,86 @@ type PageDomState = { fragments: FragmentDomState[]; }; +function pageContextSignature(context: FragmentRenderContext): string { + return [ + context.pageNumber, + context.totalPages, + context.pageNumberText ?? '', + context.pageNumberDisplayNumber ?? '', + ].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. @@ -274,6 +355,7 @@ export type FragmentRenderContext = { section: 'body' | 'header' | 'footer'; story?: LayoutStoryLocator; pageNumberText?: string; + pageNumberDisplayNumber?: number; pageIndex?: number; }; @@ -1714,6 +1796,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + pageNumberDisplayNumber: page.displayNumber, pageIndex, }; @@ -2068,6 +2151,7 @@ export class DomPainter { section: kind, story: resolveDecorationStory(kind, data), pageNumberText: page.numberText, + pageNumberDisplayNumber: page.displayNumber, pageIndex, }; @@ -2271,6 +2355,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + pageNumberDisplayNumber: page.displayNumber, pageIndex, }; @@ -2292,6 +2377,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. @@ -2307,6 +2393,7 @@ export class DomPainter { current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || + pageContextChanged || mappingUnreliable; if (needsRebuild) { @@ -2431,6 +2518,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + pageNumberDisplayNumber: page.displayNumber, pageIndex, }; diff --git a/packages/layout-engine/painters/dom/src/runs/hash.ts b/packages/layout-engine/painters/dom/src/runs/hash.ts index 94002063a2..b2cad0b642 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, + pageNumberFormat: run.pageNumberFormat ?? 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..4f96054d05 --- /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, + pageNumberDisplayNumber: 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', + pageNumberFormat: 'upperRoman', + fontFamily: 'Arial', + fontSize: 12, + }; + + expect(resolveRunText(run, context)).toBe('V'); + }); + + it('changes merge signature when pageNumberFormat changes', () => { + const baseRun: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; + const formattedRun: TextRun = { ...baseRun, pageNumberFormat: 'upperRoman' }; + + expect(textRunMergeSignature(baseRun)).not.toBe(textRunMergeSignature(formattedRun)); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index d9927998c1..e9c123ea61 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -1,5 +1,5 @@ import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts'; -import { normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts'; +import { formatPageNumber, normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts'; import { assertPmPositions } from '../pm-position-validation.js'; import type { FragmentRenderContext } from '../renderer.js'; import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; @@ -137,6 +137,9 @@ export const resolveRunText = (run: Run, context: FragmentRenderContext): string return run.text ?? ''; } if (runToken === 'pageNumber') { + if ('pageNumberFormat' in run && run.pageNumberFormat) { + return formatPageNumber(context.pageNumberDisplayNumber ?? context.pageNumber, run.pageNumberFormat); + } return context.pageNumberText ?? String(context.pageNumber); } if (runToken === 'totalPageCount') { 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 8f3d263cd6..98ff7735d6 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.pageNumberFormat).toBe('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/converters/inline-converters/generic-token.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts index 2b92b38410..22a853fee5 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.ts @@ -42,6 +42,9 @@ export function tokenNodeToRun({ fontFamily: defaultFont, fontSize: defaultSize, }; + if (token === 'pageNumber' && typeof node.attrs?.pageNumberFormat === 'string') { + run.pageNumberFormat = node.attrs.pageNumberFormat as TextRun['pageNumberFormat']; + } // Attach PM position tracking const pos = positions.get(node); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts index 11ae20ed05..28d831086b 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/text-run.ts @@ -101,6 +101,9 @@ export function tokenNodeToRun( fontFamily: defaultFont, fontSize: defaultSize, }; + if (token === 'pageNumber' && typeof node.attrs?.pageNumberFormat === 'string') { + run.pageNumberFormat = node.attrs.pageNumberFormat as TextRun['pageNumberFormat']; + } // Attach PM position tracking const pos = positions.get(node); 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..46576e9bac 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', + pageNumberFormat: 'upperRoman', + }); + }); + it('preserves bold formatting on page number token', () => { const pmDoc = { type: 'doc', 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 47b5ccd581..a1e7148315 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 a194d26baf..46e0a0512f 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'; /** * @callback InstructionPreProcessor @@ -30,7 +31,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.split(' ')[0]; + const rawInstructionType = String(instruction ?? '') + .trim() + .split(/\s+/)[0]; + const instructionType = extractFieldKeyword(instruction); switch (instructionType) { case 'PAGE': return preProcessPageInstruction; @@ -58,6 +62,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 4d11dbb570..db82c1b6c0 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,12 +22,28 @@ 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); 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); @@ -40,6 +58,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 b1cba7c12f..60e67d7b70 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -1,16 +1,32 @@ +import { parsePageInstruction } from './page-instruction.js'; + /** * 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 {unknown} [_docxOrFieldRunRPr] The parsed docx in the main import path, or w:rPr in header/footer-only preprocessing. + * @param {Array<{type: string, text?: string}>} [_instructionTokens] Raw instruction tokens. * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessPageInstruction( + nodesToCombine, + instrText = '', + _docxOrFieldRunRPr = null, + _instructionTokens, + fieldRunRPr = null, +) { + const effectiveFieldRunRPr = fieldRunRPr ?? (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); + const parsedInstruction = parsePageInstruction(instrText); const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', + attributes: { + instruction: parsedInstruction.instruction, + ...(parsedInstruction.pageNumberFormat ? { pageNumberFormat: parsedInstruction.pageNumberFormat } : {}), + }, }; // First, try to get rPr from content nodes (between separate and end) @@ -27,8 +43,8 @@ export function preProcessPageInstruction(nodesToCombine, _instrText, fieldRunRP // If no rPr was found in content nodes, use the rPr captured from the field sequence // (begin, instrText, or separate nodes) where Word stores the styling for page numbers // Validate that fieldRunRPr is actually a w:rPr node before using it - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - pageNumNode.elements = [fieldRunRPr]; + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + pageNumNode.elements = [effectiveFieldRunRPr]; } return [pageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index cc645a06d4..5d59cb09c0 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], }, ]); @@ -80,6 +98,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [contentRPr], }, ]); @@ -95,6 +114,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 613083e125..f4ce71c9b3 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(' ')[0]; - const instructionPreProcessor = getInstructionPreProcessor(instructionType); + const instructionPreProcessor = getInstructionPreProcessor(instr); if (instructionPreProcessor) { const processed = instructionPreProcessor(node.elements ?? [], instr, docx, null); 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(' ')[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.split(' ')[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 80c551a89f..788908fb57 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 0367a738ed..717e7538c7 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) { @@ -204,7 +205,7 @@ function scanFieldSequence(nodes, beginIndex) { return null; // Incomplete field } - const fieldType = instrText.trim().split(' ')[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 240dbc2453..43dcfa60c8 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 = [ { @@ -63,6 +98,16 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes).toHaveLength(1); expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); }); + + 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)', () => { @@ -86,6 +131,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 = [ { @@ -109,6 +177,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 ff0fc66bcc..3899708c85 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'; @@ -26,6 +27,12 @@ const encode = (params) => { marksAsAttrs: marks, }, }; + if (typeof node.attributes?.instruction === 'string') { + processedNode.attrs.instruction = node.attributes.instruction; + } + if (typeof node.attributes?.pageNumberFormat === 'string') { + processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat; + } return processedNode; }; @@ -39,6 +46,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const instruction = getPageInstructionText(node.attrs); const translated = [ { name: 'w:r', @@ -68,7 +76,7 @@ const decode = (params) => { elements: [ { type: 'text', - text: ' PAGE', + text: ` ${instruction}`, }, ], }, @@ -109,6 +117,25 @@ const decode = (params) => { return translated; }; +/** + * @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) { + return `PAGE \\* ${instructionSwitch}`; + } + } + + 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 28cfb48510..41fb621070 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', }, }); }); @@ -175,5 +181,34 @@ describe('sd:autoPageNumber translator', () => { expect(processOutputMarks).toHaveBeenCalledTimes(1); expect(processOutputMarks).toHaveBeenCalledWith([]); }); + + 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/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 8242a8d6ee..99b781c32f 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -48,6 +48,14 @@ export const PageNumber = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 3b7a109a4c..18950ba9dc 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 @@ -948,6 +948,10 @@ export interface PageReferenceAttrs extends InlineNodeAttributes { export interface PageNumberAttrs extends InlineNodeAttributes { /** @internal Marks stored as attributes */ marksAsAttrs?: unknown[] | null; + /** Imported or synthesized PAGE field instruction */ + instruction?: string | null; + /** PAGE field-local value formatting override */ + pageNumberFormat?: PageNumberFormat | null; } /** Total page count node attributes */ 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*-/); +});