diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 6c86a4d890..a12f98efcb 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -2260,8 +2260,9 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; export type HeaderFooterPage = { number: number; fragments: Fragment[]; - displayNumber?: number; numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; /** * Optional page-local block clones backing this page's resolved fragments. * Present when header/footer tokens were laid out per page or per bucket. diff --git a/packages/layout-engine/contracts/src/page-number-formatting.test.ts b/packages/layout-engine/contracts/src/page-number-formatting.test.ts index 529639ec1e..f372797f72 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -7,8 +7,9 @@ describe('page number formatting', () => { expect(formatPageNumber(5, 'upperRoman')).toBe('V'); expect(formatPageNumber(5, 'lowerRoman')).toBe('v'); expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); - expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); - expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); + expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); }); it('normalizes page numbers before formatting', () => { @@ -17,6 +18,10 @@ describe('page number formatting', () => { expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1'); }); + it('falls back to decimal for unsupported runtime formats', () => { + expect(formatPageNumber(5, 'chicago' as never)).toBe('5'); + }); + it('falls back to decimal for roman numerals beyond 3999', () => { expect(formatPageNumber(4000, 'upperRoman')).toBe('4000'); }); diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts index bf32393cda..413e14bb8a 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -24,16 +24,10 @@ function toUpperRoman(value: number): string { } function toUpperLetter(value: number): string { - let n = Math.max(1, value); - let result = ''; - - while (n > 0) { - const remainder = (n - 1) % 26; - result = String.fromCharCode(65 + remainder) + result; - n = Math.floor((n - 1) / 26); - } - - return result; + const normalized = Math.max(1, value); + const index = (normalized - 1) % 26; + const repeatCount = Math.floor((normalized - 1) / 26) + 1; + return String.fromCharCode(65 + index).repeat(repeatCount); } export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { @@ -49,7 +43,7 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): case 'lowerLetter': return toUpperLetter(value).toLowerCase(); case 'numberInDash': - return `-${value}-`; + return `- ${value} -`; case 'decimal': default: return String(value); diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index f2316f9613..fdad65d628 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -452,9 +452,9 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved /** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ export type ResolvedHeaderFooterPage = { number: number; - /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ - displayNumber?: number; numberText?: string; + /** Section-aware numeric page value before formatting. */ + displayNumber?: number; items: ResolvedPaintItem[]; }; diff --git a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts index 3df39b1783..7bc0bd8ddc 100644 --- a/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts +++ b/packages/layout-engine/layout-bridge/src/cacheInvalidation.ts @@ -51,6 +51,7 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string { if ('bold' in run && run.bold) parts.push('b'); if ('italic' in run && run.italic) parts.push('i'); if ('token' in run && run.token) parts.push(`token:${run.token}`); + if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`); } } } diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 814cdb9387..bd81f901e6 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -1,4 +1,11 @@ -import type { FlowBlock, HeaderFooterLayout, Measure, ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { + FlowBlock, + HeaderFooterLayout, + ListBlock, + Measure, + ParagraphBlock, + TableBlock, +} from '@superdoc/contracts'; import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine'; import { MeasureCache } from './cache'; import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens'; @@ -143,6 +150,11 @@ function hasPageTokens(blocks: FlowBlock[]): boolean { for (const block of blocks) { if (block.kind === 'paragraph') { if (paragraphHasPageToken(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphHasPageToken(item.paragraph)) return true; + } } else if (block.kind === 'table') { // SD-1332: PAGE fields can live inside table cells in headers/footers // (Word's typical layout). Skipping tables here would take the @@ -168,6 +180,11 @@ function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean for (const block of blocks) { if (block.kind === 'paragraph') { if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true; + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (paragraphRequiresPerPageLayout(item.paragraph)) return true; + } } else if (block.kind === 'table') { const table = block as TableBlock; for (const row of table.rows ?? []) { @@ -332,6 +349,7 @@ export async function layoutHeaderFooterWithCache( blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; + numberText?: string; }> = []; for (const pageNum of pagesToLayout) { @@ -372,6 +390,7 @@ export async function layoutHeaderFooterWithCache( blocks: clonedBlocks, measures, fragments: fragmentsWithLines, + numberText: displayText, }); } @@ -390,6 +409,7 @@ export async function layoutHeaderFooterWithCache( number: p.number, displayNumber: p.displayNumber, fragments: p.fragments, + numberText: p.numberText, blocks: p.blocks, measures: p.measures, })), diff --git a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts index 0d50ca0f35..fb3398face 100644 --- a/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts +++ b/packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts @@ -52,6 +52,25 @@ describe('Cache Invalidation', () => { expect(hash).toContain('token:pageNumber'); }); + it('should include page number token format in hash', () => { + const decimalBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }], + } as ParagraphBlock, + ]; + const romanBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }], + } as ParagraphBlock, + ]; + + expect(computeHeaderFooterContentHash(decimalBlocks)).not.toBe(computeHeaderFooterContentHash(romanBlocks)); + }); + it('should produce different hashes for different content', () => { const blocks1: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts index 1c6f4a6e4d..b25c583b9a 100644 --- a/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts +++ b/packages/layout-engine/layout-bridge/test/layoutHeaderFooterBucketing.test.ts @@ -89,6 +89,23 @@ const makePageTokenBlock = (id: string): FlowBlock => ({ ], }); +const makeFormattedPageTokenBlock = ( + id: string, + pageNumberFieldFormat: NonNullable, +): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}); + describe('getBucketForPageNumber', () => { it('should return d1 for single-digit page numbers (1-9)', () => { expect(getBucketForPageNumber(1)).toBe('d1'); @@ -440,6 +457,34 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => { expect(measureBlock).toHaveBeenCalledTimes(3); expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005'); }); + + it.each([ + ['decimal', { format: 'decimal' }], + ['numberInDash', { format: 'numberInDash' }], + ] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFieldFormat) => { + const sections = { + default: [makeFormattedPageTokenBlock(`header-${pageNumberFieldFormat.format}`, pageNumberFieldFormat)], + }; + + const pageResolver: PageResolver = (pageNum) => ({ + displayText: String(pageNum), + displayNumber: pageNum, + totalPages: 150, + }); + + const measureBlock = vi.fn(async () => makeMeasure(20)); + const result = await layoutHeaderFooterWithCache( + sections, + { width: 400, height: 80 }, + measureBlock, + undefined, + undefined, + pageResolver, + ); + + expect(result.default?.layout.pages).toHaveLength(3); + expect(measureBlock).toHaveBeenCalledTimes(3); + }); }); describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => { diff --git a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts index 7598711fd8..35d26065cd 100644 --- a/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveHeaderFooterTokens.test.ts @@ -81,7 +81,7 @@ describe('resolveHeaderFooterTokens', () => { resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7); const block = blocks[0] as ParagraphBlock; - expect(block.runs[0].text).toBe('-7-'); + expect(block.runs[0].text).toBe('- 7 -'); expect((block.runs[0] as TextRun).token).toBe('pageNumber'); }); diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index 87ef3473ea..777f2ee828 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -31,16 +31,20 @@ describe('formatPageNumber', () => { it('should truncate fractional numbers before formatting', () => { expect(formatPageNumber(4.9, 'decimal')).toBe('4'); }); + + it('should fall back to decimal for unsupported runtime formats', () => { + expect(formatPageNumber(5, 'chicago' as never)).toBe('5'); + }); }); describe('numberInDash format', () => { it('should wrap numbers in dashes', () => { - expect(formatPageNumber(1, 'numberInDash')).toBe('-1-'); - expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + expect(formatPageNumber(1, 'numberInDash')).toBe('- 1 -'); + expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); }); it('should clamp zero to 1', () => { - expect(formatPageNumber(0, 'numberInDash')).toBe('-1-'); + expect(formatPageNumber(0, 'numberInDash')).toBe('- 1 -'); }); }); @@ -128,19 +132,19 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'upperLetter')).toBe('Z'); }); - it('should format numbers > 26 as AA, AB, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); - expect(formatPageNumber(28, 'upperLetter')).toBe('AB'); - expect(formatPageNumber(52, 'upperLetter')).toBe('AZ'); - expect(formatPageNumber(53, 'upperLetter')).toBe('BA'); - expect(formatPageNumber(78, 'upperLetter')).toBe('BZ'); - expect(formatPageNumber(79, 'upperLetter')).toBe('CA'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(52, 'upperLetter')).toBe('ZZ'); + expect(formatPageNumber(53, 'upperLetter')).toBe('AAA'); + expect(formatPageNumber(78, 'upperLetter')).toBe('ZZZ'); + expect(formatPageNumber(79, 'upperLetter')).toBe('AAAA'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'upperLetter')).toBe('ZZ'); - expect(formatPageNumber(703, 'upperLetter')).toBe('AAA'); - expect(formatPageNumber(704, 'upperLetter')).toBe('AAB'); + expect(formatPageNumber(702, 'upperLetter')).toBe('Z'.repeat(27)); + expect(formatPageNumber(703, 'upperLetter')).toBe('A'.repeat(28)); + expect(formatPageNumber(704, 'upperLetter')).toBe('B'.repeat(28)); }); it('should clamp zero and negative to A', () => { @@ -158,16 +162,16 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'lowerLetter')).toBe('z'); }); - it('should format numbers > 26 as aa, ab, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'lowerLetter')).toBe('aa'); - expect(formatPageNumber(28, 'lowerLetter')).toBe('ab'); - expect(formatPageNumber(52, 'lowerLetter')).toBe('az'); - expect(formatPageNumber(53, 'lowerLetter')).toBe('ba'); + expect(formatPageNumber(28, 'lowerLetter')).toBe('bb'); + expect(formatPageNumber(52, 'lowerLetter')).toBe('zz'); + expect(formatPageNumber(53, 'lowerLetter')).toBe('aaa'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'lowerLetter')).toBe('zz'); - expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(702, 'lowerLetter')).toBe('z'.repeat(27)); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); }); it('should clamp zero and negative to a', () => { @@ -434,7 +438,7 @@ describe('computeDisplayPageNumber', () => { expect(result[24].displayText).toBe('Y'); expect(result[25].displayText).toBe('Z'); expect(result[26].displayText).toBe('AA'); - expect(result[27].displayText).toBe('AB'); + expect(result[27].displayText).toBe('BB'); }); it('should handle large page numbers in roman numerals', () => { diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts index 8857cf8cf4..c06f7c47eb 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts @@ -276,4 +276,27 @@ describe('resolveTokensInBlock', () => { expect((block.runs[0] as { pmStart?: number }).pmStart).toBe(10); expect((block.runs[0] as { pmEnd?: number }).pmEnd).toBe(11); }); + + it('should apply run-local page number format when resolving tokens', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-local-format', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + }; + + const wasModified = resolveTokensInBlock(block, 5, 10); + + expect(wasModified).toBe(true); + expect((block.runs[0] as TextRun).text).toBe('V'); + expect((block.runs[0] as TextRun).token).toBeUndefined(); + expect((block.runs[0] as TextRun).pageNumberFieldFormat).toBeUndefined(); + }); }); diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index b8880041ba..0b3c01e2e3 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -208,11 +208,11 @@ function cloneBlockWithResolvedTokens( if ('token' in run && run.token) { if (run.token === 'pageNumber') { // Clone the run and resolve the token - const { token: _token, ...runWithoutToken } = run; + const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run; return { ...runWithoutToken, - text: run.pageNumberFieldFormat - ? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat) + text: pageNumberFieldFormat + ? formatPageNumberFieldValue(displayPageInfo.displayNumber, pageNumberFieldFormat) : displayPageInfo.displayText, }; } else if (run.token === 'totalPageCount') { @@ -284,9 +284,12 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, if ('token' in run && run.token) { if (run.token === 'pageNumber') { // Replace placeholder text with actual page number - run.text = pageNumberStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(pageNumber, run.pageNumberFieldFormat) + : pageNumberStr; // Clear token metadata to treat as normal text after resolution delete run.token; + delete run.pageNumberFieldFormat; blockModified = true; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 89d56326f8..c75e9ba493 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -355,6 +355,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { textRun.vertAlign ?? '', textRun.baselineShift != null ? textRun.baselineShift : '', textRun.token ?? '', + textRun.pageNumberFieldFormat ? JSON.stringify(textRun.pageNumberFieldFormat) : '', trackedVersion, textRun.comments?.length ?? 0, // SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it. @@ -539,6 +540,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => { hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); hash = hashString(hash, getRunStringProp(run, 'vertAlign')); hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + hash = hashString(hash, getRunStringProp(run, 'token')); + const pageNumberFieldFormat = (run as { pageNumberFieldFormat?: unknown }).pageNumberFieldFormat; + hash = hashString(hash, pageNumberFieldFormat ? JSON.stringify(pageNumberFieldFormat) : ''); // SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash. const bidi = (run as { bidi?: unknown }).bidi; hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 76c62c6cc5..aea53d5eb2 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5763,7 +5763,7 @@ describe('DomPainter', () => { const footerEl = mount.querySelector('.superdoc-page-footer'); expect(footerEl).toBeTruthy(); - expect(footerEl?.textContent).toBe('-4-'); + expect(footerEl?.textContent).toBe('- 4 -'); }); it('bottom-aligns footer content within the footer box', () => { @@ -6472,6 +6472,62 @@ describe('DomPainter', () => { expect(svgEl?.style.transform).toBe(''); }); + it('rebuilds drawing text with PAGE fields when page context changes during patch rendering', () => { + const vectorShapeBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-page-field', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + shapeKind: 'rect', + textContent: { + parts: [ + { text: 'Page ', formatting: { fontFamily: 'Arial', fontSize: 18 } }, + { text: '', fieldType: 'PAGE', formatting: { fontFamily: 'Arial', fontSize: 18 } }, + ], + }, + textAlign: 'center', + }; + + const vectorShapeMeasure: Measure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 100, + height: 50, + scale: 1, + naturalWidth: 100, + naturalHeight: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + }; + + const drawingFragment = { + kind: 'drawing' as const, + drawingKind: 'vectorShape' as const, + blockId: 'drawing-page-field', + x: 30, + y: 40, + width: 100, + height: 50, + geometry: { width: 100, height: 50, rotation: 0, flipH: false, flipV: false }, + scale: 1, + }; + + const painter = createTestPainter({ blocks: [vectorShapeBlock], measures: [vectorShapeMeasure] }); + const firstLayout: Layout = { + pageSize: layout.pageSize, + pages: [{ number: 1, numberText: '1', fragments: [drawingFragment] }], + }; + const secondLayout: Layout = { + pageSize: layout.pageSize, + pages: [{ number: 2, numberText: '2', fragments: [drawingFragment] }], + }; + + painter.paint(firstLayout, mount); + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 1'); + + painter.paint(secondLayout, mount); + expect(mount.querySelector('.superdoc-vector-shape')?.textContent).toContain('Page 2'); + }); + describe('resolved paragraph rendering', () => { it('renders resolved paragraph lines with precomputed indent styles', () => { const paragraphBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts index 212210b2a3..d4f730c418 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -162,6 +162,7 @@ export const deriveParagraphBlockVersion = ( textRun.vertAlign ?? '', textRun.baselineShift != null ? textRun.baselineShift : '', textRun.token ?? '', + textRun.pageNumberFieldFormat ? JSON.stringify(textRun.pageNumberFieldFormat) : '', trackedVersion, textRun.comments?.length ?? 0, ].join(','); diff --git a/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts new file mode 100644 index 0000000000..606c2760a3 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, Layout, Measure, TextRun } from '@superdoc/contracts'; +import { createTestPainter } from './_test-utils.js'; + +const pageNumberBlock: FlowBlock = { + kind: 'paragraph', + id: 'page-number-block', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}; + +const pageNumberMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 10, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +const staticBlock: FlowBlock = { + kind: 'paragraph', + id: 'static-block', + runs: [ + { + text: 'Static', + fontFamily: 'Arial', + fontSize: 12, + }, + ], +}; + +const staticMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 40, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +function makeLayout(displayNumber: number): Layout { + return { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + displayNumber, + fragments: [ + { + kind: 'para', + blockId: 'page-number-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }, + { + kind: 'para', + blockId: 'static-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 20, + width: 200, + }, + ], + }, + ], + }; +} + +describe('DomPainter page-number context patching', () => { + it('rebuilds token fragments when display page number changes during incremental patch', () => { + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const painter = createTestPainter({ + blocks: [pageNumberBlock, staticBlock], + measures: [pageNumberMeasure, staticMeasure], + }); + + painter.paint(makeLayout(5), mount); + expect(mount.textContent).toContain('V'); + const staticFragment = mount.querySelector('[data-block-id="static-block"]'); + expect(staticFragment).toBeTruthy(); + + painter.paint(makeLayout(8), mount); + expect(mount.textContent).toContain('VIII'); + expect(mount.querySelector('[data-block-id="static-block"]')).toBe(staticFragment); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 18390c37e8..b1c9f832bf 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -38,6 +38,7 @@ import type { ResolvedDrawingItem, LayoutSourceIdentity, LayoutStoryLocator, + ListBlock, } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, @@ -258,6 +259,83 @@ type PageDomState = { fragments: FragmentDomState[]; }; +function pageContextSignature(context: FragmentRenderContext): string { + return [context.pageNumber, context.totalPages, context.pageNumberText ?? '', context.displayPageNumber ?? ''].join( + '|', + ); +} + +function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { + return ( + Array.isArray(textContent?.parts) && + textContent.parts.some((part) => part.fieldType === 'PAGE' || part.fieldType === 'NUMPAGES') + ); +} + +function hasPageContextTokenInShapeGroup(shapes: readonly ShapeGroupChild[] | undefined): boolean { + return ( + Array.isArray(shapes) && + shapes.some((shape) => { + if (shape.shapeType !== 'vectorShape') { + return false; + } + return hasPageContextTokenInShapeText(shape.attrs.textContent); + }) + ); +} + +function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { + if (!block) return false; + if (block.kind === 'paragraph') { + for (const run of (block as ParagraphBlock).runs) { + if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) { + return true; + } + } + } else if (block.kind === 'list') { + const list = block as ListBlock; + for (const item of list.items ?? []) { + if (hasPageContextTokenInBlock(item.paragraph)) { + return true; + } + } + } else if (block.kind === 'table') { + const table = block as TableBlock; + for (const row of table.rows ?? []) { + for (const cell of row.cells ?? []) { + const cellBlocks: FlowBlock[] = cell.blocks + ? (cell.blocks as FlowBlock[]) + : cell.paragraph + ? [cell.paragraph] + : []; + if (cellBlocks.some(hasPageContextTokenInBlock)) { + return true; + } + } + } + } else if (block.kind === 'drawing') { + const drawing = block as DrawingBlock; + if (drawing.drawingKind === 'vectorShape') { + return hasPageContextTokenInShapeText(drawing.textContent); + } + if (drawing.drawingKind === 'shapeGroup') { + return hasPageContextTokenInShapeGroup(drawing.shapes); + } + } + return false; +} + +function needsRebuildForPageContext( + currentContext: FragmentRenderContext, + nextContext: FragmentRenderContext, + resolvedItem: ResolvedPaintItem | undefined, +): boolean { + const block = resolvedItem?.kind === 'fragment' && 'block' in resolvedItem ? resolvedItem.block : undefined; + return ( + pageContextSignature(currentContext) !== pageContextSignature(nextContext) && hasPageContextTokenInBlock(block) + ); +} + /** * Rendering context passed to fragment renderers containing page metadata. * Provides information about the current page position and section for dynamic content like page numbers. @@ -2297,6 +2375,7 @@ export class DomPainter { (current.element.dataset.betweenBorder === 'true') !== (betweenInfo?.showBetweenBorder ?? false) || (current.element.dataset.suppressTopBorder === 'true') !== (betweenInfo?.suppressTopBorder ?? false) || (current.element.dataset.gapBelow ?? '') !== (betweenInfo?.gapBelow ? String(betweenInfo.gapBelow) : ''); + const pageContextChanged = needsRebuildForPageContext(current.context, contextBase, resolvedItem); // Verify the position mapping is reliable: if mapping the old pmStart doesn't produce // the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and // we must rebuild to get correct span position attributes. @@ -2312,6 +2391,7 @@ export class DomPainter { current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || + pageContextChanged || mappingUnreliable; if (needsRebuild) { diff --git a/packages/layout-engine/painters/dom/src/runs/hash.ts b/packages/layout-engine/painters/dom/src/runs/hash.ts index 94002063a2..ad14859059 100644 --- a/packages/layout-engine/painters/dom/src/runs/hash.ts +++ b/packages/layout-engine/painters/dom/src/runs/hash.ts @@ -160,6 +160,7 @@ export const textRunMergeSignature = (run: TextRun): string => highlight: run.highlight ?? null, textTransform: run.textTransform ?? null, token: run.token ?? null, + pageNumberFieldFormat: run.pageNumberFieldFormat ?? null, pageRefMetadata: run.pageRefMetadata ?? null, trackedChange: run.trackedChange ?? null, trackedChanges: run.trackedChanges ?? null, diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts new file mode 100644 index 0000000000..0647645ef8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { TextRun } from '@superdoc/contracts'; +import type { FragmentRenderContext } from '../renderer.js'; +import { textRunMergeSignature } from './hash.js'; +import { resolveRunText } from './text-run.js'; + +describe('resolveRunText', () => { + const context: FragmentRenderContext = { + pageNumber: 1, + displayPageNumber: 5, + pageNumberText: 'v', + totalPages: 10, + section: 'body', + }; + + it('uses section-formatted page number text without a local format', () => { + const run: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; + + expect(resolveRunText(run, context)).toBe('v'); + }); + + it('uses run-local page number format when present', () => { + const run: TextRun = { + text: '0', + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + fontFamily: 'Arial', + fontSize: 12, + }; + + expect(resolveRunText(run, context)).toBe('V'); + }); + + it('changes merge signature when pageNumberFieldFormat changes', () => { + const baseRun: TextRun = { text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 12 }; + const formattedRun: TextRun = { ...baseRun, pageNumberFieldFormat: { format: 'upperRoman' } }; + + expect(textRunMergeSignature(baseRun)).not.toBe(textRunMergeSignature(formattedRun)); + }); +}); diff --git a/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts b/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts index 05630084c2..d69b592f83 100644 --- a/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts +++ b/packages/layout-engine/tests/src/footnote-formatter-parity.test.ts @@ -4,21 +4,22 @@ * `v1 layout-adapter/footnote-formatting.ts` deliberately inlines its number-format * switch instead of reusing layout-engine's `formatPageNumber` — the package * graph forbids the adapter from importing layout-engine at runtime (Guard C in - * `architecture-boundaries.test.ts`). To keep the two implementations in sync - * we assert here that they agree on every supported format for cardinals 1..100. + * `architecture-boundaries.test.ts`). To keep the shared semantics in sync we + * assert here that they agree on formats with the same expected rendering. * - * If you add a new format to one helper, this test will fail until you add the - * matching case in the other helper. That is the intended behavior. + * If you add a new shared-semantics format to one helper, this test should fail + * until you add the matching case in the other helper. Helper-specific formats + * are pinned by direct-string assertions below. */ import { describe, it, expect } from 'vitest'; import { formatPageNumber } from '@superdoc/layout-engine'; import { formatFootnoteCardinal } from '@core/layout-adapter/footnote-formatting.js'; -const FORMATS = ['decimal', 'upperRoman', 'lowerRoman', 'upperLetter', 'lowerLetter', 'numberInDash'] as const; +const SHARED_FORMATS = ['decimal', 'upperRoman', 'lowerRoman'] as const; describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { - for (const fmt of FORMATS) { + for (const fmt of SHARED_FORMATS) { it(`agrees with formatPageNumber for ${fmt} on 1..100`, () => { for (let n = 1; n <= 100; n += 1) { expect(formatFootnoteCardinal(n, fmt)).toBe(formatPageNumber(n, fmt)); @@ -36,15 +37,10 @@ describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { expect(formatFootnoteCardinal(-3, 'upperRoman')).toBe(formatPageNumber(-3, 'upperRoman')); }); - // Direct-string assertions: parity-only tests close the loop only if both - // helpers are correct. Pin the expected output for the less-obvious formats - // so a regression in BOTH helpers (e.g. someone "fixing" the inlined - // numberInDash to ` ${num} ` style) fails here rather than silently passing. - it('formats numberInDash as -n- in both helpers', () => { + it('formats numberInDash according to each helper contract', () => { for (const n of [1, 5, 12, 99]) { - const expected = `-${n}-`; - expect(formatFootnoteCardinal(n, 'numberInDash')).toBe(expected); - expect(formatPageNumber(n, 'numberInDash')).toBe(expected); + expect(formatFootnoteCardinal(n, 'numberInDash')).toBe(`-${n}-`); + expect(formatPageNumber(n, 'numberInDash')).toBe(`- ${n} -`); } }); @@ -71,18 +67,25 @@ describe('SD-2986/B1: footnote formatter parity with formatPageNumber', () => { expect(formatPageNumber(9, 'lowerRoman')).toBe('ix'); }); - it('formats upperLetter / lowerLetter using base-26 cycle (a, b, ..., z, aa)', () => { + it('formats footnote upperLetter / lowerLetter using spreadsheet-style letters', () => { expect(formatFootnoteCardinal(1, 'upperLetter')).toBe('A'); expect(formatFootnoteCardinal(26, 'upperLetter')).toBe('Z'); expect(formatFootnoteCardinal(27, 'upperLetter')).toBe('AA'); + expect(formatFootnoteCardinal(28, 'upperLetter')).toBe('AB'); expect(formatFootnoteCardinal(1, 'lowerLetter')).toBe('a'); expect(formatFootnoteCardinal(26, 'lowerLetter')).toBe('z'); expect(formatFootnoteCardinal(27, 'lowerLetter')).toBe('aa'); + expect(formatFootnoteCardinal(28, 'lowerLetter')).toBe('ab'); + }); + + it('formats page upperLetter / lowerLetter using repeated letters', () => { expect(formatPageNumber(1, 'upperLetter')).toBe('A'); expect(formatPageNumber(26, 'upperLetter')).toBe('Z'); expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); expect(formatPageNumber(1, 'lowerLetter')).toBe('a'); expect(formatPageNumber(26, 'lowerLetter')).toBe('z'); expect(formatPageNumber(27, 'lowerLetter')).toBe('aa'); + expect(formatPageNumber(28, 'lowerLetter')).toBe('bb'); }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts index 0798dce8af..d59a31cb37 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/generic-token.test.ts @@ -46,6 +46,18 @@ describe('tokenNodeToRun', () => { expect(result.token).toBe('totalPageCount'); }); + it('carries PAGE field-local page number format', () => { + const tokenNode: PMNode = { + type: 'page-number', + attrs: { pageNumberFormat: 'lowerRoman' }, + }; + const positions: PositionMap = new WeakMap(); + + const result = tokenNodeToRun(tokenNode, positions, 'Arial', 16, [], 'pageNumber'); + + expect(result.pageNumberFieldFormat).toEqual({ format: 'lowerRoman' }); + }); + it('attaches PM position tracking when position exists', () => { const tokenNode: PMNode = { type: 'page-number', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts index 6fe7e37f45..9bdcc3e5b6 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts @@ -678,6 +678,31 @@ describe('toFlowBlocks', () => { }); }); + it('preserves PAGE field-local page number format on token runs', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'page-number', + attrs: { pageNumberFormat: 'upperRoman' }, + }, + ], + }, + ], + }; + + const { + blocks: [block], + } = toFlowBlocks(pmDoc); + expect(block.runs[0]).toMatchObject({ + token: 'pageNumber', + pageNumberFieldFormat: { format: 'upperRoman' }, + }); + }); + it('preserves bold formatting on page number token', () => { const pmDoc = { type: 'doc', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js new file mode 100644 index 0000000000..4b81b09341 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js @@ -0,0 +1,14 @@ +/** + * Extracts the field dispatch keyword from an instruction string. + * Field type names are case-insensitive in OOXML; only normalize the dispatch + * token so downstream processors still receive the original instruction text. + * + * @param {string} instruction + * @returns {string} + */ +export function extractFieldKeyword(instruction) { + return String(instruction ?? '') + .trim() + .split(/\s+/)[0] + .toUpperCase(); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js new file mode 100644 index 0000000000..40eccdd407 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js @@ -0,0 +1,16 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { extractFieldKeyword } from './field-keyword.js'; + +describe('extractFieldKeyword', () => { + it.each([ + [null, ''], + [undefined, ''], + ['', ''], + [' ', ''], + [' page \\* arabic ', 'PAGE'], + ['toc \\o "1-3"', 'TOC'], + ])('extracts the uppercase dispatch keyword from %s', (instruction, expected) => { + expect(extractFieldKeyword(instruction)).toBe(expected); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js index 36e56f8c43..c7d9943867 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js @@ -14,7 +14,7 @@ import { generateDocxRandomId } from '@helpers/generateDocxRandomId.js'; * when the instruction has no recognisable target. */ export function resolveHyperlinkAttributes(instruction, docx) { - const urlMatch = instruction.match(/HYPERLINK\s+"([^"]+)"/); + const urlMatch = instruction.match(/^\s*HYPERLINK\s+"([^"]+)"/i); if (urlMatch && urlMatch.length >= 2) { const url = urlMatch[1]; const rels = docx?.['word/_rels/document.xml.rels']; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js index 0188dee25f..f1b5ff97fb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.js @@ -15,6 +15,7 @@ import { preProcessBibliographyInstruction } from './bibliography-preprocessor.j import { preProcessTaInstruction } from './ta-preprocessor.js'; import { preProcessToaInstruction } from './toa-preprocessor.js'; import { preProcessDocumentStatInstruction } from './document-stat-preprocessor.js'; +import { extractFieldKeyword } from '../field-keyword.js'; /** * @typedef {object} FieldPreprocessorOptions @@ -37,7 +38,10 @@ import { preProcessDocumentStatInstruction } from './document-stat-preprocessor. * @returns {InstructionPreProcessor | null} The pre-processor function or null if not found. */ export const getInstructionPreProcessor = (instruction) => { - const instructionType = instruction.trim().split(/\s+/)[0]; + const rawInstructionType = String(instruction ?? '') + .trim() + .split(/\s+/)[0]; + const instructionType = extractFieldKeyword(instruction); switch (instructionType) { case 'PAGE': return preProcessPageInstruction; @@ -65,6 +69,7 @@ export const getInstructionPreProcessor = (instruction) => { case 'STYLEREF': return preProcessStylerefInstruction; case 'SEQ': + if (rawInstructionType !== 'SEQ') return null; return preProcessSeqInstruction; case 'CITATION': return preProcessCitationInstruction; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js index 0b6c992709..227be4e206 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js @@ -6,6 +6,8 @@ import { preProcessNumPagesInstruction } from './num-pages-preprocessor.js'; import { preProcessPageRefInstruction } from './page-ref-preprocessor.js'; import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js'; import { preProcessTocInstruction } from './toc-preprocessor.js'; +import { preProcessRefInstruction } from './ref-preprocessor.js'; +import { preProcessSeqInstruction } from './seq-preprocessor.js'; describe('getInstructionPreProcessor', () => { const mockDocx = { @@ -20,6 +22,14 @@ describe('getInstructionPreProcessor', () => { expect(processor).toBe(preProcessPageInstruction); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should return preProcessPageInstruction for case-insensitive PAGE instruction %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessPageInstruction); + }, + ); + it('should return preProcessNumPagesInstruction for NUMPAGES instruction', () => { const instruction = 'NUMPAGES'; const processor = getInstructionPreProcessor(instruction); @@ -32,6 +42,14 @@ describe('getInstructionPreProcessor', () => { expect(processor).toBe(preProcessNumPagesInstruction); }); + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should return preProcessNumPagesInstruction for case-insensitive NUMPAGES instruction %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessNumPagesInstruction); + }, + ); + it('should return preProcessPageRefInstruction for PAGEREF instruction', () => { const instruction = 'PAGEREF _Toc123456789 h'; const processor = getInstructionPreProcessor(instruction); @@ -46,6 +64,26 @@ describe('getInstructionPreProcessor', () => { expect(processor([], instruction, mockDocx)).toBeDefined(); }); + it.each([ + ['pageref _Toc123456789 h', preProcessPageRefInstruction], + ['hyperlink "http://example.com"', preProcessHyperlinkInstruction], + ['toc \\o "1-3" \\h \\z \\u', preProcessTocInstruction], + ['ref BookmarkName \\h', preProcessRefInstruction], + ])('should dispatch non-page field instruction case-insensitively: %s', (instruction, expectedProcessor) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(expectedProcessor); + }); + + it('should dispatch uppercase SEQ fields', () => { + const processor = getInstructionPreProcessor('SEQ Figure \\* ARABIC'); + expect(processor).toBe(preProcessSeqInstruction); + }); + + it('should leave lowercase seq fields unprocessed to preserve cached numbering results', () => { + const processor = getInstructionPreProcessor('seq level2 \\*arabic'); + expect(processor).toBeNull(); + }); + it('should return preProcessTocInstruction for TOC instruction', () => { const instruction = 'TOC \\o "1-3" \\h \\z \\u'; const processor = getInstructionPreProcessor(instruction); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js new file mode 100644 index 0000000000..b2e6789979 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js @@ -0,0 +1,49 @@ +const PAGE_VALUE_FORMAT_SWITCHES = { + Arabic: 'decimal', + Roman: 'upperRoman', + ROMAN: 'upperRoman', + roman: 'lowerRoman', + ALPHABETIC: 'upperLetter', + alphabetic: 'lowerLetter', + ArabicDash: 'numberInDash', +}; + +/** + * Parses the supported PAGE value-format switches from an OOXML field instruction. + * Field dispatch is case-insensitive; value-format switches preserve ECMA casing. + * + * @param {string} instruction + * @returns {{ instruction: string, pageNumberFormat?: string }} + */ +export function parsePageInstruction(instruction) { + const rawInstruction = String(instruction ?? '').trim(); + const tokens = rawInstruction.match(/"[^"]*"|'[^']*'|\\\*|\\[^\s]+|[^\s]+/g) ?? []; + const keyword = tokens[0]?.toUpperCase(); + if (keyword !== 'PAGE') { + return { instruction: rawInstruction }; + } + + for (let i = 1; i < tokens.length - 1; i += 1) { + if (tokens[i] !== '\\*') continue; + const switchName = tokens[i + 1]; + const pageNumberFormat = PAGE_VALUE_FORMAT_SWITCHES[switchName]; + if (pageNumberFormat) { + return { instruction: rawInstruction, pageNumberFormat }; + } + } + + return { instruction: rawInstruction }; +} + +/** + * @param {string} pageNumberFormat + * @returns {string | undefined} + */ +export function pageNumberFormatToInstructionSwitch(pageNumberFormat) { + for (const [switchName, format] of Object.entries(PAGE_VALUE_FORMAT_SWITCHES)) { + if (format === pageNumberFormat) { + return switchName; + } + } + return undefined; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index b04639df0b..2f85247bc9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -4,18 +4,22 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch * Processes a PAGE instruction and creates a `sd:autoPageNumber` node. * * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes between separate and end. - * @param {string} [_instrText] The instruction text (unused for PAGE). + * @param {string} [instrText] The PAGE instruction text. * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx, instructionTokens?: Array<{type: string, text?: string}> | null, fieldRunRPr?: import('../../v2/types/index.js').OpenXmlNode | null }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 1234 */ export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { const fieldRunRPr = options.fieldRunRPr ?? null; - const fieldAttrs = parsePageNumberFieldSwitches(instrText, 'PAGE'); + const normalizedInstruction = typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'PAGE'; + const fieldAttrs = { + instruction: normalizedInstruction, + ...parsePageNumberFieldSwitches(normalizedInstruction, 'PAGE'), + }; const pageNumNode = { name: 'sd:autoPageNumber', type: 'element', - ...(Object.keys(fieldAttrs).length > 0 ? { attributes: fieldAttrs } : {}), + attributes: fieldAttrs, }; // First, try to get rPr from content nodes (between separate and end) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js index 1a1edeb126..4ec52f6f1a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js @@ -13,10 +13,26 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); + it.each([ + ['PAGE', undefined], + ['PAGE \\* roman', 'lowerRoman'], + ['PAGE \\* Roman \\* MERGEFORMAT', 'upperRoman'], + ['PAGE \\* ROMAN', 'upperRoman'], + ['page \\* Arabic', 'decimal'], + ['PAGE \\* Unsupported \\* MERGEFORMAT', undefined], + ])('preserves PAGE instruction and parses supported value format: %s', (instruction, pageNumberFormat) => { + const result = preProcessPageInstruction([], instruction, mockDocx); + expect(result[0].attributes).toEqual({ + instruction, + ...(pageNumberFormat ? { pageNumberFormat } : {}), + }); + }); + it('should extract rPr from nodes', () => { const nodesToCombine = [ { @@ -33,6 +49,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], }, ]); @@ -56,6 +73,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [fieldRunRPr], }, ]); @@ -120,6 +138,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [contentRPr], }, ]); @@ -135,6 +154,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 35fe35fba2..c3e0e7b766 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -3,6 +3,7 @@ */ import { getInstructionPreProcessor } from './fld-preprocessors'; import { resolveHyperlinkAttributes } from './fld-preprocessors/hyperlink-preprocessor.js'; +import { extractFieldKeyword } from './field-keyword.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { isTrackChangeElement, isConstructiveTrackChangeElement } from '../v2/importer/trackChangeElements.js'; @@ -138,8 +139,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { if (node.name === 'w:fldSimple') { const instr = node.attributes?.['w:instr']; if (typeof instr === 'string') { - const instructionType = instr.trim().split(/\s+/)[0]; - const instructionPreProcessor = getInstructionPreProcessor(instructionType); + const instructionPreProcessor = getInstructionPreProcessor(instr); if (instructionPreProcessor) { const processed = instructionPreProcessor(node.elements ?? [], instr, { docx }); if (collecting) { @@ -324,8 +324,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { * @returns {{ nodes: OpenXmlNode[], handled: boolean }} The processed nodes and whether a preprocessor handled them. */ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, instructionTokens, fieldRunRPr) => { - const instructionType = instrText.trim().split(/\s+/)[0]; - const instructionPreProcessor = getInstructionPreProcessor(instructionType); + const instructionPreProcessor = getInstructionPreProcessor(instrText); if (instructionPreProcessor) { return { nodes: instructionPreProcessor(nodesToCombine, instrText, { docx, instructionTokens, fieldRunRPr }), @@ -349,7 +348,7 @@ const _processCombinedNodesForFldChar = (nodesToCombine = [], instrText, docx, i * @param {ParsedDocx} docx */ const applyConstructiveFieldInterpretation = (rawNodes, instrText, docx) => { - const instructionType = instrText.trim().split(/\s+/)[0]; + const instructionType = extractFieldKeyword(instrText); if (instructionType !== 'HYPERLINK') return; const linkAttributes = resolveHyperlinkAttributes(instrText, docx); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index c8ac7fe719..88ee37cf9f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -19,6 +19,19 @@ describe('preProcessNodesForFldChar', () => { }, }; + function complexFieldNodes(instruction, cachedText = '1') { + return [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: instruction }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]; + } + it('should process a simple hyperlink field', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, @@ -53,6 +66,71 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should process PAGE field instructions case-insensitively: %s', + (instruction) => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes(instruction), mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should process NUMPAGES field instructions case-insensitively: %s', + (instruction) => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes(instruction, '5'), mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); + + it('should process non-page field instructions case-insensitively', () => { + const docx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + + const { processedNodes } = preProcessNodesForFldChar( + complexFieldNodes('hyperlink "http://example.com"', 'link text'), + docx, + ); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toEqual({ + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }], + }); + expect(processedNodes[0].elements[0].elements[0].elements[0].text).toBe('link text'); + expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([ + { + type: 'element', + name: 'Relationship', + attributes: { + Id: 'rIdabc12345', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: 'http://example.com', + TargetMode: 'External', + }, + }, + ]); + }); + + it('should preserve cached visible result runs for lowercase seq fields', () => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes('seq level2 \\*arabic', '1'), mockDocx); + + expect(processedNodes).toHaveLength(5); + expect(processedNodes.some((node) => node.name === 'sd:sequenceField')).toBe(false); + expect(processedNodes[3]).toEqual({ + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }], + }); + }); + it('should handle nested fields (PAGEREF within HYPERLINK)', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js index 60df3ca4f6..08c7117bf7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.js @@ -4,6 +4,7 @@ import { preProcessPageInstruction } from './fld-preprocessors/page-preprocessor.js'; import { preProcessNumPagesInstruction } from './fld-preprocessors/num-pages-preprocessor.js'; import { preProcessDocumentStatInstruction } from './fld-preprocessors/document-stat-preprocessor.js'; +import { extractFieldKeyword } from './field-keyword.js'; const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); @@ -47,7 +48,7 @@ export const preProcessPageFieldsOnly = (nodes = [], depth = 0) => { // fldSimple has the instruction in an attribute, not nested elements if (node.name === 'w:fldSimple') { const instrAttr = node.attributes?.['w:instr'] || ''; - const fieldType = instrAttr.trim().split(/\s+/)[0]; + const fieldType = extractFieldKeyword(instrAttr); const fldSimplePreprocessor = getHeaderFooterFieldPreprocessor(fieldType); if (fldSimplePreprocessor) { @@ -206,7 +207,7 @@ function scanFieldSequence(nodes, beginIndex) { return null; // Incomplete field } - const fieldType = instrText.trim().split(/\s+/)[0]; + const fieldType = extractFieldKeyword(instrText); return { fieldType, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js index d97ad0e86b..63fd22720a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessPageFieldsOnly.test.js @@ -3,6 +3,31 @@ import { describe, it, expect } from 'vitest'; import { preProcessPageFieldsOnly } from './preProcessPageFieldsOnly.js'; describe('preProcessPageFieldsOnly', () => { + function complexFieldNodes(instruction, cachedText = '1') { + return [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: instruction }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + } + describe('complex field syntax (w:fldChar)', () => { it('should process PAGE field with fldChar syntax', () => { const nodes = [ @@ -34,6 +59,16 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); }); + it.each([' page \\* arabic ', ' Page ', ' PAGE '])( + 'should process PAGE field case-insensitively with fldChar syntax: %s', + (instruction) => { + const result = preProcessPageFieldsOnly(complexFieldNodes(instruction)); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + it('should process NUMPAGES field with fldChar syntax', () => { const nodes = [ { @@ -101,6 +136,16 @@ describe('preProcessPageFieldsOnly', () => { }, }); }); + + it.each([' numpages ', ' NumPages ', ' NUMPAGES '])( + 'should process NUMPAGES field case-insensitively with fldChar syntax: %s', + (instruction) => { + const result = preProcessPageFieldsOnly(complexFieldNodes(instruction, '5')); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); }); describe('simple field syntax (w:fldSimple)', () => { @@ -124,6 +169,29 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( + 'should process PAGE field case-insensitively with fldSimple syntax: %s', + (instruction) => { + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': instruction }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }], + }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:autoPageNumber'); + }, + ); + it('should process NUMPAGES field with fldSimple syntax', () => { const nodes = [ { @@ -147,6 +215,29 @@ describe('preProcessPageFieldsOnly', () => { expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); }); + it.each(['numpages', 'NumPages', 'NUMPAGES'])( + 'should process NUMPAGES field case-insensitively with fldSimple syntax: %s', + (instruction) => { + const nodes = [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': instruction }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '5' }] }], + }, + ], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0].name).toBe('sd:totalPageNumber'); + }, + ); + it('should preserve rPr styling from fldSimple content', () => { const nodes = [ { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js index 9f4fc1bf21..306c32c2d8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.js @@ -2,6 +2,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { parseMarks } from './../../../../v2/importer/markImporter.js'; +import { pageNumberFormatToInstructionSwitch } from '../../../../field-references/fld-preprocessors/page-instruction.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:autoPageNumber'; @@ -27,6 +28,12 @@ const encode = (params) => { ...getPageNumberFieldAttrs(node), }, }; + if (typeof node.attributes?.instruction === 'string') { + processedNode.attrs.instruction = node.attributes.instruction; + } + if (typeof node.attributes?.pageNumberFormat === 'string') { + processedNode.attrs.pageNumberFormat = node.attributes.pageNumberFormat; + } return processedNode; }; @@ -40,7 +47,7 @@ const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); - const instruction = node.attrs?.instruction || 'PAGE'; + const instruction = getPageInstructionText(node.attrs); const translated = [ { name: 'w:r', @@ -121,6 +128,33 @@ function getPageNumberFieldAttrs(node) { return attrs; } +/** + * @param {Record | undefined} attrs + * @returns {string} + */ +function getPageInstructionText(attrs = {}) { + if (typeof attrs.instruction === 'string' && attrs.instruction.trim()) { + return attrs.instruction.trim(); + } + + if (typeof attrs.pageNumberFormat === 'string') { + const instructionSwitch = pageNumberFormatToInstructionSwitch(attrs.pageNumberFormat); + if (instructionSwitch) { + const numericPicture = + typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0 + ? ` \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}` + : ''; + return `PAGE \\* ${instructionSwitch}${numericPicture}`; + } + } + + if (typeof attrs.pageNumberZeroPadding === 'number' && attrs.pageNumberZeroPadding > 0) { + return `PAGE \\# ${'0'.repeat(attrs.pageNumberZeroPadding)}`; + } + + return 'PAGE'; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js index 5de2b2e30c..903dd8eb48 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/autoPageNumber/autoPageNumber-translator.test.js @@ -37,6 +37,10 @@ describe('sd:autoPageNumber translator', () => { nodes: [ { name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', + }, elements: [ { name: 'w:rPr', @@ -59,6 +63,8 @@ describe('sd:autoPageNumber translator', () => { type: 'page-number', attrs: { marksAsAttrs: marks, + instruction: 'PAGE \\* roman', + pageNumberFormat: 'lowerRoman', }, }); }); @@ -211,5 +217,34 @@ describe('sd:autoPageNumber translator', () => { expect(result[1].elements[1].elements[0].text).toBe(' PAGE \\* ArabicDash'); }); + + it('preserves imported PAGE instruction on export', () => { + const result = config.decode({ + node: { + type: 'page-number', + attrs: { + instruction: 'PAGE \\* Roman \\* MERGEFORMAT', + pageNumberFormat: 'upperRoman', + }, + }, + }); + + const instrRun = result[1]; + expect(instrRun.elements[1].elements[0].text).toBe(' PAGE \\* Roman \\* MERGEFORMAT'); + }); + + it('synthesizes a PAGE switch for new formatted page-number nodes', () => { + const result = config.decode({ + node: { + type: 'page-number', + attrs: { + pageNumberFormat: 'lowerRoman', + }, + }, + }); + + const instrRun = result[1]; + expect(instrRun.elements[1].elements[0].text).toBe(' PAGE \\* roman'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js index d0d615cfb2..a6d1971268 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequence-field-export-routing.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { exportSchemaToJson } from '../../../../exporter.js'; import { translator as runTranslator } from '../../w/r/r-translator.js'; +import { translator as sequenceFieldTranslator } from './sequenceField-translator.js'; const SEQUENCE_FIELD_INSTRUCTION = 'SEQ Figure \\* ARABIC'; @@ -30,6 +31,28 @@ function hasFieldCharType(node, fieldType) { } describe('sequenceField export routing', () => { + it('extracts cached result text from run-wrapped field content', () => { + const encoded = sequenceFieldTranslator.encode({ + nodes: [ + { + name: 'sd:sequenceField', + attributes: { instruction: 'seq level2 \\*arabic' }, + elements: [ + { + type: 'run', + content: [{ type: 'text', text: '1', marks: [] }], + }, + ], + }, + ], + nodeListHandler: { + handler: () => [{ type: 'run', content: [{ type: 'text', text: '1', marks: [] }] }], + }, + }); + + expect(encoded.attrs.resolvedNumber).toBe('1'); + }); + it('exports sequenceField nodes as fldChar + instrText runs', () => { const exported = exportSchemaToJson({ node: buildSequenceFieldNode(), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js index b3e8d8dc94..26c1605078 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/sequenceField/sequenceField-translator.js @@ -113,10 +113,17 @@ function parseSeqInstruction(instruction) { */ function extractResolvedText(content) { if (!Array.isArray(content)) return ''; - return content - .filter((n) => n.type === 'text') - .map((n) => n.text || '') - .join(''); + let text = ''; + for (const node of content) { + if (!node) continue; + if (node.type === 'text') { + text += node.text || ''; + } + if (Array.isArray(node.content)) { + text += extractResolvedText(node.content); + } + } + return text; } /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index dcd23a2cc2..6dcf619ecd 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -14,7 +14,7 @@ import type { InlineNodeAttributes, ShapeNodeAttributes, } from '../../core/types/NodeCategories.js'; -import type { ImageHyperlink, StructuredContentLockMode } from '@superdoc/contracts'; +import type { ImageHyperlink, PageNumberFormat, StructuredContentLockMode } from '@superdoc/contracts'; // ============================================ // SHARED TYPES @@ -951,7 +951,7 @@ export interface PageNumberAttrs extends InlineNodeAttributes { /** @internal Original PAGE field instruction when switched */ instruction?: string | null; /** @internal Normalized field switch format */ - pageNumberFormat?: string | null; + pageNumberFormat?: PageNumberFormat | null; /** @internal Zero-padding width from numeric picture switch */ pageNumberZeroPadding?: number | null; } diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts index 7df11f1c74..378ad22308 100644 --- a/tests/behavior/helpers/story-fixtures.ts +++ b/tests/behavior/helpers/story-fixtures.ts @@ -479,6 +479,51 @@ function inlinePageFieldFooterXml(): string { `; } +function lowercasePageFieldFooterXml(): string { + return ` + + + + + + + Case footer + + page \\* arabic + + 1 + + + +`; +} + +function formattedPageFieldFooterXml(): string { + const pageField = (instruction: string, cachedText: string) => ` + + ${instruction} + + ${cachedText} + `; + + return ` + + + + + + + Formats + ${pageField('PAGE \\* Roman', 'I')} + + ${pageField('PAGE \\* ALPHABETIC', 'A')} + + ${pageField('PAGE \\* ArabicDash', '- 1 -')} + + +`; +} + function inlinePageFieldSingleRunFooterXml(): string { return ` @@ -631,6 +676,22 @@ export const FOOTER_INLINE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( 'word/footer2.xml': inlinePageFieldFooterXml(), }, ); +export const FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( + 'footer-lowercase-page-field.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + 'word/footer2.xml': lowercasePageFieldFooterXml(), + }, +); +export const FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( + 'footer-formatted-page-field.docx', + 'h_f-normal.docx', + { + 'word/document.xml': multiPageHeaderFooterDocumentXml(), + 'word/footer2.xml': formattedPageFieldFooterXml(), + }, +); export const FOOTER_SIMPLE_TEXT_WITH_TABLE_AND_FOOTNOTE_DOC_PATH = ensureGeneratedFixture( 'footer-simple-text-with-table-and-footnote.docx', 'h_f-normal.docx', diff --git a/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts new file mode 100644 index 0000000000..44b9aeb158 --- /dev/null +++ b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH, + FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH, +} from '../../helpers/story-fixtures.js'; + +test('lowercase PAGE field in repeated footer resolves per page instead of using cached text', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH); + await superdoc.waitForStable(); + + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const secondPageFooter = superdoc.page.locator('.superdoc-page-footer').nth(1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + + await expect(secondPageFooter).toContainText(/Case footer\s*2/); + await expect(secondPageFooter).not.toContainText(/Case footer\s*1/); +}); + +test('formatted PAGE fields in repeated footer resolve per page', async ({ superdoc }) => { + await superdoc.loadDocument(FOOTER_FORMATTED_PAGE_FIELD_DOC_PATH); + await superdoc.waitForStable(); + + await expect.poll(() => superdoc.page.locator('.superdoc-page-footer').count()).toBeGreaterThanOrEqual(2); + + const secondPageFooter = superdoc.page.locator('.superdoc-page-footer').nth(1); + await secondPageFooter.scrollIntoViewIfNeeded(); + await secondPageFooter.waitFor({ state: 'visible', timeout: 15_000 }); + + await expect(secondPageFooter).toContainText(/Formats\s*II\s*B\s*-\s*2\s*-/); + await expect(secondPageFooter).not.toContainText(/Formats\s*I\s*A\s*-\s*1\s*-/); +});