From cd9ccf2b21c530fc3bcc507d0ba0bd75a7efd973 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:34:55 -0300 Subject: [PATCH 01/14] feat(super-editor): honor PAGE/NUMPAGES field format switches Parse `\*` general-format and `\#` numeric-picture switches when importing PAGE/NUMPAGES fields and thread the requested format (roman/alphabetic/zero-padded decimal/etc.) plus the section-aware numeric page value through the converter, pm-adapter, layout engine, and DOM painter so page-number fields render in the format Word stored rather than always decimal. The original instruction is preserved on the node so export round-trips back to the same field code. --- packages/layout-engine/contracts/src/index.ts | 9 +++ .../contracts/src/resolved-layout.ts | 3 + .../layout-bridge/src/incrementalLayout.ts | 3 +- .../layout-bridge/src/layoutHeaderFooter.ts | 8 ++- .../src/resolveHeaderFooterTokens.ts | 11 ++- .../test/resolveHeaderFooterTokens.test.ts | 48 +++++++++++++ .../layout-engine/layout-engine/src/index.ts | 2 +- .../layout-engine/src/pageNumbering.ts | 11 ++- .../src/resolvePageNumberTokens.test.ts | 42 +++++++++++ .../layout-engine/src/resolvePageTokens.ts | 24 ++++--- .../src/resolveHeaderFooter.ts | 1 + .../layout-resolved/src/resolveLayout.ts | 1 + .../painters/dom/src/index.test.ts | 56 +++++++++++++++ .../painters/dom/src/renderer.ts | 69 +++++++++++++++++++ .../generic-token-format.test.ts | 40 +++++++++++ .../inline-converters/generic-token.test.ts | 18 +++++ .../inline-converters/generic-token.ts | 20 ++++++ .../converters/inline-converters/text-run.ts | 20 ++++++ .../num-pages-preprocessor.js | 7 +- .../num-pages-preprocessor.test.js | 9 +++ .../fld-preprocessors/page-preprocessor.js | 6 +- .../page-preprocessor.test.js | 16 +++++ .../shared/page-number-field-switches.js | 49 +++++++++++++ .../shared/page-number-field-switches.test.js | 30 ++++++++ .../autoPageNumber-translator.js | 14 +++- .../autoPageNumber-translator.test.js | 36 ++++++++++ .../totalPageNumber-translator.js | 13 +++- .../totalPageNumber-translator.test.js | 39 +++++++++++ .../v1/extensions/page-number/page-number.js | 30 ++++++++ .../v1/extensions/types/node-attributes.ts | 12 ++++ 30 files changed, 627 insertions(+), 20 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/generic-token-format.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 7e4fd87a2a..183986b536 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -249,6 +249,11 @@ export type FlowRunLink = { history?: boolean; }; +export type PageNumberFieldFormat = { + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + zeroPadding?: number; +}; + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. @@ -304,6 +309,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. */ @@ -1809,6 +1816,7 @@ export type Page = { /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ displayNumber?: number; numberText?: string; + displayNumber?: number; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; sectionRefs?: { @@ -2020,6 +2028,7 @@ export type HeaderFooterPage = { fragments: Fragment[]; displayNumber?: number; numberText?: string; + displayNumber?: number; /** * Optional page-local block clones backing this page's resolved fragments. * Present when header/footer tokens were laid out per page or per bucket. diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index beebde5fd3..f8c36bbbff 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -57,6 +57,8 @@ export type ResolvedPage = { displayNumber?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; /** Vertical alignment of content within this page. */ vAlign?: SectionVerticalAlign; /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ @@ -428,6 +430,7 @@ export type ResolvedHeaderFooterPage = { /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ displayNumber?: number; numberText?: string; + displayNumber?: number; items: ResolvedPaintItem[]; }; 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..7deacba188 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -24,6 +24,7 @@ export type HeaderFooterBatchResult = Partial< */ export type PageResolver = (pageNumber: number) => { displayText: string; + displayNumber?: number; totalPages: number; }; @@ -285,6 +286,7 @@ export async function layoutHeaderFooterWithCache( // Create layouts for each page (or bucket representative) const pages: Array<{ number: number; + displayNumber?: number; blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; @@ -295,9 +297,9 @@ export async function layoutHeaderFooterWithCache( const clonedBlocks = cloneHeaderFooterBlocks(blocks); // Resolve page number tokens for this specific page - const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum); + const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum); - resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText); + resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber); // Measure and layout const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock); @@ -324,6 +326,7 @@ export async function layoutHeaderFooterWithCache( // Store page-specific data pages.push({ number: pageNum, + displayNumber, blocks: clonedBlocks, measures, fragments: fragmentsWithLines, @@ -343,6 +346,7 @@ export async function layoutHeaderFooterWithCache( renderHeight: firstPageLayout.renderHeight, pages: pages.map((p) => ({ number: p.number, + displayNumber: p.displayNumber, fragments: p.fragments, blocks: p.blocks, measures: p.measures, diff --git a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts index d7eeaade60..2ecd495f9a 100644 --- a/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts +++ b/packages/layout-engine/layout-bridge/src/resolveHeaderFooterTokens.ts @@ -11,6 +11,7 @@ */ import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import { formatPageNumberFieldValue } from '@superdoc/layout-engine'; /** * Walk every paragraph block reachable through `blocks`, including those @@ -72,6 +73,7 @@ export function resolveHeaderFooterTokens( pageNumber: number, totalPages: number, pageNumberText?: string, + displayPageNumber?: number, ): void { // Validate inputs if (!blocks || blocks.length === 0) { @@ -90,6 +92,7 @@ export function resolveHeaderFooterTokens( const pageNumberStr = pageNumberText ?? String(pageNumber); const totalPagesStr = String(totalPages); + const displayNumber = displayPageNumber ?? pageNumber; // Process every paragraph block, including those nested in table cells // (SD-1332). The page-number field can live in `tableCell > paragraph > @@ -104,11 +107,15 @@ export function resolveHeaderFooterTokens( // IMPORTANT: Do NOT delete run.token - the painter needs it to // re-resolve the correct page number at render time for each page. // The text here is for measurement purposes (digit width). - run.text = pageNumberStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat) + : pageNumberStr; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count for measurement. // IMPORTANT: Keep token for painter to re-resolve if needed. - run.text = totalPagesStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr; } // Note: pageReference tokens should not appear in headers/footers typically, // but if they do, they'll be handled by the PAGEREF resolution logic diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 8bf4a48bf9..7598711fd8 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -61,6 +61,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); + it('should prefer explicit PAGE field format metadata over pageNumberText', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'header-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'numberInDash' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('-7-'); + expect((block.runs[0] as TextRun).token).toBe('pageNumber'); + }); + it('should resolve totalPageCount token in footer blocks', () => { const blocks: FlowBlock[] = [ { @@ -90,6 +114,30 @@ describe('resolveHeaderFooterTokens', () => { expect((block.runs[1] as TextRun).token).toBe('totalPageCount'); }); + it('should zero-pad explicit NUMPAGES field metadata', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'footer-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { format: 'decimal', zeroPadding: 2 }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + + resolveHeaderFooterTokens(blocks, 5, 7); + + const block = blocks[0] as ParagraphBlock; + expect(block.runs[0].text).toBe('07'); + expect((block.runs[0] as TextRun).token).toBe('totalPageCount'); + }); + it('should resolve both tokens in the same block', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 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.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index fa544310b4..ed4d7b58a6 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,7 +13,7 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, SectionMetadata } from '@superdoc/contracts'; +import type { Page, PageNumberFieldFormat, SectionMetadata } from '@superdoc/contracts'; /** * Page number format types supported by the layout engine. @@ -195,6 +195,15 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): } } +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + if (fieldFormat?.zeroPadding && format === 'decimal') { + return formatted.padStart(fieldFormat.zeroPadding, '0'); + } + return formatted; +} + /** * Computes section-aware display page numbers for all pages in a document. * diff --git a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts index c73bf03d60..7a378f43eb 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageNumberTokens.test.ts @@ -82,6 +82,48 @@ describe('resolvePageNumberTokens', () => { expect((blocks[0] as ParagraphBlock).runs[1].token).toBe('pageNumber'); }); + it('should resolve explicit PAGE field format using section-aware display number', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'para-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'lowerRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + } as ParagraphBlock, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 4, + fragments: [{ kind: 'para', blockId: 'para-format', fromLine: 0, toLine: 1, x: 0, y: 0, width: 100 }], + }, + ], + }; + const numberingCtx: NumberingContext = { + totalPages: 12, + displayPages: [ + { physicalPage: 1, displayNumber: 1, displayText: '1', sectionIndex: 0 }, + { physicalPage: 2, displayNumber: 2, displayText: '2', sectionIndex: 0 }, + { physicalPage: 3, displayNumber: 3, displayText: '3', sectionIndex: 0 }, + { physicalPage: 4, displayNumber: 5, displayText: '5', sectionIndex: 1 }, + ], + }; + + const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx); + + const updatedBlock = result.updatedBlocks.get('para-format') as ParagraphBlock; + expect(updatedBlock.runs[0].text).toBe('v'); + }); + it('should resolve totalPageCount tokens', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index 779d218d70..b8880041ba 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -15,7 +15,7 @@ */ import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts'; -import type { DisplayPageInfo } from './pageNumbering'; +import { formatPageNumberFieldValue, type DisplayPageInfo } from './pageNumbering'; /** * Numbering context for page token resolution. @@ -118,8 +118,6 @@ export function resolvePageNumberTokens( continue; } - const displayPageText = displayPageInfo.displayText; - for (const fragment of page.fragments) { // Paragraph fragments — original behaviour. if (fragment.kind === 'para') { @@ -137,7 +135,12 @@ export function resolvePageNumberTokens( continue; } - const clonedBlock = cloneBlockWithResolvedTokens(block, displayPageText, totalPagesStr); + const clonedBlock = cloneBlockWithResolvedTokens( + block, + displayPageInfo, + totalPagesStr, + numberingCtx.totalPages, + ); updatedBlocks.set(blockId, clonedBlock); affectedBlockIds.add(blockId); processedBlocks.add(blockId); @@ -189,14 +192,15 @@ function hasPageTokens(block: ParagraphBlock): boolean { * or totalPageCount tokens by replacing the text and clearing the token metadata. * * @param block - Original paragraph block (will not be mutated) - * @param displayPageText - Formatted display page number (e.g., "i", "III", "23") + * @param displayPageInfo - Section-aware page number data for this physical page * @param totalPagesStr - Total page count as string * @returns Cloned block with resolved tokens */ function cloneBlockWithResolvedTokens( block: ParagraphBlock, - displayPageText: string, + displayPageInfo: DisplayPageInfo, totalPagesStr: string, + totalPages: number, ): ParagraphBlock { // Clone the runs array and resolve tokens const clonedRuns = block.runs.map((run) => { @@ -207,14 +211,18 @@ function cloneBlockWithResolvedTokens( const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: displayPageText, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat) + : displayPageInfo.displayText, }; } else if (run.token === 'totalPageCount') { // Clone the run and resolve the token const { token: _token, ...runWithoutToken } = run; return { ...runWithoutToken, - text: totalPagesStr, + text: run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr, }; } } diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 695abb1b82..a1046f0ef9 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -29,6 +29,7 @@ export function resolveHeaderFooterLayout( number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache), ), diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index d38d68dba3..4c00932530 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -320,6 +320,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { footnoteReserved: page.footnoteReserved, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, vAlign: page.vAlign, baseMargins: page.baseMargins, sectionIndex: page.sectionIndex, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 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..957b18d05f 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -345,12 +345,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 +2218,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2569,6 +2572,7 @@ export class DomPainter { totalPages: this.totalPages, section: kind, pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2770,6 +2774,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -2927,6 +2932,7 @@ export class DomPainter { totalPages: this.totalPages, section: 'body', pageNumberText: page.numberText, + displayPageNumber: page.displayNumber, pageIndex, }; @@ -8259,10 +8265,73 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { return run.text ?? ''; } if (runToken === 'pageNumber') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.displayPageNumber ?? context.pageNumber, run.pageNumberFieldFormat); + } return context.pageNumberText ?? String(context.pageNumber); } if (runToken === 'totalPageCount') { + if (run.pageNumberFieldFormat) { + return formatPageNumberFieldValue(context.totalPages || 1, run.pageNumberFieldFormat); + } return context.totalPages ? String(context.totalPages) : (run.text ?? ''); } return run.text ?? ''; }; + +const formatPageNumberFieldValue = ( + value: number, + fieldFormat: NonNullable, +): string => { + const num = Math.max(1, Math.trunc(Number.isFinite(value) ? value : 1)); + const format = fieldFormat.format ?? 'decimal'; + const formatted = formatPageNumberByFormat(num, format); + return fieldFormat.zeroPadding && format === 'decimal' ? formatted.padStart(fieldFormat.zeroPadding, '0') : formatted; +}; + +const formatPageNumberByFormat = ( + value: number, + format: NonNullable['format'], +): string => { + switch (format) { + case 'upperRoman': + return toRoman(value); + case 'lowerRoman': + return toRoman(value).toLowerCase(); + case 'upperLetter': + return toLetters(value); + case 'lowerLetter': + return toLetters(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +}; + +const toRoman = (value: number): string => { + if (value < 1 || value > 3999) return String(value); + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + return result; +}; + +const toLetters = (value: number): string => { + let n = Math.max(1, value); + let result = ''; + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + return result; +}; 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..4711d0099d 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 @@ -41,6 +41,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); @@ -73,3 +77,19 @@ export function tokenNodeToRun({ } return run; } + +function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/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..d0922885e2 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 @@ -100,6 +100,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); @@ -126,3 +130,19 @@ export function tokenNodeToRun( } return run; } + +function getPageNumberFieldFormat( + attrs: Record | undefined, +): TextRun['pageNumberFieldFormat'] | undefined { + if (!attrs) return undefined; + const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; + const zeroPadding = + typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) + ? attrs.pageNumberZeroPadding + : undefined; + if (!format && !zeroPadding) return undefined; + return { + ...(format ? { format: format as NonNullable['format'] } : {}), + ...(zeroPadding ? { zeroPadding } : {}), + }; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index d99b9a9dfd..8195b51c56 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -1,3 +1,5 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; + /** * Processes a NUMPAGES instruction and creates a `sd:totalPageNumber` node. * @@ -7,11 +9,12 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', fieldRunRPr = null) { + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', type: 'element', - attributes: {}, + attributes: { ...fieldAttrs }, }; // Extract the cached display text from content nodes so the encoder can diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index dbde3e45ac..745a7014bc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -88,4 +88,13 @@ describe('preProcessNumPagesInstruction', () => { const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); expect(result[0].attributes.importedCachedText).toBeUndefined(); }); + + it('preserves NUMPAGES zero-padding switches as normalized attributes', () => { + const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"', null); + expect(result[0].attributes).toEqual({ + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index b1cba7c12f..c640f19d92 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -1,3 +1,5 @@ +import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switches.js'; + /** * Processes a PAGE instruction and creates a `sd:autoPageNumber` node. * @@ -7,10 +9,12 @@ * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, _instrText, fieldRunRPr = null) { +export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fieldRunRPr = null) { + const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', + ...(Object.keys(fieldAttrs).length > 0 ? { attributes: fieldAttrs } : {}), }; // First, try to get rPr from content nodes (between separate and end) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index cc645a06d4..ddc1e1e9c1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -98,4 +98,20 @@ describe('preProcessPageInstruction', () => { }, ]); }); + + it('preserves PAGE general format switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* roman', null); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); + + it('preserves PAGE ArabicDash switches as normalized attributes', () => { + const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash', null); + expect(result[0].attributes).toEqual({ + instruction: 'PAGE \\* ArabicDash', + pageNumberFormat: 'numberInDash', + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js new file mode 100644 index 0000000000..66e7f21e9c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -0,0 +1,49 @@ +const GENERAL_FORMATS = new Map([ + ['Arabic', 'decimal'], + ['roman', 'lowerRoman'], + ['ROMAN', 'upperRoman'], + ['alphabetic', 'lowerLetter'], + ['ALPHABETIC', 'upperLetter'], + ['ArabicDash', 'numberInDash'], +]); + +/** + * @param {string} instruction + * @param {'PAGE' | 'NUMPAGES'} fieldType + * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }} + */ +export function parsePageNumberFieldSwitches(instruction, fieldType) { + const normalizedInstruction = typeof instruction === 'string' ? instruction.trim().replace(/\s+/g, ' ') : fieldType; + const result = {}; + + if (normalizedInstruction && normalizedInstruction !== fieldType) { + result.instruction = normalizedInstruction; + } + + for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { + const rawValue = unquote(match[1]); + const mapped = GENERAL_FORMATS.get(rawValue); + if (mapped) { + result.pageNumberFormat = mapped; + break; + } + } + + for (const match of normalizedInstruction.matchAll(/\\#\s+("[^"]+"|\S+)/g)) { + const picture = unquote(match[1]); + if (/^0+$/.test(picture)) { + result.pageNumberFormat ??= 'decimal'; + result.pageNumberZeroPadding = picture.length; + break; + } + } + + return result; +} + +/** + * @param {string} value + */ +function unquote(value) { + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js new file mode 100644 index 0000000000..8ce9e11d09 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { parsePageNumberFieldSwitches } from './page-number-field-switches.js'; + +describe('parsePageNumberFieldSwitches', () => { + it.each([ + ['PAGE \\* Arabic', { instruction: 'PAGE \\* Arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* roman', { instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman' }], + ['PAGE \\* ROMAN', { instruction: 'PAGE \\* ROMAN', pageNumberFormat: 'upperRoman' }], + ['PAGE \\* alphabetic', { instruction: 'PAGE \\* alphabetic', pageNumberFormat: 'lowerLetter' }], + ['PAGE \\* ALPHABETIC', { instruction: 'PAGE \\* ALPHABETIC', pageNumberFormat: 'upperLetter' }], + ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], + ])('parses general format switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); + }); + + it.each([ + ['NUMPAGES \\# "00"', { instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', pageNumberZeroPadding: 2 }], + ['NUMPAGES \\# 000', { instruction: 'NUMPAGES \\# 000', pageNumberFormat: 'decimal', pageNumberZeroPadding: 3 }], + ])('parses zero-padding picture switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); + }); + + it('preserves unsupported switched instructions without format metadata', () => { + expect(parsePageNumberFieldSwitches('PAGE \\* OrdText', 'PAGE')).toEqual({ instruction: 'PAGE \\* OrdText' }); + }); + + it('omits default instruction metadata', () => { + expect(parsePageNumberFieldSwitches(' PAGE ', 'PAGE')).toEqual({}); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index ff0fc66bcc..9f4fc1bf21 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -24,6 +24,7 @@ const encode = (params) => { type: 'page-number', attrs: { marksAsAttrs: marks, + ...getPageNumberFieldAttrs(node), }, }; @@ -39,6 +40,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const instruction = node.attrs?.instruction || 'PAGE'; const translated = [ { name: 'w:r', @@ -68,7 +70,7 @@ const decode = (params) => { elements: [ { type: 'text', - text: ' PAGE', + text: ` ${instruction}`, }, ], }, @@ -109,6 +111,16 @@ const decode = (params) => { return translated; }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js index 28cfb48510..5de2b2e30c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js @@ -76,6 +76,29 @@ describe('sd:autoPageNumber translator', () => { expect(parseMarks).toHaveBeenCalledTimes(1); expect(parseMarks).toHaveBeenCalledWith({ elements: [] }); }); + + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }); + }); }); describe('decode', () => { @@ -175,5 +198,18 @@ describe('sd:autoPageNumber translator', () => { expect(processOutputMarks).toHaveBeenCalledTimes(1); expect(processOutputMarks).toHaveBeenCalledWith([]); }); + + it('exports the preserved switched PAGE instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'page-number', + attrs: { instruction: 'PAGE \\* ArabicDash' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' PAGE \\* ArabicDash'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index 60b9dd9f27..b27ee634de 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -31,6 +31,7 @@ const encode = (params) => { attrs: { marksAsAttrs: marks, importedCachedText, + ...getPageNumberFieldAttrs(node), }, }; @@ -54,9 +55,19 @@ const decode = (params) => { const hasFreshPageCount = params.statFieldCacheMap?.has?.('NUMPAGES'); const dirty = !hasFreshPageCount; - return buildComplexFieldRuns({ instruction: 'NUMPAGES', cachedText, outputMarks, dirty }); + return buildComplexFieldRuns({ instruction: node.attrs?.instruction || 'NUMPAGES', cachedText, outputMarks, dirty }); }; +function getPageNumberFieldAttrs(node) { + const attrs = {}; + if (node.attributes?.instruction) attrs.instruction = node.attributes.instruction; + if (node.attributes?.pageNumberFormat) attrs.pageNumberFormat = node.attributes.pageNumberFormat; + if (node.attributes?.pageNumberZeroPadding != null) { + attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); + } + return attrs; +} + /** * Resolves the cached page count text for export. * diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 3f0f042be1..0a27de38c9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -81,6 +81,32 @@ describe('sd:totalPageNumber translator', () => { expect(result.attrs.importedCachedText).toBe('5'); }); + it('preserves imported switched field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + + const result = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + importedCachedText: null, + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }); + }); + it('falls back to an empty rPr object when run properties are missing', () => { config.encode({ nodes: [ @@ -181,5 +207,18 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe(''); expect(result[4].elements[1].attributes['w:fldCharType']).toBe('end'); }); + + it('exports the preserved switched NUMPAGES instruction', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { instruction: 'NUMPAGES \\# "00"', importedCachedText: '07' }, + }, + }); + + expect(result[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "00"'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 8242a8d6ee..6b5dc1cb9e 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -13,6 +13,9 @@ import { isHeadless } from '@utils/headless-helpers.js'; * @typedef {Object} PageNumberAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original PAGE field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -48,6 +51,18 @@ export const PageNumber = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, }; }, @@ -120,6 +135,9 @@ export const PageNumber = Node.create({ * @typedef {Object} TotalPageCountAttributes * @category Attributes * @property {Array} [marksAsAttrs=null] @internal - Internal marks storage + * @property {string|null} [instruction=null] @internal - Original NUMPAGES field instruction when switched + * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format + * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch */ /** @@ -155,6 +173,18 @@ export const TotalPageCount = Node.create({ default: null, rendered: false, }, + instruction: { + default: null, + rendered: false, + }, + pageNumberFormat: { + default: null, + rendered: false, + }, + pageNumberZeroPadding: { + default: null, + rendered: false, + }, /** * Preserves the imported OOXML cached field result for NUMPAGES. * Used as a fallback when pagination is unavailable (headless context) diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 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; } // ============================================ From 4c66e2b2b8db73dbc169059cb54899de31f2b57b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:49:14 -0300 Subject: [PATCH 02/14] fix(super-editor): format NUMPAGES cached exports --- .../shared/page-number-field-switches.js | 67 +++++++++++++++++++ .../totalPageNumber-translator.js | 5 ++ .../totalPageNumber-translator.test.js | 22 ++++++ 3 files changed, 94 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index 66e7f21e9c..2bb6e49e23 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -41,9 +41,76 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { return result; } +/** + * @param {number} pageNumber + * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs + */ +export function formatPageNumberFieldValue(pageNumber, attrs = {}) { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + const format = attrs.pageNumberFormat || 'decimal'; + const formatted = formatPageNumberByFormat(value, format); + return attrs.pageNumberZeroPadding && format === 'decimal' + ? formatted.padStart(attrs.pageNumberZeroPadding, '0') + : formatted; +} + /** * @param {string} value */ function unquote(value) { return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; } + +/** + * @param {number} value + * @param {string} format + */ +function formatPageNumberByFormat(value, format) { + switch (format) { + case 'upperRoman': + return toRoman(value); + case 'lowerRoman': + return toRoman(value).toLowerCase(); + case 'upperLetter': + return toLetters(value); + case 'lowerLetter': + return toLetters(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +/** + * @param {number} value + */ +function toRoman(value) { + if (value < 1 || value > 3999) return String(value); + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + return result; +} + +/** + * @param {number} value + */ +function toLetters(value) { + let n = Math.max(1, value); + let result = ''; + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + return result; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index b27ee634de..d122782051 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -3,6 +3,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; import { buildComplexFieldRuns } from '../build-complex-field-runs.js'; +import { formatPageNumberFieldValue } from '../../../../field-references/shared/page-number-field-switches.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:totalPageNumber'; @@ -80,6 +81,10 @@ function getPageNumberFieldAttrs(node) { function resolveCachedPageCount(params, node) { const cacheMap = params.statFieldCacheMap; if (cacheMap?.has?.('NUMPAGES')) { + const pageCount = Number(cacheMap.get('NUMPAGES')); + if (node.attrs?.pageNumberFormat || node.attrs?.pageNumberZeroPadding) { + return formatPageNumberFieldValue(pageCount, node.attrs); + } return String(cacheMap.get('NUMPAGES')); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index 0a27de38c9..bbebd44b5e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -164,6 +164,28 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe('12'); }); + it('formats fresh NUMPAGES cached text with preserved field switches', () => { + vi.mocked(processOutputMarks).mockReturnValue([]); + + const result = config.decode({ + node: { + type: 'total-page-number', + attrs: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + importedCachedText: '05', + }, + }, + statFieldCacheMap: new Map([['NUMPAGES', 7]]), + }); + + expect(result[0].elements[1].attributes).toEqual({ + 'w:fldCharType': 'begin', + }); + expect(result[3].elements[1].elements[0].text).toBe('07'); + }); + it('falls back to resolvedText when cache map is absent', () => { vi.mocked(processOutputMarks).mockReturnValue([]); From 13e647b27100173d480d896e26bd31df3cd8a5bf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:54:04 -0300 Subject: [PATCH 03/14] fix(super-editor): pass display number to rId header layouts --- .../HeaderFooterPerRidLayout.test.ts | 51 +++++++++++++++++++ .../header-footer/HeaderFooterPerRidLayout.ts | 8 +-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts index 8036a56c98..98f0581207 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.test.ts @@ -139,6 +139,57 @@ describe('layoutPerRIdHeaderFooters', () => { expect(deps.headerLayoutsByRId.has('rId-header-orphan')).toBe(false); }); + it('passes section-aware display numbers into rId header/footer page resolution', async () => { + mockComputeDisplayPageNumber.mockReturnValue( + Array.from({ length: 10 }, (_, index) => ({ + physicalPage: index + 1, + displayNumber: index === 9 ? 1 : index + 1, + displayText: index === 9 ? 'i' : String(index + 1), + sectionIndex: index === 9 ? 1 : 0, + })), + ); + + const headerFooterInput = { + headerBlocksByRId: new Map([['rId-header-default', [makeBlock('block-default')]]]), + footerBlocksByRId: undefined, + headerBlocks: undefined, + footerBlocks: undefined, + constraints: { + width: 400, + height: 80, + }, + }; + + const layout = { + pages: Array.from({ length: 10 }, (_, index) => ({ + number: index + 1, + fragments: [], + sectionIndex: index === 9 ? 1 : 0, + })), + } as unknown as Layout; + + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'rId-header-default' }, + }, + ]; + + const deps = { + headerLayoutsByRId: new Map(), + footerLayoutsByRId: new Map(), + }; + + await layoutPerRIdHeaderFooters(headerFooterInput, layout, sectionMetadata, deps); + + const pageResolver = mockLayoutHeaderFooterWithCache.mock.calls[0][5]; + expect(pageResolver(10)).toEqual({ + displayText: 'i', + displayNumber: 1, + totalPages: 10, + }); + }); + it('lays out first-page header refs in multi-section documents with per-section constraints', async () => { const headerBlocksByRId = new Map([ ['rId-header-default', [makeBlock('block-default')]], diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts index 1228456dc2..a64adc00b0 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterPerRidLayout.ts @@ -20,6 +20,7 @@ export type HeaderFooterPerRidLayoutInput = { }; type Constraints = HeaderFooterConstraints; +type PageResolver = (pageNumber: number) => { displayText: string; displayNumber: number; totalPages: number }; /** * Layout header/footer blocks per rId, respecting per-section margins. @@ -48,11 +49,12 @@ export async function layoutPerRIdHeaderFooters( const displayPages = computeDisplayPageNumber(layout.pages, sectionMetadata); const totalPages = layout.pages.length; - const pageResolver = (pageNumber: number): { displayText: string; totalPages: number } => { + const pageResolver: PageResolver = (pageNumber) => { const pageIndex = pageNumber - 1; const displayInfo = displayPages[pageIndex]; return { displayText: displayInfo?.displayText ?? String(pageNumber), + displayNumber: displayInfo?.displayNumber ?? pageNumber, totalPages, }; }; @@ -108,7 +110,7 @@ async function layoutBlocksByRId( blocksByRId: Map | undefined, referencedRIds: Set, constraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId || referencedRIds.size === 0) return; @@ -208,7 +210,7 @@ async function layoutWithPerSectionConstraints( blocksByRId: Map | undefined, sectionMetadata: SectionMetadata[], fallbackConstraints: Constraints, - pageResolver: (pageNumber: number) => { displayText: string; totalPages: number }, + pageResolver: PageResolver, layoutsByRId: Map, ): Promise { if (!blocksByRId) return; From 82d66db5c666fbac938ff11b13ab90b468cf69fb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 17:59:43 -0300 Subject: [PATCH 04/14] fix(layout-bridge): avoid bucketing formatted page tokens --- .../layout-bridge/src/layoutHeaderFooter.ts | 37 ++++++++++++++++++- .../test/layoutHeaderFooterBucketing.test.ts | 25 +++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 7deacba188..682cdc0546 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -121,6 +121,15 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } +function paragraphHasFormattedPageNumberToken(para: ParagraphBlock): boolean { + for (const run of para.runs) { + if ('token' in run && run.token === 'pageNumber' && run.pageNumberFieldFormat) { + return true; + } + } + return false; +} + function hasPageTokens(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { @@ -146,6 +155,27 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } +function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { + for (const block of blocks) { + if (block.kind === 'paragraph') { + if (paragraphHasFormattedPageNumberToken(block as ParagraphBlock)) return true; + } else if (block.kind === 'table') { + const table = block as TableBlock; + for (const row of table.rows ?? []) { + for (const cell of row.cells ?? []) { + const cellBlocks: FlowBlock[] = cell.blocks + ? (cell.blocks as FlowBlock[]) + : cell.paragraph + ? [cell.paragraph] + : []; + if (hasFormattedPageNumberTokens(cellBlocks)) return true; + } + } + } + } + return false; +} + export class HeaderFooterLayoutCache { private readonly cache = new MeasureCache(); @@ -201,6 +231,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache(); * 2. If variant has no tokens: creates one layout reused across all pages (fast path) * 3. For small docs (<100 pages): creates per-page layouts * 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4) + * unless PAGE tokens have explicit field formatting * * @param sections - Header/footer variants (default, first, even, odd) * @param constraints - Layout constraints (width, height, margins) @@ -266,8 +297,10 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - if (!useBucketing) { - // Small doc: create layout for every page + const useBucketingForVariant = useBucketing && !hasFormattedPageNumberTokens(blocks); + + if (!useBucketingForVariant) { + // Per-page layout: small docs, disabled bucketing, or explicit PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 92a8c36d9f..36f3441eee 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -389,6 +389,31 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(pageNumbers).toContain(500); // d3 expect(pageNumbers).not.toContain(5000); // d4 not needed }); + + it('should not digit-bucket explicitly formatted page-number tokens', async () => { + const block = makePageTokenBlock('header-formatted-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'lowerRoman' }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(150); + expect(measureBlock).toHaveBeenCalledTimes(150); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { From aacbd4522890c57f8db36a7e533549af01b6da63 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 18:53:38 -0300 Subject: [PATCH 05/14] fix(super-editor): preserve field-run page number styling --- .../num-pages-preprocessor.js | 23 ++++++++++++++----- .../num-pages-preprocessor.test.js | 20 ++++++++++++++++ .../fld-preprocessors/page-preprocessor.js | 23 ++++++++++++++----- .../page-preprocessor.test.js | 21 +++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index 8195b51c56..376c8ebbff 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -5,11 +5,23 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for NUMPAGES). - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. + * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. + * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', fieldRunRPr = null) { +export function preProcessNumPagesInstruction( + nodesToCombine, + instrText = 'NUMPAGES', + _docxOrFieldRunRPr = null, + instructionTokensOrFieldRunRPr = null, + fieldRunRPr = null, +) { + const effectiveFieldRunRPr = + fieldRunRPr ?? + (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? + (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', @@ -36,10 +48,9 @@ export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPA }); // If no rPr was found in content nodes, use the rPr captured from the field sequence - // (begin, instrText, or separate nodes) where Word stores the styling for page numbers - // Validate that fieldRunRPr is actually a w:rPr node before using it - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - totalPageNumNode.elements = [fieldRunRPr]; + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + totalPageNumNode.elements = [effectiveFieldRunRPr]; } return [totalPageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index 745a7014bc..c41e8353c9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -44,6 +44,26 @@ describe('preProcessNumPagesInstruction', () => { expect(result[0].elements).toEqual([fieldRunRPr]); }); + it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + expect(result[0]).toEqual({ + name: 'sd:totalPageNumber', + type: 'element', + attributes: { + instruction: 'NUMPAGES \\# "00"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 2, + }, + elements: [fieldRunRPr], + }); + }); + it('should prefer content node rPr over fieldRunRPr', () => { const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const nodesToCombine = [ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index c640f19d92..0adf339391 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -5,11 +5,23 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for PAGE). - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes (begin, instrText, or separate). This is where Word stores styling for page number fields when no content exists between separate and end markers. Must be a node with name === 'w:rPr' to be used; other node types are ignored for safety. + * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. + * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. + * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fieldRunRPr = null) { +export function preProcessPageInstruction( + nodesToCombine, + instrText = 'PAGE', + _docxOrFieldRunRPr = null, + instructionTokensOrFieldRunRPr = null, + fieldRunRPr = null, +) { + const effectiveFieldRunRPr = + fieldRunRPr ?? + (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? + (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', @@ -29,10 +41,9 @@ export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', fi }); // If no rPr was found in content nodes, use the rPr captured from the field sequence - // (begin, instrText, or separate nodes) where Word stores the styling for page numbers - // Validate that fieldRunRPr is actually a w:rPr node before using it - if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { - pageNumNode.elements = [fieldRunRPr]; + // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. + if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { + pageNumNode.elements = [effectiveFieldRunRPr]; } return [pageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index ddc1e1e9c1..6913d77765 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -61,6 +61,27 @@ describe('preProcessPageInstruction', () => { ]); }); + it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + expect(result).toEqual([ + { + name: 'sd:autoPageNumber', + type: 'element', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, + elements: [fieldRunRPr], + }, + ]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; From 9d0d7a944a36ca40941852041512e30bb0fb862f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 09:58:29 -0300 Subject: [PATCH 06/14] fix(contracts): centralize page number formatting --- packages/layout-engine/contracts/src/index.ts | 61 ++++++ .../src/page-number-formatting.test.ts | 28 +++ .../layout-engine/src/pageNumbering.test.ts | 4 + .../layout-engine/src/pageNumbering.ts | 184 +----------------- .../painters/dom/src/renderer.ts | 58 +----- .../shared/page-number-field-switches.js | 66 +------ 6 files changed, 109 insertions(+), 292 deletions(-) create mode 100644 packages/layout-engine/contracts/src/page-number-formatting.test.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 183986b536..f774f892b1 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -254,6 +254,67 @@ export type PageNumberFieldFormat = { zeroPadding?: number; }; +export type PageNumberFormat = NonNullable; + +function toUpperRoman(value: number): string { + if (value < 1 || value > 3999) return String(value); + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toUpperLetter(value: number): string { + let n = Math.max(1, value); + let result = ''; + + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + + return result; +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + + switch (format) { + case 'upperRoman': + return toUpperRoman(value); + case 'lowerRoman': + return toUpperRoman(value).toLowerCase(); + case 'upperLetter': + return toUpperLetter(value); + case 'lowerLetter': + return toUpperLetter(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + return fieldFormat?.zeroPadding && format === 'decimal' + ? formatted.padStart(fieldFormat.zeroPadding, '0') + : formatted; +} + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts new file mode 100644 index 0000000000..c899f58bd1 --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { formatPageNumber, formatPageNumberFieldValue } from './index.js'; + +describe('page number formatting', () => { + it('formats the supported Word page number formats', () => { + expect(formatPageNumber(5, 'decimal')).toBe('5'); + expect(formatPageNumber(5, 'upperRoman')).toBe('V'); + expect(formatPageNumber(5, 'lowerRoman')).toBe('v'); + expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + }); + + it('normalizes page numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + expect(formatPageNumber(0, 'upperLetter')).toBe('A'); + expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1'); + }); + + it('falls back to decimal for roman numerals beyond 3999', () => { + expect(formatPageNumber(4000, 'upperRoman')).toBe('4000'); + }); + + it('applies decimal zero padding for field values', () => { + expect(formatPageNumberFieldValue(7, { format: 'decimal', zeroPadding: 3 })).toBe('007'); + expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii'); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index d550dab76f..87ef3473ea 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -27,6 +27,10 @@ describe('formatPageNumber', () => { expect(formatPageNumber(-1, 'decimal')).toBe('1'); expect(formatPageNumber(-100, 'decimal')).toBe('1'); }); + + it('should truncate fractional numbers before formatting', () => { + expect(formatPageNumber(4.9, 'decimal')).toBe('4'); + }); }); describe('numberInDash format', () => { diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.ts b/packages/layout-engine/layout-engine/src/pageNumbering.ts index ed4d7b58a6..ce3ed67c62 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.ts @@ -13,13 +13,15 @@ * - Handle continuous sections that inherit prior section's running count */ -import type { Page, PageNumberFieldFormat, SectionMetadata } from '@superdoc/contracts'; - -/** - * Page number format types supported by the layout engine. - * These match MS Word's page numbering format options. - */ -export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; +import { + formatPageNumber, + formatPageNumberFieldValue, + type Page, + type PageNumberFormat, + type SectionMetadata, +} from '@superdoc/contracts'; +export { formatPageNumber, formatPageNumberFieldValue }; +export type { PageNumberFormat }; /** * Display page information for a single page in the document. @@ -36,174 +38,6 @@ export interface DisplayPageInfo { sectionIndex: number; } -/** - * Converts a decimal number to uppercase Roman numeral format. - * - * Supports numbers from 1 to 3999. Uses standard Roman numeral rules - * including subtractive notation (IV, IX, XL, XC, CD, CM). - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in uppercase - * - * @example - * ```typescript - * toUpperRoman(1); // "I" - * toUpperRoman(4); // "IV" - * toUpperRoman(49); // "XLIX" - * toUpperRoman(1994); // "MCMXCIV" - * ``` - */ -function toUpperRoman(num: number): string { - if (num < 1 || num > 3999) { - // For numbers outside valid range, fall back to decimal - return String(num); - } - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - - let result = ''; - let remaining = num; - - for (let i = 0; i < values.length; i++) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -/** - * Converts a decimal number to lowercase Roman numeral format. - * - * Same conversion logic as uppercase Roman numerals, but returns - * lowercase characters. - * - * @param num - Number to convert (must be 1-3999) - * @returns Roman numeral string in lowercase - * - * @example - * ```typescript - * toLowerRoman(1); // "i" - * toLowerRoman(4); // "iv" - * toLowerRoman(49); // "xlix" - * ``` - */ -function toLowerRoman(num: number): string { - return toUpperRoman(num).toLowerCase(); -} - -/** - * Converts a decimal number to uppercase letter format (A-Z, AA-ZZ, etc.). - * - * Uses Excel-style column naming: A, B, ..., Z, AA, AB, ..., AZ, BA, ... - * This provides an alphabetical sequence that continues beyond 26. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in uppercase - * - * @example - * ```typescript - * toUpperLetter(1); // "A" - * toUpperLetter(26); // "Z" - * toUpperLetter(27); // "AA" - * toUpperLetter(52); // "AZ" - * ``` - */ -function toUpperLetter(num: number): string { - if (num < 1) { - return 'A'; - } - - let result = ''; - let n = num; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -/** - * Converts a decimal number to lowercase letter format (a-z, aa-zz, etc.). - * - * Same conversion logic as uppercase letters, but returns lowercase characters. - * - * @param num - Number to convert (1-indexed) - * @returns Letter sequence in lowercase - * - * @example - * ```typescript - * toLowerLetter(1); // "a" - * toLowerLetter(26); // "z" - * toLowerLetter(27); // "aa" - * ``` - */ -function toLowerLetter(num: number): string { - return toUpperLetter(num).toLowerCase(); -} - -/** - * Formats a page number according to the specified format. - * - * This function provides MS Word-compatible page number formatting. - * Edge cases are handled as follows: - * - Numbers <= 0 are clamped to 1 - * - Roman numerals outside 1-3999 fall back to decimal - * - All formats handle arbitrarily large positive numbers - * - * @param pageNumber - Page number to format (will be clamped to minimum 1) - * @param format - Desired output format - * @returns Formatted page number string - * - * @example - * ```typescript - * formatPageNumber(5, 'decimal'); // "5" - * formatPageNumber(5, 'upperRoman'); // "V" - * formatPageNumber(5, 'lowerRoman'); // "v" - * formatPageNumber(5, 'upperLetter'); // "E" - * formatPageNumber(5, 'lowerLetter'); // "e" - * formatPageNumber(0, 'decimal'); // "1" (clamped) - * formatPageNumber(-5, 'decimal'); // "1" (clamped) - * ``` - */ -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - // Clamp to minimum of 1 for edge cases - const num = Math.max(1, pageNumber); - - switch (format) { - case 'decimal': - return String(num); - case 'upperRoman': - return toUpperRoman(num); - case 'lowerRoman': - return toLowerRoman(num); - case 'upperLetter': - return toUpperLetter(num); - case 'lowerLetter': - return toLowerLetter(num); - case 'numberInDash': - return `-${num}-`; - default: - // TypeScript exhaustiveness check - should never reach here - return String(num); - } -} - -export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { - const format = fieldFormat?.format ?? 'decimal'; - const formatted = formatPageNumber(pageNumber, format); - if (fieldFormat?.zeroPadding && format === 'decimal') { - return formatted.padStart(fieldFormat.zeroPadding, '0'); - } - return formatted; -} - /** * Computes section-aware display page numbers for all pages in a document. * diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 957b18d05f..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'; @@ -8278,60 +8279,3 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { } return run.text ?? ''; }; - -const formatPageNumberFieldValue = ( - value: number, - fieldFormat: NonNullable, -): string => { - const num = Math.max(1, Math.trunc(Number.isFinite(value) ? value : 1)); - const format = fieldFormat.format ?? 'decimal'; - const formatted = formatPageNumberByFormat(num, format); - return fieldFormat.zeroPadding && format === 'decimal' ? formatted.padStart(fieldFormat.zeroPadding, '0') : formatted; -}; - -const formatPageNumberByFormat = ( - value: number, - format: NonNullable['format'], -): string => { - switch (format) { - case 'upperRoman': - return toRoman(value); - case 'lowerRoman': - return toRoman(value).toLowerCase(); - case 'upperLetter': - return toLetters(value); - case 'lowerLetter': - return toLetters(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -}; - -const toRoman = (value: number): string => { - if (value < 1 || value > 3999) return String(value); - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - return result; -}; - -const toLetters = (value: number): string => { - let n = Math.max(1, value); - let result = ''; - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - return result; -}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index 2bb6e49e23..ddc6f945dc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -1,3 +1,5 @@ +import { formatPageNumberFieldValue as formatSharedPageNumberFieldValue } from '@superdoc/contracts'; + const GENERAL_FORMATS = new Map([ ['Arabic', 'decimal'], ['roman', 'lowerRoman'], @@ -46,12 +48,10 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs */ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { - const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); - const format = attrs.pageNumberFormat || 'decimal'; - const formatted = formatPageNumberByFormat(value, format); - return attrs.pageNumberZeroPadding && format === 'decimal' - ? formatted.padStart(attrs.pageNumberZeroPadding, '0') - : formatted; + return formatSharedPageNumberFieldValue(pageNumber, { + format: attrs.pageNumberFormat || 'decimal', + zeroPadding: attrs.pageNumberZeroPadding ?? undefined, + }); } /** @@ -60,57 +60,3 @@ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { function unquote(value) { return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; } - -/** - * @param {number} value - * @param {string} format - */ -function formatPageNumberByFormat(value, format) { - switch (format) { - case 'upperRoman': - return toRoman(value); - case 'lowerRoman': - return toRoman(value).toLowerCase(); - case 'upperLetter': - return toLetters(value); - case 'lowerLetter': - return toLetters(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -} - -/** - * @param {number} value - */ -function toRoman(value) { - if (value < 1 || value > 3999) return String(value); - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - return result; -} - -/** - * @param {number} value - */ -function toLetters(value) { - let n = Math.max(1, value); - let result = ''; - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - return result; -} From 9b249730dd12b95d07306363ca9c6501b87a2dd1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 09:59:45 -0300 Subject: [PATCH 07/14] refactor(pm-adapter): share page field format extraction --- .../inline-converters/generic-token.ts | 17 +---------------- .../page-number-field-format.test.ts | 18 ++++++++++++++++++ .../page-number-field-format.ts | 17 +++++++++++++++++ .../converters/inline-converters/text-run.ts | 17 +---------------- 4 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.test.ts create mode 100644 packages/layout-engine/pm-adapter/src/converters/inline-converters/page-number-field-format.ts 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 4711d0099d..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. @@ -77,19 +78,3 @@ export function tokenNodeToRun({ } return run; } - -function getPageNumberFieldFormat( - attrs: Record | undefined, -): TextRun['pageNumberFieldFormat'] | undefined { - if (!attrs) return undefined; - const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; - const zeroPadding = - typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) - ? attrs.pageNumberZeroPadding - : undefined; - if (!format && !zeroPadding) return undefined; - return { - ...(format ? { format: format as NonNullable['format'] } : {}), - ...(zeroPadding ? { zeroPadding } : {}), - }; -} diff --git a/packages/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 d0922885e2..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. @@ -130,19 +131,3 @@ export function tokenNodeToRun( } return run; } - -function getPageNumberFieldFormat( - attrs: Record | undefined, -): TextRun['pageNumberFieldFormat'] | undefined { - if (!attrs) return undefined; - const format = typeof attrs.pageNumberFormat === 'string' ? attrs.pageNumberFormat : undefined; - const zeroPadding = - typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) - ? attrs.pageNumberZeroPadding - : undefined; - if (!format && !zeroPadding) return undefined; - return { - ...(format ? { format: format as NonNullable['format'] } : {}), - ...(zeroPadding ? { zeroPadding } : {}), - }; -} From 5698d7a937eea6eb62568d081c31ed13d6e73936 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:02:48 -0300 Subject: [PATCH 08/14] fix(super-editor): pass page field options explicitly --- .../num-pages-preprocessor.js | 21 +++------- .../num-pages-preprocessor.test.js | 39 ++++++++++++++----- .../fld-preprocessors/page-preprocessor.js | 21 +++------- .../page-preprocessor.test.js | 37 +++++++++++++----- .../preProcessNodesForFldChar.js | 9 ++++- .../preProcessPageFieldsOnly.js | 11 ++++-- 6 files changed, 82 insertions(+), 56 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js index 376c8ebbff..2698748ef5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.js @@ -5,23 +5,12 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for NUMPAGES). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1233 */ -export function preProcessNumPagesInstruction( - nodesToCombine, - instrText = 'NUMPAGES', - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessNumPagesInstruction(nodesToCombine, instrText = 'NUMPAGES', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'NUMPAGES'); const totalPageNumNode = { name: 'sd:totalPageNumber', @@ -49,8 +38,8 @@ export function preProcessNumPagesInstruction( // If no rPr was found in content nodes, use the rPr captured from the field sequence // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - totalPageNumNode.elements = [effectiveFieldRunRPr]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + totalPageNumNode.elements = [fieldRunRPr]; } return [totalPageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js index c41e8353c9..3b74113cee 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/num-pages-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessNumPagesInstruction', () => { it('should create a sd:totalPageNumber node', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toHaveLength(1); expect(result[0].name).toBe('sd:totalPageNumber'); expect(result[0].type).toBe('element'); @@ -25,7 +25,7 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result[0].elements).toEqual([{ name: 'w:rPr', elements: [{ name: 'w:b' }] }]); }); @@ -40,18 +40,22 @@ describe('preProcessNumPagesInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result[0].elements).toEqual([fieldRunRPr]); }); - it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + it('should use fieldRunRPr from the generic field pipeline options', () => { const nodesToCombine = []; const instruction = 'NUMPAGES \\# "00"'; const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); expect(result[0]).toEqual({ name: 'sd:totalPageNumber', type: 'element', @@ -64,6 +68,21 @@ describe('preProcessNumPagesInstruction', () => { }); }); + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'NUMPAGES \\# "00"'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const nodesToCombine = [ @@ -77,7 +96,7 @@ describe('preProcessNumPagesInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result[0].elements).toEqual([contentRPr]); }); @@ -85,7 +104,7 @@ describe('preProcessNumPagesInstruction', () => { const nodesToCombine = []; const instruction = 'NUMPAGES'; const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result[0].elements).toBeUndefined(); }); @@ -100,17 +119,17 @@ describe('preProcessNumPagesInstruction', () => { }, ]; const instruction = 'NUMPAGES'; - const result = preProcessNumPagesInstruction(nodesToCombine, instruction, null); + const result = preProcessNumPagesInstruction(nodesToCombine, instruction); expect(result[0].attributes.importedCachedText).toBe('3'); }); it('should not set importedCachedText when no content text exists', () => { - const result = preProcessNumPagesInstruction([], 'NUMPAGES', null); + const result = preProcessNumPagesInstruction([], 'NUMPAGES'); expect(result[0].attributes.importedCachedText).toBeUndefined(); }); it('preserves NUMPAGES zero-padding switches as normalized attributes', () => { - const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"', null); + const result = preProcessNumPagesInstruction([], 'NUMPAGES \\# "00"'); expect(result[0].attributes).toEqual({ instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index 0adf339391..b04639df0b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -5,23 +5,12 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. * @param {string} [_instrText] The instruction text (unused for PAGE). - * @param {import('../../v2/docxHelper').ParsedDocx | import('../../v2/types/index.js').OpenXmlNode | null} [_docxOrFieldRunRPr=null] The generic body pipeline passes docx here; standalone field processing passes the captured w:rPr. - * @param {Array<{type: string, text?: string}> | import('../../v2/types/index.js').OpenXmlNode | null} [instructionTokensOrFieldRunRPr=null] Raw instruction tokens in the body pipeline, or a legacy w:rPr position in alternate callers. - * @param {import('../../v2/types/index.js').OpenXmlNode | null} [fieldRunRPr=null] The w:rPr node captured from field sequence nodes. + * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ -export function preProcessPageInstruction( - nodesToCombine, - instrText = 'PAGE', - _docxOrFieldRunRPr = null, - instructionTokensOrFieldRunRPr = null, - fieldRunRPr = null, -) { - const effectiveFieldRunRPr = - fieldRunRPr ?? - (instructionTokensOrFieldRunRPr?.name === 'w:rPr' ? instructionTokensOrFieldRunRPr : null) ?? - (_docxOrFieldRunRPr?.name === 'w:rPr' ? _docxOrFieldRunRPr : null); +export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { + const fieldRunRPr = options.fieldRunRPr ?? null; const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); const pageNumNode = { name: 'sd:autoPageNumber', @@ -42,8 +31,8 @@ export function preProcessPageInstruction( // If no rPr was found in content nodes, use the rPr captured from the field sequence // (begin, instrText, or separate nodes) where Word stores the styling for page numbers. - if (!foundContentRPr && effectiveFieldRunRPr && effectiveFieldRunRPr.name === 'w:rPr') { - pageNumNode.elements = [effectiveFieldRunRPr]; + if (!foundContentRPr && fieldRunRPr && fieldRunRPr.name === 'w:rPr') { + pageNumNode.elements = [fieldRunRPr]; } return [pageNumNode]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index 6913d77765..1a1edeb126 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -8,7 +8,7 @@ describe('preProcessPageInstruction', () => { it('should create a sd:autoPageNumber node', () => { const nodesToCombine = []; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -28,7 +28,7 @@ describe('preProcessPageInstruction', () => { }, ]; const instruction = 'PAGE'; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx); + const result = preProcessPageInstruction(nodesToCombine, instruction, { docx: mockDocx }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -51,7 +51,7 @@ describe('preProcessPageInstruction', () => { { name: 'w:b' }, ], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -61,14 +61,18 @@ describe('preProcessPageInstruction', () => { ]); }); - it('should use fifth-argument fieldRunRPr from the generic field pipeline', () => { + it('should use fieldRunRPr from the generic field pipeline options', () => { const nodesToCombine = []; const instruction = 'PAGE \\* roman'; const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, mockDocx, [], fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: mockDocx, + instructionTokens: [], + fieldRunRPr, + }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -82,6 +86,21 @@ describe('preProcessPageInstruction', () => { ]); }); + it('should use options-object fieldRunRPr without inspecting docx shape', () => { + const nodesToCombine = []; + const instruction = 'PAGE \\* roman'; + const fieldRunRPr = { + name: 'w:rPr', + elements: [{ name: 'w:b' }], + }; + const docxWithName = { name: 'w:rPr' }; + const result = preProcessPageInstruction(nodesToCombine, instruction, { + docx: docxWithName, + fieldRunRPr, + }); + expect(result[0].elements).toEqual([fieldRunRPr]); + }); + it('should prefer content node rPr over fieldRunRPr', () => { // Content between separate and end takes priority over field sequence styling const contentRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; @@ -96,7 +115,7 @@ describe('preProcessPageInstruction', () => { name: 'w:rPr', elements: [{ name: 'w:b' }], }; - const result = preProcessPageInstruction(nodesToCombine, instruction, fieldRunRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -111,7 +130,7 @@ describe('preProcessPageInstruction', () => { const instruction = 'PAGE'; // Pass something that's not a w:rPr node const invalidRPr = { name: 'w:r', elements: [] }; - const result = preProcessPageInstruction(nodesToCombine, instruction, invalidRPr); + const result = preProcessPageInstruction(nodesToCombine, instruction, { fieldRunRPr: invalidRPr }); expect(result).toEqual([ { name: 'sd:autoPageNumber', @@ -121,7 +140,7 @@ describe('preProcessPageInstruction', () => { }); it('preserves PAGE general format switches as normalized attributes', () => { - const result = preProcessPageInstruction([], 'PAGE \\* roman', null); + const result = preProcessPageInstruction([], 'PAGE \\* roman'); expect(result[0].attributes).toEqual({ instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman', @@ -129,7 +148,7 @@ describe('preProcessPageInstruction', () => { }); it('preserves PAGE ArabicDash switches as normalized attributes', () => { - const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash', null); + const result = preProcessPageInstruction([], 'PAGE \\* ArabicDash'); expect(result[0].attributes).toEqual({ instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 82d19e5e2d..7df4ff0fa7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -9,6 +9,7 @@ import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/im const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); +const isPageNumberFieldInstruction = (instructionType) => instructionType === 'PAGE' || instructionType === 'NUMPAGES'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. @@ -141,7 +142,9 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionType = instr.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - const processed = instructionPreProcessor(node.elements ?? [], instr, docx, null); + const processed = isPageNumberFieldInstruction(instructionType) + ? instructionPreProcessor(node.elements ?? [], instr, { docx }) + : instructionPreProcessor(node.elements ?? [], instr, docx, null); if (collecting) { collectedNodesStack[collectedNodesStack.length - 1].push(...processed); rawCollectedNodesStack[rawCollectedNodesStack.length - 1].push(...processed); @@ -322,7 +325,9 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { - nodes: instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + nodes: isPageNumberFieldInstruction(instructionType) + ? instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }) + : instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), handled: true, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 0367a738ed..6c5cc0cea3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -8,6 +8,7 @@ import { preProcessDocumentStatInstruction } from './fld-preprocessors/document- const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); +const isPageNumberFieldType = (fieldType) => fieldType === 'PAGE' || fieldType === 'NUMPAGES'; /** * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering. @@ -62,7 +63,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = isPageNumberFieldType(fieldType) + ? fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }) + : fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); processedNodes.push(...processedField); i++; continue; @@ -98,7 +101,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) // which is where Word stores the styling for page number fields const contentNodes = fieldInfo.contentNodes; - const processedField = preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); + const processedField = isPageNumberFieldType(fieldInfo.fieldType) + ? preprocessor(contentNodes, fieldInfo.instrText, { fieldRunRPr: fieldInfo.fieldRunRPr }) + : preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); processedNodes.push(...processedField); // Skip past the entire field sequence @@ -127,7 +132,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // to a PAGE field by emitting sd:autoPageNumber. if (node.name === 'w:r' && node.elements?.some((el) => el.name === 'w:pgNum')) { const rPr = node.elements.find((el) => el.name === 'w:rPr') || null; - const processedField = preProcessPageInstruction([], '', rPr); + const processedField = preProcessPageInstruction([], '', { fieldRunRPr: rPr }); processedNodes.push(...processedField); i++; continue; From 9b668d24f5d16abe06b48f2cc94f22bdf3586884 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:08:50 -0300 Subject: [PATCH 09/14] refactor(super-editor): use field processor options object --- .../bibliography-preprocessor.js | 4 +++- .../citation-preprocessor.js | 6 +++--- .../document-stat-preprocessor.js | 21 +++++-------------- .../document-stat-preprocessor.test.js | 18 ++++++++-------- .../hyperlink-preprocessor.js | 5 +++-- .../hyperlink-preprocessor.test.js | 6 +++--- .../fld-preprocessors/index-preprocessor.js | 6 +++--- .../index-preprocessor.test.js | 4 ++-- .../fld-preprocessors/index.js | 9 +++++++- .../fld-preprocessors/noteref-preprocessor.js | 4 +++- .../page-ref-preprocessor.js | 4 +++- .../page-ref-preprocessor.test.js | 6 ++---- .../fld-preprocessors/ref-preprocessor.js | 4 +++- .../fld-preprocessors/seq-preprocessor.js | 6 +++--- .../styleref-preprocessor.js | 4 +++- .../fld-preprocessors/ta-preprocessor.js | 6 +++--- .../fld-preprocessors/tc-preprocessor.js | 6 +++--- .../fld-preprocessors/toa-preprocessor.js | 6 +++--- .../fld-preprocessors/toc-preprocessor.js | 4 +++- .../fld-preprocessors/xe-preprocessor.js | 6 +++--- .../fld-preprocessors/xe-preprocessor.test.js | 4 ++-- .../preProcessNodesForFldChar.js | 9 ++------ .../preProcessPageFieldsOnly.js | 11 ++++------ 23 files changed, 79 insertions(+), 80 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js index 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/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 7df4ff0fa7..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 @@ -9,7 +9,6 @@ import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/im const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); -const isPageNumberFieldInstruction = (instructionType) => instructionType === 'PAGE' || instructionType === 'NUMPAGES'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. @@ -142,9 +141,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const instructionType = instr.trim().split(' ')[0]; const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { - const processed = isPageNumberFieldInstruction(instructionType) - ? instructionPreProcessor(node.elements ?? [], instr, { docx }) - : instructionPreProcessor(node.elements ?? [], instr, docx, null); + const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); if (collecting) { collectedNodesStack[collectedNodesStack.length - 1].push(...processed); rawCollectedNodesStack[rawCollectedNodesStack.length - 1].push(...processed); @@ -325,9 +322,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i const instructionPreProcessor = getInstructionPreProcessor(instructionType); if (instructionPreProcessor) { return { - nodes: isPageNumberFieldInstruction(instructionType) - ? instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }) - : instructionPreProcessor(nodesToCombine, instrText, docx, instructionTokens, fieldRunRPr), + nodes: instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }), handled: true, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 6c5cc0cea3..a72e799b1b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -8,7 +8,6 @@ import { preProcessDocumentStatInstruction } from './fld-preprocessors/document- const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); -const isPageNumberFieldType = (fieldType) => fieldType === 'PAGE' || fieldType === 'NUMPAGES'; /** * Pre-processes nodes to convert PAGE and NUMPAGES field codes for header/footer rendering. @@ -63,9 +62,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { } } - const processedField = isPageNumberFieldType(fieldType) - ? fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }) - : fldSimplePreprocessor(contentNodes, instrAttr.trim(), fieldRunRPr); + const processedField = fldSimplePreprocessor(contentNodes, instrAttr.trim(), { fieldRunRPr }); processedNodes.push(...processedField); i++; continue; @@ -101,9 +98,9 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // Also pass the captured rPr from field sequence nodes (begin, instrText, separate) // which is where Word stores the styling for page number fields const contentNodes = fieldInfo.contentNodes; - const processedField = isPageNumberFieldType(fieldInfo.fieldType) - ? preprocessor(contentNodes, fieldInfo.instrText, { fieldRunRPr: fieldInfo.fieldRunRPr }) - : preprocessor(contentNodes, fieldInfo.instrText, fieldInfo.fieldRunRPr); + const processedField = preprocessor(contentNodes, fieldInfo.instrText, { + fieldRunRPr: fieldInfo.fieldRunRPr, + }); processedNodes.push(...processedField); // Skip past the entire field sequence From 08133dcc5c2e834cc0d9b8f6861b9284066712c1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 10:17:59 -0300 Subject: [PATCH 10/14] refactor(contracts): move page number formatting --- packages/layout-engine/contracts/src/index.ts | 73 ++----------------- .../src/page-number-formatting.test.ts | 2 +- .../contracts/src/page-number-formatting.ts | 65 +++++++++++++++++ 3 files changed, 73 insertions(+), 67 deletions(-) create mode 100644 packages/layout-engine/contracts/src/page-number-formatting.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f774f892b1..e2d2bc4f4a 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'; @@ -249,72 +256,6 @@ export type FlowRunLink = { history?: boolean; }; -export type PageNumberFieldFormat = { - format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; - zeroPadding?: number; -}; - -export type PageNumberFormat = NonNullable; - -function toUpperRoman(value: number): string { - if (value < 1 || value > 3999) return String(value); - - const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; - let remaining = value; - let result = ''; - - for (let i = 0; i < values.length; i += 1) { - while (remaining >= values[i]) { - result += numerals[i]; - remaining -= values[i]; - } - } - - return result; -} - -function toUpperLetter(value: number): string { - let n = Math.max(1, value); - let result = ''; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; -} - -export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { - const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); - - switch (format) { - case 'upperRoman': - return toUpperRoman(value); - case 'lowerRoman': - return toUpperRoman(value).toLowerCase(); - case 'upperLetter': - return toUpperLetter(value); - case 'lowerLetter': - return toUpperLetter(value).toLowerCase(); - case 'numberInDash': - return `-${value}-`; - case 'decimal': - default: - return String(value); - } -} - -export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { - const format = fieldFormat?.format ?? 'decimal'; - const formatted = formatPageNumber(pageNumber, format); - return fieldFormat?.zeroPadding && format === 'decimal' - ? formatted.padStart(fieldFormat.zeroPadding, '0') - : formatted; -} - /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts index c899f58bd1..529639ec1e 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatPageNumber, formatPageNumberFieldValue } from './index.js'; +import { formatPageNumber, formatPageNumberFieldValue } from './page-number-formatting.js'; describe('page number formatting', () => { it('formats the supported Word page number formats', () => { diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts new file mode 100644 index 0000000000..bf32393cda --- /dev/null +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -0,0 +1,65 @@ +export type PageNumberFieldFormat = { + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + zeroPadding?: number; +}; + +export type PageNumberFormat = NonNullable; + +function toUpperRoman(value: number): string { + if (value < 1 || value > 3999) return String(value); + + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']; + let remaining = value; + let result = ''; + + for (let i = 0; i < values.length; i += 1) { + while (remaining >= values[i]) { + result += numerals[i]; + remaining -= values[i]; + } + } + + return result; +} + +function toUpperLetter(value: number): string { + let n = Math.max(1, value); + let result = ''; + + while (n > 0) { + const remainder = (n - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + n = Math.floor((n - 1) / 26); + } + + return result; +} + +export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + + switch (format) { + case 'upperRoman': + return toUpperRoman(value); + case 'lowerRoman': + return toUpperRoman(value).toLowerCase(); + case 'upperLetter': + return toUpperLetter(value); + case 'lowerLetter': + return toUpperLetter(value).toLowerCase(); + case 'numberInDash': + return `-${value}-`; + case 'decimal': + default: + return String(value); + } +} + +export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + const format = fieldFormat?.format ?? 'decimal'; + const formatted = formatPageNumber(pageNumber, format); + return fieldFormat?.zeroPadding && format === 'decimal' + ? formatted.padStart(fieldFormat.zeroPadding, '0') + : formatted; +} From 29ebdcba7af3af011f4c276e39fe9281e974f923 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 11:14:31 -0300 Subject: [PATCH 11/14] fix(super-editor): preserve active header display numbers --- .../HeaderFooterSessionManager.ts | 1 + .../tests/HeaderFooterSessionManager.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index c3d328781b..7627011f54 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -2205,6 +2205,7 @@ export class HeaderFooterSessionManager { number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, + displayNumber: page.displayNumber, fragments: page.fragments, })), }; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 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); From 2aa057601f377d41bede651d87040f471ea62c3f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 11:48:20 -0300 Subject: [PATCH 12/14] fix(contracts): remove duplicate display number fields --- packages/layout-engine/contracts/src/index.ts | 2 -- packages/layout-engine/contracts/src/resolved-layout.ts | 3 --- .../layout-engine/layout-resolved/src/resolveHeaderFooter.ts | 1 - packages/layout-engine/layout-resolved/src/resolveLayout.ts | 1 - .../header-footer/HeaderFooterSessionManager.ts | 1 - 5 files changed, 8 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e2d2bc4f4a..19232a90c5 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1818,7 +1818,6 @@ export type Page = { /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ displayNumber?: number; numberText?: string; - displayNumber?: number; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; sectionRefs?: { @@ -2030,7 +2029,6 @@ export type HeaderFooterPage = { fragments: Fragment[]; displayNumber?: number; numberText?: string; - displayNumber?: number; /** * Optional page-local block clones backing this page's resolved fragments. * Present when header/footer tokens were laid out per page or per bucket. diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index f8c36bbbff..beebde5fd3 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -57,8 +57,6 @@ export type ResolvedPage = { displayNumber?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; - /** Section-aware numeric page value before formatting. */ - displayNumber?: number; /** Vertical alignment of content within this page. */ vAlign?: SectionVerticalAlign; /** Base section margins before header/footer inflation. Used for vAlign centering calculations. */ @@ -430,7 +428,6 @@ export type ResolvedHeaderFooterPage = { /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ displayNumber?: number; numberText?: string; - displayNumber?: number; items: ResolvedPaintItem[]; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index a1046f0ef9..695abb1b82 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -29,7 +29,6 @@ export function resolveHeaderFooterLayout( number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache), ), diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 4c00932530..d38d68dba3 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -320,7 +320,6 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { footnoteReserved: page.footnoteReserved, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, vAlign: page.vAlign, baseMargins: page.baseMargins, sectionIndex: page.sectionIndex, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 7627011f54..c3d328781b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -2205,7 +2205,6 @@ export class HeaderFooterSessionManager { number: page.number, displayNumber: page.displayNumber, numberText: page.numberText, - displayNumber: page.displayNumber, fragments: page.fragments, })), }; From 6157e5d22252a697c64d6397782f3c80b6159f07 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 13:34:35 -0300 Subject: [PATCH 13/14] fix(layout-bridge): bucket zero-padded page numbers --- .../layout-bridge/src/layoutHeaderFooter.ts | 25 ++++++++++++------ .../test/layoutHeaderFooterBucketing.test.ts | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 682cdc0546..814cdb9387 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -121,9 +121,18 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean { return false; } -function paragraphHasFormattedPageNumberToken(para: ParagraphBlock): boolean { +function isDigitBucketCompatiblePageNumberFormat(format?: string): boolean { + return !format || format === 'decimal' || format === 'numberInDash'; +} + +function paragraphRequiresPerPageLayout(para: ParagraphBlock): boolean { for (const run of para.runs) { - if ('token' in run && run.token === 'pageNumber' && run.pageNumberFieldFormat) { + if ( + 'token' in run && + run.token === 'pageNumber' && + run.pageNumberFieldFormat && + !isDigitBucketCompatiblePageNumberFormat(run.pageNumberFieldFormat.format) + ) { return true; } } @@ -155,10 +164,10 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { return false; } -function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { +function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { - if (paragraphHasFormattedPageNumberToken(block as ParagraphBlock)) return true; + if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true; } else if (block.kind === 'table') { const table = block as TableBlock; for (const row of table.rows ?? []) { @@ -168,7 +177,7 @@ function hasFormattedPageNumberTokens(blocks: FlowBlock[]): boolean { : cell.paragraph ? [cell.paragraph] : []; - if (hasFormattedPageNumberTokens(cellBlocks)) return true; + if (hasPageNumberTokensRequiringPerPageLayout(cellBlocks)) return true; } } } @@ -231,7 +240,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache(); * 2. If variant has no tokens: creates one layout reused across all pages (fast path) * 3. For small docs (<100 pages): creates per-page layouts * 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4) - * unless PAGE tokens have explicit field formatting + * unless PAGE tokens use non-decimal field formatting * * @param sections - Header/footer variants (default, first, even, odd) * @param constraints - Layout constraints (width, height, margins) @@ -297,10 +306,10 @@ export async function layoutHeaderFooterWithCache( // Determine which pages to create layouts for let pagesToLayout: number[]; - const useBucketingForVariant = useBucketing && !hasFormattedPageNumberTokens(blocks); + const useBucketingForVariant = useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks); if (!useBucketingForVariant) { - // Per-page layout: small docs, disabled bucketing, or explicit PAGE formats. + // Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats. pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1); HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false); } else { diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 36f3441eee..1c6f4a6e4d 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -414,6 +414,32 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(result.default?.layout.pages).toHaveLength(150); expect(measureBlock).toHaveBeenCalledTimes(150); }); + + it('should digit-bucket zero-padded decimal page-number tokens', async () => { + const block = makePageTokenBlock('header-zero-padded-page'); + const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun; + pageNumberRun.pageNumberFieldFormat = { format: 'decimal', zeroPadding: 3 }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + { default: [block] }, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(3); + expect(measureBlock).toHaveBeenCalledTimes(3); + expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005'); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { From 0b29b76558296a82a6714143a963ad18b62c9985 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 10:09:34 -0300 Subject: [PATCH 14/14] fix(super-editor): parse numeric page switch casing --- .../shared/page-number-field-switches.js | 8 +++++++- .../shared/page-number-field-switches.test.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index ddc6f945dc..969ea83a9b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -3,12 +3,18 @@ import { formatPageNumberFieldValue as formatSharedPageNumberFieldValue } from ' const GENERAL_FORMATS = new Map([ ['Arabic', 'decimal'], ['roman', 'lowerRoman'], + ['Roman', 'upperRoman'], ['ROMAN', 'upperRoman'], ['alphabetic', 'lowerLetter'], ['ALPHABETIC', 'upperLetter'], ['ArabicDash', 'numberInDash'], ]); +const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ + ['arabic', 'decimal'], + ['arabicdash', 'numberInDash'], +]); + /** * @param {string} instruction * @param {'PAGE' | 'NUMPAGES'} fieldType @@ -24,7 +30,7 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { const rawValue = unquote(match[1]); - const mapped = GENERAL_FORMATS.get(rawValue); + const mapped = GENERAL_FORMATS.get(rawValue) ?? CASE_INSENSITIVE_GENERAL_FORMATS.get(rawValue.toLowerCase()); if (mapped) { result.pageNumberFormat = mapped; break; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js index 8ce9e11d09..3c4a11b96f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -4,15 +4,27 @@ import { parsePageNumberFieldSwitches } from './page-number-field-switches.js'; describe('parsePageNumberFieldSwitches', () => { it.each([ ['PAGE \\* Arabic', { instruction: 'PAGE \\* Arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* arabic', { instruction: 'PAGE \\* arabic', pageNumberFormat: 'decimal' }], + ['PAGE \\* ARABIC', { instruction: 'PAGE \\* ARABIC', pageNumberFormat: 'decimal' }], ['PAGE \\* roman', { instruction: 'PAGE \\* roman', pageNumberFormat: 'lowerRoman' }], + ['PAGE \\* Roman', { instruction: 'PAGE \\* Roman', pageNumberFormat: 'upperRoman' }], ['PAGE \\* ROMAN', { instruction: 'PAGE \\* ROMAN', pageNumberFormat: 'upperRoman' }], ['PAGE \\* alphabetic', { instruction: 'PAGE \\* alphabetic', pageNumberFormat: 'lowerLetter' }], ['PAGE \\* ALPHABETIC', { instruction: 'PAGE \\* ALPHABETIC', pageNumberFormat: 'upperLetter' }], ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* arabicdash', { instruction: 'PAGE \\* arabicdash', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* ARABICDASH', { instruction: 'PAGE \\* ARABICDASH', pageNumberFormat: 'numberInDash' }], ])('parses general format switch %s', (instruction, expected) => { expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); }); + it.each([['PAGE \\* rOman'], ['PAGE \\* Alphabetic'], ['PAGE \\* aLpHaBeTiC']])( + 'does not case-fold output-case-sensitive switch %s', + (instruction) => { + expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual({ instruction }); + }, + ); + it.each([ ['NUMPAGES \\# "00"', { instruction: 'NUMPAGES \\# "00"', pageNumberFormat: 'decimal', pageNumberZeroPadding: 2 }], ['NUMPAGES \\# 000', { instruction: 'NUMPAGES \\# 000', pageNumberFormat: 'decimal', pageNumberZeroPadding: 3 }],