diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ce20da4a91..525c715f13 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -449,7 +449,7 @@ export type TextRun = RunMarks & { visualPlaceholder?: SdtVisualPlaceholder; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ - token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount'; + token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount' | 'seq'; /** Explicit formatting requested by PAGE/NUMPAGES/SECTIONPAGES field switches. */ pageNumberFieldFormat?: PageNumberFieldFormat; /** Absolute ProseMirror position (inclusive) of first character in this run. */ @@ -469,6 +469,21 @@ export type TextRun = RunMarks & { /** CHARFORMAT / MERGEFORMAT, if present. */ fieldResultFormat?: FieldResultFormat; }; + /** Metadata for SEQ tokens (resolved by super-editor before layout measurement). */ + seqMetadata?: { + identifier: string; + instruction?: string; + fieldArgument?: string; + sequenceMode?: 'next' | 'current'; + hideResult?: boolean; + restartNumber?: number | null; + restartLevel?: number | null; + format?: string; + hasGeneralFormat?: boolean; + pageNumberFieldFormat?: PageNumberFieldFormat | null; + numericPictureFormat?: NumericPictureFormat | null; + cachedText?: string; + }; /** Tracked-change metadata from ProseMirror marks. */ trackedChange?: TrackedChangeMeta; /** All tracked-change layers on this run, preserving overlap order. */ 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 607453e87e..0a79b13606 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.test.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.test.ts @@ -14,6 +14,19 @@ describe('page number formatting', () => { expect(formatPageNumber(28, 'upperLetter')).toBe('BB'); expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28)); expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -'); + expect(formatPageNumber(1, 'ordinal')).toBe('1st'); + expect(formatPageNumber(2, 'ordinal')).toBe('2nd'); + expect(formatPageNumber(3, 'ordinal')).toBe('3rd'); + expect(formatPageNumber(4, 'ordinal')).toBe('4th'); + expect(formatPageNumber(11, 'ordinal')).toBe('11th'); + expect(formatPageNumber(12, 'ordinal')).toBe('12th'); + expect(formatPageNumber(13, 'ordinal')).toBe('13th'); + expect(formatPageNumber(21, 'ordinal')).toBe('21st'); + expect(formatPageNumber(22, 'ordinal')).toBe('22nd'); + expect(formatPageNumber(23, 'ordinal')).toBe('23rd'); + expect(formatPageNumber(111, 'ordinal')).toBe('111th'); + expect(formatPageNumber(112, 'ordinal')).toBe('112th'); + expect(formatPageNumber(113, 'ordinal')).toBe('113th'); }); it('normalizes page numbers before formatting', () => { @@ -35,6 +48,16 @@ describe('page number formatting', () => { expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii'); }); + it('formats ordinal field values', () => { + expect(formatPageNumberFieldValue(32, { format: 'ordinal' })).toBe('32nd'); + }); + + it('uses numeric pictures before enum format and zero padding', () => { + expect(formatPageNumberFieldValue(1234, { numericPicture: '#,##0' })).toBe('1,234'); + expect(formatPageNumberFieldValue(7, { format: 'ordinal', zeroPadding: 3, numericPicture: '00' })).toBe('07'); + expect(formatPageNumberFieldValue(0, { numericPicture: '00' })).toBe('01'); + }); + it('formats integer values with numeric pictures', () => { expect(formatIntegerWithNumericPicture(5, '00')).toBe('05'); expect(formatIntegerWithNumericPicture(1234, '#,##0')).toBe('1,234'); diff --git a/packages/layout-engine/contracts/src/page-number-formatting.ts b/packages/layout-engine/contracts/src/page-number-formatting.ts index 255914cb2f..5d584aba1a 100644 --- a/packages/layout-engine/contracts/src/page-number-formatting.ts +++ b/packages/layout-engine/contracts/src/page-number-formatting.ts @@ -1,6 +1,7 @@ export type PageNumberFieldFormat = { - format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash'; + format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash' | 'ordinal'; zeroPadding?: number; + numericPicture?: string; }; export type PageNumberFormat = NonNullable; @@ -31,6 +32,22 @@ function toUpperLetter(value: number): string { return String.fromCharCode(65 + index).repeat(repeatCount); } +function toOrdinal(value: number): string { + const remainder = value % 100; + if (remainder >= 11 && remainder <= 13) return `${value}th`; + + switch (value % 10) { + case 1: + return `${value}st`; + case 2: + return `${value}nd`; + case 3: + return `${value}rd`; + default: + return `${value}th`; + } +} + export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string { const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); @@ -45,6 +62,8 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): return toUpperLetter(value).toLowerCase(); case 'numberInDash': return `- ${value} -`; + case 'ordinal': + return toOrdinal(value); case 'decimal': default: return String(value); @@ -52,6 +71,11 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat): } export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string { + if (fieldFormat?.numericPicture) { + const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1)); + return formatIntegerWithNumericPicture(value, fieldFormat.numericPicture); + } + const format = fieldFormat?.format ?? 'decimal'; const formatted = formatPageNumber(pageNumber, format); return fieldFormat?.zeroPadding && format === 'decimal' @@ -101,7 +125,7 @@ export function formatSectionPageNumberText(args: { } /** - * Formats integer field values with a Word numeric picture subset used by PAGEREF. + * Formats integer page field values with a Word numeric picture subset. * Unsupported ECMA features are intentionally out of scope here: backtick * numbered-item references, localized separators, and fractional rounding. */ diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 8fc49161e8..16e8b676bd 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -30,6 +30,7 @@ import type { NormalizedColumnLayout, DocumentBackground, HeaderFooterResolutionSection, + PageNumberFormat, } from '@superdoc/contracts'; import { buildLayoutSourceIdentityForFragment, @@ -1427,8 +1428,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Paginator encapsulation for page/column helpers let pageCount = 0; // Page numbering state - let activeNumberFormat: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash' = - 'decimal'; + let activeNumberFormat: PageNumberFormat = 'decimal'; let activePageCounter = 1; let activeSectionPageCounterStart = activePageCounter; let pendingNumbering: SectionNumbering | null = null; diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts index c06f7c47eb..b7250bd413 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts @@ -299,4 +299,70 @@ describe('resolveTokensInBlock', () => { expect((block.runs[0] as TextRun).token).toBeUndefined(); expect((block.runs[0] as TextRun).pageNumberFieldFormat).toBeUndefined(); }); + + it('should apply run-local total page count zero padding', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-total-count-zero-padding-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { format: 'decimal', zeroPadding: 3 }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + }; + + const wasModified = resolveTokensInBlock(block, 1, 7); + + expect(wasModified).toBe(true); + expect((block.runs[0] as TextRun).text).toBe('007'); + expect((block.runs[0] as TextRun).token).toBeUndefined(); + }); + + it('should apply run-local total page count grouping picture', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-total-count-picture-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { numericPicture: '#,##0' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + }; + + const wasModified = resolveTokensInBlock(block, 1, 1234); + + expect(wasModified).toBe(true); + expect((block.runs[0] as TextRun).text).toBe('1,234'); + expect((block.runs[0] as TextRun).token).toBeUndefined(); + }); + + it('should apply run-local total page count ordinal format', () => { + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-total-count-ordinal-format', + runs: [ + { + text: '0', + token: 'totalPageCount', + pageNumberFieldFormat: { format: 'ordinal' }, + fontFamily: 'Arial', + fontSize: 12, + } as TextRun, + ], + }; + + const wasModified = resolveTokensInBlock(block, 1, 22); + + expect(wasModified).toBe(true); + expect((block.runs[0] as TextRun).text).toBe('22nd'); + expect((block.runs[0] as TextRun).token).toBeUndefined(); + }); }); diff --git a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts index e9d1e23313..f6865ce6db 100644 --- a/packages/layout-engine/layout-engine/src/resolvePageTokens.ts +++ b/packages/layout-engine/layout-engine/src/resolvePageTokens.ts @@ -335,9 +335,12 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, blockModified = true; } else if (run.token === 'totalPageCount') { // Replace placeholder text with total page count - run.text = totalPagesStr; + run.text = run.pageNumberFieldFormat + ? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat) + : totalPagesStr; // Clear token metadata to treat as normal text after resolution delete run.token; + delete run.pageNumberFieldFormat; blockModified = true; } // Note: pageReference tokens are handled by resolvePageRefs.ts diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index f6407cb251..3ce189e25f 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -8,8 +8,10 @@ import type { DrawingMeasure, DrawingBlock, TableMeasure, + TextRun, } from '@superdoc/contracts'; import { EMPTY_SDT_PLACEHOLDER_TEXT } from '@superdoc/contracts'; +import { resolvePhysicalFamily } from '@superdoc/font-system'; const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { expect(measure.kind).toBe('paragraph'); @@ -83,6 +85,91 @@ describe('measureBlock', () => { expect(measure.totalHeight).toBe(measure.lines[0].lineHeight); }); + it('does not count empty text runs as justification spaces', async () => { + const visibleRuns = [ + { + text: '1. Confidential Information. In connection with ', + fontFamily: 'Arial', + fontSize: 16, + }, + ]; + const withoutEmptyRuns: FlowBlock = { + kind: 'paragraph', + id: 'without-empty-runs', + runs: visibleRuns, + attrs: { alignment: 'justify' }, + }; + const withEmptyRuns: FlowBlock = { + kind: 'paragraph', + id: 'with-empty-runs', + runs: [ + { text: '', fontFamily: 'Arial', fontSize: 16 }, + { text: '', fontFamily: 'Arial', fontSize: 16 }, + { text: '', fontFamily: 'Arial', fontSize: 16 }, + ...visibleRuns, + ], + attrs: { alignment: 'justify' }, + }; + + const baseMeasure = expectParagraphMeasure(await measureBlock(withoutEmptyRuns, 1000)); + const emptyPrefixMeasure = expectParagraphMeasure(await measureBlock(withEmptyRuns, 1000)); + + expect(emptyPrefixMeasure.lines).toHaveLength(1); + expect(emptyPrefixMeasure.lines[0].width).toBeCloseTo(baseMeasure.lines[0].width, 3); + expect(emptyPrefixMeasure.lines[0].spaceCount).toBe(baseMeasure.lines[0].spaceCount); + expect(emptyPrefixMeasure.lines[0].segments).toEqual([ + { + runIndex: 3, + fromChar: 0, + toChar: visibleRuns[0].text.length, + width: expect.any(Number), + }, + ]); + }); + + it('keeps empty-only paragraphs measurable without phantom spaces', async () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'empty-only-runs', + runs: [ + { text: '', fontFamily: 'Arial', fontSize: 16 }, + { text: '', fontFamily: 'Arial', fontSize: 16 }, + ], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(0); + expect(measure.lines[0].spaceCount).toBeUndefined(); + expect(measure.lines[0].segments).toEqual([]); + expect(measure.totalHeight).toBeGreaterThan(0); + }); + + it('does not let skipped empty runs inflate visible line height', async () => { + const visibleBlock: FlowBlock = { + kind: 'paragraph', + id: 'visible-small-run', + runs: [{ text: 'visible', fontFamily: 'Arial', fontSize: 12 }], + attrs: {}, + }; + const emptyPrefixBlock: FlowBlock = { + kind: 'paragraph', + id: 'empty-large-prefix-run', + runs: [ + { text: '', fontFamily: 'Arial', fontSize: 48 }, + { text: 'visible', fontFamily: 'Arial', fontSize: 12 }, + ], + attrs: {}, + }; + + const visibleMeasure = expectParagraphMeasure(await measureBlock(visibleBlock, 1000)); + const emptyPrefixMeasure = expectParagraphMeasure(await measureBlock(emptyPrefixBlock, 1000)); + + expect(emptyPrefixMeasure.lines[0].lineHeight).toBeCloseTo(visibleMeasure.lines[0].lineHeight, 3); + }); + // SD-3330: a line containing only tabs must be measured at the run's font size, // not the 12px fallback, so it has the same height as a text line in the same // paragraph font. Without this, tab-only lines render shorter than text lines. @@ -744,7 +831,7 @@ describe('measureBlock', () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); expect(ctx).not.toBeNull(); - ctx!.font = '16px Arial'; + ctx!.font = `16px ${resolvePhysicalFamily('Arial')}`; const transformedPlaceholderText = EMPTY_SDT_PLACEHOLDER_TEXT.toUpperCase(); const transformedWidth = ctx!.measureText(transformedPlaceholderText).width; const untransformedWidth = ctx!.measureText(EMPTY_SDT_PLACEHOLDER_TEXT).width; diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index f7d97ac1aa..43b1322d1d 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2198,6 +2198,10 @@ async function measureParagraphBlock( // Handle text runs lastFontSize = run.fontSize; hasSeenTextRun = true; + if (run.text === '') { + pendingRunSpacing = 0; + continue; + } const { font } = buildFontString(run, fontContext); const tabSegments = run.text.split('\t'); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 2e0bcf3468..bbc6207eb3 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6946,6 +6946,31 @@ describe('DomPainter', () => { resolvePartText({ text: '3', fieldType: 'SECTIONPAGES' }, { pageNumber: 1, totalPages: 9, section: 'body' }), ).toBe('3'); }); + + it('formats NUMPAGES drawing text with supported pageNumberFormat', () => { + const painter = new DomPainter(); + const resolvePartText = ( + painter as unknown as { + resolveShapeTextPartText: ( + part: { text: string; fieldType: string; pageNumberFormat?: string }, + context: { pageNumber: number; totalPages: number; section: 'body' }, + ) => string; + } + ).resolveShapeTextPartText.bind(painter); + + expect( + resolvePartText( + { text: '9', fieldType: 'NUMPAGES', pageNumberFormat: 'upperRoman' }, + { pageNumber: 1, totalPages: 9, section: 'body' }, + ), + ).toBe('IX'); + expect( + resolvePartText( + { text: '9', fieldType: 'NUMPAGES', pageNumberFormat: 'ordinal' }, + { pageNumber: 1, totalPages: 12, section: 'body' }, + ), + ).toBe('12th'); + }); 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 e995186b3b..88d7633747 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3158,7 +3158,8 @@ export class DomPainter { return context?.pageNumberText ?? String(context?.pageNumber ?? 1); } if (part.fieldType === 'NUMPAGES') { - return String(context?.totalPages ?? 1); + const totalPages = context?.totalPages ?? 1; + return part.pageNumberFormat ? formatPageNumber(totalPages, part.pageNumberFormat) : String(totalPages); } if (part.fieldType === 'SECTIONPAGES') { if (context?.sectionPageCount == null) return part.text ?? '1'; diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts index 0a6733e1fc..9f0cf8c8b7 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts @@ -311,6 +311,28 @@ describe('HeaderFooterEditorManager', () => { expect(sectionPages.textContent).toBe('IV'); }); + it('refreshes total page count DOM text with node numeric picture format', () => { + const editor = createMockEditor(); + const manager = new HeaderFooterEditorManager(editor); + const descriptor = { id: 'rId-header-default', kind: 'header' } as const; + const host = document.createElement('div'); + + const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host }); + expect(sectionEditor).toBeDefined(); + const totalPages = document.createElement('span'); + totalPages.dataset.id = 'auto-total-pages'; + totalPages.textContent = '1'; + sectionEditor!.view.dom.appendChild(totalPages); + (sectionEditor!.view as unknown as { posAtDOM: ReturnType }).posAtDOM = vi.fn(() => 0); + (sectionEditor as unknown as { state: { doc: { nodeAt: ReturnType } } }).state = { + doc: { nodeAt: vi.fn(() => ({ attrs: { pageNumberNumericPicture: '000' } })) }, + }; + + manager.ensureEditorSync(descriptor, { editorHost: host, totalPageCount: 12 }); + + expect(totalPages.textContent).toBe('012'); + }); + it('refreshes chapter-prefixed page number DOM text with node pageNumberFormat', () => { const editor = createMockEditor(); const manager = new HeaderFooterEditorManager(editor); diff --git a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts index 6d6c544b7f..2e88fc26fb 100644 --- a/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts @@ -2,6 +2,7 @@ import { toFlowBlocks } from '@core/layout-adapter'; import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js'; import { formatPageNumber, + formatPageNumberFieldValue, formatSectionPageNumberText, type FlowBlock, type PageNumberChapterSeparator, @@ -14,6 +15,7 @@ import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; import type { ConverterContext } from '@core/layout-adapter/converter-context.js'; import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js'; +import { getPageNumberFieldFormat } from '../layout-adapter/converters/inline-converters/page-number-field-format.js'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; const DEFAULT_HEADER_FOOTER_HEIGHT = 100; @@ -504,7 +506,7 @@ export class HeaderFooterEditorManager extends EventEmitter { typeof opts.currentPageChapterSeparator === 'string' ? (opts.currentPageChapterSeparator as PageNumberChapterSeparator) : undefined; - const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1'); + const totalPages = Number(opts.totalPageCount || parentEditor?.currentTotalPages || 1) || 1; const sectionPages = opts.sectionPageCount; const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]'); @@ -524,7 +526,9 @@ export class HeaderFooterEditorManager extends EventEmitter { if (el.textContent !== text) el.textContent = text; }); totalPagesEls.forEach((el) => { - if (el.textContent !== totalPages) el.textContent = totalPages; + const pageNumberFieldFormat = this.#getPageNumberFieldFormatForDomNode(editor, el); + const text = formatPageNumberFieldValue(totalPages, pageNumberFieldFormat); + if (el.textContent !== text) el.textContent = text; }); sectionPagesEls.forEach((el) => { if (sectionPages == null) return; @@ -548,6 +552,18 @@ export class HeaderFooterEditorManager extends EventEmitter { } } + #getPageNumberFieldFormatForDomNode(editor: Editor, el: Element): ReturnType { + try { + const view = editor.view; + if (!view) return undefined; + const pos = view.posAtDOM(el, 0); + const node = editor.state.doc.nodeAt(pos); + return getPageNumberFieldFormat(node?.attrs); + } catch { + return undefined; + } + } + /** * Retrieves the editor instance for a given header/footer descriptor, * if one has been created. diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts index 82bc22cff8..f3374297a1 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts @@ -203,6 +203,18 @@ const resolveHeadingLevel = ( return undefined; }; +export const resolveParagraphHeadingLevel = ( + paragraphProperties: ParagraphProperties | undefined, + converterContext?: ConverterContext, +): number | undefined => { + const properties = paragraphProperties ?? {}; + const resolvedParagraphProperties = converterContext + ? resolveParagraphProperties(converterContext, properties, converterContext.tableInfo) + : properties; + + return resolveHeadingLevel(resolvedParagraphProperties.styleId, resolvedParagraphProperties, converterContext); +}; + const TRACKED_CHANGE_KEYS = new Set(['trackInsert', 'trackDelete']); export const hasExplicitParagraphRunProperties = ( diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts index 04e9e519b7..b314e415b9 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.test.ts @@ -11,6 +11,15 @@ describe('getPageNumberFieldFormat', () => { ).toEqual({ format: 'decimal', zeroPadding: 2 }); }); + it('threads ordinal and numeric-picture attributes for layout runs', () => { + expect( + getPageNumberFieldFormat({ + pageNumberFormat: 'ordinal', + pageNumberNumericPicture: '#,##0', + }), + ).toEqual({ format: 'ordinal', numericPicture: '#,##0' }); + }); + it('ignores invalid format attributes', () => { expect(getPageNumberFieldFormat(undefined)).toBeUndefined(); expect(getPageNumberFieldFormat({ pageNumberFormat: 1, pageNumberZeroPadding: Number.NaN })).toBeUndefined(); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts index 9b1c9e0969..0b1f9f6cf6 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/page-number-field-format.ts @@ -9,9 +9,14 @@ export function getPageNumberFieldFormat( typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding) ? attrs.pageNumberZeroPadding : undefined; - if (!format && !zeroPadding) return undefined; + const numericPicture = + typeof attrs.pageNumberNumericPicture === 'string' && attrs.pageNumberNumericPicture.length > 0 + ? attrs.pageNumberNumericPicture + : undefined; + if (!format && !zeroPadding && !numericPicture) return undefined; return { ...(format ? { format: format as NonNullable['format'] } : {}), - ...(zeroPadding ? { zeroPadding } : {}), + ...(zeroPadding != null ? { zeroPadding } : {}), + ...(numericPicture ? { numericPicture } : {}), }; } diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts index 1544b3e90d..06780eccd3 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/inline-converters/sequence-field.ts @@ -4,18 +4,33 @@ import { textNodeToRun } from './text-run.js'; import type { InlineConverterParams } from './common.js'; /** - * Converts a sequenceField PM node to a TextRun with the resolved sequence number. + * Converts a sequenceField PM node to a TextRun token for post-assembly resolution. */ export function sequenceFieldNodeToRun(params: InlineConverterParams): TextRun | null { const { node, positions, sdtMetadata } = params; const attrs = (node.attrs ?? {}) as Record; - const resolvedNumber = (attrs.resolvedNumber as string) || '0'; + const cachedText = typeof attrs.resolvedNumber === 'string' ? attrs.resolvedNumber : ''; const run = textNodeToRun({ ...params, - node: { type: 'text', text: resolvedNumber, marks: [...(node.marks ?? [])] } as PMNode, + node: { type: 'text', text: cachedText || '1', marks: [...(node.marks ?? [])] } as PMNode, }); + run.token = 'seq'; + run.seqMetadata = { + identifier: String(attrs.identifier ?? ''), + instruction: String(attrs.instruction ?? ''), + fieldArgument: String(attrs.fieldArgument ?? ''), + sequenceMode: attrs.sequenceMode === 'current' ? 'current' : 'next', + hideResult: attrs.hideResult === true, + restartNumber: typeof attrs.restartNumber === 'number' ? attrs.restartNumber : null, + restartLevel: typeof attrs.restartLevel === 'number' ? attrs.restartLevel : null, + format: typeof attrs.format === 'string' ? attrs.format : undefined, + hasGeneralFormat: attrs.hasGeneralFormat === true, + pageNumberFieldFormat: readObjectAttr(attrs.pageNumberFieldFormat), + numericPictureFormat: readObjectAttr(attrs.numericPictureFormat), + cachedText, + }; const pos = positions.get(node); if (pos) { @@ -29,3 +44,7 @@ export function sequenceFieldNodeToRun(params: InlineConverterParams): TextRun | return run; } + +function readObjectAttr(value: unknown): T | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as T) : null; +} diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts index 4e138888fd..48617a7b8b 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/internal.ts @@ -21,6 +21,7 @@ import { } from './sections/index.js'; import { normalizePrefix, buildPositionMap, createBlockIdGenerator } from './utilities.js'; import { stampTrackedChangeColors } from '@superdoc/contracts'; +import { resolveSequenceFieldTokens } from './resolve-sequence-fields.js'; import { paragraphToFlowBlocks, contentBlockNodeToDrawingBlock, @@ -295,6 +296,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): // read it directly without invoking app callbacks. Passing `undefined` // clears stale colors from cached blocks when the host disables the feature. stampTrackedChangeColors(mergedBlocks, options?.resolveTrackedChangeColor); + resolveSequenceFieldTokens(mergedBlocks); // Commit cache cycle - swaps next to previous, retaining only blocks seen this render flowBlockCache?.commit(); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.test.ts new file mode 100644 index 0000000000..912e772a1f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; +import { toFlowBlocks as baseToFlowBlocks, FlowBlockCache } from './index.js'; +import { resolveSequenceFieldTokens } from './resolve-sequence-fields.js'; +import type { AdapterOptions, PMNode } from './index.js'; + +const createDefaultConverterContext = () => ({ + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, +}); + +const toFlowBlocks = (pmDoc: PMNode | object, options: AdapterOptions = {}) => + baseToFlowBlocks(pmDoc, { converterContext: createDefaultConverterContext(), ...options }); + +const seq = (attrs: Record = {}) => ({ + type: 'sequenceField', + attrs: { + instruction: 'SEQ Figure', + identifier: 'Figure', + sequenceMode: 'next', + hideResult: false, + restartNumber: null, + restartLevel: null, + format: 'Arabic', + hasGeneralFormat: false, + pageNumberFieldFormat: null, + numericPictureFormat: null, + resolvedNumber: '', + ...attrs, + }, +}); + +const paragraph = (content: PMNode['content'] = [], attrs: Record = {}) => ({ + type: 'paragraph', + attrs, + content, +}); + +const stableParagraph = (id: string, content: PMNode['content'] = []) => + paragraph(content, { sdBlockId: id, sdBlockRev: 1 }); + +const tableWithCellContent = (content: PMNode['content']) => ({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content, + }, + ], + }, + ], +}); + +const textRuns = (blocks: FlowBlock[]): TextRun[] => { + const runs: TextRun[] = []; + const visit = (block: FlowBlock) => { + if (block.kind === 'paragraph') { + for (const run of (block as ParagraphBlock).runs) { + if ('text' in run) runs.push(run as TextRun); + } + return; + } + if (block.kind === 'table') { + for (const row of (block as TableBlock).rows) { + for (const cell of row.cells) { + for (const childBlock of cell.blocks ?? (cell.paragraph ? [cell.paragraph] : [])) { + visit(childBlock); + } + } + } + return; + } + if (block.kind === 'list') { + for (const item of block.items) { + visit(item.paragraph); + } + } + }; + blocks.forEach(visit); + return runs; +}; + +const runTexts = (blocks: FlowBlock[]) => textRuns(blocks).map((run) => run.text); + +describe('resolveSequenceFieldTokens', () => { + it('renders two cached-empty sequence fields as 1 and 2', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [paragraph([seq()]), paragraph([seq()])], + }); + + expect(runTexts(blocks)).toEqual(['1', '2']); + expect(textRuns(blocks).map((run) => run.token)).toEqual(['seq', 'seq']); + }); + + it('keeps interleaved identifiers independent', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([seq({ identifier: 'Figure' })]), + paragraph([seq({ identifier: 'Table', instruction: 'SEQ Table' })]), + paragraph([seq({ identifier: 'Figure' })]), + ], + }); + + expect(runTexts(blocks)).toEqual(['1', '1', '2']); + }); + + it('repeats the prior display for current mode', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [paragraph([seq()]), paragraph([seq({ sequenceMode: 'current', instruction: 'SEQ Figure \\c' })])], + }); + + expect(runTexts(blocks)).toEqual(['1', '1']); + }); + + it('honors restartNumber and continues from it', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [paragraph([seq({ restartNumber: 10, instruction: 'SEQ Figure \\r 10' })]), paragraph([seq()])], + }); + + expect(runTexts(blocks)).toEqual(['10', '11']); + }); + + it('hides hidden results while still advancing the counter', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([seq()]), + paragraph([seq({ hideResult: true, instruction: 'SEQ Figure \\h' })]), + paragraph([seq()]), + ], + }); + + expect(runTexts(blocks)).toEqual(['1', '', '3']); + }); + + it('lets hidden restart-zero fields seed the next visible value at one', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([ + seq({ + instruction: 'seq level2 \\h \\r0', + identifier: 'level2', + hideResult: true, + restartNumber: 0, + }), + ]), + paragraph([seq({ instruction: 'seq level2 \\*arabic', identifier: 'level2', hasGeneralFormat: true })]), + ], + }); + + expect(runTexts(blocks)).toEqual(['', '1']); + }); + + it('restarts after resolved heading-level paragraphs', () => { + const headingAttrs = { paragraphProperties: { outlineLvl: 0 } }; + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([{ type: 'text', text: 'Chapter 1' }], headingAttrs), + paragraph([seq({ restartLevel: 1, instruction: 'SEQ Figure \\s 1' })]), + paragraph([seq({ restartLevel: 1, instruction: 'SEQ Figure \\s 1' })]), + paragraph([{ type: 'text', text: 'Chapter 2' }], headingAttrs), + paragraph([seq({ restartLevel: 1, instruction: 'SEQ Figure \\s 1' })]), + ], + }); + + expect(runTexts(blocks)).toEqual(['Chapter 1', '1', '2', 'Chapter 2', '1']); + }); + + it('applies page-number and numeric-picture formats', () => { + const roman = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([seq({ pageNumberFieldFormat: { format: 'lowerRoman' }, hasGeneralFormat: true })]), + paragraph([seq({ pageNumberFieldFormat: { format: 'lowerRoman' }, hasGeneralFormat: true })]), + ], + }); + const picture = toFlowBlocks({ + type: 'doc', + content: [ + paragraph([seq({ numericPictureFormat: { picture: '00' } })]), + paragraph([seq({ numericPictureFormat: { picture: '00' } })]), + ], + }); + + expect(runTexts(roman.blocks)).toEqual(['i', 'ii']); + expect(runTexts(picture.blocks)).toEqual(['01', '02']); + }); + + it('isolates counters across separate toFlowBlocks calls', () => { + const doc = { type: 'doc', content: [paragraph([seq()])] }; + + expect(runTexts(toFlowBlocks(doc).blocks)).toEqual(['1']); + expect(runTexts(toFlowBlocks(doc).blocks)).toEqual(['1']); + }); + + it('counts sequence fields inside table cells in document order', () => { + const { blocks } = toFlowBlocks({ + type: 'doc', + content: [paragraph([seq()]), tableWithCellContent([paragraph([seq()])]), paragraph([seq()])], + }); + + expect(runTexts(blocks)).toEqual(['1', '2', '3']); + }); + + it('renumbers cache-hit paragraphs after a sequence field is inserted above them', () => { + const cache = new FlowBlockCache(); + const firstDoc = { + type: 'doc', + content: [stableParagraph('p1', [seq()]), stableParagraph('p2', [seq()]), stableParagraph('p3', [seq()])], + }; + expect(runTexts(toFlowBlocks(firstDoc, { flowBlockCache: cache }).blocks)).toEqual(['1', '2', '3']); + + const secondDoc = { + type: 'doc', + content: [ + stableParagraph('p1', [seq()]), + stableParagraph('inserted', [seq()]), + stableParagraph('p2', [seq()]), + stableParagraph('p3', [seq()]), + ], + }; + const { blocks } = toFlowBlocks(secondDoc, { flowBlockCache: cache }); + + expect(cache.stats.hits).toBeGreaterThanOrEqual(3); + expect(runTexts(blocks)).toEqual(['1', '2', '3', '4']); + }); + + it('walks list item paragraphs when list blocks are present', () => { + const seqRun = (identifier = 'Figure'): TextRun => ({ + text: '1', + token: 'seq', + seqMetadata: { identifier, cachedText: '' }, + fontFamily: 'Times New Roman, serif', + fontSize: 12, + }); + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list-1', + listType: 'number', + items: [ + { + id: 'item-1', + marker: { text: '1.', width: 10 }, + paragraph: { kind: 'paragraph', id: 'p1', runs: [seqRun()] }, + }, + { + id: 'item-2', + marker: { text: '2.', width: 10 }, + paragraph: { kind: 'paragraph', id: 'p2', runs: [seqRun()] }, + }, + ], + }, + ]; + + resolveSequenceFieldTokens(blocks); + + expect(runTexts(blocks)).toEqual(['1', '2']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.ts new file mode 100644 index 0000000000..d364f186d3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/resolve-sequence-fields.ts @@ -0,0 +1,60 @@ +import type { FlowBlock, ListBlock, ParagraphBlock, Run, TableBlock } from '@superdoc/contracts'; +import { SequenceFieldEvaluator } from '../super-converter/field-references/shared/seq-evaluator.js'; + +/** + * Resolve SEQ token runs after all blocks have been assembled in document order. + * This keeps numbering cache-safe because cached paragraphs still contribute + * their preserved token metadata to the linear pass. + */ +export function resolveSequenceFieldTokens(blocks: FlowBlock[]): void { + const evaluator = new SequenceFieldEvaluator(); + for (const block of blocks) { + resolveBlock(block, evaluator); + } +} + +function resolveBlock(block: FlowBlock, evaluator: SequenceFieldEvaluator): void { + if (block.kind === 'paragraph') { + resolveParagraph(block as ParagraphBlock, evaluator); + return; + } + + if (block.kind === 'table') { + resolveTable(block as TableBlock, evaluator); + return; + } + + if (block.kind === 'list') { + resolveList(block as ListBlock, evaluator); + } +} + +function resolveParagraph(block: ParagraphBlock, evaluator: SequenceFieldEvaluator): void { + evaluator.enterParagraph({ paragraphHeadingLevel: block.attrs?.headingLevel }); + for (const run of block.runs) { + resolveRun(run, evaluator); + } +} + +function resolveTable(block: TableBlock, evaluator: SequenceFieldEvaluator): void { + for (const row of block.rows) { + for (const cell of row.cells) { + const childBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); + for (const childBlock of childBlocks) { + resolveBlock(childBlock, evaluator); + } + } + } +} + +function resolveList(block: ListBlock, evaluator: SequenceFieldEvaluator): void { + for (const item of block.items) { + resolveParagraph(item.paragraph, evaluator); + } +} + +function resolveRun(run: Run, evaluator: SequenceFieldEvaluator): void { + if ('token' in run && run.token === 'seq' && run.seqMetadata) { + run.text = evaluator.evaluateField(run.seqMetadata).text; + } +} 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 f0b46891d0..f13aba78de 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 @@ -39,9 +39,6 @@ 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': @@ -72,7 +69,6 @@ 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 c1c2dfd3b8..74bcd0a83e 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 @@ -87,10 +87,13 @@ describe('getInstructionPreProcessor', () => { 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.each(['seq level2 \\*arabic', 'Seq Figure \\* Arabic'])( + 'should dispatch SEQ fields case-insensitively: %s', + (instruction) => { + const processor = getInstructionPreProcessor(instruction); + expect(processor).toBe(preProcessSeqInstruction); + }, + ); it('should return preProcessTocInstruction for TOC instruction', () => { const instruction = 'TOC \\o "1-3" \\h \\z \\u'; 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 90ea7d0d1c..309a19adbc 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 @@ -86,6 +86,44 @@ describe('preProcessNodesForFldChar', () => { }, ); + it('preserves complex NUMPAGES numeric picture switches', () => { + const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes('NUMPAGES \\# "#,##0"', '1,234'), mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + importedCachedText: '1,234', + }, + }); + }); + + it('preserves fldSimple NUMPAGES zero-padding switches', () => { + const { processedNodes } = preProcessNodesForFldChar( + [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': 'NUMPAGES \\# "000"' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '007' }] }] }], + }, + ], + mockDocx, + ); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + importedCachedText: '007', + }, + }); + }); + it('preserves SECTIONPAGES field run properties when cached result has no run properties', () => { const fieldRunRPr = { name: 'w:rPr', elements: [{ name: 'w:i' }] }; const { processedNodes } = preProcessNodesForFldChar( @@ -143,15 +181,47 @@ describe('preProcessNodesForFldChar', () => { ]); }); - it('should preserve cached visible result runs for lowercase seq fields', () => { - const { processedNodes } = preProcessNodesForFldChar(complexFieldNodes('seq level2 \\*arabic', '1'), mockDocx); + it.each([ + ['uppercase complex', complexFieldNodes('SEQ Figure \\* ARABIC', '7'), 'SEQ Figure \\* ARABIC', true], + ['lowercase complex', complexFieldNodes('seq Figure \\* arabic', '8'), 'seq Figure \\* arabic', true], + [ + 'uppercase fldSimple', + [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': 'SEQ Figure \\* ARABIC' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '9' }] }] }], + }, + ], + 'SEQ Figure \\* ARABIC', + false, + ], + [ + 'lowercase fldSimple', + [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': 'seq Figure \\* arabic' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10' }] }] }], + }, + ], + 'seq Figure \\* arabic', + false, + ], + ])('processes %s SEQ fields and preserves cached result runs', (_name, nodes, instruction, hasInstructionTokens) => { + const { processedNodes } = preProcessNodesForFldChar(nodes, 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' }] }], + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0]).toMatchObject({ + name: 'sd:sequenceField', + attributes: { instruction }, }); + expect(processedNodes[0].elements).toHaveLength(1); + expect(processedNodes[0].elements[0].name).toBe('w:r'); + expect(processedNodes[0].elements[0].elements?.[0]?.name).toBe('w:t'); + expect(processedNodes[0].attributes.instructionTokens).toEqual( + hasInstructionTokens ? [{ type: 'text', text: instruction }] : undefined, + ); }); it('should handle nested fields (PAGEREF within HYPERLINK)', () => { 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 2208479796..71e7cea8b1 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 @@ -182,7 +182,8 @@ function scanFieldSequence(nodes, beginIndex) { const instrTextEl = node.elements?.find((el) => el.name === 'w:instrText'); if (instrTextEl) { - instrText += (instrTextEl.elements?.[0]?.text || '') + ' '; + const fragment = instrTextEl.elements?.[0]?.text || ''; + instrText += shouldInsertSwitchBoundarySpace(instrText, fragment) ? ` ${fragment}` : fragment; } // Capture rPr from field sequence nodes (before separate) if we don't have one yet @@ -220,6 +221,13 @@ function scanFieldSequence(nodes, beginIndex) { }; } +function shouldInsertSwitchBoundarySpace(existingInstruction, nextFragment) { + return ( + (/\w$/.test(existingInstruction) && /^\\/.test(nextFragment)) || + (/(^|\s)\\[#*]$/.test(existingInstruction) && /^\S/.test(nextFragment)) + ); +} + /** * Returns the appropriate preprocessor for fields recognized in headers/footers, * or null for unrecognized field types. 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 3e1f00541b..e9ba71321c 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 @@ -28,6 +28,31 @@ describe('preProcessPageFieldsOnly', () => { ]; } + function complexFieldNodesFromInstructionFragments(instructionFragments, cachedText = '1') { + return [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + ...instructionFragments.map((text) => ({ + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text }] }], + })), + { + 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 = [ @@ -137,6 +162,119 @@ describe('preProcessPageFieldsOnly', () => { }); }); + it('should preserve NUMPAGES quoted numeric picture whitespace across split instrText runs', () => { + const nodes = [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'NUMPAGES \\# "#' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: ' pages"' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1 pages' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "# pages"', + pageNumberNumericPicture: '# pages', + importedCachedText: '1 pages', + }, + }); + }); + + it('should process NUMPAGES switches split at a run boundary without whitespace', () => { + const nodes = [ + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'NUMPAGES' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: '\\# "000"' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }], + }, + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: '007' }] }], + }, + { + name: 'w:r', + elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }], + }, + ]; + + const result = preProcessPageFieldsOnly(nodes); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + importedCachedText: '007', + }, + }); + }); + + it('should process NUMPAGES numeric switches split between operator and argument', () => { + const result = preProcessPageFieldsOnly( + complexFieldNodesFromInstructionFragments(['NUMPAGES', '\\#', '"000"'], '007'), + ); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "000"', + pageNumberFormat: 'decimal', + pageNumberZeroPadding: 3, + importedCachedText: '007', + }, + }); + }); + + it('should process PAGE general-format switches split between operator and argument', () => { + const result = preProcessPageFieldsOnly(complexFieldNodesFromInstructionFragments(['PAGE', '\\*', 'Roman'])); + + expect(result.processedNodes).toHaveLength(1); + expect(result.processedNodes[0]).toMatchObject({ + name: 'sd:autoPageNumber', + attributes: { + instruction: 'PAGE \\* Roman', + pageNumberFormat: 'upperRoman', + }, + }); + }); + it.each([' numpages ', ' NumPages ', ' NUMPAGES '])( 'should process NUMPAGES field case-insensitively with fldChar syntax: %s', (instruction) => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js index f6107df893..cd3db34dce 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.js @@ -8,6 +8,7 @@ export const GENERAL_FORMATS = new Map([ ['alphabetic', 'lowerLetter'], ['ALPHABETIC', 'upperLetter'], ['ArabicDash', 'numberInDash'], + ['Ordinal', 'ordinal'], ]); export const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ @@ -18,17 +19,18 @@ export const CASE_INSENSITIVE_GENERAL_FORMATS = new Map([ /** * @param {string} instruction * @param {'PAGE' | 'NUMPAGES' | 'SECTIONPAGES'} fieldType - * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number }} + * @returns {{ instruction?: string, pageNumberFormat?: string, pageNumberZeroPadding?: number, pageNumberNumericPicture?: string }} */ export function parsePageNumberFieldSwitches(instruction, fieldType) { - const normalizedInstruction = typeof instruction === 'string' ? instruction.trim().replace(/\s+/g, ' ') : fieldType; + const switchInstruction = typeof instruction === 'string' ? instruction.trim() : fieldType; + const normalizedInstruction = normalizeInstructionWhitespace(switchInstruction); const result = {}; if (normalizedInstruction && normalizedInstruction !== fieldType) { result.instruction = normalizedInstruction; } - for (const match of normalizedInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { + for (const match of switchInstruction.matchAll(/\\\*\s+("[^"]+"|\S+)/g)) { const rawValue = unquote(match[1]); const mapped = GENERAL_FORMATS.get(rawValue) ?? CASE_INSENSITIVE_GENERAL_FORMATS.get(rawValue.toLowerCase()); if (mapped) { @@ -37,13 +39,18 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { } } - for (const match of normalizedInstruction.matchAll(/\\#\s+("[^"]+"|\S+)/g)) { + for (const match of switchInstruction.matchAll(/\\#\s+("[^"]+"|\S+)/g)) { const picture = unquote(match[1]); + if (!picture) continue; + if (/^0+$/.test(picture)) { result.pageNumberFormat ??= 'decimal'; result.pageNumberZeroPadding = picture.length; - break; + } else { + result.pageNumberNumericPicture = picture; } + + break; } return result; @@ -51,12 +58,13 @@ export function parsePageNumberFieldSwitches(instruction, fieldType) { /** * @param {number} pageNumber - * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null }} attrs + * @param {{ pageNumberFormat?: string | null, pageNumberZeroPadding?: number | null, pageNumberNumericPicture?: string | null }} attrs */ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { return formatSharedPageNumberFieldValue(pageNumber, { format: attrs.pageNumberFormat || 'decimal', zeroPadding: attrs.pageNumberZeroPadding ?? undefined, + numericPicture: attrs.pageNumberNumericPicture ?? undefined, }); } @@ -66,3 +74,40 @@ export function formatPageNumberFieldValue(pageNumber, attrs = {}) { function unquote(value) { return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; } + +/** + * Collapse field-code whitespace outside quoted switch arguments while + * preserving significant whitespace inside numeric-picture literals. + * + * @param {string} instruction + */ +function normalizeInstructionWhitespace(instruction) { + let normalized = ''; + let inQuote = false; + let pendingSpace = false; + + for (const char of instruction) { + if (char === '"') { + if (pendingSpace && normalized.length > 0) { + normalized += ' '; + pendingSpace = false; + } + normalized += char; + inQuote = !inQuote; + continue; + } + + if (!inQuote && /\s/.test(char)) { + pendingSpace = true; + continue; + } + + if (pendingSpace && normalized.length > 0) { + normalized += ' '; + pendingSpace = false; + } + normalized += char; + } + + return normalized; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js index 287723b79f..4fbd4e48ca 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/page-number-field-switches.test.js @@ -14,10 +14,18 @@ describe('parsePageNumberFieldSwitches', () => { ['PAGE \\* ArabicDash', { instruction: 'PAGE \\* ArabicDash', pageNumberFormat: 'numberInDash' }], ['PAGE \\* arabicdash', { instruction: 'PAGE \\* arabicdash', pageNumberFormat: 'numberInDash' }], ['PAGE \\* ARABICDASH', { instruction: 'PAGE \\* ARABICDASH', pageNumberFormat: 'numberInDash' }], + ['PAGE \\* Ordinal', { instruction: 'PAGE \\* Ordinal', pageNumberFormat: 'ordinal' }], ])('parses general format switch %s', (instruction, expected) => { expect(parsePageNumberFieldSwitches(instruction, 'PAGE')).toEqual(expected); }); + it('parses NUMPAGES Ordinal format switches', () => { + expect(parsePageNumberFieldSwitches('NUMPAGES \\* Ordinal', 'NUMPAGES')).toEqual({ + instruction: 'NUMPAGES \\* Ordinal', + pageNumberFormat: 'ordinal', + }); + }); + it.each([['PAGE \\* rOman'], ['PAGE \\* Alphabetic'], ['PAGE \\* aLpHaBeTiC']])( 'does not case-fold output-case-sensitive switch %s', (instruction) => { @@ -32,6 +40,15 @@ describe('parsePageNumberFieldSwitches', () => { expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); }); + it.each([ + ['NUMPAGES \\# "#,##0"', { instruction: 'NUMPAGES \\# "#,##0"', pageNumberNumericPicture: '#,##0' }], + ['NUMPAGES \\# #,##0', { instruction: 'NUMPAGES \\# #,##0', pageNumberNumericPicture: '#,##0' }], + ['NUMPAGES \\# "# pages"', { instruction: 'NUMPAGES \\# "# pages"', pageNumberNumericPicture: '# pages' }], + ['NUMPAGES \\# "# pages"', { instruction: 'NUMPAGES \\# "# pages"', pageNumberNumericPicture: '# pages' }], + ])('preserves non-zero numeric picture switch %s', (instruction, expected) => { + expect(parsePageNumberFieldSwitches(instruction, 'NUMPAGES')).toEqual(expected); + }); + it('parses SECTIONPAGES zero-padding picture switches', () => { expect(parsePageNumberFieldSwitches('SECTIONPAGES \\# "000"', 'SECTIONPAGES')).toEqual({ instruction: 'SECTIONPAGES \\# "000"', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.js new file mode 100644 index 0000000000..cc84e1d7a3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.js @@ -0,0 +1,163 @@ +import { formatIntegerWithNumericPicture, formatPageNumberFieldValue } from '@superdoc/contracts'; +import { normalizeSeqIdentifier } from './seq-instruction.js'; + +/** + * @typedef {{ + * initialCounters?: Map, + * }} SeqEvaluatorOptions + * + * Note: no `storyKey` is needed. One evaluator instance is created per linear + * pass over one story's ordered content, so per-story isolation is structural. + * + * @typedef {{ + * identifier: string, + * instruction?: string, + * fieldArgument?: string, + * sequenceMode?: 'next' | 'current', + * hideResult?: boolean, + * restartNumber?: number | null, + * restartLevel?: number | null, + * format?: string, + * hasGeneralFormat?: boolean, + * pageNumberFieldFormat?: import('@superdoc/contracts').PageNumberFieldFormat | null, + * numericPictureFormat?: { picture: string } | null, + * cachedText?: string, + * }} SeqFieldInput + * + * @typedef {{ + * paragraphHeadingLevel?: number | null, + * }} SeqFieldContext + * + * @typedef {{ + * value: number | null, + * text: string, + * hidden: boolean, + * }} SeqFieldEvaluation + */ + +export class SequenceFieldEvaluator { + /** + * @param {SeqEvaluatorOptions} options + */ + constructor(options = {}) { + this.counters = new Map(options.initialCounters ?? []); + this.headingSerialsByLevel = new Map(); + this.lastResetSerialByIdentifierLevel = new Map(); + } + + /** + * @param {SeqFieldContext} context + */ + enterParagraph(context = {}) { + const level = context.paragraphHeadingLevel; + if (!isValidHeadingLevel(level)) return; + + this.headingSerialsByLevel.set(level, (this.headingSerialsByLevel.get(level) ?? 0) + 1); + for (let deeperLevel = level + 1; deeperLevel <= 9; deeperLevel += 1) { + this.headingSerialsByLevel.delete(deeperLevel); + } + } + + /** + * @param {SeqFieldInput} field + * @returns {SeqFieldEvaluation} + */ + evaluateField(field) { + const identifier = normalizeSeqIdentifier(field?.identifier); + const cachedText = typeof field?.cachedText === 'string' ? field.cachedText : ''; + + if (!identifier) { + return { value: null, text: cachedText, hidden: false }; + } + + if (hasFieldArgument(field)) { + // A SEQ field argument references a bookmarked item elsewhere. Correct + // behavior needs bookmark position resolution; until Phase 7 wires that + // in, preserve cached text or conservatively repeat the current counter. + // This short-circuit intentionally bypasses \h suppression for now. + if (cachedText) return { value: null, text: cachedText, hidden: false }; + if (!this.counters.has(identifier)) return { value: null, text: '', hidden: false }; + + const value = this.counters.get(identifier); + return { value, text: formatSeqValue(value, field), hidden: false }; + } + + const restartLevel = normalizeRestartLevel(field?.restartLevel); + if (restartLevel != null) { + const key = `${identifier}|${restartLevel}`; + const serial = this.headingSerialsByLevel.get(restartLevel) ?? 0; + const previousSerial = this.lastResetSerialByIdentifierLevel.get(key); + if (previousSerial === undefined || serial !== previousSerial) { + // ECMA says \s "resets to the heading level"; Word interprets this as + // restarting sequence numbering within each heading-N section. + this.counters.set(identifier, 0); + this.lastResetSerialByIdentifierLevel.set(key, serial); + } + } + + const restartNumber = normalizeRestartNumber(field?.restartNumber); + let value; + if (restartNumber != null) { + this.counters.set(identifier, restartNumber); + value = restartNumber; + } else if (field?.sequenceMode === 'current') { + if (!this.counters.has(identifier)) { + return { value: 0, text: cachedText, hidden: false }; + } + value = this.counters.get(identifier); + } else { + value = (this.counters.get(identifier) ?? 0) + 1; + this.counters.set(identifier, value); + } + + const hidden = Boolean(field?.hideResult && !field.hasGeneralFormat && !field.numericPictureFormat); + return { + value, + text: hidden ? '' : formatSeqValue(value, field), + hidden, + }; + } +} + +/** + * @param {unknown} value + */ +function isValidHeadingLevel(value) { + return Number.isInteger(value) && value >= 1 && value <= 9; +} + +/** + * @param {unknown} value + */ +function normalizeRestartLevel(value) { + return isValidHeadingLevel(value) ? value : null; +} + +/** + * @param {unknown} value + */ +function normalizeRestartNumber(value) { + return Number.isFinite(value) && Number.isInteger(value) ? Math.trunc(value) : null; +} + +/** + * @param {SeqFieldInput | undefined} field + */ +function hasFieldArgument(field) { + return typeof field?.fieldArgument === 'string' && field.fieldArgument.trim().length > 0; +} + +/** + * @param {number} value + * @param {SeqFieldInput | undefined} field + */ +function formatSeqValue(value, field) { + const picture = field?.numericPictureFormat?.picture; + if (picture) { + return formatIntegerWithNumericPicture(value, picture); + } + if (field?.pageNumberFieldFormat) { + return formatPageNumberFieldValue(value, field.pageNumberFieldFormat); + } + return String(value); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.test.js new file mode 100644 index 0000000000..08adf5cdb9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-evaluator.test.js @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { SequenceFieldEvaluator } from './seq-evaluator.js'; + +describe('SequenceFieldEvaluator', () => { + it('numbers each identifier independently in document order', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 1, text: '1' }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 2, text: '2' }); + expect(evaluator.evaluateField({ identifier: 'Table' })).toMatchObject({ value: 1, text: '1' }); + }); + + it('treats explicit next mode as the default', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure', sequenceMode: 'next' })).toMatchObject({ + value: 1, + text: '1', + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 2, text: '2' }); + }); + + it('repeats the current value without incrementing', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.evaluateField({ identifier: 'Figure' }); + expect(evaluator.evaluateField({ identifier: 'Figure', sequenceMode: 'current' })).toMatchObject({ + value: 1, + text: '1', + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 2, text: '2' }); + }); + + it('uses cached text or empty text for current mode before any prior value', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure', sequenceMode: 'current', cachedText: 'cached' })).toEqual({ + value: 0, + text: 'cached', + hidden: false, + }); + expect(evaluator.evaluateField({ identifier: 'Table', sequenceMode: 'current' })).toEqual({ + value: 0, + text: '', + hidden: false, + }); + }); + + it('applies explicit restart and continues from that value', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure', restartNumber: 10 })).toMatchObject({ + value: 10, + text: '10', + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 11, text: '11' }); + }); + + it('hides text while still updating the counter', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 1, text: '1' }); + expect(evaluator.evaluateField({ identifier: 'Figure', hideResult: true })).toEqual({ + value: 2, + text: '', + hidden: true, + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 3, text: '3' }); + }); + + it('does not hide text when a general format is present', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect( + evaluator.evaluateField({ + identifier: 'Figure', + hideResult: true, + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'decimal' }, + }), + ).toEqual({ value: 1, text: '1', hidden: false }); + }); + + it('restarts on the first heading-level reset field and when that heading level changes', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 1 })).toMatchObject({ value: 1, text: '1' }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 1 })).toMatchObject({ value: 2, text: '2' }); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 1 })).toMatchObject({ value: 1, text: '1' }); + }); + + it('restarts for level 2 only when the level 2 serial changes', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 2 })).toMatchObject({ value: 1, text: '1' }); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 2 })).toMatchObject({ value: 2, text: '2' }); + + evaluator.enterParagraph({ paragraphHeadingLevel: 2 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 2 })).toMatchObject({ value: 1, text: '1' }); + + evaluator.enterParagraph({ paragraphHeadingLevel: 2 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 2 })).toMatchObject({ value: 1, text: '1' }); + }); + + it('treats a deleted deeper heading serial as 0 for heading-level resets', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + evaluator.enterParagraph({ paragraphHeadingLevel: 3 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 3 })).toMatchObject({ value: 1, text: '1' }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 2, text: '2' }); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 3 })).toMatchObject({ value: 1, text: '1' }); + }); + + it('lets explicit restart win over heading-level reset', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.enterParagraph({ paragraphHeadingLevel: 1 }); + expect(evaluator.evaluateField({ identifier: 'Figure', restartLevel: 1, restartNumber: 7 })).toMatchObject({ + value: 7, + text: '7', + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 8, text: '8' }); + }); + + it('repeats the current counter after explicit restart', () => { + const evaluator = new SequenceFieldEvaluator(); + + evaluator.evaluateField({ identifier: 'Figure', restartNumber: 7 }); + expect(evaluator.evaluateField({ identifier: 'Figure', sequenceMode: 'current' })).toMatchObject({ + value: 7, + text: '7', + }); + }); + + it('formats values with general and numeric-picture formats', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect( + evaluator.evaluateField({ identifier: 'Roman', pageNumberFieldFormat: { format: 'lowerRoman' } }), + ).toMatchObject({ value: 1, text: 'i' }); + expect( + evaluator.evaluateField({ identifier: 'Alpha', pageNumberFieldFormat: { format: 'upperLetter' } }), + ).toMatchObject({ value: 1, text: 'A' }); + expect( + evaluator.evaluateField({ identifier: 'Dash', pageNumberFieldFormat: { format: 'numberInDash' } }), + ).toMatchObject({ value: 1, text: '- 1 -' }); + expect(evaluator.evaluateField({ identifier: 'Picture', numericPictureFormat: { picture: '00' } })).toMatchObject({ + value: 1, + text: '01', + }); + }); + + it('gives numeric-picture formatting priority over general formatting', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect( + evaluator.evaluateField({ + identifier: 'Figure', + pageNumberFieldFormat: { format: 'lowerRoman' }, + numericPictureFormat: { picture: '00' }, + }), + ).toMatchObject({ value: 1, text: '01' }); + }); + + it('defaults unknown formats to decimal display instead of cached text', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect( + evaluator.evaluateField({ + identifier: 'Figure', + format: 'OrdText', + hasGeneralFormat: true, + cachedText: 'cached', + }), + ).toMatchObject({ value: 1, text: '1' }); + }); + + it('returns cached fallback for empty identifiers without mutating counters', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: '', cachedText: 'cached' })).toEqual({ + value: null, + text: 'cached', + hidden: false, + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 1, text: '1' }); + }); + + it('handles field arguments conservatively without mutating counters', () => { + const evaluator = new SequenceFieldEvaluator(); + + expect(evaluator.evaluateField({ identifier: 'Figure', fieldArgument: 'bookmark', cachedText: 'cached' })).toEqual({ + value: null, + text: 'cached', + hidden: false, + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 1, text: '1' }); + expect(evaluator.evaluateField({ identifier: 'Figure', fieldArgument: 'bookmark' })).toEqual({ + value: 1, + text: '1', + hidden: false, + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 2, text: '2' }); + }); + + it('can start from caller-provided initial counters', () => { + const evaluator = new SequenceFieldEvaluator({ initialCounters: new Map([['Figure', 4]]) }); + + expect(evaluator.evaluateField({ identifier: 'Figure', sequenceMode: 'current' })).toMatchObject({ + value: 4, + text: '4', + }); + expect(evaluator.evaluateField({ identifier: 'Figure' })).toMatchObject({ value: 5, text: '5' }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.js new file mode 100644 index 0000000000..ccb379ba7e --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.js @@ -0,0 +1,293 @@ +import { extractFieldKeyword } from '../field-keyword.js'; +import { CASE_INSENSITIVE_GENERAL_FORMATS, GENERAL_FORMATS } from './page-number-field-switches.js'; + +const TOKEN_PATTERN = /"((?:[^"\\]|\\.)*)"|\\[#*](?=\s|$)|\\[^\s]+|[^\s]+/g; + +/** + * @typedef {'next' | 'current'} SeqMode + * @typedef {{ picture: string }} SeqNumericPictureFormat + * @typedef {{ + * instruction: string, + * keyword: string, + * identifier: string, + * fieldArgument: string, + * sequenceMode: SeqMode, + * hideResult: boolean, + * restartNumber: number | null, + * restartLevel: number | null, + * format: string, + * pageNumberFieldFormat?: import('@superdoc/contracts').PageNumberFieldFormat, + * numericPictureFormat: SeqNumericPictureFormat | null, + * hasGeneralFormat: boolean, + * unknownSwitches: string[], + * }} ParsedSeqInstruction + * + * @typedef {{ + * identifier: string, + * fieldArgument: string, + * sequenceMode: SeqMode, + * hideResult: boolean, + * restartNumber: number | null, + * restartLevel: number | null, + * format: string, + * hasGeneralFormat: boolean, + * pageNumberFieldFormat: import('@superdoc/contracts').PageNumberFieldFormat | null, + * numericPictureFormat: SeqNumericPictureFormat | null, + * }} SequenceFieldParsedAttrs + */ + +/** + * @param {string} instruction + * @returns {ParsedSeqInstruction} + */ +export function parseSeqInstruction(instruction) { + const rawInstruction = typeof instruction === 'string' ? instruction : ''; + const tokens = tokenizeInstruction(rawInstruction); + const keyword = tokens[0]?.value ?? ''; + const result = createEmptyParse(rawInstruction, keyword); + + if (extractFieldKeyword(rawInstruction) !== 'SEQ') { + return result; + } + + let sawSwitch = false; + + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index].value; + if (!token) continue; + + if (token.startsWith('\\')) { + sawSwitch = true; + const attachedValueSwitch = parseAttachedNumericSwitch(token); + const normalized = (attachedValueSwitch?.switchToken ?? token).toLowerCase(); + + if (normalized === '\\n') { + result.sequenceMode = 'next'; + continue; + } + + if (normalized === '\\c') { + result.sequenceMode = 'current'; + continue; + } + + if (normalized === '\\h') { + result.hideResult = true; + continue; + } + + if (normalized === '\\r') { + const value = attachedValueSwitch?.value ?? tokens[index + 1]?.value; + if (value != null && (attachedValueSwitch || !value.startsWith('\\'))) { + const parsed = parseInteger(value); + if (parsed != null) result.restartNumber = parsed; + if (!attachedValueSwitch) index += 1; + } + continue; + } + + if (normalized === '\\s') { + const value = attachedValueSwitch?.value ?? tokens[index + 1]?.value; + if (value != null && (attachedValueSwitch || !value.startsWith('\\'))) { + const parsed = parseInteger(value); + if (parsed != null && parsed >= 1 && parsed <= 9) result.restartLevel = parsed; + if (!attachedValueSwitch) index += 1; + } + continue; + } + + const attachedGeneralFormat = parseAttachedGeneralFormatSwitch(token); + if (normalized === '\\*' || attachedGeneralFormat != null) { + const value = attachedGeneralFormat ?? tokens[index + 1]?.value; + if (value != null && (attachedGeneralFormat != null || !value.startsWith('\\'))) { + result.format = value; + result.hasGeneralFormat = true; + applyGeneralFormat(result, value); + if (attachedGeneralFormat == null) index += 1; + } else { + result.unknownSwitches.push(token); + } + continue; + } + + const attachedNumericPicture = parseAttachedNumericPictureSwitch(token); + if (normalized === '\\#' || attachedNumericPicture != null) { + const value = attachedNumericPicture ?? tokens[index + 1]?.value; + if (value != null && (attachedNumericPicture != null || !value.startsWith('\\'))) { + if (result.numericPictureFormat == null) { + result.numericPictureFormat = { picture: value }; + } else { + result.unknownSwitches.push(token, value); + } + if (attachedNumericPicture == null) index += 1; + } else { + result.unknownSwitches.push(token); + } + continue; + } + + result.unknownSwitches.push(token); + // Unknown switch arity is ambiguous; preserve the adjacent value token + // when present so later phases do not silently drop raw instruction data. + const value = tokens[index + 1]?.value; + if (value != null && !value.startsWith('\\')) { + result.unknownSwitches.push(value); + index += 1; + } + continue; + } + + if (!result.identifier) { + result.identifier = normalizeSeqIdentifier(token); + continue; + } + + if (!sawSwitch && !result.fieldArgument) { + result.fieldArgument = token; + } + } + + return result; +} + +/** + * @param {string} instruction + */ +export function isSeqInstruction(instruction) { + return extractFieldKeyword(instruction) === 'SEQ'; +} + +/** + * @param {unknown} identifier + */ +export function normalizeSeqIdentifier(identifier) { + return typeof identifier === 'string' ? identifier.trim() : ''; +} + +/** + * Project parsed SEQ instruction metadata into sequenceField PM attrs. + * + * @param {ParsedSeqInstruction} parsed + * @returns {SequenceFieldParsedAttrs} + */ +export function sequenceFieldAttrsFromParsed(parsed) { + return { + identifier: parsed.identifier, + fieldArgument: parsed.fieldArgument, + sequenceMode: parsed.sequenceMode, + hideResult: parsed.hideResult, + restartNumber: parsed.restartNumber, + restartLevel: parsed.restartLevel, + format: parsed.hasGeneralFormat ? parsed.format : 'ARABIC', + hasGeneralFormat: parsed.hasGeneralFormat, + pageNumberFieldFormat: parsed.pageNumberFieldFormat ?? null, + numericPictureFormat: parsed.numericPictureFormat, + }; +} + +/** + * @param {string} instruction + */ +function tokenizeInstruction(instruction) { + const tokens = []; + for (const match of instruction.matchAll(TOKEN_PATTERN)) { + tokens.push({ value: match[1] !== undefined ? unescapeQuotedToken(match[1]) : match[0] }); + } + return tokens; +} + +/** + * @param {string} instruction + * @param {string} keyword + * @returns {ParsedSeqInstruction} + */ +function createEmptyParse(instruction, keyword) { + return { + instruction, + keyword, + identifier: '', + fieldArgument: '', + sequenceMode: 'next', + hideResult: false, + restartNumber: null, + restartLevel: null, + format: 'Arabic', + numericPictureFormat: null, + hasGeneralFormat: false, + unknownSwitches: [], + }; +} + +/** + * @param {string} value + */ +function parseInteger(value) { + const number = Number(value); + if (!Number.isFinite(number) || !Number.isInteger(number)) return null; + return Math.trunc(number); +} + +/** + * Word often serializes numeric SEQ switches without a separating space + * (`\r0`, `\s1`). Normalize those into the same path as `\r 0` / `\s 1`. + * + * @param {string} token + */ +function parseAttachedNumericSwitch(token) { + const match = /^\\([rRsS])([+-]?\d+(?:\.\d+)?)$/.exec(token); + if (!match) return null; + return { + switchToken: `\\${match[1]}`, + value: match[2], + }; +} + +/** + * Word can serialize general-format switches without a separating space + * (`\*roman`). Normalize those into the same path as `\* roman`. + * + * @param {string} token + */ +function parseAttachedGeneralFormatSwitch(token) { + const match = /^\\\*(\S+)$/.exec(token); + return normalizeAttachedSwitchValue(match?.[1]); +} + +/** + * Keep attached numeric picture switches (`\#00`) equivalent to `\# 00`. + * + * @param {string} token + */ +function parseAttachedNumericPictureSwitch(token) { + const match = /^\\#(\S+)$/.exec(token); + return normalizeAttachedSwitchValue(match?.[1]); +} + +/** + * @param {string | undefined} value + */ +function normalizeAttachedSwitchValue(value) { + if (value == null) return null; + if (value.startsWith('"') && value.endsWith('"')) { + return unescapeQuotedToken(value.slice(1, -1)); + } + return value; +} + +/** + * @param {ParsedSeqInstruction} result + * @param {string} value + */ +function applyGeneralFormat(result, value) { + const mapped = GENERAL_FORMATS.get(value) ?? CASE_INSENSITIVE_GENERAL_FORMATS.get(value.toLowerCase()); + if (mapped) { + result.pageNumberFieldFormat = { format: mapped }; + } +} + +/** + * @param {string} value + */ +function unescapeQuotedToken(value) { + return value.replace(/\\(["\\])/g, '$1'); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.test.js new file mode 100644 index 0000000000..a220d58379 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/shared/seq-instruction.test.js @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { + isSeqInstruction, + normalizeSeqIdentifier, + parseSeqInstruction, + sequenceFieldAttrsFromParsed, +} from './seq-instruction.js'; + +describe('parseSeqInstruction', () => { + it.each([ + ['SEQ Figure', { keyword: 'SEQ', identifier: 'Figure', sequenceMode: 'next', format: 'Arabic' }], + ['seq Figure', { keyword: 'seq', identifier: 'Figure', sequenceMode: 'next', format: 'Arabic' }], + ['Seq Figure \\n', { keyword: 'Seq', identifier: 'Figure', sequenceMode: 'next' }], + ['SEQ Figure \\c', { identifier: 'Figure', sequenceMode: 'current' }], + ['SEQ Figure \\h', { identifier: 'Figure', hideResult: true }], + ['SEQ Figure \\r 10', { identifier: 'Figure', restartNumber: 10 }], + ['SEQ Figure \\r0', { identifier: 'Figure', restartNumber: 0 }], + ['SEQ Figure \\R0', { identifier: 'Figure', restartNumber: 0 }], + ['SEQ Figure \\s 1', { identifier: 'Figure', restartLevel: 1 }], + ['SEQ Figure \\s1', { identifier: 'Figure', restartLevel: 1 }], + ['SEQ Figure \\S1', { identifier: 'Figure', restartLevel: 1 }], + ['seq level2 \\h \\r0', { keyword: 'seq', identifier: 'level2', hideResult: true, restartNumber: 0 }], + ['SEQ Figure bookmarkName', { identifier: 'Figure', fieldArgument: 'bookmarkName' }], + ['SEQ Figure "bookmark name"', { identifier: 'Figure', fieldArgument: 'bookmark name' }], + [ + 'SEQ Figure \\* roman', + { + identifier: 'Figure', + format: 'roman', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'lowerRoman' }, + }, + ], + [ + 'SEQ Figure \\*roman', + { + identifier: 'Figure', + format: 'roman', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'lowerRoman' }, + }, + ], + [ + 'seq level2 \\*arabic', + { + keyword: 'seq', + identifier: 'level2', + format: 'arabic', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'decimal' }, + }, + ], + [ + 'SEQ Figure \\*"Roman"', + { + identifier: 'Figure', + format: 'Roman', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'upperRoman' }, + }, + ], + [ + 'SEQ Figure \\* ALPHABETIC', + { + identifier: 'Figure', + format: 'ALPHABETIC', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'upperLetter' }, + }, + ], + [ + 'SEQ Figure \\* ArabicDash', + { + identifier: 'Figure', + format: 'ArabicDash', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'numberInDash' }, + }, + ], + ['SEQ Figure \\# "00"', { identifier: 'Figure', numericPictureFormat: { picture: '00' } }], + ['SEQ Figure \\#00', { identifier: 'Figure', numericPictureFormat: { picture: '00' } }], + ['SEQ Figure \\#"00"', { identifier: 'Figure', numericPictureFormat: { picture: '00' } }], + ['SEQ Figure \\# "#,##0"', { identifier: 'Figure', numericPictureFormat: { picture: '#,##0' } }], + [ + 'SEQ Figure \\r 10 \\* roman \\h', + { + identifier: 'Figure', + restartNumber: 10, + format: 'roman', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'lowerRoman' }, + hideResult: true, + }, + ], + ['SEQ Figure \\c \\n', { identifier: 'Figure', sequenceMode: 'next' }], + ['SEQ Figure \\r nope \\s 99', { identifier: 'Figure', restartNumber: null, restartLevel: null }], + ])('parses %s', (instruction, expected) => { + expect(parseSeqInstruction(instruction)).toMatchObject({ + instruction, + fieldArgument: '', + hideResult: false, + restartNumber: null, + restartLevel: null, + numericPictureFormat: null, + hasGeneralFormat: false, + unknownSwitches: [], + ...expected, + }); + }); + + it('returns a safe empty parse for non-SEQ instructions', () => { + expect(parseSeqInstruction('PAGEREF bookmark')).toEqual({ + instruction: 'PAGEREF bookmark', + keyword: 'PAGEREF', + identifier: '', + fieldArgument: '', + sequenceMode: 'next', + hideResult: false, + restartNumber: null, + restartLevel: null, + format: 'Arabic', + numericPictureFormat: null, + hasGeneralFormat: false, + unknownSwitches: [], + }); + }); + + it('uses the shared keyword extractor for SEQ dispatch', () => { + expect(parseSeqInstruction('"SEQ" Figure')).toMatchObject({ + keyword: 'SEQ', + identifier: '', + sequenceMode: 'next', + format: 'Arabic', + }); + }); + + it('preserves the original instruction string without trimming', () => { + expect(parseSeqInstruction(' SEQ Figure \\n ')).toMatchObject({ + instruction: ' SEQ Figure \\n ', + keyword: 'SEQ', + identifier: 'Figure', + }); + }); + + it('keeps unknown general formats without page-number mapping', () => { + const parsed = parseSeqInstruction('SEQ Figure \\* OrdText'); + expect(parsed).toMatchObject({ + format: 'OrdText', + hasGeneralFormat: true, + }); + expect(parsed).not.toHaveProperty('pageNumberFieldFormat'); + }); + + it('preserves unknown switches as raw tokens', () => { + expect(parseSeqInstruction('SEQ Figure \\z value \\q').unknownSwitches).toEqual(['\\z', 'value', '\\q']); + }); + + it('preserves only the first numeric picture and records later numeric switches as unknown', () => { + expect(parseSeqInstruction('SEQ Figure \\# "00" \\# "000"')).toMatchObject({ + numericPictureFormat: { picture: '00' }, + unknownSwitches: ['\\#', '000'], + }); + }); + + it('parses quoted identifiers and switch values', () => { + expect(parseSeqInstruction('SEQ "Figure Set" \\* "Roman" \\# "00"')).toMatchObject({ + identifier: 'Figure Set', + format: 'Roman', + pageNumberFieldFormat: { format: 'upperRoman' }, + numericPictureFormat: { picture: '00' }, + }); + }); + + it('unescapes quoted tokens without rewriting unquoted tokens', () => { + expect(parseSeqInstruction('SEQ "Figure \\"Set\\""').identifier).toBe('Figure "Set"'); + expect(parseSeqInstruction('SEQ Figure\\\\Set').identifier).toBe('Figure\\\\Set'); + }); +}); + +describe('isSeqInstruction', () => { + it('matches SEQ instructions case-insensitively', () => { + expect(isSeqInstruction('SEQ Figure')).toBe(true); + expect(isSeqInstruction('seq Figure')).toBe(true); + expect(isSeqInstruction('PAGEREF target')).toBe(false); + }); +}); + +describe('normalizeSeqIdentifier', () => { + it('trims string identifiers and ignores non-strings', () => { + expect(normalizeSeqIdentifier(' Figure ')).toBe('Figure'); + expect(normalizeSeqIdentifier(null)).toBe(''); + }); +}); + +describe('sequenceFieldAttrsFromParsed', () => { + it('projects parsed SEQ metadata into normalized sequenceField attrs', () => { + const attrs = sequenceFieldAttrsFromParsed(parseSeqInstruction('SEQ Figure \\r 3 \\* roman')); + + expect(attrs).toEqual({ + identifier: 'Figure', + fieldArgument: '', + sequenceMode: 'next', + hideResult: false, + restartNumber: 3, + restartLevel: null, + format: 'roman', + hasGeneralFormat: true, + pageNumberFieldFormat: { format: 'lowerRoman' }, + numericPictureFormat: null, + }); + }); + + it('keeps the parser default separate from the legacy PM attr default', () => { + const parsed = parseSeqInstruction('SEQ Figure'); + + expect(parsed.format).toBe('Arabic'); + expect(sequenceFieldAttrsFromParsed(parsed)).toMatchObject({ + format: 'ARABIC', + pageNumberFieldFormat: null, + numericPictureFormat: null, + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index afba65010a..9cbb01d27d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -18,6 +18,7 @@ import { autoPageHandlerEntity, autoTotalPageCountEntity, sectionPageCountEntity import { documentStatFieldHandlerEntity } from './documentStatFieldImporter.js'; import { pageReferenceEntity } from './pageReferenceImporter.js'; import { crossReferenceEntity } from './crossReferenceImporter.js'; +import { sequenceFieldEntity } from './sequenceFieldImporter.js'; import { pictNodeHandlerEntity } from './pictNodeImporter.js'; import { importCommentData } from './documentCommentsImporter.js'; import { buildTrackedChangeIdMap, buildTrackedChangeIdMapsByPart } from './trackedChangeIdMapper.js'; @@ -376,6 +377,7 @@ export const defaultNodeListHandler = () => { documentStatFieldHandlerEntity, pageReferenceEntity, crossReferenceEntity, + sequenceFieldEntity, permStartHandlerEntity, permEndHandlerEntity, mathNodeHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.integration.test.js new file mode 100644 index 0000000000..4d9f22f1ba --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.integration.test.js @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; +import { sequenceFieldEntity } from './sequenceFieldImporter.js'; +import { preProcessNodesForFldChar } from '../../field-references/index.js'; + +const createEditorStub = () => ({ + schema: { + nodes: { + run: { isInline: true, spec: { group: 'inline' } }, + sequenceField: { isInline: true, spec: { group: 'inline', atom: true } }, + }, + }, +}); + +describe('sequenceField v2 importer wiring', () => { + it('registers sequenceFieldEntity before passthrough in defaultNodeListHandler', () => { + const entities = defaultNodeListHandler().handlerEntities; + expect(entities).toContain(sequenceFieldEntity); + expect(entities.indexOf(sequenceFieldEntity)).toBeLessThan( + entities.findIndex((entity) => entity.handlerName === 'passthroughNodeHandler'), + ); + }); + + it.each([ + ['uppercase complex', 'SEQ Figure \\n \\r 10 \\s 2 \\h \\* roman', '10', 'next', 'roman'], + ['lowercase complex', 'seq Figure \\c \\r 10 \\s 2 \\h \\* arabic', '11', 'current', 'arabic'], + ['uppercase fldSimple', 'SEQ Figure \\n \\r 10 \\s 2 \\h \\* roman', '12', 'next', 'roman'], + ['lowercase fldSimple', 'seq Figure \\c \\r 10 \\s 2 \\h \\* arabic', '13', 'current', 'arabic'], + ])( + 'imports %s SEQ fields through the real preprocessor and v2 route', + (_name, instruction, cachedText, sequenceMode, format) => { + const paragraph = _name.includes('fldSimple') + ? buildFldSimpleSeqParagraph(instruction, cachedText) + : buildComplexSeqParagraph(instruction, cachedText); + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const sequenceField = collectNodesOfType(pmNodes[0], 'sequenceField')[0]; + expect(sequenceField).toBeTruthy(); + expect(sequenceField.attrs).toMatchObject({ + instruction, + identifier: 'Figure', + format, + sequenceMode, + restartLevel: 2, + restartNumber: 10, + hideResult: true, + resolvedNumber: cachedText, + resolvedNumberIsCurrent: false, + }); + }, + ); + + it('imports sequence field arguments and numeric formats onto PM attrs', () => { + const instruction = 'SEQ Figure bookmark \\# "00"'; + const paragraph = buildComplexSeqParagraph(instruction, '07'); + + const { processedNodes } = preProcessNodesForFldChar([paragraph], {}); + const nodeListHandler = defaultNodeListHandler(); + const pmNodes = nodeListHandler.handler({ + nodes: processedNodes, + docx: {}, + editor: createEditorStub(), + path: [], + }); + + const sequenceField = collectNodesOfType(pmNodes[0], 'sequenceField')[0]; + expect(sequenceField.attrs).toMatchObject({ + instruction, + identifier: 'Figure', + fieldArgument: 'bookmark', + numericPictureFormat: { picture: '00' }, + hasGeneralFormat: false, + pageNumberFieldFormat: null, + resolvedNumber: '07', + resolvedNumberIsCurrent: false, + }); + }); +}); + +function buildComplexSeqParagraph(instruction, cachedText) { + const run = (inner) => ({ name: 'w:r', elements: inner }); + return { + name: 'w:p', + elements: [ + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }]), + run([{ name: 'w:instrText', elements: [{ type: 'text', text: instruction }] }]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }]), + run([{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }]), + run([{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }]), + ], + }; +} + +function buildFldSimpleSeqParagraph(instruction, cachedText) { + return { + name: 'w:p', + elements: [ + { + name: 'w:fldSimple', + attributes: { 'w:instr': instruction }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: cachedText }] }] }], + }, + ], + }; +} + +function collectNodesOfType(root, type) { + const out = []; + const visit = (node) => { + if (!node) return; + if (node.type === type) out.push(node); + if (Array.isArray(node.content)) node.content.forEach(visit); + }; + visit(root); + return out; +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.js new file mode 100644 index 0000000000..e29edf18ee --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/sequenceFieldImporter.js @@ -0,0 +1,7 @@ +import { generateV2HandlerEntity } from '@core/super-converter/v3/handlers/utils'; +import { translator } from '../../v3/handlers/sd/sequenceField/sequenceField-translator.js'; + +/** + * @type {import("./docxImporter").NodeHandlerEntry} + */ +export const sequenceFieldEntity = generateV2HandlerEntity('sequenceFieldNodeHandler', translator); 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 a6d1971268..aa0b13bee3 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 @@ -30,6 +30,16 @@ function hasFieldCharType(node, fieldType) { ); } +function resultTexts(exported) { + const separateIndex = exported.findIndex((node) => hasFieldCharType(node, 'separate')); + const endIndex = exported.findIndex((node) => hasFieldCharType(node, 'end')); + return exported + .slice(separateIndex + 1, endIndex) + .flatMap((node) => node?.elements ?? []) + .filter((element) => element?.name === 'w:t') + .map((element) => element?.elements?.[0]?.text); +} + describe('sequenceField export routing', () => { it('extracts cached result text from run-wrapped field content', () => { const encoded = sequenceFieldTranslator.encode({ @@ -51,6 +61,83 @@ describe('sequenceField export routing', () => { }); expect(encoded.attrs.resolvedNumber).toBe('1'); + expect(encoded.attrs.resolvedNumberIsCurrent).toBe(false); + expect(encoded.attrs.identifier).toBe('level2'); + expect(encoded.attrs.format).toBe('arabic'); + }); + + it('round-trips lowercase SEQ cached result text before recompute', () => { + const encoded = sequenceFieldTranslator.encode({ + nodes: [ + { + name: 'sd:sequenceField', + attributes: { instruction: 'seq Figure \\* arabic' }, + elements: [ + { + type: 'run', + content: [{ type: 'text', text: '42', marks: [] }], + }, + ], + }, + ], + nodeListHandler: { + handler: () => [{ type: 'run', content: [{ type: 'text', text: '42', marks: [] }] }], + }, + }); + + const exported = exportSchemaToJson({ node: encoded }); + const resultRun = exported.find( + (node) => + node?.name === 'w:r' && + node?.elements?.some((element) => element?.name === 'w:t' && element?.elements?.[0]?.text === '42'), + ); + + expect(encoded.attrs.resolvedNumberIsCurrent).toBe(false); + expect(resultRun).toBeTruthy(); + }); + + it('exports current resolvedNumber instead of stale cached child content', () => { + const exported = exportSchemaToJson({ + node: buildSequenceFieldNode({ + attrs: { resolvedNumber: '12', resolvedNumberIsCurrent: true }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: '3' }] }], + }), + }); + + expect(resultTexts(exported)).toEqual(['12']); + }); + + it('exports no result runs for current hidden or empty output', () => { + const exported = exportSchemaToJson({ + node: buildSequenceFieldNode({ + attrs: { resolvedNumber: '', resolvedNumberIsCurrent: true }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: '3' }] }], + }), + }); + + expect(resultTexts(exported)).toEqual([]); + }); + + it('preserves imported cached child content when resolvedNumber is not current', () => { + const exported = exportSchemaToJson({ + node: buildSequenceFieldNode({ + attrs: { resolvedNumber: '12', resolvedNumberIsCurrent: false }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: '3' }] }], + }), + }); + + expect(resultTexts(exported)).toEqual(['3']); + }); + + it('falls back to non-current resolvedNumber when no cached child content exists', () => { + const exported = exportSchemaToJson({ + node: buildSequenceFieldNode({ + attrs: { resolvedNumber: '12', resolvedNumberIsCurrent: false }, + content: [], + }), + }); + + expect(resultTexts(exported)).toEqual(['12']); }); it('exports sequenceField nodes as fldChar + instrText runs', () => { @@ -71,6 +158,32 @@ describe('sequenceField export routing', () => { expect(instructionElement?.elements?.[0]?.text).toBe(SEQUENCE_FIELD_INSTRUCTION); }); + it('preserves raw split instructionTokens during export', () => { + const instructionTokens = [ + { type: 'text', text: 'SEQ Figure ' }, + { type: 'text', text: '\\* roman' }, + ]; + const exported = exportSchemaToJson({ + node: buildSequenceFieldNode({ + attrs: { + instruction: 'SEQ Figure \\* roman', + instructionTokens, + resolvedNumber: '1', + resolvedNumberIsCurrent: true, + }, + }), + }); + + const instructionRun = exported.find( + (node) => node?.name === 'w:r' && node?.elements?.some((element) => element?.name === 'w:instrText'), + ); + const instructionTexts = instructionRun?.elements + ?.filter((element) => element?.name === 'w:instrText') + .map((element) => element?.elements?.[0]?.text); + + expect(instructionTexts).toEqual(['SEQ Figure ', '\\* roman']); + }); + it('expands run-wrapped sequenceField nodes into field-code runs', () => { const decoded = runTranslator.decode({ node: { 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 26c1605078..b19403012d 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 @@ -1,6 +1,10 @@ // @ts-check import { NodeTranslator } from '@translator'; import { exportSchemaToJson, processOutputMarks } from '../../../../exporter.js'; +import { + parseSeqInstruction, + sequenceFieldAttrsFromParsed, +} from '../../../../field-references/shared/seq-instruction.js'; import { buildInstructionElements } from '../shared/index.js'; /** @type {import('@translator').XmlNodeName} */ @@ -24,17 +28,18 @@ const encode = (params) => { }); const instruction = node.attributes?.instruction || ''; - const { identifier, format, restartLevel } = parseSeqInstruction(instruction); + const parsed = parseSeqInstruction(instruction); + const parsedAttrs = sequenceFieldAttrsFromParsed(parsed); return { type: SD_NODE_NAME, attrs: { instruction, instructionTokens: node.attributes?.instructionTokens || null, - identifier, - format, - restartLevel, + // Raw instruction remains the export source of truth; these parsed attrs support import-time routing and later evaluation. + ...parsedAttrs, resolvedNumber: extractResolvedText(processedText), + resolvedNumberIsCurrent: false, marksAsAttrs: node.marks || [], }, content: processedText, @@ -49,7 +54,7 @@ const encode = (params) => { const decode = (params) => { const { node } = params; const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); - const contentNodes = (node.content ?? []).flatMap((n) => exportSchemaToJson({ ...params, node: n })); + const contentNodes = buildResultContentNodes(params, outputMarks); const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); return [ @@ -83,27 +88,43 @@ const decode = (params) => { }; /** - * Parses a SEQ instruction into its components. - * @param {string} instruction - * @returns {{ identifier: string; format: string; restartLevel: number | null }} + * @param {import('@translator').SCDecoderConfig} params + * @param {Array} outputMarks + * @returns {Array} */ -function parseSeqInstruction(instruction) { - const parts = instruction.trim().split(/\s+/); - const identifier = parts[1] || ''; - let format = 'ARABIC'; - let restartLevel = null; - - for (let i = 2; i < parts.length; i++) { - if (parts[i] === '\\*' && parts[i + 1]) { - format = parts[i + 1]; - i++; - } else if (parts[i] === '\\s' && parts[i + 1]) { - restartLevel = parseInt(parts[i + 1], 10) || null; - i++; - } +function buildResultContentNodes(params, outputMarks) { + const { node } = params; + const resolvedNumber = node.attrs?.resolvedNumber; + const hasCurrentResult = node.attrs?.resolvedNumberIsCurrent === true; + + if (hasCurrentResult) { + return typeof resolvedNumber === 'string' && resolvedNumber.length > 0 + ? [buildResolvedNumberRun(resolvedNumber, outputMarks)] + : []; + } + + if (Array.isArray(node.content) && node.content.length > 0) { + return node.content.flatMap((n) => exportSchemaToJson({ ...params, node: n })); } - return { identifier, format, restartLevel }; + return typeof resolvedNumber === 'string' && resolvedNumber.length > 0 + ? [buildResolvedNumberRun(resolvedNumber, outputMarks)] + : []; +} + +/** + * @param {string} text + * @param {Array} outputMarks + * @returns {any} + */ +function buildResolvedNumberRun(text, outputMarks) { + return { + name: 'w:r', + elements: [ + { name: 'w:rPr', elements: outputMarks }, + { name: 'w:t', elements: [{ type: 'text', text }] }, + ], + }; } /** diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js index d122782051..22987538ad 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.js @@ -66,6 +66,9 @@ function getPageNumberFieldAttrs(node) { if (node.attributes?.pageNumberZeroPadding != null) { attrs.pageNumberZeroPadding = Number(node.attributes.pageNumberZeroPadding); } + if (node.attributes?.pageNumberNumericPicture) { + attrs.pageNumberNumericPicture = node.attributes.pageNumberNumericPicture; + } return attrs; } @@ -82,7 +85,7 @@ function resolveCachedPageCount(params, node) { const cacheMap = params.statFieldCacheMap; if (cacheMap?.has?.('NUMPAGES')) { const pageCount = Number(cacheMap.get('NUMPAGES')); - if (node.attrs?.pageNumberFormat || node.attrs?.pageNumberZeroPadding) { + if (node.attrs?.pageNumberFormat || node.attrs?.pageNumberZeroPadding || node.attrs?.pageNumberNumericPicture) { return formatPageNumberFieldValue(pageCount, node.attrs); } return String(cacheMap.get('NUMPAGES')); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js index bbebd44b5e..0d00bd76ab 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/totalPageNumber/totalPageNumber-translator.test.js @@ -84,6 +84,30 @@ describe('sd:totalPageNumber translator', () => { it('preserves imported switched field attributes', () => { vi.mocked(parseMarks).mockReturnValue([]); + const result = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + }, + elements: [], + }, + ], + }); + + expect(result.attrs).toEqual({ + marksAsAttrs: [], + importedCachedText: null, + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + }); + }); + + it('preserves imported zero-padding field attributes', () => { + vi.mocked(parseMarks).mockReturnValue([]); + const result = config.encode({ nodes: [ { @@ -186,6 +210,39 @@ describe('sd:totalPageNumber translator', () => { expect(result[3].elements[1].elements[0].text).toBe('07'); }); + it('round-trips an imported numeric-picture attr through PM attrs and exported cached text', () => { + vi.mocked(parseMarks).mockReturnValue([]); + vi.mocked(processOutputMarks).mockReturnValue([]); + + const imported = config.encode({ + nodes: [ + { + name: 'sd:totalPageNumber', + attributes: { + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + importedCachedText: '1,000', + }, + elements: [], + }, + ], + }); + + expect(imported.attrs).toMatchObject({ + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + importedCachedText: '1,000', + }); + + const exported = config.decode({ + node: imported, + statFieldCacheMap: new Map([['NUMPAGES', 1234]]), + }); + + expect(exported[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "#,##0"'); + expect(exported[3].elements[1].elements[0].text).toBe('1,234'); + }); + it('falls back to resolvedText when cache map is absent', () => { vi.mocked(processOutputMarks).mockReturnValue([]); @@ -236,11 +293,11 @@ describe('sd:totalPageNumber translator', () => { const result = config.decode({ node: { type: 'total-page-number', - attrs: { instruction: 'NUMPAGES \\# "00"', importedCachedText: '07' }, + attrs: { instruction: 'NUMPAGES \\# "# pages"', importedCachedText: '7 pages' }, }, }); - expect(result[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "00"'); + expect(result[1].elements[1].elements[0].text).toBe(' NUMPAGES \\# "# pages"'); }); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.test.ts index d5b05568f4..89bb6392d5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.test.ts @@ -81,4 +81,39 @@ describe('caption resolver', () => { expect(seqCaption?.instruction).toBe('SEQ Figure \\* ARABIC'); expect(seqCaption?.label).toBe('Figure'); }); + + it('detects lowercase seq fields by SEQ field fallback', () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + })); + + const sequenceField = editor.schema.nodes.sequenceField.create({ + instruction: 'seq Figure \\* arabic', + identifier: 'Figure', + format: 'arabic', + resolvedNumber: '1', + marksAsAttrs: [], + sdBlockId: 'seq-caption-node-lowercase', + }); + + const captionParagraph = editor.schema.nodes.paragraph.create( + { + sdBlockId: 'caption-seq-only-lowercase', + }, + [sequenceField, editor.schema.text(': Caption with lowercase seq')], + ); + + editor.dispatch(editor.state.tr.insert(editor.state.doc.content.size, captionParagraph)); + + const captions = findAllCaptions(editor.state.doc); + const seqCaption = captions.find((caption) => caption.nodeId === 'caption-seq-only-lowercase'); + + expect(seqCaption).toBeTruthy(); + expect(seqCaption?.instruction).toBe('seq Figure \\* arabic'); + expect(seqCaption?.label).toBe('Figure'); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.ts index 4ae198f1b7..5656c2a38e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/caption-resolver.ts @@ -11,6 +11,7 @@ import type { Editor } from '../../core/Editor.js'; import type { CaptionAddress, CaptionDomain, CaptionInfo, DiscoveryItem } from '@superdoc/document-api'; import { buildDiscoveryItem, buildResolvedHandle } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; +import { isSeqInstruction } from '../../core/super-converter/field-references/shared/seq-instruction.js'; // --------------------------------------------------------------------------- // Types @@ -54,7 +55,7 @@ function isCaptionParagraph(node: ProseMirrorNode): boolean { const seqField = findSeqField(node); if (!seqField) return false; const instruction = (seqField.attrs?.instruction as string) ?? ''; - return instruction.trim().startsWith('SEQ '); + return isSeqInstruction(instruction); } function findSeqField(node: ProseMirrorNode): ProseMirrorNode | null { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts index 205dc72810..c8f1b7a22a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.test.ts @@ -22,6 +22,27 @@ const schema = new Schema({ resolvedText: { default: null }, }, }, + 'total-page-number': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + }, + }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: '' }, + identifier: { default: '' }, + resolvedNumber: { default: '' }, + }, + }, }, }); @@ -32,6 +53,13 @@ function createDocWithSectionPageCount(attrs: Record, text?: st return schema.nodes.doc.create(null, paragraph); } +function createDocWithTotalPageNumber(attrs: Record, text?: string): ProseMirrorNode { + const content = text ? schema.text(text) : undefined; + const field = schema.nodes['total-page-number'].create(attrs, content); + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); + return schema.nodes.doc.create(null, paragraph); +} + describe('field-resolver synthetic section page count fields', () => { it('discovers section-page-count as SECTIONPAGES with imported instruction', () => { const doc = createDocWithSectionPageCount({ instruction: 'SECTIONPAGES \\* roman' }, 'iii'); @@ -65,3 +93,43 @@ describe('field-resolver synthetic section page count fields', () => { ]); }); }); + +describe('field-resolver synthetic total page number fields', () => { + it('discovers total-page-number with imported switched instruction', () => { + const doc = createDocWithTotalPageNumber({ instruction: 'NUMPAGES \\# "#,##0"', resolvedText: '1,234' }); + + expect(findAllFields(doc)).toEqual([ + { + pos: 1, + blockId: 'block-1', + occurrenceIndex: 0, + nestingDepth: 0, + instruction: 'NUMPAGES \\# "#,##0"', + fieldType: 'NUMPAGES', + resolvedText: '1,234', + }, + ]); + }); +}); + +describe('field-resolver sequence fields', () => { + it('uses sequenceField.resolvedNumber as resolvedText', () => { + const field = schema.nodes.sequenceField.create({ + instruction: 'SEQ Figure \\* ARABIC', + identifier: 'Figure', + resolvedNumber: '2', + }); + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-seq' }, field); + const doc = schema.nodes.doc.create(null, paragraph); + + expect(findAllFields(doc)).toContainEqual({ + pos: 1, + blockId: 'block-seq', + occurrenceIndex: 0, + nestingDepth: 0, + instruction: 'SEQ Figure \\* ARABIC', + fieldType: 'SEQ', + resolvedText: '2', + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts index 60939eb5a4..dc3ed6370b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/field-resolver.ts @@ -56,7 +56,14 @@ const SYNTHETIC_FIELD_NODE_TYPES: Record< string, { fieldType: string; instruction: string; resolveInstruction?: (node: ProseMirrorNode) => string } > = { - 'total-page-number': { fieldType: 'NUMPAGES', instruction: 'NUMPAGES' }, + 'total-page-number': { + fieldType: 'NUMPAGES', + instruction: 'NUMPAGES', + resolveInstruction: (node) => + typeof node.attrs?.instruction === 'string' && node.attrs.instruction.trim() + ? node.attrs.instruction + : 'NUMPAGES', + }, 'section-page-count': { fieldType: 'SECTIONPAGES', instruction: 'SECTIONPAGES', @@ -112,7 +119,10 @@ export function findAllFields(doc: ProseMirrorNode): ResolvedField[] { blockOccurrenceCounters.set(blockId, counter + 1); const fieldType = extractFieldType(instruction); - const resolvedText = (node.attrs?.resolvedText as string) ?? ''; + const resolvedText = + node.type.name === 'sequenceField' + ? ((node.attrs?.resolvedNumber as string) ?? '') + : ((node.attrs?.resolvedText as string) ?? ''); results.push({ pos, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.test.ts new file mode 100644 index 0000000000..36391d3136 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.test.ts @@ -0,0 +1,193 @@ +import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import { updateSequenceFieldsInTransaction } from './sequence-field-updater.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { + sdBlockId: { default: null }, + paragraphProperties: { default: null }, + styleName: { default: null }, + }, + }, + text: { group: 'inline' }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: '' }, + identifier: { default: '' }, + fieldArgument: { default: '' }, + sequenceMode: { default: 'next' }, + hideResult: { default: false }, + restartNumber: { default: null }, + restartLevel: { default: null }, + format: { default: 'Arabic' }, + hasGeneralFormat: { default: false }, + pageNumberFieldFormat: { default: null }, + numericPictureFormat: { default: null }, + resolvedNumber: { default: '' }, + resolvedNumberIsCurrent: { default: false }, + sdBlockId: { default: null }, + }, + }, + }, +}); + +function seq(instruction: string, attrs: Record = {}) { + return schema.nodes.sequenceField.create({ + instruction, + ...attrs, + }); +} + +function p(...content: ProseMirrorNode[]) { + return schema.nodes.paragraph.create({}, content); +} + +function resolvedNumbers(doc: ProseMirrorNode): string[] { + const values: string[] = []; + doc.descendants((node) => { + if (node.type.name === 'sequenceField') values.push(node.attrs.resolvedNumber as string); + return true; + }); + return values; +} + +function updateDoc(doc: ProseMirrorNode, options: Parameters[0] = {} as any) { + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + const result = updateSequenceFieldsInTransaction({ + tr, + schema, + ...options, + }); + return { result, doc: tr.doc }; +} + +describe('updateSequenceFieldsInTransaction', () => { + it('recomputes all SEQ fields in document order and marks them current', () => { + const doc = schema.nodes.doc.create(null, [ + p(seq('SEQ Figure \\* ARABIC', { resolvedNumber: '9' })), + p(seq('SEQ Figure \\* ARABIC', { resolvedNumber: '9' })), + p(seq('SEQ Table \\* ARABIC', { resolvedNumber: '9' })), + ]); + + const updated = updateDoc(doc); + + expect(updated.result).toEqual({ changed: true, updated: 3 }); + expect(resolvedNumbers(updated.doc)).toEqual(['1', '2', '1']); + updated.doc.descendants((node) => { + if (node.type.name === 'sequenceField') expect(node.attrs.resolvedNumberIsCurrent).toBe(true); + return true; + }); + }); + + it('evaluates fields before a range but only writes overlapping nodes', () => { + const first = p(seq('SEQ Figure')); + const second = p(seq('SEQ Figure')); + const doc = schema.nodes.doc.create(null, [first, second]); + const secondPos = first.nodeSize + 1; + + const updated = updateDoc(doc, { scope: { kind: 'range', from: secondPos, to: secondPos + second.nodeSize } }); + + expect(updated.result).toEqual({ changed: true, updated: 1 }); + expect(resolvedNumbers(updated.doc)).toEqual(['', '2']); + }); + + it('updates only the requested identifier while preserving shared counter evaluation', () => { + const doc = schema.nodes.doc.create(null, [ + p(seq('SEQ Figure')), + p(seq('SEQ Table')), + p(seq('SEQ Figure')), + p(seq('SEQ Table')), + ]); + + const updated = updateDoc(doc, { scope: { kind: 'identifier', identifier: 'Table' } }); + + expect(updated.result).toEqual({ changed: true, updated: 2 }); + expect(resolvedNumbers(updated.doc)).toEqual(['', '1', '', '2']); + }); + + it('uses shared style-aware heading resolution for restart-level fields when converter context is available', () => { + const heading = schema.nodes.paragraph.create({ + paragraphProperties: { styleId: 'HeadingOne' }, + }); + const caption1 = p(seq('SEQ Figure \\s 1')); + const heading2 = schema.nodes.paragraph.create({ + paragraphProperties: { styleId: 'HeadingOne' }, + }); + const caption2 = p(seq('SEQ Figure \\s 1')); + const doc = schema.nodes.doc.create(null, [heading, caption1, heading2, caption2]); + + const updated = updateDoc(doc, { + converterContext: { + translatedLinkedStyles: { + docDefaults: {}, + styles: { + HeadingOne: { name: 'Custom Heading', paragraphProperties: { outlineLvl: 0 } }, + }, + }, + translatedNumbering: {}, + } as any, + }); + + expect(resolvedNumbers(updated.doc)).toEqual(['1', '1']); + }); + + it('skips restart-level heading resets when converter context is unavailable', () => { + const heading = schema.nodes.paragraph.create({ + paragraphProperties: { outlineLvl: 0 }, + }); + const caption1 = p(seq('SEQ Figure \\s 1')); + const heading2 = schema.nodes.paragraph.create({ + paragraphProperties: { outlineLvl: 0 }, + }); + const caption2 = p(seq('SEQ Figure \\s 1')); + const doc = schema.nodes.doc.create(null, [heading, caption1, heading2, caption2]); + + const updated = updateDoc(doc); + + expect(resolvedNumbers(updated.doc)).toEqual(['1', '2']); + }); + + it('parses stale attrs from the raw instruction before writing current results', () => { + const doc = schema.nodes.doc.create(null, [ + p( + seq('SEQ Figure \\r 7 \\* roman', { + identifier: 'Stale', + restartNumber: null, + format: 'Arabic', + }), + ), + ]); + + const updated = updateDoc(doc); + const field = updated.doc.nodeAt(1); + + expect(field?.attrs.identifier).toBe('Figure'); + expect(field?.attrs.restartNumber).toBe(7); + expect(field?.attrs.format).toBe('roman'); + expect(field?.attrs.pageNumberFieldFormat).toEqual({ format: 'lowerRoman' }); + expect(field?.attrs.resolvedNumber).toBe('vii'); + }); + + it('handles field arguments conservatively without advancing counters', () => { + const doc = schema.nodes.doc.create(null, [ + p(seq('SEQ Figure')), + p(seq('SEQ Figure bookmark', { resolvedNumber: 'cached' })), + p(seq('SEQ Figure bookmark')), + p(seq('SEQ Figure')), + ]); + + const updated = updateDoc(doc); + + expect(resolvedNumbers(updated.doc)).toEqual(['1', 'cached', '1', '2']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.ts new file mode 100644 index 0000000000..0e7ba42ac7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/sequence-field-updater.ts @@ -0,0 +1,181 @@ +import type { Schema, Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; +import type { PageNumberFieldFormat } from '@superdoc/contracts'; +import type { ParagraphProperties } from '@superdoc/style-engine/ooxml'; +import type { ConverterContext } from '../../core/layout-adapter/converter-context.js'; +import { resolveParagraphHeadingLevel } from '../../core/layout-adapter/attributes/paragraph.js'; +import { SequenceFieldEvaluator } from '../../core/super-converter/field-references/shared/seq-evaluator.js'; +import { + normalizeSeqIdentifier, + parseSeqInstruction, + sequenceFieldAttrsFromParsed, +} from '../../core/super-converter/field-references/shared/seq-instruction.js'; + +export type SequenceFieldUpdateScope = + | { kind: 'all' } + | { kind: 'range'; from: number; to: number } + | { kind: 'identifier'; identifier: string }; + +type SequenceFieldAttrs = Record & { + instruction: string; + identifier: string; + fieldArgument: string; + sequenceMode: 'next' | 'current'; + hideResult: boolean; + restartNumber: number | null; + restartLevel: number | null; + format: string; + hasGeneralFormat: boolean; + pageNumberFieldFormat: PageNumberFieldFormat | null; + numericPictureFormat: { picture: string } | null; + resolvedNumber?: string; + resolvedNumberIsCurrent?: boolean; +}; + +export function updateSequenceFieldsInTransaction(args: { + tr: Transaction; + schema: Schema; + scope?: SequenceFieldUpdateScope; + converterContext?: ConverterContext; +}): { changed: boolean; updated: number } { + const { tr, scope = { kind: 'all' }, converterContext } = args; + const sequenceFieldType = args.schema.nodes.sequenceField; + if (!sequenceFieldType) return { changed: false, updated: 0 }; + + const evaluator = new SequenceFieldEvaluator(); + let changed = false; + let updated = 0; + + // Body-only by design: tr.doc is the main story document. Header/footer and + // note editors render correctly via layout, but are not rewritten by this API path. + tr.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph') { + evaluator.enterParagraph({ + paragraphHeadingLevel: resolveNodeHeadingLevel(node, converterContext), + }); + return true; + } + + if (node.type !== sequenceFieldType) return true; + + const nextAttrs = buildEvaluatedSequenceAttrs(node); + const evaluation = evaluator.evaluateField({ + identifier: nextAttrs.identifier, + instruction: nextAttrs.instruction, + fieldArgument: nextAttrs.fieldArgument, + sequenceMode: nextAttrs.sequenceMode, + hideResult: nextAttrs.hideResult, + restartNumber: nextAttrs.restartNumber, + restartLevel: nextAttrs.restartLevel, + format: nextAttrs.format, + hasGeneralFormat: nextAttrs.hasGeneralFormat, + pageNumberFieldFormat: nextAttrs.pageNumberFieldFormat, + numericPictureFormat: nextAttrs.numericPictureFormat, + cachedText: typeof node.attrs.resolvedNumber === 'string' ? node.attrs.resolvedNumber : '', + }); + + if (!shouldWriteSequenceField(node, pos, scope, nextAttrs.identifier)) return true; + + nextAttrs.resolvedNumber = evaluation.text; + nextAttrs.resolvedNumberIsCurrent = true; + + if (!attrsEqual(node.attrs, nextAttrs)) { + tr.setNodeMarkup(resolveCurrentTransactionPos(tr, pos, node), undefined, nextAttrs); + changed = true; + } + updated += 1; + return true; + }); + + return { changed, updated }; +} + +export function getSequenceFieldUpdaterConverterContext(editor: unknown): ConverterContext | undefined { + const converter = (editor as { converter?: Partial } | null | undefined)?.converter; + if (!converter?.translatedLinkedStyles) return undefined; + + return { + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering ?? {}, + docx: converter.docx, + } as ConverterContext; +} + +function resolveNodeHeadingLevel(node: ProseMirrorNode, converterContext?: ConverterContext): number | undefined { + const paragraphProperties = node.attrs?.paragraphProperties; + if (converterContext) { + return resolveParagraphHeadingLevel( + isRecord(paragraphProperties) ? (paragraphProperties as ParagraphProperties) : undefined, + converterContext, + ); + } + + // Without style data, skip SEQ \s heading resets rather than applying a + // partial heuristic that can diverge from the rendered layout. + return undefined; +} + +function buildEvaluatedSequenceAttrs(node: ProseMirrorNode): SequenceFieldAttrs { + const instruction = typeof node.attrs.instruction === 'string' ? node.attrs.instruction : ''; + const parsed = parseSeqInstruction(instruction); + const parsedAttrs = sequenceFieldAttrsFromParsed(parsed); + + return { + ...node.attrs, + instruction, + ...parsedAttrs, + identifier: parsedAttrs.identifier || readStringAttr(node, 'identifier'), + }; +} + +function shouldWriteSequenceField( + node: ProseMirrorNode, + pos: number, + scope: SequenceFieldUpdateScope, + identifier: string, +): boolean { + if (scope.kind === 'all') return true; + if (scope.kind === 'range') { + const end = pos + node.nodeSize; + return pos < scope.to && end > scope.from; + } + + return normalizeSeqIdentifier(identifier) === normalizeSeqIdentifier(scope.identifier); +} + +function readStringAttr(node: ProseMirrorNode, key: string): string { + const value = node.attrs?.[key]; + return typeof value === 'string' ? value : ''; +} + +function attrsEqual(left: Record, right: Record): boolean { + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + if (!valuesEqual(left[key], right[key])) return false; + } + return true; +} + +function valuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (!isRecord(left) || !isRecord(right)) return false; + + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + if (!valuesEqual(left[key], right[key])) return false; + } + return true; +} + +function resolveCurrentTransactionPos(tr: Transaction, pos: number, node: ProseMirrorNode): number { + // This helper walks tr.doc, so positions are usually already current. When a + // caller provides a transaction with prior steps (for example fields.insert), + // mapping those current positions again can point inside text nodes. + const currentNode = tr.doc.nodeAt(pos); + if (currentNode?.type === node.type) return pos; + return tr.mapping.map(pos); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.seq-fields.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.seq-fields.test.ts new file mode 100644 index 0000000000..d0cc76af41 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.seq-fields.test.ts @@ -0,0 +1,108 @@ +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { findAllCaptions } from '../helpers/caption-resolver.js'; +import { captionsConfigureWrapper, captionsInsertWrapper } from './caption-wrappers.js'; +import { registerBuiltInExecutors } from './register-executors.js'; + +registerBuiltInExecutors(); + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { sdBlockId: { default: null }, paragraphProperties: { default: null } }, + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, + hardBreak: { group: 'inline', inline: true, atom: true, toDOM: () => ['br'] }, + tab: { group: 'inline', inline: true, atom: true, toDOM: () => ['span'] }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: '' }, + identifier: { default: '' }, + fieldArgument: { default: '' }, + sequenceMode: { default: 'next' }, + hideResult: { default: false }, + restartNumber: { default: null }, + restartLevel: { default: null }, + format: { default: 'Arabic' }, + hasGeneralFormat: { default: false }, + pageNumberFieldFormat: { default: null }, + numericPictureFormat: { default: null }, + resolvedNumber: { default: '' }, + resolvedNumberIsCurrent: { default: false }, + sdBlockId: { default: null }, + }, + toDOM: () => ['span', 0], + }, + }, +}); + +function createEditor(): Editor { + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create({ sdBlockId: 'anchor-1' }, schema.text('Anchor 1')), + schema.nodes.paragraph.create({ sdBlockId: 'anchor-2' }, schema.text('Anchor 2')), + ]); + const editor = { + schema, + state: EditorState.create({ schema, doc }), + converter: { + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + translatedNumbering: {}, + }, + view: { dispatch: () => {} }, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; + return editor as unknown as Editor; +} + +function insertCaption(editor: Editor, anchorId: string, text: string) { + return captionsInsertWrapper(editor, { + label: 'Figure', + adjacentTo: { kind: 'block', nodeType: 'paragraph', nodeId: anchorId }, + position: 'below', + text, + }); +} + +describe('caption wrappers SEQ fields', () => { + it('captions.insert recomputes SEQ numbers for inserted captions', () => { + const editor = createEditor(); + + expect(insertCaption(editor, 'anchor-1', 'One').success).toBe(true); + expect(insertCaption(editor, 'anchor-2', 'Two').success).toBe(true); + + const captions = findAllCaptions(editor.state.doc).filter((caption) => caption.label === 'Figure'); + expect(captions.map((caption) => caption.number)).toEqual(['1', '2']); + }); + + it('captions.configure updates matching SEQ format attrs and recomputes values', () => { + const editor = createEditor(); + insertCaption(editor, 'anchor-1', 'One'); + insertCaption(editor, 'anchor-2', 'Two'); + + const result = captionsConfigureWrapper(editor, { label: 'Figure', format: 'lowerRoman' }); + + expect(result.success).toBe(true); + const fields: any[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === 'sequenceField') fields.push(node); + return true; + }); + expect(fields.map((field) => field.attrs.instruction)).toEqual(['SEQ Figure \\* roman', 'SEQ Figure \\* roman']); + expect(fields.map((field) => field.attrs.pageNumberFieldFormat)).toEqual([ + { format: 'lowerRoman' }, + { format: 'lowerRoman' }, + ]); + expect(fields.map((field) => field.attrs.resolvedNumber)).toEqual(['i', 'ii']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts index 70b48978ea..75d3c6d58d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts @@ -31,6 +31,14 @@ import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { Fragment } from 'prosemirror-model'; import { clearIndexCache } from '../helpers/index-cache.js'; import { buildTextWithTabs } from '../helpers/text-with-tabs.js'; +import { + getSequenceFieldUpdaterConverterContext, + updateSequenceFieldsInTransaction, +} from '../helpers/sequence-field-updater.js'; +import { + parseSeqInstruction, + sequenceFieldAttrsFromParsed, +} from '../../core/super-converter/field-references/shared/seq-instruction.js'; // --------------------------------------------------------------------------- // Result helpers @@ -130,12 +138,14 @@ export function captionsInsertWrapper( // Add SEQ field if the node type exists if (schema.nodes.sequenceField) { + const instruction = `SEQ ${label} \\* ARABIC`; + const parsed = parseSeqInstruction(instruction); children.push( schema.nodes.sequenceField.create({ - instruction: `SEQ ${label} \\* ARABIC`, - identifier: label, - format: 'ARABIC', + instruction, + ...sequenceFieldAttrsFromParsed(parsed), resolvedNumber: '', + resolvedNumberIsCurrent: false, sdBlockId: `seq-${Date.now()}`, }), ); @@ -155,6 +165,12 @@ export function captionsInsertWrapper( const { tr } = editor.state; tr.insert(pos, captionParagraph); + updateSequenceFieldsInTransaction({ + tr, + schema, + scope: { kind: 'identifier', identifier: label }, + converterContext: getSequenceFieldUpdaterConverterContext(editor), + }); editor.dispatch(tr); clearIndexCache(editor); return true; @@ -268,18 +284,33 @@ export function captionsConfigureWrapper( const format = CAPTION_FORMAT_TO_OOXML[input.format ?? 'decimal'] ?? 'ARABIC'; const newInstruction = `SEQ ${input.label} \\* ${format}`; - if (node.attrs.instruction === newInstruction && node.attrs.format === format) return true; + const parsed = parseSeqInstruction(newInstruction); + const parsedAttrs = sequenceFieldAttrsFromParsed(parsed); + if ( + node.attrs.instruction === newInstruction && + node.attrs.format === format && + JSON.stringify(node.attrs.pageNumberFieldFormat) === JSON.stringify(parsedAttrs.pageNumberFieldFormat) + ) { + return true; + } tr.setNodeMarkup(tr.mapping.map(pos), undefined, { ...node.attrs, instruction: newInstruction, - format, + ...parsedAttrs, + resolvedNumberIsCurrent: false, }); changed = true; return true; }); if (!changed) return false; + updateSequenceFieldsInTransaction({ + tr, + schema: editor.schema, + scope: { kind: 'identifier', identifier: input.label }, + converterContext: getSequenceFieldUpdaterConverterContext(editor), + }); editor.dispatch(tr); clearIndexCache(editor); return true; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts index 15c8f01e17..4bcc432f9d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.section-pages.test.ts @@ -44,6 +44,21 @@ const schema = new Schema({ }, toDOM: () => ['span', 0], }, + 'total-page-number': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + pageNumberFormat: { default: null }, + pageNumberZeroPadding: { default: null }, + pageNumberNumericPicture: { default: null }, + }, + toDOM: () => ['span', 0], + }, }, }); @@ -73,10 +88,39 @@ function createEditorWithSectionPageCount( return editor as unknown as Editor; } -function createEditorForInsert(sectionPageCount?: number): Editor { +function createEditorWithTotalPageNumber( + pageCount: number | undefined, + initialValue = '1', + attrs: Record = {}, +): Editor { + const field = schema.nodes['total-page-number'].create( + { instruction: 'NUMPAGES', resolvedText: initialValue, ...attrs }, + schema.text(initialValue), + ); + const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, field); + const doc = schema.nodes.doc.create(null, paragraph); + + const editor = { + schema, + state: EditorState.create({ schema, doc }), + currentTotalPages: pageCount, + options: {}, + view: { dispatch: () => {} }, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; + + return editor as unknown as Editor; +} + +function createEditorForInsert(sectionPageCount?: number, isHeaderOrFooter = false): Editor { const paragraph = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, schema.text('x')); const doc = schema.nodes.doc.create(null, paragraph); - const options = sectionPageCount == null ? {} : { sectionPageCount }; + const options = { + ...(sectionPageCount == null ? {} : { sectionPageCount }), + ...(isHeaderOrFooter ? { isHeaderOrFooter: true } : {}), + }; const editor = { schema, @@ -179,3 +223,90 @@ describe('fieldsRebuildWrapper SECTIONPAGES fields', () => { expect(updatedField?.textContent).toBe('3'); }); }); + +describe('fieldsRebuildWrapper NUMPAGES fields', () => { + it('inserts NUMPAGES as a total-page-number node with numeric picture attrs in headers/footers', () => { + const editor = createEditorForInsert(undefined, true); + + const result = fieldsInsertWrapper(editor, { + mode: 'raw', + instruction: 'NUMPAGES \\# "#,##0"', + at: { kind: 'text', segments: [{ blockId: 'block-1', range: { start: 0, end: 0 } }] }, + }); + + expect(result.success).toBe(true); + const insertedField = editor.state.doc.nodeAt(1); + expect(insertedField?.type.name).toBe('total-page-number'); + expect(insertedField?.attrs).toMatchObject({ + instruction: 'NUMPAGES \\# "#,##0"', + pageNumberNumericPicture: '#,##0', + }); + }); + + it('preserves quoted NUMPAGES numeric picture whitespace during insert', () => { + const editor = createEditorForInsert(undefined, true); + + const result = fieldsInsertWrapper(editor, { + mode: 'raw', + instruction: 'NUMPAGES \\# "# pages"', + at: { kind: 'text', segments: [{ blockId: 'block-1', range: { start: 0, end: 0 } }] }, + }); + + expect(result.success).toBe(true); + const insertedField = editor.state.doc.nodeAt(1); + expect(insertedField?.type.name).toBe('total-page-number'); + expect(insertedField?.attrs).toMatchObject({ + instruction: 'NUMPAGES \\# "# pages"', + pageNumberNumericPicture: '# pages', + }); + }); + + it('inserts NUMPAGES as a total-page-number node with general format attrs in headers/footers', () => { + const editor = createEditorForInsert(undefined, true); + + const result = fieldsInsertWrapper(editor, { + mode: 'raw', + instruction: 'NUMPAGES \\* Ordinal', + at: { kind: 'text', segments: [{ blockId: 'block-1', range: { start: 0, end: 0 } }] }, + }); + + expect(result.success).toBe(true); + const insertedField = editor.state.doc.nodeAt(1); + expect(insertedField?.type.name).toBe('total-page-number'); + expect(insertedField?.attrs).toMatchObject({ + instruction: 'NUMPAGES \\* Ordinal', + pageNumberFormat: 'ordinal', + }); + }); + + it('formats rebuilt total-page-number values with pageNumberFormat', () => { + const editor = createEditorWithTotalPageNumber(4, '1', { pageNumberFormat: 'upperRoman' }); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('total-page-number'); + expect(updatedField?.attrs.resolvedText).toBe('IV'); + expect(updatedField?.textContent).toBe('IV'); + }); + + it('formats rebuilt total-page-number values with numeric picture switches', () => { + const editor = createEditorWithTotalPageNumber(1234, '1', { + pageNumberFormat: 'decimal', + pageNumberNumericPicture: '#,##0 pages', + }); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + const updatedField = editor.state.doc.nodeAt(1); + expect(updatedField?.type.name).toBe('total-page-number'); + expect(updatedField?.attrs.resolvedText).toBe('1,234 pages'); + expect(updatedField?.textContent).toBe('1,234 pages'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.seq-fields.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.seq-fields.test.ts new file mode 100644 index 0000000000..ccf191b304 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.seq-fields.test.ts @@ -0,0 +1,149 @@ +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { fieldsInsertWrapper, fieldsRebuildWrapper } from './field-wrappers.js'; +import { registerBuiltInExecutors } from './register-executors.js'; + +registerBuiltInExecutors(); + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + attrs: { sdBlockId: { default: null }, paragraphProperties: { default: null } }, + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: '' }, + identifier: { default: '' }, + fieldArgument: { default: '' }, + sequenceMode: { default: 'next' }, + hideResult: { default: false }, + restartNumber: { default: null }, + restartLevel: { default: null }, + format: { default: 'Arabic' }, + hasGeneralFormat: { default: false }, + pageNumberFieldFormat: { default: null }, + numericPictureFormat: { default: null }, + resolvedNumber: { default: '' }, + resolvedNumberIsCurrent: { default: false }, + sdBlockId: { default: null }, + }, + toDOM: () => ['span', 0], + }, + }, +}); + +function createEditor(doc = schema.nodes.doc.create(null, [paragraph('block-1', 'A')])): Editor { + const editor = { + schema, + state: EditorState.create({ schema, doc }), + converter: { + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + translatedNumbering: {}, + }, + view: { dispatch: () => {} }, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; + return editor as unknown as Editor; +} + +function paragraph(id: string, text?: string) { + return schema.nodes.paragraph.create({ sdBlockId: id }, text ? schema.text(text) : null); +} + +function seq(instruction: string, attrs: Record = {}) { + return schema.nodes.sequenceField.create({ instruction, ...attrs }); +} + +function fieldInsertInput(instruction: string) { + return { + mode: 'raw' as const, + instruction, + at: { + segments: [{ blockId: 'block-1', range: { start: 1, end: 1 } }], + }, + }; +} + +function sequenceFields(editor: Editor) { + const fields: any[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === 'sequenceField') fields.push(node); + return true; + }); + return fields; +} + +describe('field wrappers SEQ fields', () => { + it('fields.insert raw SEQ creates parsed attrs and a computed result', () => { + const editor = createEditor(); + + const result = fieldsInsertWrapper(editor, fieldInsertInput('SEQ Figure \\* ARABIC')); + + expect(result.success).toBe(true); + const [field] = sequenceFields(editor); + expect(field.attrs.identifier).toBe('Figure'); + expect(field.attrs.pageNumberFieldFormat).toEqual({ format: 'decimal' }); + expect(field.attrs.resolvedNumber).toBe('1'); + expect(field.attrs.resolvedNumberIsCurrent).toBe(true); + }); + + it('fields.insert recomputes existing matching SEQ fields in document order', () => { + const firstCaption = schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, [ + seq('SEQ Figure \\* ARABIC', { resolvedNumber: '9' }), + schema.text('A'), + ]); + const doc = schema.nodes.doc.create(null, [firstCaption]); + const editor = createEditor(doc); + + const result = fieldsInsertWrapper(editor, fieldInsertInput('SEQ Figure \\* ARABIC')); + + expect(result.success).toBe(true); + expect(sequenceFields(editor).map((node) => node.attrs.resolvedNumber)).toEqual(['1', '2']); + }); + + it('fields.rebuild recomputes stale SEQ resolvedNumber values for the full document', () => { + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, [ + seq('SEQ Figure \\* ARABIC', { resolvedNumber: '9' }), + seq('SEQ Figure \\* ARABIC', { resolvedNumber: '9' }), + ]), + ]); + const editor = createEditor(doc); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + expect(sequenceFields(editor).map((node) => node.attrs.resolvedNumber)).toEqual(['1', '2']); + }); + + it('fields.rebuild succeeds when SEQ values are already current', () => { + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create({ sdBlockId: 'block-1' }, [ + seq('SEQ Figure \\* ARABIC', { resolvedNumber: '1', resolvedNumberIsCurrent: true }), + seq('SEQ Figure \\* ARABIC', { resolvedNumber: '2', resolvedNumberIsCurrent: true }), + ]), + ]); + const editor = createEditor(doc); + + const result = fieldsRebuildWrapper(editor, { + target: { kind: 'field', blockId: 'block-1', occurrenceIndex: 0, nestingDepth: 0 }, + }); + + expect(result.success).toBe(true); + expect(sequenceFields(editor).map((node) => node.attrs.resolvedNumber)).toEqual(['1', '2']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts index 4f597a432f..b2fac2ad67 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/field-wrappers.ts @@ -15,6 +15,7 @@ import type { MutationOptions, ReceiptFailureCode, } from '@superdoc/document-api'; +import { formatPageNumberFieldValue } from '@superdoc/contracts'; import { buildDiscoveryResult } from '@superdoc/document-api'; import { findAllFields, @@ -31,6 +32,16 @@ import { DocumentApiAdapterError } from '../errors.js'; import { getWordStatistics, resolveDocumentStatFieldValue, resolveMainBodyEditor } from '../helpers/word-statistics.js'; import { resolveSectionPageCountFieldValue } from '../helpers/section-page-count.js'; import { parsePageNumberFieldSwitches } from '../../core/super-converter/field-references/shared/page-number-field-switches.js'; +import { getPageNumberFieldFormat } from '../../core/layout-adapter/converters/inline-converters/page-number-field-format.js'; +import { + isSeqInstruction, + parseSeqInstruction, + sequenceFieldAttrsFromParsed, +} from '../../core/super-converter/field-references/shared/seq-instruction.js'; +import { + getSequenceFieldUpdaterConverterContext, + updateSequenceFieldsInTransaction, +} from '../helpers/sequence-field-updater.js'; // --------------------------------------------------------------------------- // Result helpers @@ -110,7 +121,7 @@ export function fieldsInsertWrapper( } if (fieldType === 'NUMPAGES') { - return insertNumPagesField(editor, resolved, options); + return insertNumPagesField(editor, input, resolved, options); } if (fieldType === 'SECTIONPAGES') { @@ -164,6 +175,7 @@ function insertDocumentStatField( function insertNumPagesField( editor: Editor, + input: FieldInsertInput, resolved: { from: number }, options?: MutationOptions, ): FieldMutationResult { @@ -181,10 +193,12 @@ function insertNumPagesField( ); } + const parsedInstruction = parsePageNumberFieldSwitches(input.instruction, 'NUMPAGES'); + const receipt = executeDomainCommand( editor, (): boolean => { - const node = nodeType.create({}); + const node = nodeType.create(parsedInstruction); const { tr } = editor.state; tr.insert(resolved.from, node); editor.dispatch(tr); @@ -265,15 +279,35 @@ function insertRawField( editor, (): boolean => { const fieldType = extractFieldType(input.instruction); - const node = fieldNodeType.create({ - instruction: input.instruction, - identifier: fieldType, - format: 'ARABIC', - resolvedNumber: '', - sdBlockId: `field-${Date.now()}`, - }); + const isSeq = isSeqInstruction(input.instruction); + const parsed = isSeq ? parseSeqInstruction(input.instruction) : null; + const node = fieldNodeType.create( + parsed + ? { + instruction: input.instruction, + ...sequenceFieldAttrsFromParsed(parsed), + resolvedNumber: '', + resolvedNumberIsCurrent: false, + sdBlockId: `field-${Date.now()}`, + } + : { + instruction: input.instruction, + identifier: fieldType, + format: 'ARABIC', + resolvedNumber: '', + sdBlockId: `field-${Date.now()}`, + }, + ); const { tr } = editor.state; tr.insert(resolved.from, node); + if (parsed) { + updateSequenceFieldsInTransaction({ + tr, + schema: editor.schema, + scope: { kind: 'identifier', identifier: parsed.identifier }, + converterContext: getSequenceFieldUpdaterConverterContext(editor), + }); + } editor.dispatch(tr); clearIndexCache(editor); return true; @@ -318,6 +352,10 @@ export function fieldsRebuildWrapper( return rebuildSectionPageCount(editor, resolved, address, options); } + if (node.type.name === 'sequenceField' && isSeqInstruction((node.attrs?.instruction as string) ?? '')) { + return rebuildSequenceFields(editor, address, options); + } + // Default: clear resolvedNumber to force re-evaluation (sequence fields, etc.) const receipt = executeDomainCommand( editor, @@ -340,6 +378,29 @@ export function fieldsRebuildWrapper( return fieldSuccess(address); } +function rebuildSequenceFields(editor: Editor, address: FieldAddress, options?: MutationOptions): FieldMutationResult { + executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + const result = updateSequenceFieldsInTransaction({ + tr, + schema: editor.schema, + scope: { kind: 'all' }, + converterContext: getSequenceFieldUpdaterConverterContext(editor), + }); + if (result.changed) { + editor.dispatch(tr); + clearIndexCache(editor); + } + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + return fieldSuccess(address); +} + /** * Rebuilds a documentStatField by recomputing its value from the Word-statistics helper. */ @@ -397,7 +458,10 @@ function rebuildTotalPageNumber( if (stats.pages == null) return fieldSuccess(address); - const freshValue = String(stats.pages); + const node = editor.state.doc.nodeAt(resolved.pos); + if (!node) return fieldFailure('TARGET_NOT_FOUND', 'Node not found.'); + + const freshValue = formatPageNumberFieldValue(stats.pages, getPageNumberFieldFormat(node.attrs)); const receipt = executeDomainCommand( editor, diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js index f2a1882981..cf0a3ed5e1 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.js @@ -1,4 +1,5 @@ import { Extension } from '@core/Extension.js'; +import { formatPageNumberFieldValue } from '@superdoc/contracts'; import { findFieldsInRange } from '../../document-api-adapters/helpers/field-resolver.js'; import { findAllTocNodes } from '../../document-api-adapters/helpers/toc-resolver.js'; import { @@ -7,10 +8,20 @@ import { resolveMainBodyEditor, } from '../../document-api-adapters/helpers/word-statistics.js'; import { resolveSectionPageCountFieldValue } from '../../document-api-adapters/helpers/section-page-count.js'; +import { + getSequenceFieldUpdaterConverterContext, + updateSequenceFieldsInTransaction, +} from '../../document-api-adapters/helpers/sequence-field-updater.js'; +import { getPageNumberFieldFormat } from '../../core/layout-adapter/converters/inline-converters/page-number-field-format.js'; /** Stat-field types refreshed by F9 when the doc has no TOCs. */ const UPDATABLE_FIELD_TYPES = new Set(['NUMWORDS', 'NUMCHARS', 'NUMPAGES', 'SECTIONPAGES']); +function resolveTotalPageNumberFieldValue(stats, node) { + if (stats.pages == null) return null; + return formatPageNumberFieldValue(stats.pages, getPageNumberFieldFormat(node.attrs)); +} + /** * @module FieldUpdate * @sidebarTitle Field Update @@ -40,6 +51,8 @@ export const FieldUpdate = Extension.create({ () => ({ editor, state, tr: outerTr, dispatch }) => { const { from, to } = state.selection; + const originalSelectionFields = findFieldsInRange(state.doc, from, to); + const selectionHadSeq = originalSelectionFields.some((field) => field.fieldType === 'SEQ'); let tocPathRan = false; // toc.update dispatches its own transaction per TOC; CommandService @@ -85,14 +98,21 @@ export const FieldUpdate = Extension.create({ } } - const fields = findFieldsInRange(state.doc, from, to); + const activeState = tocPathRan && editor?.state?.doc ? editor.state : state; + const activeDoc = activeState.doc ?? state.doc; + const activeSchema = activeState.schema ?? state.schema; + const activeFrom = Math.min(from, activeDoc.content.size); + const activeTo = to >= state.doc.content.size ? activeDoc.content.size : Math.min(to, activeDoc.content.size); + + const fields = findFieldsInRange(activeDoc, activeFrom, activeTo); const updatable = fields.filter((f) => UPDATABLE_FIELD_TYPES.has(f.fieldType)); - if (updatable.length === 0) return tocPathRan; + const hasSeqSelection = selectionHadSeq || fields.some((field) => field.fieldType === 'SEQ'); + if (updatable.length === 0 && !hasSeqSelection) return tocPathRan; const mainEditor = resolveMainBodyEditor(editor); const stats = getWordStatistics(mainEditor); - const tr = state.tr; + const tr = activeState.tr; let changed = false; // Process in reverse position order so earlier positions stay valid @@ -106,14 +126,16 @@ export const FieldUpdate = Extension.create({ const freshValue = field.fieldType === 'SECTIONPAGES' ? resolveSectionPageCountFieldValue(editor, node) - : resolveDocumentStatFieldValue(field.fieldType, stats); + : field.fieldType === 'NUMPAGES' && node.type.name === 'total-page-number' + ? resolveTotalPageNumberFieldValue(stats, node) + : resolveDocumentStatFieldValue(field.fieldType, stats); if (freshValue == null) continue; if (node.type.name === 'total-page-number' || node.type.name === 'section-page-count') { // Page-count fields store their display value as a text child, // not just an attr. Replace the entire node so both the text // content and resolvedText stay in sync. - const textChild = freshValue ? state.schema.text(freshValue) : null; + const textChild = freshValue ? activeSchema.text(freshValue) : null; const newNode = node.type.create({ ...node.attrs, resolvedText: freshValue }, textChild); tr.replaceWith(field.pos, field.pos + node.nodeSize, newNode); changed = true; @@ -129,6 +151,16 @@ export const FieldUpdate = Extension.create({ } } + if (hasSeqSelection) { + const result = updateSequenceFieldsInTransaction({ + tr, + schema: activeSchema, + scope: { kind: 'all' }, + converterContext: getSequenceFieldUpdaterConverterContext(editor), + }); + changed = changed || result.changed; + } + if (!changed) return tocPathRan; if (dispatch) dispatch(tr); return true; diff --git a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js index cdda0f7eea..6fd940ac8b 100644 --- a/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js +++ b/packages/super-editor/src/editors/v1/extensions/field-update/field-update.test.js @@ -308,6 +308,42 @@ const mixedSchema = new Schema({ }, toDOM: () => ['span', 0], }, + 'total-page-number': { + group: 'inline', + inline: true, + atom: true, + content: 'text*', + attrs: { + instruction: { default: null }, + importedCachedText: { default: null }, + resolvedText: { default: null }, + pageNumberFormat: { default: null }, + pageNumberZeroPadding: { default: null }, + pageNumberNumericPicture: { default: null }, + }, + toDOM: () => ['span', 0], + }, + sequenceField: { + group: 'inline', + inline: true, + atom: true, + attrs: { + instruction: { default: '' }, + identifier: { default: '' }, + fieldArgument: { default: '' }, + sequenceMode: { default: 'next' }, + hideResult: { default: false }, + restartNumber: { default: null }, + restartLevel: { default: null }, + format: { default: 'Arabic' }, + hasGeneralFormat: { default: false }, + pageNumberFieldFormat: { default: null }, + numericPictureFormat: { default: null }, + resolvedNumber: { default: '' }, + resolvedNumberIsCurrent: { default: false }, + }, + toDOM: () => ['span', 0], + }, text: { group: 'inline' }, }, }); @@ -429,6 +465,44 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)', expect(updatedField.textContent).toBe('004'); }); + it('updates NUMPAGES fields with preserved numeric picture formatting', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const totalPageNumberField = mixedSchema.nodes['total-page-number'].create( + { + instruction: 'NUMPAGES \\# "#,##0 pages"', + pageNumberNumericPicture: '#,##0 pages', + resolvedText: '1 pages', + }, + mixedSchema.text('1 pages'), + ); + const doc = mixedSchema.nodes.doc.create({}, [para([totalPageNumberField])]); + const editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + currentTotalPages: 1234, + state: editorState, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const outerTr = editorState.tr; + const dispatch = vi.fn(); + const state = { + doc, + selection: { from: 0, to: doc.content.size }, + schema: mixedSchema, + tr: outerTr, + }; + + const result = command({ editor, state, tr: outerTr, dispatch }); + + expect(result).toBe(true); + const updatedDoc = dispatch.mock.calls[0][0].doc; + const updatedField = updatedDoc.nodeAt(1); + expect(updatedField.type.name).toBe('total-page-number'); + expect(updatedField.attrs.resolvedText).toBe('1,234 pages'); + expect(updatedField.textContent).toBe('1,234 pages'); + }); + it('leaves SECTIONPAGES fields unchanged when section page context is unavailable', () => { const para = (children) => mixedSchema.nodes.paragraph.create({}, children); const sectionPageCountField = mixedSchema.nodes['section-page-count'].create( @@ -464,6 +538,98 @@ describe('updateFieldsInSelection — TOC + stat fields combined (regression)', expect(unchangedField.attrs.resolvedText).toBe('3'); expect(unchangedField.textContent).toBe('3'); }); + + it('recomputes stale SEQ fields when the selection contains SEQ', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const seq = (instruction) => + mixedSchema.nodes.sequenceField.create({ + instruction, + identifier: 'Figure', + resolvedNumber: '9', + }); + const doc = mixedSchema.nodes.doc.create({}, [para([seq('SEQ Figure')]), para([seq('SEQ Figure')])]); + const editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + state: editorState, + converter: { translatedLinkedStyles: { docDefaults: {}, styles: {} }, translatedNumbering: {} }, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const dispatch = vi.fn(); + const result = command({ + editor, + state: { doc, selection: { from: 0, to: doc.content.size }, schema: mixedSchema, tr: editorState.tr }, + tr: editorState.tr, + dispatch, + }); + + expect(result).toBe(true); + expect(dispatch).toHaveBeenCalledTimes(1); + const updatedValues = []; + dispatch.mock.calls[0][0].doc.descendants((node) => { + if (node.type.name === 'sequenceField') updatedValues.push(node.attrs.resolvedNumber); + return true; + }); + expect(updatedValues).toEqual(['1', '2']); + }); + + it('updates SEQ from fresh state after TOC update dispatches its own transaction', () => { + const para = (children) => mixedSchema.nodes.paragraph.create({}, children); + const text = (t) => mixedSchema.text(t); + const toc = mixedSchema.nodes.tableOfContents.create({ sdBlockId: 'toc-1' }, [para([text('entry')])]); + const seqField = mixedSchema.nodes.sequenceField.create({ + instruction: 'SEQ Figure', + identifier: 'Figure', + resolvedNumber: '9', + }); + const doc = mixedSchema.nodes.doc.create({}, [toc, para([seqField])]); + let editorState = EditorState.create({ schema: mixedSchema, doc }); + const editor = { + doc: { + toc: { + update: vi.fn(() => { + // Simulate toc.update dispatching independently before the SEQ + // path runs. The later SEQ transaction must be based on this fresh + // state, preserving the TOC edit and the shifted SEQ position. + editorState = editorState.apply(editorState.tr.insertText(' updated', 7)); + return { success: true }; + }), + }, + }, + get state() { + return editorState; + }, + converter: { translatedLinkedStyles: { docDefaults: {}, styles: {} }, translatedNumbering: {} }, + }; + + const commands = FieldUpdate.config.addCommands.call({ editor }); + const command = commands.updateFieldsInSelection(); + const outerTr = editorState.tr; + outerTr.setMeta = vi.fn(outerTr.setMeta.bind(outerTr)); + const dispatch = vi.fn(); + const seqPos = toc.nodeSize + 1; + const result = command({ + editor, + state: { doc, selection: { from: seqPos, to: seqPos + seqField.nodeSize }, schema: mixedSchema, tr: outerTr }, + tr: outerTr, + dispatch, + }); + + expect(result).toBe(true); + expect(editor.doc.toc.update).toHaveBeenCalledTimes(1); + expect(outerTr.setMeta).toHaveBeenCalledWith('preventDispatch', true); + expect(dispatch).toHaveBeenCalledTimes(1); + + const dispatchedDoc = dispatch.mock.calls[0][0].doc; + expect(dispatchedDoc.textContent).toContain('entry updated'); + const seqValues = []; + dispatchedDoc.descendants((node) => { + if (node.type.name === 'sequenceField') seqValues.push(node.attrs.resolvedNumber); + return true; + }); + expect(seqValues).toEqual(['1']); + }); }); describe('FieldUpdate extension shortcuts', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js index 0641287272..64d8884e04 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.js @@ -2,6 +2,7 @@ import { Node } from '@core/Node.js'; import { Attribute } from '@core/Attribute.js'; import { isHeadless } from '@utils/headless-helpers.js'; import { formatPageNumberFieldValue, formatSectionPageNumberText } from '@superdoc/contracts'; +import { getPageNumberFieldFormat } from '../../core/layout-adapter/converters/inline-converters/page-number-field-format.js'; /** * Configuration options for PageNumber * @typedef {Object} PageNumberOptions @@ -139,6 +140,7 @@ export const PageNumber = Node.create({ * @property {string|null} [instruction=null] @internal - Original NUMPAGES field instruction when switched * @property {string|null} [pageNumberFormat=null] @internal - Normalized field switch format * @property {number|null} [pageNumberZeroPadding=null] @internal - Zero-padding width from numeric picture switch + * @property {string|null} [pageNumberNumericPicture=null] @internal - Raw numeric picture switch */ /** @@ -186,6 +188,10 @@ export const TotalPageCount = Node.create({ default: null, rendered: false, }, + pageNumberNumericPicture: { + default: null, + rendered: false, + }, /** * Preserves the imported OOXML cached field result for NUMPAGES. * Used as a fallback when pagination is unavailable (headless context) @@ -384,13 +390,16 @@ const getNodeAttributes = (nodeName, editor, node = null) => { ariaLabel: 'Page number node', }; } - case 'total-page-number': + case 'total-page-number': { + const totalPageCount = + Number(editor.options.totalPageCount || editor.options.parentEditor?.currentTotalPages || 1) || 1; return { - text: editor.options.totalPageCount || editor.options.parentEditor?.currentTotalPages || '1', + text: formatPageNumberFieldValue(totalPageCount, getPageNumberFieldFormat(node?.attrs)), className: 'sd-editor-auto-total-pages', dataId: 'auto-total-pages', ariaLabel: 'Total page count node', }; + } case 'section-page-count': { const sectionPageCount = editor.options.sectionPageCount; const cachedText = node?.attrs?.resolvedText ?? node?.attrs?.importedCachedText ?? node?.textContent ?? '1'; diff --git a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js index bd2ea400ef..92b0ddcd08 100644 --- a/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js +++ b/packages/super-editor/src/editors/v1/extensions/page-number/page-number.test.js @@ -318,6 +318,25 @@ describe('AutoPageNumberNodeView', () => { expect(nodeView.dom.getAttribute('data-id')).toBe('auto-total-pages'); }); + it('renders formatted total page count node in edit mode', () => { + const doc = { + resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), + nodeAt: vi.fn().mockReturnValue({ isText: false, attrs: { marksAsAttrs: [] } }), + }; + const tr = { setNodeMarkup: vi.fn().mockReturnValue({}) }; + const state = { doc, tr }; + const editor = { + options: { totalPageCount: 12, parentEditor: { currentTotalPages: 12 } }, + state, + view: { state, dispatch: vi.fn() }, + }; + + const node = { type: { name: 'total-page-number' }, attrs: { pageNumberNumericPicture: '000' } }; + const nodeView = new AutoPageNumberNodeView(node, () => 7, [], editor); + + expect(nodeView.dom.textContent).toBe('012'); + }); + it('renders formatted section page count node', () => { const doc = { resolve: vi.fn().mockReturnValue({ nodeBefore: null, nodeAfter: null }), diff --git a/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.js b/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.js index b28e344f7d..3e59d32aba 100644 --- a/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.js +++ b/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.js @@ -38,6 +38,22 @@ export const SequenceField = Node.create({ default: '', rendered: false, }, + fieldArgument: { + default: '', + rendered: false, + }, + sequenceMode: { + default: 'next', + rendered: false, + }, + hideResult: { + default: false, + rendered: false, + }, + restartNumber: { + default: null, + rendered: false, + }, format: { default: 'ARABIC', rendered: false, @@ -46,10 +62,26 @@ export const SequenceField = Node.create({ default: null, rendered: false, }, + hasGeneralFormat: { + default: false, + rendered: false, + }, + pageNumberFieldFormat: { + default: null, + rendered: false, + }, + numericPictureFormat: { + default: null, + rendered: false, + }, resolvedNumber: { default: '', rendered: false, }, + resolvedNumberIsCurrent: { + default: false, + rendered: false, + }, sdBlockId: { default: null, rendered: false, @@ -66,7 +98,7 @@ export const SequenceField = Node.create({ }, renderDOM({ node, htmlAttributes }) { - const text = node.attrs.resolvedNumber || '0'; + const text = node.attrs.resolvedNumber || ''; return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), text]; }, }); diff --git a/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.test.js b/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.test.js new file mode 100644 index 0000000000..3b1ff799bc --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/sequence-field/sequence-field.test.js @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +describe('SequenceField extension', () => { + it('keeps the legacy ARABIC schema default for format', () => { + const { editor } = initTestEditor({ mode: 'text', content: '

', isHeadless: true }); + const node = editor.schema.nodes.sequenceField.create({ instruction: 'SEQ Figure' }); + + expect(node.attrs.format).toBe('ARABIC'); + + editor.destroy(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js index 8ed9faf3a8..fd1676a887 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.js @@ -159,7 +159,8 @@ export function createTextElement(textContent, textAlign, width, height, options }); } if (part.fieldType === 'NUMPAGES') { - return totalPages != null ? String(totalPages) : '1'; + const count = totalPages ?? 1; + return part.pageNumberFormat ? formatPageNumber(count, part.pageNumberFormat) : String(count); } if (part.fieldType === 'SECTIONPAGES') { if (sectionPageCount == null) return part.text ?? '1'; diff --git a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js index 641cf0ce48..dcd4c80d16 100644 --- a/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js +++ b/packages/super-editor/src/editors/v1/extensions/shared/svg-utils.test.js @@ -214,6 +214,30 @@ describe('svg-utils', () => { expect(span.textContent).toBe('10'); }); + it('should format NUMPAGES field type with pageNumberFormat', () => { + const textContent = { + parts: [{ text: '', fieldType: 'NUMPAGES', pageNumberFormat: 'upperRoman', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + totalPages: 9, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('IX'); + }); + + it('should format NUMPAGES field type with ordinal pageNumberFormat', () => { + const textContent = { + parts: [{ text: '', fieldType: 'NUMPAGES', pageNumberFormat: 'ordinal', formatting: {} }], + }; + const result = createTextElement(textContent, 'left', 100, 50, { + totalPages: 12, + }); + + const span = result.querySelector('span'); + expect(span.textContent).toBe('12th'); + }); + it('should default PAGE to "1" when pageNumber not provided', () => { const textContent = { parts: [{ text: '', fieldType: 'PAGE', formatting: {} }], 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 d720910b76..754fcf307c 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 @@ -986,9 +986,11 @@ export interface TotalPageCountAttrs extends InlineNodeAttributes { /** @internal Original NUMPAGES 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; + /** @internal Raw numeric picture switch */ + pageNumberNumericPicture?: string | null; } /** Section page count node attributes */