diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 7e4fd87a2a..19232a90c5 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1,4 +1,5 @@ import type { TabStop } from './engines/tabs.js'; +import type { PageNumberFieldFormat } from './page-number-formatting.js'; export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js'; // Re-export TabStop for external consumers @@ -74,6 +75,12 @@ export { export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + formatPageNumber, + formatPageNumberFieldValue, + type PageNumberFieldFormat, + type PageNumberFormat, +} from './page-number-formatting.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; @@ -304,6 +311,8 @@ export type TextRun = RunMarks & { link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; + /** Explicit formatting requested by PAGE/NUMPAGES field switches. */ + pageNumberFieldFormat?: PageNumberFieldFormat; /** Absolute ProseMirror position (inclusive) of first character in this run. */ pmStart?: number; /** Absolute ProseMirror position (exclusive) after the last character. */ diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts new file mode 100644 index 0000000000..529639ec1e --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { formatPageNumber, formatPageNumberFieldValue } from './page-number-formatting.js'; + +describe('page number formatting', () => { + it('formats the supported Word page number formats', () => { + expect(formatPageNumber(5, 'decimal')).toBe('5'); + expect(formatPageNumber(5, 'upperRoman')).toBe('V'); + expect(formatPageNumber(5, 'lowerRoman')).toBe('v'); + expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + }); + + it('normalizes page numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + expect(formatPageNumber(0, 'upperLetter')).toBe('A'); + expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1'); + }); + + it('falls back to decimal for roman numerals beyond 3999', () => { + expect(formatPageNumber(4000, 'upperRoman')).toBe('4000'); + }); + + it('applies decimal zero padding for field values', () => { + expect(formatPageNumberFieldValue(7, { format: 'decimal', zeroPadding: 3 })).toBe('007'); + expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii'); + }); +}); diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts new file mode 100644 index 0000000000..bf32393cda --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -0,0 +1,65 @@ +export type PageNumberFieldFormat = { + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + zeroPadding?: number; +}; + +export type PageNumberFormat = NonNullable; + +function toUpperRoman(value: number): string { + if (value < 1 || value > 3999) return String(value); + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toUpperLetter(value: number): string { + let n = Math.max(1, value); + let result = ''; + + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + + return result; +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + + switch (format) { + case 'upperRoman': + return toUpperRoman(value); + case 'lowerRoman': + return toUpperRoman(value).toLowerCase(); + case 'upperLetter': + return toUpperLetter(value); + case 'lowerLetter': + return toUpperLetter(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + return fieldFormat?.zeroPadding && format === 'decimal' + ? formatted.padStart(fieldFormat.zeroPadding, '0') + : formatted; +} diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 73390f3601..c8299da173 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -2048,11 +2048,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..814cdb9387 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -24,6 +24,7 @@ export type HeaderFooterBatchResult = Partial< */ export type PageResolver = (pageNumber: number) => { displayText: string; + displayNumber?: number; totalPages: number; }; @@ -120,6 +121,24 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } +function isDigitBucketCompatiblePageNumberFormat(format?: string): boolean { + return !format || format === 'decimal' || format === 'numberInDash'; +} + +function paragraphRequiresPerPageLayout(para: ParagraphBlock): boolean { + for (const run of para.runs) { + if ( + 'token' in run && + run.token === 'pageNumber' && + run.pageNumberFieldFormat && + !isDigitBucketCompatiblePageNumberFormat(run.pageNumberFieldFormat.format) + ) { + return true; + } + } + return false; +} + function hasPageTokens(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { @@ -145,6 +164,27 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } +function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean { + for (const block of blocks) { + if (block.kind === 'paragraph') { + if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true; + } else if (block.kind === '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 (hasPageNumberTokensRequiringPerPageLayout(cellBlocks)) return true; + } + } + } + } + return false; +} + export class HeaderFooterLayoutCache { private readonly cache = new MeasureCache(); @@ -200,6 +240,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache(); * 2. If variant has no tokens: creates one layout reused across all pages (fast path) * 3. For small docs (<100 pages): creates per-page layouts * 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4) + * unless PAGE tokens use non-decimal field formatting * * @param sections - Header/footer variants (default, first, even, odd) * @param constraints - Layout constraints (width, height, margins) @@ -265,8 +306,10 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - if (!useBucketing) { - // Small doc: create layout for every page + const useBucketingForVariant = useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks); + + if (!useBucketingForVariant) { + // Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { @@ -285,6 +328,7 @@ export async function layoutHeaderFooterWithCache( // Create layouts for each page (or bucket representative) const pages: Array<{ number: number; + displayNumber?: number; blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; @@ -295,9 +339,9 @@ export async function layoutHeaderFooterWithCache( const clonedBlocks = cloneHeaderFooterBlocks(blocks); // Resolve page number tokens for this specific page - const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum); + const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum); - resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText); + resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber); // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); @@ -324,6 +368,7 @@ export async function layoutHeaderFooterWithCache( // Store page-specific data pages.push({ number: pageNum, + displayNumber, blocks: clonedBlocks, measures, fragments: fragmentsWithLines, @@ -343,6 +388,7 @@ export async function layoutHeaderFooterWithCache( renderHeight: firstPageLayout.renderHeight, pages: pages.map((p) => ({ number: p.number, + displayNumber: p.displayNumber, fragments: p.fragments, blocks: p.blocks, measures: p.measures, diff --git a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts index d7eeaade60..2ecd495f9a 100644 --- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts +++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts @@ -11,6 +11,7 @@ */ import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import { formatPageNumberFieldValue } from '@superdoc/layout-engine'; /** * Walk every paragraph block reachable through `blocks`, including those @@ -72,6 +73,7 @@ export function resolveHeaderFooterTokens( pageNumber: number, totalPages: number, pageNumberText?: string, + displayPageNumber?: number, ): void { // Validate inputs if (!blocks || blocks.length === 0) { @@ -90,6 +92,7 @@ export function resolveHeaderFooterTokens( const pageNumberStr = pageNumberText ?? String(pageNumber); const totalPagesStr = String(totalPages); + const displayNumber = displayPageNumber ?? pageNumber; // Process every paragraph block, including those nested in table cells // (SD-1332). The page-number field can live in `tableCell > paragraph > @@ -104,11 +107,15 @@ export function resolveHeaderFooterTokens( // IMPORTANT: Do NOT delete run.token - the painter needs it to // re-resolve the correct page number at render time for each page. // The text here is for measurement purposes (digit width). - run.text = pageNumberStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat) + : pageNumberStr; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count for measurement. // IMPORTANT: Keep token for painter to re-resolve if needed. - run.text = totalPagesStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr; } // Note: pageReference tokens should not appear in headers/footers typically, // but if they do, they'll be handled by the PAGEREF resolution logic diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 92a8c36d9f..1c6f4a6e4d 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -389,6 +389,57 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(pageNumbers).toContain(500); // d3 expect(pageNumbers).not.toContain(5000); // d4 not needed }); + + it('should not digit-bucket explicitly formatted page-number tokens', async () => { + const block = makePageTokenBlock('header-formatted-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'lowerRoman' }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(150); + expect(measureBlock).toHaveBeenCalledTimes(150); + }); + + it('should digit-bucket zero-padded decimal page-number tokens', async () => { + const block = makePageTokenBlock('header-zero-padded-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'decimal', zeroPadding: 3 }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(3); + expect(measureBlock).toHaveBeenCalledTimes(3); + expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005'); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 8bf4a48bf9..7598711fd8 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -61,6 +61,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); + it('should prefer explicit PAGE field format metadata over pageNumberText', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'header-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'numberInDash' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('-7-'); + expect((block.runs[0] as TextRun).token).toBe('pageNumber'); + }); + it('should resolve totalPageCount token in footer blocks', () => { const blocks: FlowBlock[] = [ { @@ -90,6 +114,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[1] as TextRun).token).toBe('totalPageCount'); }); + it('should zero-pad explicit NUMPAGES field metadata', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'footer-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { format: 'decimal', zeroPadding: 2 }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 5, 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('07'); + expect((block.runs[0] as TextRun).token).toBe('totalPageCount'); + }); + it('should resolve both tokens in the same block', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 973d28e11b..377b2750f5 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3392,7 +3392,7 @@ const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: num export { buildAnchorMap, resolvePageRefTokens, getTocBlocksForRemeasurement } from './resolvePageRefs.js'; // Export page numbering utilities -export { formatPageNumber, computeDisplayPageNumber } from './pageNumbering.js'; +export { formatPageNumber, formatPageNumberFieldValue, computeDisplayPageNumber } from './pageNumbering.js'; export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js'; // Export page token resolution utilities diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index d550dab76f..87ef3473ea 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -27,6 +27,10 @@ describe('formatPageNumber', () => { expect(formatPageNumber(-1, 'decimal')).toBe('1'); expect(formatPageNumber(-100, 'decimal')).toBe('1'); }); + + it('should truncate fractional numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + }); }); describe('numberInDash format', () => { diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index fa544310b4..ce3ed67c62 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,13 +13,15 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, SectionMetadata } from '@superdoc/contracts'; - -/** - * Page number format types supported by the layout engine. - * These match MS Word's page numbering format options. - */ -export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; +import { + formatPageNumber, + formatPageNumberFieldValue, + type Page, + type PageNumberFormat, + type SectionMetadata, +} from '@superdoc/contracts'; +export { formatPageNumber, formatPageNumberFieldValue }; +export type { PageNumberFormat }; /** * Display page information for a single page in the document. @@ -36,165 +38,6 @@ export interface DisplayPageInfo { sectionIndex: number; } -/** - * Converts a decimal number to uppercase Roman numeral format. - * - * Supports numbers from 1 to 3999. Uses standard Roman numeral rules - * including subtractive notation (IV, IX, XL, XC, CD, CM). - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in uppercase - * - * @example - * ```typescript - * toUpperRoman(1); // "I" - * toUpperRoman(4); // "IV" - * toUpperRoman(49); // "XLIX" - * toUpperRoman(1994); // "MCMXCIV" - * ``` - */ -function toUpperRoman(num: number): string { - if (num < 1 || num > 3999) { - // For numbers outside valid range, fall back to decimal - return String(num); - } - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - - let result = ''; - let remaining = num; - - for (let i = 0; i < values.length; i++) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -/** - * Converts a decimal number to lowercase Roman numeral format. - * - * Same conversion logic as uppercase Roman numerals, but returns - * lowercase characters. - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in lowercase - * - * @example - * ```typescript - * toLowerRoman(1); // "i" - * toLowerRoman(4); // "iv" - * toLowerRoman(49); // "xlix" - * ``` - */ -function toLowerRoman(num: number): string { - return toUpperRoman(num).toLowerCase(); -} - -/** - * Converts a decimal number to uppercase letter format (A-Z, AA-ZZ, etc.). - * - * Uses Excel-style column naming: A, B, ..., Z, AA, AB, ..., AZ, BA, ... - * This provides an alphabetical sequence that continues beyond 26. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in uppercase - * - * @example - * ```typescript - * toUpperLetter(1); // "A" - * toUpperLetter(26); // "Z" - * toUpperLetter(27); // "AA" - * toUpperLetter(52); // "AZ" - * ``` - */ -function toUpperLetter(num: number): string { - if (num < 1) { - return 'A'; - } - - let result = ''; - let n = num; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -/** - * Converts a decimal number to lowercase letter format (a-z, aa-zz, etc.). - * - * Same conversion logic as uppercase letters, but returns lowercase characters. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in lowercase - * - * @example - * ```typescript - * toLowerLetter(1); // "a" - * toLowerLetter(26); // "z" - * toLowerLetter(27); // "aa" - * ``` - */ -function toLowerLetter(num: number): string { - return toUpperLetter(num).toLowerCase(); -} - -/** - * Formats a page number according to the specified format. - * - * This function provides MS Word-compatible page number formatting. - * Edge cases are handled as follows: - * - Numbers <= 0 are clamped to 1 - * - Roman numerals outside 1-3999 fall back to decimal - * - All formats handle arbitrarily large positive numbers - * - * @param pageNumber - Page number to format (will be clamped to minimum 1) - * @param format - Desired output format - * @returns Formatted page number string - * - * @example - * ```typescript - * formatPageNumber(5, 'decimal'); // "5" - * formatPageNumber(5, 'upperRoman'); // "V" - * formatPageNumber(5, 'lowerRoman'); // "v" - * formatPageNumber(5, 'upperLetter'); // "E" - * formatPageNumber(5, 'lowerLetter'); // "e" - * formatPageNumber(0, 'decimal'); // "1" (clamped) - * formatPageNumber(-5, 'decimal'); // "1" (clamped) - * ``` - */ -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - // Clamp to minimum of 1 for edge cases - const num = Math.max(1, pageNumber); - - switch (format) { - case 'decimal': - return String(num); - case 'upperRoman': - return toUpperRoman(num); - case 'lowerRoman': - return toLowerRoman(num); - case 'upperLetter': - return toUpperLetter(num); - case 'lowerLetter': - return toLowerLetter(num); - case 'numberInDash': - return `-${num}-`; - default: - // TypeScript exhaustiveness check - should never reach here - return String(num); - } -} - /** * Computes section-aware display page numbers for all pages in a document. * diff --git a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts index c73bf03d60..7a378f43eb 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts @@ -82,6 +82,48 @@ describe('resolvePageNumberTokens', () => { expect((blocks[0] as ParagraphBlock).runs[1].token).toBe('pageNumber'); }); + it('should resolve explicit PAGE field format using section-aware display number', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'para-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'lowerRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 4, + fragments: [{ kind: 'para', blockId: 'para-format', fromLine: 0, toLine: 1, x: 0, y: 0, width: 100 }], + }, + ], + }; + const numberingCtx: NumberingContext = { + totalPages: 12, + displayPages: [ + { physicalPage: 1, displayNumber: 1, displayText: '1', sectionIndex: 0 }, + { physicalPage: 2, displayNumber: 2, displayText: '2', sectionIndex: 0 }, + { physicalPage: 3, displayNumber: 3, displayText: '3', sectionIndex: 0 }, + { physicalPage: 4, displayNumber: 5, displayText: '5', sectionIndex: 1 }, + ], + }; + + const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx); + + const updatedBlock = result.updatedBlocks.get('para-format') as ParagraphBlock; + expect(updatedBlock.runs[0].text).toBe('v'); + }); + it('should resolve totalPageCount tokens', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 779d218d70..b8880041ba 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -15,7 +15,7 @@ */ import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts'; -import type { DisplayPageInfo } from './pageNumbering'; +import { formatPageNumberFieldValue, type DisplayPageInfo } from './pageNumbering'; /** * Numbering context for page token resolution. @@ -118,8 +118,6 @@ export function resolvePageNumberTokens( continue; } - const displayPageText = displayPageInfo.displayText; - for (const fragment of page.fragments) { // Paragraph fragments — original behaviour. if (fragment.kind === 'para') { @@ -137,7 +135,12 @@ export function resolvePageNumberTokens( continue; } - const clonedBlock = cloneBlockWithResolvedTokens(block, displayPageText, totalPagesStr); + const clonedBlock = cloneBlockWithResolvedTokens( + block, + displayPageInfo, + totalPagesStr, + numberingCtx.totalPages, + ); updatedBlocks.set(blockId, clonedBlock); affectedBlockIds.add(blockId); processedBlocks.add(blockId); @@ -189,14 +192,15 @@ function hasPageTokens(block: ParagraphBlock): boolean { * or totalPageCount tokens by replacing the text and clearing the token metadata. * * @param block - Original paragraph block (will not be mutated) - * @param displayPageText - Formatted display page number (e.g., "i", "III", "23") + * @param displayPageInfo - Section-aware page number data for this physical page * @param totalPagesStr - Total page count as string * @returns Cloned block with resolved tokens */ function cloneBlockWithResolvedTokens( block: ParagraphBlock, - displayPageText: string, + displayPageInfo: DisplayPageInfo, totalPagesStr: string, + totalPages: number, ): ParagraphBlock { // Clone the runs array and resolve tokens const clonedRuns = block.runs.map((run) => { @@ -207,14 +211,18 @@ function cloneBlockWithResolvedTokens( const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: displayPageText, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat) + : displayPageInfo.displayText, }; } else if (run.token === 'totalPageCount') { // Clone the run and resolve the token const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: totalPagesStr, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr, }; } } diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 9c7c3cb49f..5f01fab56d 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -4940,6 +4940,62 @@ describe('DomPainter', () => { expect(footerEl?.textContent).toBe('Footer: 3'); }); + it('renders footer page-number tokens with explicit field format metadata', () => { + const footerBlock: FlowBlock = { + kind: 'paragraph', + id: 'footer-formatted-page', + runs: [ + { + text: '0', + fontFamily: 'Arial', + fontSize: 12, + token: 'pageNumber', + pageNumberFieldFormat: { format: 'numberInDash' }, + }, + ], + }; + const footerMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 40, + ascent: 10, + descent: 2, + lineHeight: 14, + }, + ], + totalHeight: 14, + }; + const footerFragment = { + kind: 'para' as const, + blockId: 'footer-formatted-page', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }; + + const painter = createTestPainter({ + blocks: [block, footerBlock], + measures: [measure, footerMeasure], + footerProvider: () => ({ fragments: [footerFragment], height: 14 }), + }); + + painter.paint( + { ...layout, pages: [{ ...layout.pages[0], number: 10, displayNumber: 4, numberText: 'iv' }] }, + mount, + ); + + const footerEl = mount.querySelector('.superdoc-page-footer'); + expect(footerEl).toBeTruthy(); + expect(footerEl?.textContent).toBe('-4-'); + }); + it('bottom-aligns footer content within the footer box', () => { const footerBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 407565773a..12513e9e24 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -67,6 +67,7 @@ import { shouldApplyJustify, sliceRunsForLine, SPACE_CHARS, + formatPageNumberFieldValue, } from '@superdoc/contracts'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; @@ -345,12 +346,14 @@ type PageDomState = { * @property {number} totalPages - Total number of pages in the document * @property {'body'|'header'|'footer'} section - Document section being rendered * @property {string} [pageNumberText] - Optional formatted page number text (e.g., "Page 1 of 10") + * @property {number} [displayPageNumber] - Section-aware numeric page value before formatting */ export type FragmentRenderContext = { pageNumber: number; totalPages: number; section: 'body' | 'header' | 'footer'; pageNumberText?: string; + displayPageNumber?: number; pageIndex?: number; }; @@ -2216,6 +2219,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2569,6 +2573,7 @@ export class DomPainter { totalPages: this.totalPages, section: kind, pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2770,6 +2775,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2927,6 +2933,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -8259,9 +8266,15 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { return run.text ?? ''; } if (runToken === 'pageNumber') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.displayPageNumber ?? context.pageNumber, run.pageNumberFieldFormat); + } return context.pageNumberText ?? String(context.pageNumber); } if (runToken === 'totalPageCount') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.totalPages || 1, run.pageNumberFieldFormat); + } return context.totalPages ? String(context.totalPages) : (run.text ?? ''); } return run.text ?? ''; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token-format.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token-format.test.ts new file mode 100644 index 0000000000..06f5ee296a --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token-format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { PMNode, PositionMap } from '../../types.js'; +import { tokenNodeToRun } from './generic-token.js'; + +describe('generic tokenNodeToRun field formatting', () => { + it('forwards normalized page-number field format metadata', () => { + const node: PMNode = { + type: 'total-page-number', + attrs: { + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + }; + + const run = tokenNodeToRun({ + node, + positions: new WeakMap() as PositionMap, + inheritedMarks: [], + defaultFont: 'Arial', + defaultSize: 16, + sdtMetadata: undefined, + hyperlinkConfig: { enableRichHyperlinks: false }, + themeColors: undefined, + runProperties: undefined, + paragraphProperties: undefined, + converterContext: { + translatedNumbering: {}, + translatedLinkedStyles: {}, + }, + enableComments: true, + visitNode: () => {}, + bookmarks: undefined, + tabOrdinal: 0, + paragraphAttrs: {}, + nextBlockId: () => 'b1', + }); + + expect(run?.pageNumberFieldFormat).toEqual({ format: 'decimal', zeroPadding: 2 }); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts index 8f3d263cd6..0798dce8af 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.test.ts @@ -172,6 +172,24 @@ describe('tokenNodeToRun', () => { expect(result.text).toBe('0'); }); + it('forwards page-number field format metadata', () => { + const tokenNode: PMNode = { + type: 'page-number', + attrs: { + pageNumberFormat: 'numberInDash', + pageNumberZeroPadding: 2, + }, + }; + const positions: PositionMap = new WeakMap(); + + const result = tokenNodeToRun(tokenNode, positions, 'Arial', 16, [], 'pageNumber'); + + expect(result.pageNumberFieldFormat).toEqual({ + format: 'numberInDash', + zeroPadding: 2, + }); + }); + it('handles token with various token types', () => { const tokenTypes: Array = ['pageNumber', 'totalPageCount']; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts index fe77a18543..ae7543df02 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token.ts @@ -3,6 +3,7 @@ import type { PMMark } from '../../types.js'; import { applyMarksToRun } from '../../marks/index.js'; import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; import { TOKEN_INLINE_TYPES } from '../../constants.js'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; /** * Converts a token PM node (e.g., page-number) to a TextRun with token metadata. @@ -41,6 +42,10 @@ export function tokenNodeToRun({ fontFamily: defaultFont, fontSize: defaultSize, }; + const pageNumberFieldFormat = getPageNumberFieldFormat(node.attrs); + if (pageNumberFieldFormat) { + run.pageNumberFieldFormat = pageNumberFieldFormat; + } // Attach PM position tracking const pos = positions.get(node); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.test.ts new file mode 100644 index 0000000000..04e9e519b7 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; + +describe('getPageNumberFieldFormat', () => { + it('normalizes PAGE/NUMPAGES format attributes for layout runs', () => { + expect( + getPageNumberFieldFormat({ + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }), + ).toEqual({ format: 'decimal', zeroPadding: 2 }); + }); + + it('ignores invalid format attributes', () => { + expect(getPageNumberFieldFormat(undefined)).toBeUndefined(); + expect(getPageNumberFieldFormat({ pageNumberFormat: 1, pageNumberZeroPadding: Number.NaN })).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.ts new file mode 100644 index 0000000000..9b1c9e0969 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.ts @@ -0,0 +1,17 @@ +import type { TextRun } from '@superdoc/contracts'; + +export function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts index c051b8fe8e..e49f1dabcb 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/text-run.ts @@ -12,6 +12,7 @@ import type { PMNode, PMMark, PositionMap, HyperlinkConfig, ThemeColorPalette } import { applyMarksToRun } from '../../marks/index.js'; import { DEFAULT_HYPERLINK_CONFIG } from '../../constants.js'; import { applyInlineRunProperties, type InlineConverterParams } from './common.js'; +import { getPageNumberFieldFormat } from './page-number-field-format.js'; /** * Converts a text PM node to a TextRun. @@ -100,6 +101,10 @@ export function tokenNodeToRun( fontFamily: defaultFont, fontSize: defaultSize, }; + const pageNumberFieldFormat = getPageNumberFieldFormat(node.attrs); + if (pageNumberFieldFormat) { + run.pageNumberFieldFormat = pageNumberFieldFormat; + } // Attach PM position tracking const pos = positions.get(node); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 8036a56c98..98f0581207 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -139,6 +139,57 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + it('passes section-aware display numbers into rId header/footer page resolution', async () => { + mockComputeDisplayPageNumber.mockReturnValue( + Array.from({ length: 10 }, (_, index) => ({ + physicalPage: index + 1, + displayNumber: index === 9 ? 1 : index + 1, + displayText: index === 9 ? 'i' : String(index + 1), + sectionIndex: index === 9 ? 1 : 0, + })), + ); + + const headerFooterInput = { + headerBlocksByRId: new Map([['rId-header-default', [makeBlock('block-default')]]]), + footerBlocksByRId: undefined, + headerBlocks: undefined, + footerBlocks: undefined, + constraints: { + width: 400, + height: 80, + }, + }; + + const layout = { + pages: Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + fragments: [], + sectionIndex: index === 9 ? 1 : 0, + })), + } as unknown as Layout; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'rId-header-default' }, + }, + ]; + + const deps = { + headerLayoutsByRId: new Map(), + footerLayoutsByRId: new Map(), + }; + + await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps); + + const pageResolver = mockLayoutHeaderFooterWithCache.mock.calls[0][5]; + expect(pageResolver(10)).toEqual({ + displayText: 'i', + displayNumber: 1, + totalPages: 10, + }); + }); + it('lays out first-page header refs in multi-section documents with per-section constraints', async () => { const headerBlocksByRId = new Map([ ['rId-header-default', [makeBlock('block-default')]], diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index 1228456dc2..a64adc00b0 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -20,6 +20,7 @@ export type HeaderFooterPerRidLayoutInput = { }; type Constraints = HeaderFooterConstraints; +type PageResolver = (pageNumber: number) => { displayText: string; displayNumber: number; totalPages: number }; /** * Layout header/footer blocks per rId, respecting per-section margins. @@ -48,11 +49,12 @@ export async function layoutPerRIdHeaderFooters( const displayPages = computeDisplayPageNumber(layout.pages, sectionMetadata); const totalPages = layout.pages.length; - const pageResolver = (pageNumber: number): { displayText: string; totalPages: number } => { + const pageResolver: PageResolver = (pageNumber) => { const pageIndex = pageNumber - 1; const displayInfo = displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), + displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages, }; }; @@ -108,7 +110,7 @@ async function layoutBlocksByRId( blocksByRId: Map | undefined, referencedRIds: Set, constraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId || referencedRIds.size === 0) return; @@ -208,7 +210,7 @@ async function layoutWithPerSectionConstraints( blocksByRId: Map | undefined, sectionMetadata: SectionMetadata[], fallbackConstraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId) return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index b94b3a2527..0696f284b2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -298,6 +298,29 @@ describe('HeaderFooterSessionManager', () => { expect(context?.measures).toEqual([{ id: 'blank-header-measure' }]); }); + it('preserves display page numbers in active per-rId layout contexts', async () => { + await setupWithZoom(1); + + manager.headerLayoutResults = null; + manager.headerLayoutsByRId.set('rId-header-default', { + kind: 'header', + type: 'default', + layout: { + height: 47, + pages: [{ number: 10, numberText: '1', displayNumber: 1, fragments: [] }], + }, + blocks: [], + measures: [], + }); + + const context = manager.getContext(); + expect(context?.layout.pages[0]).toMatchObject({ + number: 10, + numberText: '1', + displayNumber: 1, + }); + }); + it('falls back to zoom=1 when zoom is negative', async () => { await setupWithZoom(-1); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js index 2228653d51..f5a21c0288 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessBibliographyInstruction(nodesToCombine, instrText) { +export function preProcessBibliographyInstruction(nodesToCombine, instrText, options = {}) { + void options; const contentNodes = Array.isArray(nodesToCombine) && nodesToCombine.length > 0 ? nodesToCombine diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js index dac7ec469b..f9354fff32 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/citation-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessCitationInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessCitationInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:citation', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js index 041c90d23e..4f3f8ce7c3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.js @@ -6,22 +6,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} instrText The full instruction string (e.g. "NUMWORDS" or "NUMCHARS \* MERGEFORMAT"). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] In the generic body pipeline this position still carries `docx`; in header/footer standalone processing it carries the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the generic body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes for complex body fields. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessDocumentStatInstruction( - nodesToCombine, - instrText, - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessDocumentStatInstruction(nodesToCombine, instrText, options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const statFieldNode = { name: 'sd:documentStatField', type: 'element', @@ -44,8 +33,8 @@ export function preProcessDocumentStatInstruction( }); // Priority 2: Use rPr from field sequence if content has none - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - statFieldNode.elements = [effectiveFieldRunRPr, ...nodesToCombine]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + statFieldNode.elements = [fieldRunRPr, ...nodesToCombine]; } return [statFieldNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js index 41e6d7c0f4..2085b22b8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/document-stat-preprocessor.test.js @@ -41,7 +41,7 @@ describe('document-stat-preprocessor', () => { const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', fieldRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { fieldRunRPr: fieldRPr }); expect(result[0].elements).toContain(fieldRPr); }); @@ -50,7 +50,7 @@ describe('document-stat-preprocessor', () => { const notRPr = { name: 'w:other', elements: [] }; const contentNodes = []; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', notRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { fieldRunRPr: notRPr }); expect(result[0].elements).not.toContain(notRPr); }); @@ -61,22 +61,22 @@ describe('document-stat-preprocessor', () => { expect(result[0].attributes.instruction).toBe('NUMWORDS \\* MERGEFORMAT'); }); - it('uses 5th param fieldRunRPr when 3rd param is docx (body pipeline)', () => { - const docx = { 'word/document.xml': {} }; + it('uses options.fieldRunRPr without depending on docx shape', () => { + const docx = { name: 'w:rPr' }; const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', docx, null, fieldRPr); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMWORDS', { docx, fieldRunRPr: fieldRPr }); expect(result[0].elements[0]).toBe(fieldRPr); }); - it('falls back to 3rd param w:rPr when 5th param is null (header/footer pipeline)', () => { - const rPrFromThirdParam = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; + it('uses options.fieldRunRPr for header/footer processing', () => { + const fieldRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const contentNodes = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '5' }] }] }]; - const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS', rPrFromThirdParam, null, null); + const result = preProcessDocumentStatInstruction(contentNodes, 'NUMCHARS', { fieldRunRPr: fieldRPr }); - expect(result[0].elements[0]).toBe(rPrFromThirdParam); + expect(result[0].elements[0]).toBe(fieldRPr); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js index 47b5ccd581..36e56f8c43 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js @@ -67,11 +67,12 @@ export function resolveHyperlinkAttributes(instruction, docx) { * Processes a HYPERLINK instruction and creates a `w:hyperlink` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instruction The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [docx] - The docx object. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1216 */ -export function preProcessHyperlinkInstruction(nodesToCombine, instruction, docx) { +export function preProcessHyperlinkInstruction(nodesToCombine, instruction, options = {}) { + const docx = options.docx; const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {}; return [ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js index db5401acc8..44bbc626e7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js @@ -31,7 +31,7 @@ describe('preProcessHyperlinkInstruction', () => { }, }; - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'w:hyperlink', @@ -144,7 +144,7 @@ describe('preProcessHyperlinkInstruction', () => { }, }; - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); // The Relationship Id should start with 'rId', not with a digit const relationshipId = mockDocx['word/_rels/document.xml.rels'].elements[0].elements[0].attributes.Id; @@ -161,7 +161,7 @@ describe('preProcessHyperlinkInstruction', () => { 'word/_rels/document.xml.rels': { elements: [] }, // Missing Relationships element }; // Expect it not to crash, but to return w:anchor as before - const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessHyperlinkInstruction(mockNodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'w:hyperlink', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js index cad5064bf8..04166f5964 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.js @@ -2,11 +2,11 @@ * Processes an INDEX instruction and creates an `sd:index` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessIndexInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessIndexInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:index', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js index 8d24db968e..0cc3df971b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index-preprocessor.test.js @@ -20,13 +20,13 @@ describe('preProcessIndexInstruction', () => { const instrText = 'INDEX \\e "\t"'; const instructionTokens = [{ type: 'text', text: 'INDEX \\e "' }, { type: 'tab' }, { type: 'text', text: '"' }]; - const result = preProcessIndexInstruction(nodesToCombine, instrText, null, instructionTokens); + const result = preProcessIndexInstruction(nodesToCombine, instrText, { instructionTokens }); expect(result[0].attributes.instructionTokens).toEqual(instructionTokens); }); it('omits instructionTokens when null', () => { - const result = preProcessIndexInstruction([], 'INDEX', null, null); + const result = preProcessIndexInstruction([], 'INDEX', { instructionTokens: null }); expect(result[0].attributes).not.toHaveProperty('instructionTokens'); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index a194d26baf..d637f96126 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -16,11 +16,18 @@ import { preProcessTaInstruction } from './ta-preprocessor.js'; import { preProcessToaInstruction } from './toa-preprocessor.js'; import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; +/** + * @typedef {object} FieldPreprocessorOptions + * @property {import('../../v2/docxHelper').ParsedDocx} [docx] The docx object. + * @property {Array<{type: string, text?: string}> | null} [instructionTokens] Raw instruction tokens. + * @property {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr] The w:rPr node captured from field sequence nodes. + */ + /** * @callback InstructionPreProcessor * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine * @param {string} instruction - * @param {import('../../v2/docxHelper').ParsedDocx} [docx] - The docx object. + * @param {FieldPreprocessorOptions} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js index cee267402f..6d4f20fda5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/noteref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessNoterefInstruction(nodesToCombine, instrText) { +export function preProcessNoterefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index d99b9a9dfd..2698748ef5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -1,17 +1,21 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; + /** * Processes a NUMPAGES instruction and creates a `sd:totalPageNumber` node. * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for NUMPAGES). - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', type: 'element', - attributes: {}, + attributes: { ...fieldAttrs }, }; // Extract the cached display text from content nodes so the encoder can @@ -33,8 +37,7 @@ export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldR }); // 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 + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { totalPageNumNode.elements = [fieldRunRPr]; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index dbde3e45ac..3b74113cee 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessNumPagesInstruction', () => { it('should create a sd:totalPageNumber node', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toHaveLength(1); expect(result[0].name).toBe('sd:totalPageNumber'); expect(result[0].type).toBe('element'); @@ -25,7 +25,7 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result[0].elements).toEqual([{ name: 'w:rPr', elements: [{ name: 'w:b' }] }]); }); @@ -40,7 +40,46 @@ describe('preProcessNumPagesInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + + it('should use fieldRunRPr from the generic field pipeline options', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); + expect(result[0]).toEqual({ + name: 'sd:totalPageNumber', + type: 'element', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [fieldRunRPr], + }); + }); + + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); expect(result[0].elements).toEqual([fieldRunRPr]); }); @@ -57,7 +96,7 @@ describe('preProcessNumPagesInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result[0].elements).toEqual([contentRPr]); }); @@ -65,7 +104,7 @@ describe('preProcessNumPagesInstruction', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result[0].elements).toBeUndefined(); }); @@ -80,12 +119,21 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, null); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction); expect(result[0].attributes.importedCachedText).toBe('3'); }); it('should not set importedCachedText when no content text exists', () => { - const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); + const result = preProcessNumPagesInstruction([], 'NUMPAGES'); expect(result[0].attributes.importedCachedText).toBeUndefined(); }); + + it('preserves NUMPAGES zero-padding switches as normalized attributes', () => { + const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"'); + expect(result[0].attributes).toEqual({ + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index b1cba7c12f..b04639df0b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -1,16 +1,21 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.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 {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', + ...(Object.keys(fieldAttrs).length > 0 ? { attributes: fieldAttrs } : {}), }; // First, try to get rPr from content nodes (between separate and end) @@ -25,8 +30,7 @@ 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 + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { pageNumNode.elements = [fieldRunRPr]; } 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..1a1edeb126 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessPageInstruction', () => { it('should create a sd:autoPageNumber node', () => { const nodesToCombine = []; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -28,7 +28,7 @@ describe('preProcessPageInstruction', () => { }, ]; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -51,7 +51,7 @@ describe('preProcessPageInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -61,6 +61,46 @@ describe('preProcessPageInstruction', () => { ]); }); + it('should use fieldRunRPr from the generic field pipeline options', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); + expect(result).toEqual([ + { + name: 'sd:autoPageNumber', + type: 'element', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [fieldRunRPr], + }, + ]); + }); + + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; @@ -75,7 +115,7 @@ describe('preProcessPageInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -90,7 +130,7 @@ describe('preProcessPageInstruction', () => { const instruction = 'PAGE'; // Pass something that's not a w:rPr node const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessPageInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -98,4 +138,20 @@ describe('preProcessPageInstruction', () => { }, ]); }); + + it('preserves PAGE general format switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* roman'); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); + + it('preserves PAGE ArabicDash switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash'); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* ArabicDash', + pageNumberFormat: 'numberInDash', + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js index a61f24f739..d4e2f07e7c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.js @@ -2,10 +2,12 @@ * Processes a PAGEREF instruction and creates a `sd:pageReference` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageRefInstruction(nodesToCombine, instrText) { +export function preProcessPageRefInstruction(nodesToCombine, instrText, options = {}) { + void options; const pageRefNode = { name: 'sd:pageReference', type: 'element', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js index 2d4ae76112..25c4ed076f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-ref-preprocessor.test.js @@ -3,13 +3,11 @@ import { describe, it, expect } from 'vitest'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; describe('preProcessPageRefInstruction', () => { - const mockDocx = {}; - const mockNodesToCombine = [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }]; it('should process a page reference instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; - const result = preProcessPageRefInstruction(mockNodesToCombine, instruction, mockDocx); + const result = preProcessPageRefInstruction(mockNodesToCombine, instruction, {}); expect(result).toEqual([ { name: 'sd:pageReference', @@ -25,7 +23,7 @@ describe('preProcessPageRefInstruction', () => { it('should handle no text nodes', () => { const instruction = 'PAGEREF _Toc123456789 h'; const nodesWithoutText = []; - const result = preProcessPageRefInstruction(nodesWithoutText, instruction, mockDocx); + const result = preProcessPageRefInstruction(nodesWithoutText, instruction, {}); expect(result).toEqual([ { name: 'sd:pageReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js index dabe3d4a23..6bfe72c1b2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessRefInstruction(nodesToCombine, instrText) { +export function preProcessRefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js index 5d16712bf0..25cbdf8cc5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/seq-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessSeqInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessSeqInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:sequenceField', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js index 94256b59ae..adf3e5dfe9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/styleref-preprocessor.js @@ -5,9 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessStylerefInstruction(nodesToCombine, instrText) { +export function preProcessStylerefInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:crossReference', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js index 9cab61da30..7804be9938 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/ta-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessTaInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessTaInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:authorityEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js index 54223fcd26..f304ad687f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js @@ -2,11 +2,11 @@ * Processes a TC (table of contents entry) instruction and creates an `sd:tableOfContentsEntry` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessTcInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessTcInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:tableOfContentsEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js index 81349bd025..ad84e9630f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toa-preprocessor.js @@ -5,11 +5,11 @@ * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessToaInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessToaInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:tableOfAuthorities', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js index 8e49d7c0cf..97380ccb3a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/toc-preprocessor.js @@ -2,10 +2,12 @@ * Processes a TOC instruction and creates a `sd:tableOfContents` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. + * @param {object} [_options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1251 */ -export function preProcessTocInstruction(nodesToCombine, instrText) { +export function preProcessTocInstruction(nodesToCombine, instrText, options = {}) { + void options; return [ { name: 'sd:tableOfContents', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js index b5c979295b..92c77614de 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.js @@ -2,11 +2,11 @@ * Processes an XE (index entry) instruction and creates an `sd:indexEntry` node. * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. * @param {string} instrText The instruction text. - * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). - * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @param {{ instructionTokens?: Array<{type: string, text?: string}> | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessXeInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { +export function preProcessXeInstruction(nodesToCombine, instrText, options = {}) { + const instructionTokens = options.instructionTokens ?? null; return [ { name: 'sd:indexEntry', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js index b1b9d1a2a5..f03423be56 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/xe-preprocessor.test.js @@ -20,13 +20,13 @@ describe('preProcessXeInstruction', () => { const instrText = 'XE "Term:Subterm"'; const instructionTokens = [{ type: 'text', text: 'XE "Term:Subterm"' }]; - const result = preProcessXeInstruction(nodesToCombine, instrText, null, instructionTokens); + const result = preProcessXeInstruction(nodesToCombine, instrText, { instructionTokens }); expect(result[0].attributes.instructionTokens).toEqual(instructionTokens); }); it('omits instructionTokens when null', () => { - const result = preProcessXeInstruction([], 'XE "Test"', null, null); + const result = preProcessXeInstruction([], 'XE "Test"', { instructionTokens: null }); expect(result[0].attributes).not.toHaveProperty('instructionTokens'); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 82d19e5e2d..c4b5147c88 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 @@ -141,7 +141,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionType = instr.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - const processed = instructionPreProcessor(node.elements ?? [], instr, docx, null); + const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); if (collecting) { collectedNodesStack[collectedNodesStack.length - 1].push(...processed); rawCollectedNodesStack[rawCollectedNodesStack.length - 1].push(...processed); @@ -322,7 +322,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { - nodes: instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + nodes: instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }), handled: true, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 0367a738ed..a72e799b1b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -62,7 +62,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }); processedNodes.push(...processedField); i++; continue; @@ -98,7 +98,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) // which is where Word stores the styling for page number fields const contentNodes = fieldInfo.contentNodes; - const processedField = preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); + const processedField = preprocessor(contentNodes, fieldInfo.instrText, { + fieldRunRPr: fieldInfo.fieldRunRPr, + }); processedNodes.push(...processedField); // Skip past the entire field sequence @@ -127,7 +129,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // to a PAGE field by emitting sd:autoPageNumber. if (node.name === 'w:r' && node.elements?.some((el) => el.name === 'w:pgNum')) { const rPr = node.elements.find((el) => el.name === 'w:rPr') || null; - const processedField = preProcessPageInstruction([], '', rPr); + const processedField = preProcessPageInstruction([], '', { fieldRunRPr: rPr }); processedNodes.push(...processedField); i++; continue; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js new file mode 100644 index 0000000000..969ea83a9b --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -0,0 +1,68 @@ +import { formatPageNumberFieldValue as formatSharedPageNumberFieldValue } from '@superdoc/contracts'; + +const GENERAL_FORMATS = new Map([ + ['Arabic', 'decimal'], + ['roman', 'lowerRoman'], + ['Roman', 'upperRoman'], + ['ROMAN', 'upperRoman'], + ['alphabetic', 'lowerLetter'], + ['ALPHABETIC', 'upperLetter'], + ['ArabicDash', 'numberInDash'], +]); + +const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ + ['arabic', 'decimal'], + ['arabicdash', 'numberInDash'], +]); + +/** + * @param {string} instruction + * @param {'PAGE' | 'NUMPAGES'} fieldType + * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }} + */ +export function parsePageNumberFieldSwitches(instruction, fieldType) { + const normalizedInstruction = typeof instruction === 'string' ? instruction.trim().replace(/\s+/g, ' ') : fieldType; + const result = {}; + + if (normalizedInstruction && normalizedInstruction !== fieldType) { + result.instruction = normalizedInstruction; + } + + for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { + const rawValue = unquote(match[1]); + const mapped = GENERAL_FORMATS.get(rawValue) ?? CASE_INSENSITIVE_GENERAL_FORMATS.get(rawValue.toLowerCase()); + if (mapped) { + result.pageNumberFormat = mapped; + break; + } + } + + for (const match of normalizedInstruction.matchAll(/\\#\s+("[^"]+"|\S+)/g)) { + const picture = unquote(match[1]); + if (/^0+$/.test(picture)) { + result.pageNumberFormat ??= 'decimal'; + result.pageNumberZeroPadding = picture.length; + break; + } + } + + return result; +} + +/** + * @param {number} pageNumber + * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs + */ +export function formatPageNumberFieldValue(pageNumber, attrs = {}) { + return formatSharedPageNumberFieldValue(pageNumber, { + format: attrs.pageNumberFormat || 'decimal', + zeroPadding: attrs.pageNumberZeroPadding ?? undefined, + }); +} + +/** + * @param {string} value + */ +function unquote(value) { + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js new file mode 100644 index 0000000000..3c4a11b96f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { parsePageNumberFieldSwitches } from './page-number-field-switches.js'; + +describe('parsePageNumberFieldSwitches', () => { + it.each([ + ['PAGE \\* Arabic', { instruction: 'PAGE \\* Arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* arabic', { instruction: 'PAGE \\* arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* ARABIC', { instruction: 'PAGE \\* ARABIC', pageNumberFormat: 'decimal' }], + ['PAGE \\* roman', { instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman' }], + ['PAGE \\* Roman', { instruction: 'PAGE \\* Roman', pageNumberFormat: 'upperRoman' }], + ['PAGE \\* ROMAN', { instruction: 'PAGE \\* ROMAN', pageNumberFormat: 'upperRoman' }], + ['PAGE \\* alphabetic', { instruction: 'PAGE \\* alphabetic', pageNumberFormat: 'lowerLetter' }], + ['PAGE \\* ALPHABETIC', { instruction: 'PAGE \\* ALPHABETIC', pageNumberFormat: 'upperLetter' }], + ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* arabicdash', { instruction: 'PAGE \\* arabicdash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* ARABICDASH', { instruction: 'PAGE \\* ARABICDASH', pageNumberFormat: 'numberInDash' }], + ])('parses general format switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); + }); + + it.each([['PAGE \\* rOman'], ['PAGE \\* Alphabetic'], ['PAGE \\* aLpHaBeTiC']])( + 'does not case-fold output-case-sensitive switch %s', + (instruction) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual({ instruction }); + }, + ); + + it.each([ + ['NUMPAGES \\# "00"', { instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', pageNumberZeroPadding: 2 }], + ['NUMPAGES \\# 000', { instruction: 'NUMPAGES \\# 000', pageNumberFormat: 'decimal', pageNumberZeroPadding: 3 }], + ])('parses zero-padding picture switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); + }); + + it('preserves unsupported switched instructions without format metadata', () => { + expect(parsePageNumberFieldSwitches('PAGE \\* OrdText', 'PAGE')).toEqual({ instruction: 'PAGE \\* OrdText' }); + }); + + it('omits default instruction metadata', () => { + expect(parsePageNumberFieldSwitches(' PAGE ', 'PAGE')).toEqual({}); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index ff0fc66bcc..9f4fc1bf21 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -24,6 +24,7 @@ const encode = (params) => { type: 'page-number', attrs: { marksAsAttrs: marks, + ...getPageNumberFieldAttrs(node), }, }; @@ -39,6 +40,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const instruction = node.attrs?.instruction || 'PAGE'; const translated = [ { name: 'w:r', @@ -68,7 +70,7 @@ const decode = (params) => { elements: [ { type: 'text', - text: ' PAGE', + text: ` ${instruction}`, }, ], }, @@ -109,6 +111,16 @@ const decode = (params) => { return translated; }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js index 28cfb48510..5de2b2e30c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js @@ -76,6 +76,29 @@ describe('sd:autoPageNumber translator', () => { expect(parseMarks).toHaveBeenCalledTimes(1); expect(parseMarks).toHaveBeenCalledWith({ elements: [] }); }); + + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); }); describe('decode', () => { @@ -175,5 +198,18 @@ describe('sd:autoPageNumber translator', () => { expect(processOutputMarks).toHaveBeenCalledTimes(1); expect(processOutputMarks).toHaveBeenCalledWith([]); }); + + it('exports the preserved switched PAGE instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'page-number', + attrs: { instruction: 'PAGE \\* ArabicDash' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' PAGE \\* ArabicDash'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index 60b9dd9f27..d122782051 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -3,6 +3,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; +import { formatPageNumberFieldValue } from '../../../../field-references/shared/page-number-field-switches.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:totalPageNumber'; @@ -31,6 +32,7 @@ const encode = (params) => { attrs: { marksAsAttrs: marks, importedCachedText, + ...getPageNumberFieldAttrs(node), }, }; @@ -54,9 +56,19 @@ const decode = (params) => { const hasFreshPageCount = params.statFieldCacheMap?.has?.('NUMPAGES'); const dirty = !hasFreshPageCount; - return buildComplexFieldRuns({ instruction: 'NUMPAGES', cachedText, outputMarks, dirty }); + return buildComplexFieldRuns({ instruction: node.attrs?.instruction || 'NUMPAGES', cachedText, outputMarks, dirty }); }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** * Resolves the cached page count text for export. * @@ -69,6 +81,10 @@ const decode = (params) => { function resolveCachedPageCount(params, node) { const cacheMap = params.statFieldCacheMap; if (cacheMap?.has?.('NUMPAGES')) { + const pageCount = Number(cacheMap.get('NUMPAGES')); + if (node.attrs?.pageNumberFormat || node.attrs?.pageNumberZeroPadding) { + return formatPageNumberFieldValue(pageCount, node.attrs); + } return String(cacheMap.get('NUMPAGES')); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 3f0f042be1..bbebd44b5e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -81,6 +81,32 @@ describe('sd:totalPageNumber translator', () => { expect(result.attrs.importedCachedText).toBe('5'); }); + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + importedCachedText: null, + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); + it('falls back to an empty rPr object when run properties are missing', () => { config.encode({ nodes: [ @@ -138,6 +164,28 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe('12'); }); + it('formats fresh NUMPAGES cached text with preserved field switches', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + importedCachedText: '05', + }, + }, + statFieldCacheMap: new Map([['NUMPAGES', 7]]), + }); + + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + }); + expect(result[3].elements[1].elements[0].text).toBe('07'); + }); + it('falls back to resolvedText when cache map is absent', () => { vi.mocked(processOutputMarks).mockReturnValue([]); @@ -181,5 +229,18 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe(''); expect(result[4].elements[1].attributes['w:fldCharType']).toBe('end'); }); + + it('exports the preserved switched NUMPAGES instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { instruction: 'NUMPAGES \\# "00"', importedCachedText: '07' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "00"'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 8242a8d6ee..6b5dc1cb9e 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -13,6 +13,9 @@ import { isHeadless } from '@utils/headless-helpers.js'; * @typedef {Object} PageNumberAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original PAGE field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -48,6 +51,18 @@ export const PageNumber = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, }; }, @@ -120,6 +135,9 @@ export const PageNumber = Node.create({ * @typedef {Object} TotalPageCountAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original NUMPAGES field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -155,6 +173,18 @@ export const TotalPageCount = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, /** * Preserves the imported OOXML cached field result for NUMPAGES. * Used as a fallback when pagination is unavailable (headless context) diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 48528bd9a5..48f62803f3 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 @@ -888,12 +888,24 @@ export interface PageReferenceAttrs extends InlineNodeAttributes { export interface PageNumberAttrs extends InlineNodeAttributes { /** @internal Marks stored as attributes */ marksAsAttrs?: unknown[] | null; + /** @internal Original PAGE field instruction when switched */ + instruction?: string | null; + /** @internal Normalized field switch format */ + pageNumberFormat?: string | null; + /** @internal Zero-padding width from numeric picture switch */ + pageNumberZeroPadding?: number | null; } /** Total page count node attributes */ export interface TotalPageCountAttrs extends InlineNodeAttributes { /** @internal Marks stored as attributes */ marksAsAttrs?: unknown[] | null; + /** @internal Original NUMPAGES field instruction when switched */ + instruction?: string | null; + /** @internal Normalized field switch format */ + pageNumberFormat?: string | null; + /** @internal Zero-padding width from numeric picture switch */ + pageNumberZeroPadding?: number | null; } // ============================================