From 38fe893e8d2bb006318a85c45a5108300366e27a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 14:56:15 -0300 Subject: [PATCH 01/14] feat(super-converter): match field dispatch keywords case-insensitively OOXML field type names are case-insensitive, but the field-reference preprocessors dispatched on the raw first token (e.g. only "PAGE", not "page"). A lowercase PAGE/NUMPAGES field in a repeated footer fell through to the cached static text and showed the same number on every page. Add a shared extractFieldKeyword helper that normalizes the dispatch token to upper case while leaving the original instruction text intact for downstream processors, and route fldSimple/fldChar dispatch and the header/footer page-field scan through it. Make the HYPERLINK target regex case-insensitive and anchored. Cover the new behavior with unit tests and a behavior spec asserting a lowercase PAGE footer resolves per page. --- .../field-references/field-keyword.js | 16 ++++ .../hyperlink-preprocessor.js | 2 +- .../fld-preprocessors/index.js | 3 +- .../fld-preprocessors/index.test.js | 24 +++++ .../preProcessNodesForFldChar.js | 9 +- .../preProcessNodesForFldChar.test.js | 67 ++++++++++++++ .../preProcessPageFieldsOnly.js | 7 +- .../preProcessPageFieldsOnly.test.js | 91 +++++++++++++++++++ tests/behavior/helpers/story-fixtures.ts | 27 ++++++ .../footer-page-keyword-case.spec.ts | 16 ++++ 10 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js create mode 100644 tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts 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..a67ea4d9a7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.js @@ -0,0 +1,16 @@ +/** + * 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/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..6ca3717f87 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,7 @@ 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 instructionType = extractFieldKeyword(instruction); switch (instructionType) { case 'PAGE': return preProcessPageInstruction; 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..de12249f40 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 @@ -20,6 +20,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 +40,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 +62,14 @@ describe('getInstructionPreProcessor', () => { expect(processor([], instruction, mockDocx)).toBeDefined(); }); + it.each([ + ['pageref _Toc123456789 h', preProcessPageRefInstruction], + ['hyperlink "http://example.com"', preProcessHyperlinkInstruction], + ])('should dispatch non-page field instruction case-insensitively: %s', (instruction, expectedProcessor) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(expectedProcessor); + }); + 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/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..87e6d35b8e 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,60 @@ 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 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..dafa8e9de0 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, @@ -225,7 +226,7 @@ function scanFieldSequence(nodes, beginIndex) { * @returns {Function | null} */ function getHeaderFooterFieldPreprocessor(fieldType) { - switch (fieldType) { + switch (extractFieldKeyword(fieldType)) { case 'PAGE': return preProcessPageInstruction; case 'NUMPAGES': 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/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts index 7df11f1c74..23bae4b1c0 100644 --- a/tests/behavior/helpers/story-fixtures.ts +++ b/tests/behavior/helpers/story-fixtures.ts @@ -479,6 +479,25 @@ function inlinePageFieldFooterXml(): string { `; } +function lowercasePageFieldFooterXml(): string { + return ` + + + + + + + Case footer + + page \\* arabic + + 1 + + + +`; +} + function inlinePageFieldSingleRunFooterXml(): string { return ` @@ -631,6 +650,14 @@ 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_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..8785f936e5 --- /dev/null +++ b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { 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/); +}); From 2a9a24d483bdad9417c72a8204596bfd9d438417 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 15:11:22 -0300 Subject: [PATCH 02/14] test(super-converter): cover field keyword dispatch --- .../field-references/field-keyword.js | 10 ++++------ .../field-references/field-keyword.test.js | 16 ++++++++++++++++ .../fld-preprocessors/index.test.js | 3 +++ 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/field-keyword.test.js 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 index a67ea4d9a7..4b81b09341 100644 --- 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 @@ -7,10 +7,8 @@ * @returns {string} */ export function extractFieldKeyword(instruction) { - return ( - String(instruction ?? '') - .trim() - .split(/\s+/)[0] - ?.toUpperCase() ?? '' - ); + 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/index.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/index.test.js index de12249f40..7c51f2423a 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,7 @@ 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'; describe('getInstructionPreProcessor', () => { const mockDocx = { @@ -65,6 +66,8 @@ describe('getInstructionPreProcessor', () => { 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); From 023859bdb1d9c789bb0eb3b38abdbb455940722e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 15:11:52 -0300 Subject: [PATCH 03/14] fix(super-converter): trust header footer field keyword --- .../field-references/preProcessPageFieldsOnly.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dafa8e9de0..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 @@ -226,7 +226,7 @@ function scanFieldSequence(nodes, beginIndex) { * @returns {Function | null} */ function getHeaderFooterFieldPreprocessor(fieldType) { - switch (extractFieldKeyword(fieldType)) { + switch (fieldType) { case 'PAGE': return preProcessPageInstruction; case 'NUMPAGES': From 1b34d1825120a6aa7bd7e551c94d65dccc718519 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 16:04:33 -0300 Subject: [PATCH 04/14] feat(page-number): support PAGE field value-format switches Parse the `\*` value-format switches on PAGE field instructions (Arabic, Roman/roman, ALPHABETIC/alphabetic, ArabicDash) into a run-local pageNumberFormat override, and apply it independently of section numbering when resolving page-number tokens. - add parsePageInstruction / pageNumberFormatToInstructionSwitch in a new page-instruction.js; page-preprocessor stores the original instruction and parsed format on sd:autoPageNumber - round-trip instruction + pageNumberFormat through the autoPageNumber translator and the page-number extension node (preserve imported instruction text, synthesize a switch for new formatted nodes) - add pageNumberFormat to TextRun and thread it through layout-bridge, layout-resolved, painters (resolveRunText), and stamp section-aware displayNumber on pages so formatting uses the pre-format numeric value - move formatPageNumber + PageNumberFormat into @superdoc/contracts as the single source of truth; re-export from pageNumbering - include pageNumberFormat in block-version, merge, and hash signatures so format changes invalidate cached layouts upperLetter/lowerLetter now render as repeated letters (AA, BB, CC) to match Word instead of the previous Excel-style sequence (AA, AB). --- packages/layout-engine/contracts/src/index.ts | 2 + .../src/page-number-formatting.test.ts | 3 +- .../contracts/src/page-number-formatting.ts | 14 ++---- .../contracts/src/resolved-layout.ts | 2 + .../layout-bridge/src/layoutHeaderFooter.ts | 6 +++ .../layout-engine/src/pageNumbering.test.ts | 32 ++++++------- .../src/resolvePageTokens.test.ts | 23 +++++++++ .../layout-engine/src/resolvePageTokens.ts | 11 +++-- .../src/resolveHeaderFooter.ts | 1 + .../layout-resolved/src/versionSignature.ts | 4 ++ .../dom/src/paragraph/block-version.ts | 1 + .../painters/dom/src/runs/hash.ts | 1 + .../painters/dom/src/runs/text-run.test.ts | 40 ++++++++++++++++ .../inline-converters/generic-token.test.ts | 12 +++++ .../v1/core/layout-adapter/index.test.ts | 25 ++++++++++ .../fld-preprocessors/page-instruction.js | 48 +++++++++++++++++++ .../fld-preprocessors/page-preprocessor.js | 10 ++-- .../page-preprocessor.test.js | 19 ++++++++ .../autoPageNumber-translator.js | 36 +++++++++++++- .../autoPageNumber-translator.test.js | 35 ++++++++++++++ .../v1/extensions/types/node-attributes.ts | 4 +- 21 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/runs/text-run.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 6c86a4d890..e5b5cf5b78 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -2262,6 +2262,8 @@ export type HeaderFooterPage = { 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..91c28316a1 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -7,7 +7,8 @@ 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(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); }); diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts index bf32393cda..ac7eaf0eb3 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 { diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index f2316f9613..926f78d267 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -455,6 +455,8 @@ export type ResolvedHeaderFooterPage = { /** 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/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 814cdb9387..9ff466f2f8 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -332,6 +332,8 @@ export async function layoutHeaderFooterWithCache( blocks: FlowBlock[]; measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; + numberText?: string; + displayNumber?: number; }> = []; for (const pageNum of pagesToLayout) { @@ -372,6 +374,8 @@ export async function layoutHeaderFooterWithCache( blocks: clonedBlocks, measures, fragments: fragmentsWithLines, + numberText: displayText, + displayNumber, }); } @@ -390,6 +394,8 @@ export async function layoutHeaderFooterWithCache( number: p.number, displayNumber: p.displayNumber, fragments: p.fragments, + numberText: p.numberText, + displayNumber: p.displayNumber, blocks: p.blocks, measures: p.measures, })), diff --git a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index 87ef3473ea..63cc2c5386 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -128,19 +128,19 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'upperLetter')).toBe('Z'); }); - it('should format numbers > 26 as AA, AB, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); - expect(formatPageNumber(28, 'upperLetter')).toBe('AB'); - expect(formatPageNumber(52, 'upperLetter')).toBe('AZ'); - expect(formatPageNumber(53, 'upperLetter')).toBe('BA'); - expect(formatPageNumber(78, 'upperLetter')).toBe('BZ'); - expect(formatPageNumber(79, 'upperLetter')).toBe('CA'); + expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); + expect(formatPageNumber(52, 'upperLetter')).toBe('ZZ'); + expect(formatPageNumber(53, 'upperLetter')).toBe('AAA'); + expect(formatPageNumber(78, 'upperLetter')).toBe('ZZZ'); + expect(formatPageNumber(79, 'upperLetter')).toBe('AAAA'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'upperLetter')).toBe('ZZ'); - expect(formatPageNumber(703, 'upperLetter')).toBe('AAA'); - expect(formatPageNumber(704, 'upperLetter')).toBe('AAB'); + expect(formatPageNumber(702, 'upperLetter')).toBe('Z'.repeat(27)); + expect(formatPageNumber(703, 'upperLetter')).toBe('A'.repeat(28)); + expect(formatPageNumber(704, 'upperLetter')).toBe('B'.repeat(28)); }); it('should clamp zero and negative to A', () => { @@ -158,16 +158,16 @@ describe('formatPageNumber', () => { expect(formatPageNumber(26, 'lowerLetter')).toBe('z'); }); - it('should format numbers > 26 as aa, ab, etc.', () => { + it('should format numbers > 26 as repeated letters', () => { expect(formatPageNumber(27, 'lowerLetter')).toBe('aa'); - expect(formatPageNumber(28, 'lowerLetter')).toBe('ab'); - expect(formatPageNumber(52, 'lowerLetter')).toBe('az'); - expect(formatPageNumber(53, 'lowerLetter')).toBe('ba'); + expect(formatPageNumber(28, 'lowerLetter')).toBe('bb'); + expect(formatPageNumber(52, 'lowerLetter')).toBe('zz'); + expect(formatPageNumber(53, 'lowerLetter')).toBe('aaa'); }); it('should format large numbers correctly', () => { - expect(formatPageNumber(702, 'lowerLetter')).toBe('zz'); - expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa'); + expect(formatPageNumber(702, 'lowerLetter')).toBe('z'.repeat(27)); + expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); }); it('should clamp zero and negative to a', () => { @@ -434,7 +434,7 @@ describe('computeDisplayPageNumber', () => { expect(result[24].displayText).toBe('Y'); expect(result[25].displayText).toBe('Z'); expect(result[26].displayText).toBe('AA'); - expect(result[27].displayText).toBe('AB'); + expect(result[27].displayText).toBe('BB'); }); it('should handle large page numbers in roman numerals', () => { diff --git a/packages/layout-engine/layout-engine/src/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/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 48d8f1a22c..f5c2960c45 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -31,6 +31,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, story), ), 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/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/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/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/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..d3a8e78163 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js @@ -0,0 +1,48 @@ +const PAGE_VALUE_FORMAT_SWITCHES = { + Arabic: 'decimal', + 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..f45d4df018 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,25 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); + it.each([ + ['PAGE', undefined], + ['PAGE \\* roman', 'lowerRoman'], + ['PAGE \\* Roman \\* MERGEFORMAT', '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 +48,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], }, ]); @@ -56,6 +72,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [fieldRunRPr], }, ]); @@ -120,6 +137,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, elements: [contentRPr], }, ]); @@ -135,6 +153,7 @@ describe('preProcessPageInstruction', () => { { name: 'sd:autoPageNumber', type: 'element', + attributes: { instruction: 'PAGE' }, }, ]); }); 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/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; } From 04e0a43d8abe7efd87b440c74071fc581bc1b6d6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 16:10:38 -0300 Subject: [PATCH 05/14] fix(page-number): render ArabicDash spacing --- .../contracts/src/page-number-formatting.test.ts | 2 +- .../layout-engine/contracts/src/page-number-formatting.ts | 2 +- .../layout-engine/layout-engine/src/pageNumbering.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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 91c28316a1..104bad581c 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -9,7 +9,7 @@ describe('page number formatting', () => { expect(formatPageNumber(27, 'upperLetter')).toBe('AA'); expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); - expect(formatPageNumber(12, 'numberInDash')).toBe('-12-'); + expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); }); it('normalizes page numbers before formatting', () => { diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts index ac7eaf0eb3..413e14bb8a 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -43,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/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index 63cc2c5386..f4c7894db7 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -35,12 +35,12 @@ describe('formatPageNumber', () => { 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 -'); }); }); From 87f1ed0a77aee1d8b9d1c3167f7565300552aa4c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 16:20:06 -0300 Subject: [PATCH 06/14] fix(layout-bridge): hash page number formats --- .../layout-bridge/src/cacheInvalidation.ts | 1 + .../test/cacheInvalidation.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) 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/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[] = [ { From 7323d85d63e7c889fe831b376522e5fa626b9fcb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 16:20:38 -0300 Subject: [PATCH 07/14] fix(page-number): fall back for unknown formats --- .../contracts/src/page-number-formatting.test.ts | 4 ++++ .../layout-engine/layout-engine/src/pageNumbering.test.ts | 4 ++++ 2 files changed, 8 insertions(+) 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 104bad581c..f372797f72 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -18,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/layout-engine/src/pageNumbering.test.ts b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts index f4c7894db7..777f2ee828 100644 --- a/packages/layout-engine/layout-engine/src/pageNumbering.test.ts +++ b/packages/layout-engine/layout-engine/src/pageNumbering.test.ts @@ -31,6 +31,10 @@ 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', () => { From 2d17cc2a78c342daecf58d51682a0fb658963ba0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 16:22:16 -0300 Subject: [PATCH 08/14] test(behavior): cover formatted footer page fields --- tests/behavior/helpers/story-fixtures.ts | 34 +++++++++++++++++++ .../footer-page-keyword-case.spec.ts | 19 ++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/behavior/helpers/story-fixtures.ts b/tests/behavior/helpers/story-fixtures.ts index 23bae4b1c0..378ad22308 100644 --- a/tests/behavior/helpers/story-fixtures.ts +++ b/tests/behavior/helpers/story-fixtures.ts @@ -498,6 +498,32 @@ function lowercasePageFieldFooterXml(): string { `; } +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 ` @@ -658,6 +684,14 @@ export const FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture( '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 index 8785f936e5..44b9aeb158 100644 --- a/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts +++ b/tests/behavior/tests/field-annotations/footer-page-keyword-case.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { FOOTER_LOWERCASE_PAGE_FIELD_DOC_PATH } from '../../helpers/story-fixtures.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); @@ -14,3 +17,17 @@ test('lowercase PAGE field in repeated footer resolves per page instead of using 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*-/); +}); From 1eb7f2c56fdabc5ed77ad9bac23114ba834d5c71 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 17:17:10 -0300 Subject: [PATCH 09/14] fix(page-number): address PAGE field review feedback --- .../layout-bridge/src/layoutHeaderFooter.ts | 19 ++- .../test/layoutHeaderFooterBucketing.test.ts | 45 +++++++ .../src/renderer-page-context-patch.test.ts | 116 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 56 +++++++++ .../fld-preprocessors/page-instruction.js | 1 + .../page-preprocessor.test.js | 1 + 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 9ff466f2f8..6968251321 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 ?? []) { 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/painters/dom/src/renderer-page-context-patch.test.ts b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts new file mode 100644 index 0000000000..5756cc288f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/renderer-page-context-patch.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, Layout, Measure, TextRun } from '@superdoc/contracts'; +import { createTestPainter } from './_test-utils.js'; + +const pageNumberBlock: FlowBlock = { + kind: 'paragraph', + id: 'page-number-block', + runs: [ + { + text: '0', + token: 'pageNumber', + pageNumberFormat: 'upperRoman', + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], +}; + +const pageNumberMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 10, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +const staticBlock: FlowBlock = { + kind: 'paragraph', + id: 'static-block', + runs: [ + { + text: 'Static', + fontFamily: 'Arial', + fontSize: 12, + }, + ], +}; + +const staticMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 40, + ascent: 8, + descent: 2, + lineHeight: 10, + }, + ], + totalHeight: 10, +}; + +function makeLayout(displayNumber: number): Layout { + return { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + displayNumber, + fragments: [ + { + kind: 'para', + blockId: 'page-number-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 200, + }, + { + kind: 'para', + blockId: 'static-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 20, + width: 200, + }, + ], + }, + ], + }; +} + +describe('DomPainter page-number context patching', () => { + it('rebuilds token fragments when display page number changes during incremental patch', () => { + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const painter = createTestPainter({ + blocks: [pageNumberBlock, staticBlock], + measures: [pageNumberMeasure, staticMeasure], + }); + + painter.paint(makeLayout(5), mount); + expect(mount.textContent).toContain('V'); + const staticFragment = mount.querySelector('[data-block-id="static-block"]'); + expect(staticFragment).toBeTruthy(); + + painter.paint(makeLayout(8), mount); + expect(mount.textContent).toContain('VIII'); + expect(mount.querySelector('[data-block-id="static-block"]')).toBe(staticFragment); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 18390c37e8..f9090188d1 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,59 @@ type PageDomState = { fragments: FragmentDomState[]; }; +function pageContextSignature(context: FragmentRenderContext): string { + return [ + context.pageNumber, + context.totalPages, + context.pageNumberText ?? '', + context.pageNumberDisplayNumber ?? '', + ].join('|'); +} + +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; + } + } + } + } + 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 +2351,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 +2367,7 @@ export class DomPainter { current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || + pageContextChanged || mappingUnreliable; if (needsRebuild) { 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 index d3a8e78163..b2e6789979 100644 --- 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 @@ -1,6 +1,7 @@ const PAGE_VALUE_FORMAT_SWITCHES = { Arabic: 'decimal', Roman: 'upperRoman', + ROMAN: 'upperRoman', roman: 'lowerRoman', ALPHABETIC: 'upperLetter', alphabetic: 'lowerLetter', 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 f45d4df018..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 @@ -22,6 +22,7 @@ describe('preProcessPageInstruction', () => { ['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) => { From 821173dc02e9da4ca6dfc62b62a4eb6efaf8743e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 17:25:45 -0300 Subject: [PATCH 10/14] fix(sequence-field): preserve cached numbering for lowercase seq fields Only dispatch the SEQ pre-processor for uppercase SEQ instructions so lowercase `seq` fields keep their cached visible result runs instead of being re-resolved. Also recurse into run-wrapped content when extracting resolved text so cached numbers nested inside runs are captured. --- .../fld-preprocessors/index.js | 4 ++++ .../fld-preprocessors/index.test.js | 11 +++++++++ .../preProcessNodesForFldChar.test.js | 11 +++++++++ .../sequence-field-export-routing.test.js | 23 +++++++++++++++++++ .../sequenceField/sequenceField-translator.js | 15 ++++++++---- 5 files changed, 60 insertions(+), 4 deletions(-) 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 6ca3717f87..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 @@ -38,6 +38,9 @@ import { extractFieldKeyword } from '../field-keyword.js'; * @returns {InstructionPreProcessor | null} The pre-processor function or null if not found. */ export const getInstructionPreProcessor = (instruction) => { + const rawInstructionType = String(instruction ?? '') + .trim() + .split(/\s+/)[0]; const instructionType = extractFieldKeyword(instruction); switch (instructionType) { case 'PAGE': @@ -66,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 7c51f2423a..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 @@ -7,6 +7,7 @@ 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 = { @@ -73,6 +74,16 @@ describe('getInstructionPreProcessor', () => { 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/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 87e6d35b8e..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 @@ -120,6 +120,17 @@ describe('preProcessNodesForFldChar', () => { ]); }); + 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/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} */ From fa3e573be99da0567004aa9b00b11e53dd2671ae Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 1 Jun 2026 17:41:25 -0300 Subject: [PATCH 11/14] fix(painter): rebuild drawing page fields on context changes --- .../painters/dom/src/index.test.ts | 56 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 27 +++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 76c62c6cc5..f5e7ac9100 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -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/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f9090188d1..0c11c92248 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -268,6 +268,25 @@ function pageContextSignature(context: FragmentRenderContext): string { ].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') { @@ -297,6 +316,14 @@ function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean { } } } + } 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; } From 9f67b028f916def3e20d23a5353495b62a5a952f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 2 Jun 2026 14:18:58 -0300 Subject: [PATCH 12/14] fix: footnote formatter parity test --- .../src/footnote-formatter-parity.test.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) 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'); }); }); From a23b921ef226f1b09aef479d34f38ce8605de808 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 10:25:57 -0300 Subject: [PATCH 13/14] fix(layout): remove duplicate displayNumber fields and fix page signature ref Drop the redundant displayNumber declarations from HeaderFooterPage, ResolvedHeaderFooterPage, and the layout-bridge page builder, keeping the section-aware variant. Correct the renderer page context signature to read displayPageNumber instead of the nonexistent pageNumberDisplayNumber. --- packages/layout-engine/contracts/src/index.ts | 1 - packages/layout-engine/contracts/src/resolved-layout.ts | 2 -- .../layout-bridge/src/layoutHeaderFooter.ts | 3 --- .../layout-resolved/src/resolveHeaderFooter.ts | 1 - packages/layout-engine/painters/dom/src/renderer.ts | 9 +++------ 5 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e5b5cf5b78..a12f98efcb 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -2260,7 +2260,6 @@ 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; diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 926f78d267..fdad65d628 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -452,8 +452,6 @@ 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; diff --git a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts index 6968251321..bd81f901e6 100644 --- a/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts +++ b/packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts @@ -350,7 +350,6 @@ export async function layoutHeaderFooterWithCache( measures: Measure[]; fragments: HeaderFooterLayout['pages'][0]['fragments']; numberText?: string; - displayNumber?: number; }> = []; for (const pageNum of pagesToLayout) { @@ -392,7 +391,6 @@ export async function layoutHeaderFooterWithCache( measures, fragments: fragmentsWithLines, numberText: displayText, - displayNumber, }); } @@ -412,7 +410,6 @@ export async function layoutHeaderFooterWithCache( displayNumber: p.displayNumber, fragments: p.fragments, numberText: p.numberText, - displayNumber: p.displayNumber, blocks: p.blocks, measures: p.measures, })), diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index f5c2960c45..48d8f1a22c 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -31,7 +31,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, story), ), diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0c11c92248..b1c9f832bf 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -260,12 +260,9 @@ type PageDomState = { }; function pageContextSignature(context: FragmentRenderContext): string { - return [ - context.pageNumber, - context.totalPages, - context.pageNumberText ?? '', - context.pageNumberDisplayNumber ?? '', - ].join('|'); + return [context.pageNumber, context.totalPages, context.pageNumberText ?? '', context.displayPageNumber ?? ''].join( + '|', + ); } function hasPageContextTokenInShapeText(textContent: ShapeTextContent | undefined): boolean { From b4d861327a996889d9a860152823cf118b7b7b92 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 10:33:03 -0300 Subject: [PATCH 14/14] test(layout): update page-number field expectations Adjust header/footer token and footer rendering expectations to the spaced "- N -" format, and migrate the renderer page-context test to the pageNumberFieldFormat shape. --- .../layout-bridge/test/resolveHeaderFooterTokens.test.ts | 2 +- packages/layout-engine/painters/dom/src/index.test.ts | 2 +- .../painters/dom/src/renderer-page-context-patch.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index f5e7ac9100..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', () => { 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 index 5756cc288f..606c2760a3 100644 --- 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 @@ -9,7 +9,7 @@ const pageNumberBlock: FlowBlock = { { text: '0', token: 'pageNumber', - pageNumberFormat: 'upperRoman', + pageNumberFieldFormat: { format: 'upperRoman' }, fontFamily: 'Arial', fontSize: 12, } as TextRun,