diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 225efc875f..74a1848218 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { paragraphToFlowBlocks as baseParagraphToFlowBlocks, + handleParagraphNode, mergeAdjacentRuns, dataAttrsCompatible, commentsCompatible, @@ -22,6 +23,7 @@ import type { HyperlinkConfig, ThemeColorPalette, NestedConverters, + NodeHandlerContext, } from '../types.js'; import type { ConverterContext } from '../converter-context.js'; import type { @@ -796,6 +798,109 @@ describe('paragraph converters', () => { vi.mocked(applyMarksToRun).mockImplementation(() => undefined); }); + const mockParagraphMarkTrackedChanges = () => { + vi.mocked(collectTrackedChangeFromMarks).mockImplementation((marks) => { + const markType = marks[0]?.type; + const id = typeof marks[0]?.attrs?.id === 'string' ? marks[0].attrs.id : undefined; + if (markType === 'trackInsert') { + return { + kind: 'insert', + ...(id ? { id } : {}), + }; + } + if (markType === 'trackDelete') { + return { + kind: 'delete', + ...(id ? { id } : {}), + }; + } + return undefined; + }); + }; + + const createTrackedListParagraph = ({ + ordinal, + markerText, + numberingType, + paragraphMarkChange, + customFormat, + }: { + ordinal: number; + markerText: string; + numberingType: string; + paragraphMarkChange?: 'insert' | 'delete'; + customFormat?: string; + }): PMNode => ({ + type: 'paragraph', + content: [], + attrs: { + listRendering: { + numberingType, + path: [ordinal], + markerText, + ...(customFormat ? { customFormat } : {}), + }, + ...(paragraphMarkChange + ? { + paragraphProperties: { + runProperties: { + [paragraphMarkChange === 'insert' ? 'trackInsert' : 'trackDelete']: { + id: `${paragraphMarkChange}-${ordinal}`, + author: 'Test Author', + date: '2026-03-01T12:00:00Z', + }, + }, + }, + } + : {}), + mockParagraphAttrs: { + numberingProperties: { ilvl: 0, numId: 42 }, + wordLayout: { + marker: { + markerText, + }, + }, + }, + }, + }); + + const mockParagraphAttrsFromNode = () => { + vi.mocked(computeParagraphAttrs).mockImplementation((node) => ({ + paragraphAttrs: + ((node.attrs ?? {}) as { mockParagraphAttrs?: Record }).mockParagraphAttrs ?? {}, + resolvedParagraphProperties: {}, + })); + }; + + const createParagraphHandlerContext = (trackedChangesConfig: TrackedChangesConfig): NodeHandlerContext => ({ + blocks: [], + recordBlockKind: vi.fn(), + nextBlockId, + positions, + defaultFont: 'Arial', + defaultSize: 16, + converterContext, + trackedChangesConfig, + hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG, + enableComments: true, + bookmarks: new Map(), + sectionState: { + ranges: [], + currentSectionIndex: 0, + currentParagraphIndex: 0, + }, + converters: { + paragraphToFlowBlocks: baseParagraphToFlowBlocks, + } as unknown as NestedConverters, + trackedListMarkerOffsets: new Map(), + trackedListLastOrdinals: new Map(), + }); + + const getMarkerText = (block: FlowBlock | undefined): string | undefined => { + const attrs = (block as { attrs?: { wordLayout?: { marker?: { markerText?: string } } } } | undefined)?.attrs; + return attrs?.wordLayout?.marker?.markerText; + }; + describe('Basic functionality', () => { it('should create empty paragraph for node with no content', () => { const para: PMNode = { @@ -2213,6 +2318,158 @@ describe('paragraph converters', () => { expect(blocks).toHaveLength(0); }); + it('should skip tracked empty list paragraph artifacts from paragraph mark revisions', () => { + const para: PMNode = { + type: 'paragraph', + content: [], + attrs: { + paragraphProperties: { + runProperties: { + trackInsert: { + id: 'ins-1', + author: 'Test Author', + date: '2026-03-01T12:00:00Z', + }, + }, + }, + }, + }; + + const trackedChanges: TrackedChangesConfig = { + mode: 'review', + enabled: true, + }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + numberingProperties: { ilvl: 0, numId: 42 }, + }, + resolvedParagraphProperties: {}, + }); + vi.mocked(collectTrackedChangeFromMarks).mockReturnValue({ + kind: 'insert', + id: 'ins-1', + }); + vi.mocked(applyTrackedChangesModeToRuns).mockImplementation((runs) => runs); + + const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, trackedChanges); + + expect(blocks).toHaveLength(0); + }); + + it('should preserve tracked empty list paragraph artifacts when tracked changes mode is off', () => { + const para: PMNode = { + type: 'paragraph', + content: [], + attrs: { + paragraphProperties: { + runProperties: { + trackInsert: { + id: 'ins-1', + author: 'Test Author', + date: '2026-03-01T12:00:00Z', + }, + }, + }, + }, + }; + + const trackedChanges: TrackedChangesConfig = { + mode: 'off', + enabled: true, + }; + + const filteredRuns: Run[] = [ + { + text: '', + fontFamily: 'Arial', + fontSize: 16, + }, + ]; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { + numberingProperties: { ilvl: 0, numId: 42 }, + }, + resolvedParagraphProperties: {}, + }); + vi.mocked(collectTrackedChangeFromMarks).mockReturnValue({ + kind: 'insert', + id: 'ins-1', + }); + vi.mocked(applyTrackedChangesModeToRuns).mockReturnValue(filteredRuns); + + const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, trackedChanges); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { + numberingProperties: { ilvl: 0, numId: 42 }, + trackedChangesMode: 'off', + trackedChangesEnabled: true, + }, + }); + }); + + it.each([ + { + mode: 'final' as const, + paragraphMarkChange: 'insert' as const, + }, + { + mode: 'original' as const, + paragraphMarkChange: 'delete' as const, + }, + ])( + 'should keep empty tracked list paragraphs in $mode mode when the paragraph mark change survives', + ({ mode, paragraphMarkChange }) => { + mockParagraphMarkTrackedChanges(); + mockParagraphAttrsFromNode(); + + const para = createTrackedListParagraph({ + ordinal: 2, + markerText: '2.', + numberingType: 'decimal', + paragraphMarkChange, + }); + + const trackedChanges: TrackedChangesConfig = { + mode, + enabled: true, + }; + + // Simulate real final/original behavior: surviving runs have insert/delete + // metadata stripped (tracked-changes.ts:503-512), keeping only format changes. + vi.mocked(applyTrackedChangesModeToRuns).mockImplementation((runs) => + runs.map((run) => { + if (!('trackedChange' in run)) return run; + const copy = { ...run }; + delete (copy as Record).trackedChange; + return copy; + }), + ); + + const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, trackedChanges); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { + numberingProperties: { ilvl: 0, numId: 42 }, + trackedChangesMode: mode, + trackedChangesEnabled: true, + }, + }); + + const paragraphBlock = blocks[0] as ParagraphBlock; + expect(paragraphBlock.runs).toHaveLength(1); + expect(paragraphBlock.runs[0]).toMatchObject({ text: '' }); + // insert/delete metadata is stripped by applyTrackedChangesModeToRuns in final/original mode + expect(paragraphBlock.runs[0]).not.toHaveProperty('trackedChange'); + }, + ); + it('should not apply tracked changes mode when config not provided', () => { const para: PMNode = { type: 'paragraph', @@ -2242,6 +2499,72 @@ describe('paragraph converters', () => { expect(blocks.some((b) => b.kind === 'pageBreak')).toBe(true); }); + + it('should preserve custom list marker formatting when renumbering after a suppressed ghost item', () => { + mockParagraphMarkTrackedChanges(); + mockParagraphAttrsFromNode(); + + const trackedChanges: TrackedChangesConfig = { + mode: 'review', + enabled: true, + }; + const context = createParagraphHandlerContext(trackedChanges); + + handleParagraphNode( + createTrackedListParagraph({ + ordinal: 2, + markerText: '002.', + numberingType: 'custom', + paragraphMarkChange: 'insert', + customFormat: '001, 002, 003, ...', + }), + context, + ); + handleParagraphNode( + createTrackedListParagraph({ + ordinal: 3, + markerText: '003.', + numberingType: 'custom', + customFormat: '001, 002, 003, ...', + }), + context, + ); + + expect(context.blocks).toHaveLength(1); + expect(getMarkerText(context.blocks[0])).toBe('002.'); + }); + + it('should renumber non-ASCII list markers after a suppressed ghost item', () => { + mockParagraphMarkTrackedChanges(); + mockParagraphAttrsFromNode(); + + const trackedChanges: TrackedChangesConfig = { + mode: 'review', + enabled: true, + }; + const context = createParagraphHandlerContext(trackedChanges); + + handleParagraphNode( + createTrackedListParagraph({ + ordinal: 2, + markerText: '二.', + numberingType: 'japaneseCounting', + paragraphMarkChange: 'insert', + }), + context, + ); + handleParagraphNode( + createTrackedListParagraph({ + ordinal: 3, + markerText: '三.', + numberingType: 'japaneseCounting', + }), + context, + ); + + expect(context.blocks).toHaveLength(1); + expect(getMarkerText(context.blocks[0])).toBe('二.'); + }); }); describe('Run merging', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index fbb6f52746..91228be8e6 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -8,7 +8,7 @@ */ import type { ParagraphProperties, RunProperties } from '@superdoc/style-engine/ooxml'; -import type { FlowBlock, Run, TextRun, SdtMetadata, DrawingBlock } from '@superdoc/contracts'; +import type { FlowBlock, Run, TextRun, SdtMetadata, DrawingBlock, TrackedChangeMeta } from '@superdoc/contracts'; import type { PMNode, PMMark, @@ -21,7 +21,7 @@ import { getStableParagraphId, shiftCachedBlocks } from '../cache.js'; import type { ConverterContext } from '../converter-context.js'; import { computeParagraphAttrs, deepClone } from '../attributes/index.js'; import { shouldRequirePageBoundary, hasIntrinsicBoundarySignals, createSectionBreakBlock } from '../sections/index.js'; -import { trackedChangesCompatible, applyMarksToRun } from '../marks/index.js'; +import { trackedChangesCompatible, applyMarksToRun, collectTrackedChangeFromMarks } from '../marks/index.js'; import { applyTrackedChangesModeToRuns } from '../tracked-changes.js'; import { textNodeToRun } from './inline-converters/text-run.js'; import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js'; @@ -51,6 +51,8 @@ import { lineBreakNodeToRun } from './inline-converters/line-break.js'; import { lineBreakNodeToBreakBlock } from './break.js'; import { inlineContentBlockConverter } from './inline-converters/content-block.js'; import { handleImageNode } from './image.js'; +import { generateOrderedListIndex } from '../list-helpers.js'; +import { getListOrdinalFromPath, getListRendering } from '@superdoc/common/list-rendering'; import { shapeContainerNodeToDrawingBlock, shapeGroupNodeToDrawingBlock, @@ -227,6 +229,249 @@ function extractDefaultFontProperties( }; } +const toTrackChangeAttrs = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + return value as Record; +}; + +// Paragraph-mark revisions are stored in paragraphProperties.runProperties (pPr/rPr), not inline text marks. +// Convert them into mark-like metadata so tracked-change filtering can reuse the same projection pipeline. +const getParagraphMarkTrackedChange = (paragraphProperties: ParagraphProperties): TrackedChangeMeta | undefined => { + const runProperties = + paragraphProperties?.runProperties && typeof paragraphProperties.runProperties === 'object' + ? (paragraphProperties.runProperties as Record) + : undefined; + if (!runProperties) { + return undefined; + } + + const trackInsertAttrs = toTrackChangeAttrs(runProperties.trackInsert); + const trackDeleteAttrs = toTrackChangeAttrs(runProperties.trackDelete); + if (!trackInsertAttrs && !trackDeleteAttrs) { + return undefined; + } + + const marks: PMMark[] = []; + if (trackInsertAttrs) { + marks.push({ type: 'trackInsert', attrs: trackInsertAttrs }); + } + if (trackDeleteAttrs) { + marks.push({ type: 'trackDelete', attrs: trackDeleteAttrs }); + } + return collectTrackedChangeFromMarks(marks); +}; + +const isEmptyTextRun = (run: Run): boolean => { + if (!isTextRun(run)) { + return false; + } + return run.text.length === 0; +}; + +/** + * Extracts the marker record from a paragraph FlowBlock's wordLayout attrs. + * Shared by ghost-list marker adjustment helpers to avoid duplicated extraction logic. + */ +const getBlockMarker = (block: FlowBlock): Record | undefined => { + if (!('attrs' in block) || !block.attrs || typeof block.attrs !== 'object') return undefined; + const wordLayout = (block.attrs as Record).wordLayout; + if (!wordLayout || typeof wordLayout !== 'object') return undefined; + const marker = (wordLayout as Record).marker; + if (!marker || typeof marker !== 'object') return undefined; + return marker as Record; +}; + +/** + * Returns the original (pre-adjustment) marker text from a marker record. + * Prefers the saved pmAdapterOriginalMarkerText over the current markerText. + */ +const getOriginalMarkerText = (marker: Record): string | undefined => { + if (typeof marker.pmAdapterOriginalMarkerText === 'string') return marker.pmAdapterOriginalMarkerText; + return typeof marker.markerText === 'string' ? marker.markerText : undefined; +}; + +/** + * Ghost list artifact suppression only applies in modes that display tracked changes + * visually (review/markup). In final/original, applyTrackedChangesModeToRuns already + * handles visibility correctly — surviving empty list items are real content. + */ +const isGhostSuppressionMode = (mode: string): boolean => mode !== 'off' && mode !== 'final' && mode !== 'original'; + +const getListKey = (numId: unknown, ilvl: unknown): string | undefined => { + if ((typeof numId !== 'number' && typeof numId !== 'string') || typeof ilvl !== 'number') { + return undefined; + } + const normalizedNumId = String(numId).trim(); + if (!normalizedNumId) { + return undefined; + } + return `${normalizedNumId}:${ilvl}`; +}; + +const getParagraphListKeyFromAttrs = (attrs: unknown): string | undefined => { + if (!attrs || typeof attrs !== 'object') { + return undefined; + } + const numberingProperties = (attrs as { numberingProperties?: { numId?: unknown; ilvl?: unknown } }) + .numberingProperties; + if (!numberingProperties) { + return undefined; + } + return getListKey(numberingProperties.numId, numberingProperties.ilvl); +}; + +const TRAILING_MARKER_TOKEN_RE = /^(.*?)([\p{L}\p{N}]+)([^\p{L}\p{N}]*)$/u; + +const getNodeListOrdinal = (node: PMNode): number | undefined => { + const listRendering = getListRendering(node.attrs?.listRendering); + if (!listRendering || listRendering.numberingType === 'bullet') { + return undefined; + } + return getListOrdinalFromPath(listRendering.path); +}; + +const formatListOrdinalToken = (numberingType: string, ordinal: number, customFormat?: string): string | undefined => { + if (!Number.isFinite(ordinal) || ordinal < 1 || numberingType === 'bullet') { + return undefined; + } + const formatted = generateOrderedListIndex({ + listLevel: [Math.trunc(ordinal)], + lvlText: '%1', + listNumberingType: numberingType, + customFormat, + }); + return formatted ?? undefined; +}; + +const replaceTrailingMarkerToken = (markerText: string, replacementToken: string): string | undefined => { + const match = TRAILING_MARKER_TOKEN_RE.exec(markerText); + if (!match) { + return undefined; + } + return `${match[1] ?? ''}${replacementToken}${match[3] ?? ''}`; +}; + +const updateGhostListMarkerOffsets = ( + node: PMNode, + paragraphBlocks: FlowBlock[], + context: NodeHandlerContext, +): void => { + if (!context.trackedChangesConfig.enabled) { + return; + } + if (paragraphBlocks.some((block) => block.kind === 'paragraph')) { + return; + } + if (Array.isArray(node.content) && node.content.length > 0) { + return; + } + const paragraphProperties = + typeof node.attrs?.paragraphProperties === 'object' && node.attrs.paragraphProperties !== null + ? (node.attrs.paragraphProperties as ParagraphProperties) + : {}; + if (!getParagraphMarkTrackedChange(paragraphProperties)) { + return; + } + + const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext); + const key = getParagraphListKeyFromAttrs(paragraphAttrs); + if (!key) { + return; + } + const offsets = context.trackedListMarkerOffsets; + if (!offsets) { + return; + } + // Each suppressed empty tracked list paragraph consumes one source ordinal that Word does not render. + offsets.set(key, (offsets.get(key) ?? 0) + 1); +}; + +const getNodeListKey = (node: PMNode, context: NodeHandlerContext): string | undefined => { + const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext); + return getParagraphListKeyFromAttrs(paragraphAttrs); +}; + +const applyGhostListMarkerOffsets = (node: PMNode, paragraphBlocks: FlowBlock[], context: NodeHandlerContext): void => { + const offsets = context.trackedListMarkerOffsets; + const lastOrdinals = context.trackedListLastOrdinals; + if (!offsets || offsets.size === 0 || !context.trackedChangesConfig.enabled) { + return; + } + const listRendering = getListRendering(node.attrs?.listRendering); + const numberingType = listRendering?.numberingType; + if (!numberingType || numberingType === 'bullet') { + return; + } + const sourceOrdinal = getNodeListOrdinal(node); + if (!sourceOrdinal) { + return; + } + const nodeListKey = getNodeListKey(node, context); + if (!nodeListKey) { + return; + } + const previousOrdinal = lastOrdinals?.get(nodeListKey); + // Restart detection must be per source paragraph node (not per emitted block), + // because one source list paragraph can split into multiple rendered blocks. + if (previousOrdinal != null && sourceOrdinal <= previousOrdinal) { + // Marker sequence moved backwards/repeated -> list restart boundary. + offsets.delete(nodeListKey); + } + lastOrdinals?.set(nodeListKey, sourceOrdinal); + + const offset = offsets.get(nodeListKey) ?? 0; + if (offset <= 0) { + return; + } + const adjustedOrdinal = sourceOrdinal - offset; + if (adjustedOrdinal < 1) { + // Stale offset would underflow this marker; treat as a restart boundary. + offsets.delete(nodeListKey); + paragraphBlocks.forEach((block) => { + if (block.kind !== 'paragraph' || getParagraphListKeyFromAttrs(block.attrs) !== nodeListKey) return; + const marker = getBlockMarker(block); + if (!marker) return; + const sourceMarkerText = getOriginalMarkerText(marker); + if (sourceMarkerText) { + marker.markerText = sourceMarkerText; + } + }); + return; + } + + const replacementToken = formatListOrdinalToken(numberingType, adjustedOrdinal, listRendering.customFormat); + if (!replacementToken) { + return; + } + + paragraphBlocks.forEach((block) => { + if (block.kind !== 'paragraph' || getParagraphListKeyFromAttrs(block.attrs) !== nodeListKey) return; + const marker = getBlockMarker(block); + if (!marker) return; + const sourceMarkerText = getOriginalMarkerText(marker); + if (!sourceMarkerText) return; + const adjustedText = replaceTrailingMarkerToken(sourceMarkerText, replacementToken); + if (!adjustedText || adjustedText === sourceMarkerText) return; + // Preserve the source marker so repeated conversions/cache reuse remain idempotent. + marker.pmAdapterOriginalMarkerText = sourceMarkerText; + marker.markerText = adjustedText; + }); +}; + +const applyTrackedGhostListAdjustments = ( + node: PMNode, + paragraphBlocks: FlowBlock[], + context: NodeHandlerContext, +): void => { + if (!context.trackedChangesConfig.enabled) { + return; + } + updateGhostListMarkerOffsets(node, paragraphBlocks, context); + applyGhostListMarkerOffsets(node, paragraphBlocks, context); +}; + /** * Converts a paragraph PM node to an array of FlowBlocks. * @@ -301,6 +546,7 @@ export function paragraphToFlowBlocks({ if (paragraphProps.runProperties?.vanish) { return blocks; } + const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps); // Get the PM position of the empty paragraph for caret rendering const paraPos = positions.get(para); const emptyRun: TextRun = { @@ -308,6 +554,9 @@ export function paragraphToFlowBlocks({ fontFamily: defaultFont, fontSize: defaultSize, }; + if (paragraphMarkTrackedChange) { + emptyRun.trackedChange = paragraphMarkTrackedChange; + } // For empty paragraphs, the cursor position is inside the paragraph (start + 1) // The range spans from the opening to closing position of the paragraph if (paraPos) { @@ -328,6 +577,47 @@ export function paragraphToFlowBlocks({ runs: [emptyRun], attrs: emptyParagraphAttrs, }); + if (!trackedChangesConfig) { + return blocks; + } + + const paragraphBlock = blocks[blocks.length - 1]; + if (paragraphBlock?.kind !== 'paragraph') { + return blocks; + } + + const filteredRuns = applyTrackedChangesModeToRuns( + paragraphBlock.runs, + trackedChangesConfig, + hyperlinkConfig, + applyMarksToRun, + themeColors, + enableComments, + ); + + // Ghost list artifact suppression only applies in markup/review modes. + // In final/original, applyTrackedChangesModeToRuns already handles visibility: + // insertions survive in final and deletions survive in original — these are real content, + // not phantom list items that need hiding. + const isGhostTrackedListArtifact = + trackedChangesConfig.enabled && + isGhostSuppressionMode(trackedChangesConfig.mode) && + Boolean(paragraphAttrs.numberingProperties) && + Boolean(paragraphMarkTrackedChange) && + filteredRuns.length > 0 && + filteredRuns.every(isEmptyTextRun); + + if (trackedChangesConfig.enabled && (filteredRuns.length === 0 || isGhostTrackedListArtifact)) { + blocks.pop(); + return blocks; + } + + paragraphBlock.runs = filteredRuns; + paragraphBlock.attrs = { + ...(paragraphBlock.attrs ?? {}), + trackedChangesMode: trackedChangesConfig.mode, + trackedChangesEnabled: trackedChangesConfig.enabled, + }; return blocks; } @@ -679,6 +969,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): // Cache hit: reuse blocks with position adjustment const delta = pmStart - cached.pmStart; const reusedBlocks = shiftCachedBlocks(cached.blocks, delta); + applyTrackedGhostListAdjustments(node, reusedBlocks, context); reusedBlocks.forEach((block) => { blocks.push(block); @@ -705,6 +996,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): enableComments, stableBlockId: prefixedStableId, }); + applyTrackedGhostListAdjustments(node, paragraphBlocks, context); paragraphBlocks.forEach((block) => { blocks.push(block); @@ -730,6 +1022,8 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): enableComments, stableBlockId: prefixedStableId ?? undefined, }); + applyTrackedGhostListAdjustments(node, paragraphBlocks, context); + paragraphBlocks.forEach((block) => { blocks.push(block); recordBlockKind?.(block.kind); diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index ff9f5a3dba..a59c3f065f 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3799,6 +3799,413 @@ describe('toFlowBlocks', () => { expect(originalBlocks.some((block) => block.kind === 'image')).toBe(true); }); + it('renumbers visible list markers after suppressing tracked empty list artifacts', () => { + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 7, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerLetter', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(a)', [1], 'Alpha item'), + listParagraph('(b)', [2], null, { id: 'ghost-b', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(c)', [3], null, { id: 'ghost-c', author: 'Tester', date: '2026-03-01T12:01:00Z' }), + listParagraph('(d)', [4], 'Delta content that should render as (b)'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const paragraphBlocks = blocks.filter((block) => block.kind === 'paragraph'); + + expect(paragraphBlocks).toHaveLength(2); + const markerTexts = paragraphBlocks.map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }); + expect(markerTexts).toEqual(['(a)', '(b)']); + const secondParagraphText = paragraphBlocks[1].runs + .filter((run) => 'text' in run) + .map((run) => run.text) + .join(''); + expect(secondParagraphText).toContain('Delta content'); + }); + + it('clears ghost offsets when marker sequence restarts within the same list key', () => { + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 7, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerLetter', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(a)', [1], 'Alpha item'), + listParagraph('(b)', [2], null, { id: 'ghost-b', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(c)', [3], null, { id: 'ghost-c', author: 'Tester', date: '2026-03-01T12:01:00Z' }), + listParagraph('(d)', [4], 'Adjusted to b'), + listParagraph('(e)', [5], 'Adjusted to c'), + listParagraph('(c)', [3], 'Restart should stay c'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const paragraphBlocks = blocks.filter((block) => block.kind === 'paragraph'); + + const markerTexts = paragraphBlocks.map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }); + expect(markerTexts).toEqual(['(a)', '(b)', '(c)', '(c)']); + }); + + it('keeps ghost offsets across split paragraph blocks from the same source list item', () => { + const listParagraph = ( + markerText: string, + path: number[], + content: PMNode[] | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 7, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerLetter', + suffix: 'tab', + justification: 'left', + }, + }, + content: content ?? [], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(a)', [1], [{ type: 'text', text: 'Alpha item' }]), + listParagraph('(b)', [2], null, { id: 'ghost-b', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(c)', [3], [ + { type: 'text', text: 'Split item before image' }, + { + type: 'image', + attrs: { + src: 'data:image/png;base64,iVBORw0KGgo=', + size: { width: 10, height: 10 }, + wrap: { type: 'Square' }, + }, + }, + { type: 'text', text: 'Split item after image' }, + ]), + listParagraph('(d)', [4], [{ type: 'text', text: 'Delta should render as c' }]), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const markerTexts = blocks + .filter((block) => block.kind === 'paragraph') + .map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }) + .filter((value): value is string => typeof value === 'string'); + + expect(markerTexts.length).toBeGreaterThanOrEqual(3); + expect(markerTexts[0]).toBe('(a)'); + expect(markerTexts[1]).toBe('(b)'); + expect(markerTexts.at(-1)).toBe('(c)'); + }); + + it('keeps ghost offsets across non-list paragraphs within the same logical list sequence', () => { + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 7, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerLetter', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(a)', [1], 'Alpha item'), + listParagraph('(b)', [2], null, { id: 'ghost-b', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(c)', [3], null, { id: 'ghost-c', author: 'Tester', date: '2026-03-01T12:01:00Z' }), + listParagraph('(d)', [4], 'Adjusted to b'), + { type: 'paragraph', attrs: {}, content: [{ type: 'text', text: 'Intro paragraph' }] }, + listParagraph('(e)', [5], 'Should continue as c after the intro paragraph'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const paragraphBlocks = blocks.filter((block) => block.kind === 'paragraph'); + + const markerTexts = paragraphBlocks + .map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }) + .filter((value): value is string => typeof value === 'string'); + expect(markerTexts).toEqual(['(a)', '(b)', '(c)']); + }); + + it('uses listRendering.path as the source ordinal instead of parsing marker text', () => { + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 11, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'decimal', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('Item 1.', [1], 'Alpha item'), + listParagraph('Item two.', [2], null, { id: 'ghost-two', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('Item three.', [3], 'Adjusted to 2 from path metadata'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const paragraphBlocks = blocks.filter((block) => block.kind === 'paragraph'); + + const markerTexts = paragraphBlocks + .map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }) + .filter((value): value is string => typeof value === 'string'); + expect(markerTexts).toEqual(['Item 1.', 'Item 2.']); + }); + + it('continues style-based lists across non-list paragraphs when numbering is inherited from the paragraph style', () => { + const converterContext = { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: { + MLAgr3: { + type: 'paragraph', + paragraphProperties: { + styleId: 'MLAgr3', + numberingProperties: { numId: 5, ilvl: 2 }, + }, + }, + }, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, + }; + + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + styleId: 'MLAgr3', + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerLetter', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(a)', [1], 'Alpha item'), + listParagraph('(b)', [2], null, { id: 'ghost-b', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(c)', [3], null, { id: 'ghost-c', author: 'Tester', date: '2026-03-01T12:01:00Z' }), + listParagraph('(d)', [4], 'Adjusted to b'), + { type: 'paragraph', attrs: {}, content: [{ type: 'text', text: 'By way of example, you will:' }] }, + listParagraph('(e)', [5], 'Should continue as c from style-based numbering'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { + trackedChangesMode: 'review', + converterContext, + }); + const markerTexts = blocks + .filter((block) => block.kind === 'paragraph') + .map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }) + .filter((value): value is string => typeof value === 'string'); + + expect(markerTexts).toEqual(['(a)', '(b)', '(c)']); + }); + + it('renumbers roman markers correctly and avoids single-letter roman corruption', () => { + const listParagraph = ( + markerText: string, + path: number[], + text: string | null, + trackInsert?: { id: string; author: string; date: string }, + ): PMNode => ({ + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 9, ilvl: 0 }, + ...(trackInsert + ? { + runProperties: { + trackInsert, + }, + } + : {}), + }, + listRendering: { + markerText, + path, + numberingType: 'lowerRoman', + suffix: 'tab', + justification: 'left', + }, + }, + content: text == null ? [] : [{ type: 'text', text }], + }); + + const pmDoc: PMNode = { + type: 'doc', + content: [ + listParagraph('(i)', [1], 'Roman one'), + listParagraph('(ii)', [2], null, { id: 'ghost-ii', author: 'Tester', date: '2026-03-01T12:00:00Z' }), + listParagraph('(iii)', [3], 'Should render as ii'), + listParagraph('(i)', [1], 'Restart should remain i'), + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { trackedChangesMode: 'review' }); + const paragraphBlocks = blocks.filter((block) => block.kind === 'paragraph'); + + const markerTexts = paragraphBlocks.map((block) => { + const marker = (block.attrs?.wordLayout as { marker?: { markerText?: string } } | undefined)?.marker; + return marker?.markerText; + }); + expect(markerTexts).toEqual(['(i)', '(ii)', '(i)']); + }); + describe('adversarial input protection', () => { it('rejects trackFormat marks with excessively large JSON payloads', () => { const hugeString = 'x'.repeat(15000); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 3abfa65532..2931d43a3a 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -203,6 +203,8 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): converters, themeColors, flowBlockCache, + trackedListMarkerOffsets: new Map(), + trackedListLastOrdinals: new Map(), }; // Process nodes using handler dispatch pattern diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index fc227b17e5..8b44501b1e 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -308,6 +308,10 @@ export interface NodeHandlerContext { themeColors?: ThemeColorPalette; // FlowBlock cache for incremental conversion (optional) flowBlockCache?: import('./cache.js').FlowBlockCache; + // Per-list marker offsets caused by suppressed tracked-change ghost items + trackedListMarkerOffsets?: Map; + // Last seen source ordinal per list key for restart detection + trackedListLastOrdinals?: Map; } /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js index 2d2e32f701..97f5ad2de1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js @@ -44,6 +44,10 @@ import { translator as numFormTranslator } from '../w14-numForm/numForm-translat import { translator as numSpacingTranslator } from '../w14-numSpacing/numSpacing-translator.js'; import { translator as stylisticSetsTranslator } from '../w14-stylisticSets/stylisticSets-translator.js'; import { translator as cntxtAltsTranslator } from '../w14-cntxtAlts/cntxtAlts-translator.js'; +import { + trackInsertRunPropertyTranslator, + trackDeleteRunPropertyTranslator, +} from './track-change-run-property-translator.js'; // Property translators for w:rPr child elements // Each translator handles a specific property of the run properties @@ -91,6 +95,8 @@ export const propertyTranslators = [ numSpacingTranslator, stylisticSetsTranslator, cntxtAltsTranslator, + trackInsertRunPropertyTranslator, + trackDeleteRunPropertyTranslator, webHiddenTranslator, wTranslator, ]; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.test.js index 7de6455f37..eb416aee6a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.test.js @@ -47,4 +47,40 @@ describe('w:rPr translator (attribute aggregator)', () => { expect(result).toBeUndefined(); }); + + it('maps paragraph-mark tracked-change nodes from run properties', () => { + const params = makeParams([ + { + name: 'w:ins', + attributes: { + 'w:id': '28', + 'w:author': 'Test Author', + 'w:date': '2026-03-01T12:00:00Z', + }, + }, + { + name: 'w:del', + attributes: { + 'w:id': '32', + 'w:author': 'Test Author', + 'w:date': '2026-03-01T12:05:00Z', + }, + }, + ]); + + const result = translator.encode(params); + + expect(result).toEqual({ + trackInsert: { + id: '28', + author: 'Test Author', + date: '2026-03-01T12:00:00Z', + }, + trackDelete: { + id: '32', + author: 'Test Author', + date: '2026-03-01T12:05:00Z', + }, + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/track-change-run-property-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/track-change-run-property-translator.js new file mode 100644 index 0000000000..438b68ded5 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/track-change-run-property-translator.js @@ -0,0 +1,33 @@ +import { NodeTranslator } from '@translator'; +import { createAttributeHandler } from '@converter/v3/handlers/utils.js'; + +export const createTrackChangeRunPropertyTranslator = (xmlName, sdNodeOrKeyName) => + NodeTranslator.from({ + xmlName, + sdNodeOrKeyName, + attributes: [ + createAttributeHandler('w:id', 'id'), + createAttributeHandler('w:author', 'author'), + createAttributeHandler('w:authorEmail', 'authorEmail'), + createAttributeHandler('w:date', 'date'), + ], + encode: (_params, encodedAttrs = {}) => { + return Object.keys(encodedAttrs).length ? encodedAttrs : undefined; + }, + decode: function ({ node }) { + const source = node?.attrs?.[sdNodeOrKeyName]; + if (!source || typeof source !== 'object') { + return undefined; + } + const decodedAttrs = this.decodeAttributes({ + node: { + ...node, + attrs: source, + }, + }); + return Object.keys(decodedAttrs).length ? { attributes: decodedAttrs } : undefined; + }, + }); + +export const trackInsertRunPropertyTranslator = createTrackChangeRunPropertyTranslator('w:ins', 'trackInsert'); +export const trackDeleteRunPropertyTranslator = createTrackChangeRunPropertyTranslator('w:del', 'trackDelete'); diff --git a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts index d8a3deb262..95c722a084 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/list-item-resolver.ts @@ -1,5 +1,6 @@ import { ListHelpers } from '@helpers/list-numbering-helpers.js'; import type { Editor } from '../../core/Editor.js'; +import { getListOrdinalFromPath, getListRendering } from '@superdoc/common/list-rendering'; import type { BlockNodeAddress, ListItemAddress, @@ -29,12 +30,6 @@ export type ListItemProjection = { text?: string; }; -function toPath(value: unknown): number[] | undefined { - if (!Array.isArray(value)) return undefined; - const parsed = value.map((entry) => toFiniteNumber(entry)).filter((entry): entry is number => entry != null); - return parsed.length > 0 ? parsed : undefined; -} - function getNumberingProperties(node: BlockCandidate['node']): { numId?: number; level?: number } { const attrs = (node.attrs ?? {}) as { paragraphProperties?: { numberingProperties?: { numId?: unknown; ilvl?: unknown } | null } | null; @@ -85,16 +80,14 @@ function getListText(candidate: BlockCandidate): string | undefined { export function projectListItemCandidate(editor: Editor, candidate: BlockCandidate): ListItemProjection { const attrs = (candidate.node.attrs ?? {}) as { - listRendering?: { - markerText?: unknown; - path?: unknown; - } | null; + listRendering?: unknown; }; const { numId, level } = getNumberingProperties(candidate.node); - const path = toPath(attrs.listRendering?.path); - const ordinal = path?.length ? path[path.length - 1] : undefined; - const marker = typeof attrs.listRendering?.markerText === 'string' ? attrs.listRendering.markerText : undefined; + const listRendering = getListRendering(attrs.listRendering); + const path = listRendering?.path; + const ordinal = getListOrdinalFromPath(path); + const marker = listRendering?.markerText; return { candidate, diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts index a93b2c58c7..0ab573b4ce 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { getListOrdinalFromPath, getListRendering } from '@superdoc/common/list-rendering'; import { getHeadingLevel, type BlockCandidate } from './node-address-resolver.js'; import type { InlineCandidate } from './inline-address-resolver.js'; import { resolveCommentIdFromAttrs, toFiniteNumber } from './value-utils.js'; @@ -101,15 +102,13 @@ function mapParagraphProperties(attrs: ParagraphAttrs | null | undefined): Parag } function mapListNumbering(attrs: ParagraphAttrs | null | undefined): ListNumbering | undefined { - const listRendering = attrs?.listRendering ?? undefined; + const listRendering = getListRendering(attrs?.listRendering); if (!listRendering) return undefined; const listNumbering: ListNumbering = {}; if (listRendering.markerText) listNumbering.marker = listRendering.markerText; if (Array.isArray(listRendering.path)) listNumbering.path = listRendering.path; - if (Array.isArray(listRendering.path) && listRendering.path.length > 0) { - listNumbering.ordinal = listRendering.path[listRendering.path.length - 1]; - } + listNumbering.ordinal = getListOrdinalFromPath(listRendering.path); return Object.keys(listNumbering).length ? listNumbering : undefined; } diff --git a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js index d031db79f2..d588feacd2 100644 --- a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js +++ b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js @@ -251,6 +251,7 @@ export function createNumberingPlugin(editor) { justification, path, numberingType: listNumberingType, + ...(customFormat ? { customFormat } : {}), }; if (JSON.stringify(node.attrs.listRendering) !== JSON.stringify(newListRendering)) { diff --git a/shared/common/index.ts b/shared/common/index.ts index 690f4a320c..21158ff68a 100644 --- a/shared/common/index.ts +++ b/shared/common/index.ts @@ -18,6 +18,7 @@ export type { // List numbering helpers export * from './list-numbering'; +export * from './list-rendering'; // File helpers export * from './helpers/get-file-object'; diff --git a/shared/common/list-rendering.test.ts b/shared/common/list-rendering.test.ts new file mode 100644 index 0000000000..6ccdfcc5b8 --- /dev/null +++ b/shared/common/list-rendering.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { getListOrdinalFromPath, getListRendering } from './list-rendering'; + +describe('list-rendering helpers', () => { + describe('getListRendering', () => { + it('normalizes marker text, numbering type, and numeric paths', () => { + expect( + getListRendering({ + markerText: '1.', + numberingType: 'decimal', + path: [1, '2', 'bad', 3], + }), + ).toEqual({ + markerText: '1.', + numberingType: 'decimal', + path: [1, 2, 3], + }); + }); + + it('returns undefined when no usable list metadata exists', () => { + expect(getListRendering({})).toBeUndefined(); + expect(getListRendering(null)).toBeUndefined(); + }); + }); + + describe('getListOrdinalFromPath', () => { + it('returns the last positive ordinal from a path', () => { + expect(getListOrdinalFromPath([1, 2, 3])).toBe(3); + expect(getListOrdinalFromPath(['1', '2'])).toBe(2); + }); + + it('returns undefined for empty or invalid paths', () => { + expect(getListOrdinalFromPath([])).toBeUndefined(); + expect(getListOrdinalFromPath(['bad'])).toBeUndefined(); + expect(getListOrdinalFromPath(null)).toBeUndefined(); + }); + }); +}); diff --git a/shared/common/list-rendering.ts b/shared/common/list-rendering.ts new file mode 100644 index 0000000000..460dcb2ec4 --- /dev/null +++ b/shared/common/list-rendering.ts @@ -0,0 +1,46 @@ +export type ListRendering = { + markerText?: string; + numberingType?: string; + path?: number[]; + customFormat?: string; +}; + +const toFiniteNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +}; + +export const getListOrdinalFromPath = (path: unknown): number | undefined => { + if (!Array.isArray(path) || path.length === 0) return undefined; + const ordinal = toFiniteNumber(path[path.length - 1]); + return ordinal != null && ordinal > 0 ? ordinal : undefined; +}; + +export const getListRendering = (value: unknown): ListRendering | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const attrs = value as Record; + const markerText = typeof attrs.markerText === 'string' ? attrs.markerText : undefined; + const numberingType = typeof attrs.numberingType === 'string' ? attrs.numberingType : undefined; + const customFormat = typeof attrs.customFormat === 'string' ? attrs.customFormat : undefined; + const path = Array.isArray(attrs.path) + ? attrs.path.map(toFiniteNumber).filter((entry): entry is number => entry != null) + : undefined; + + if (!markerText && !numberingType && (!path || path.length === 0)) { + return undefined; + } + + return { + markerText, + numberingType, + ...(path && path.length > 0 ? { path } : {}), + ...(customFormat ? { customFormat } : {}), + }; +}; diff --git a/shared/common/package.json b/shared/common/package.json index 366d030402..e5284252a1 100644 --- a/shared/common/package.json +++ b/shared/common/package.json @@ -15,6 +15,7 @@ "./helpers/*": "./helpers/*.ts", "./list-numbering": "./list-numbering/index.ts", "./list-numbering/*": "./list-numbering/*.ts", + "./list-rendering": "./list-rendering.ts", "./layout-constants": "./layout-constants.ts", "./list-marker-utils": "./list-marker-utils.ts", "./*.ts": "./*.ts", diff --git a/tests/visual/tests/rendering/sd-1949-ghost-list-rendering.spec.ts b/tests/visual/tests/rendering/sd-1949-ghost-list-rendering.spec.ts new file mode 100644 index 0000000000..b7f69dd1dc --- /dev/null +++ b/tests/visual/tests/rendering/sd-1949-ghost-list-rendering.spec.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from '../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH_CANDIDATES = [ + path.resolve(__dirname, '../../test-data/rendering/sd-1949-ghost-list-rendering.docx'), + path.resolve(__dirname, '../../../../test-corpus/rendering/sd-1949-ghost-list-rendering.docx'), +]; +const DOC_PATH = DOC_PATH_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ?? DOC_PATH_CANDIDATES[0]; + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); + +test('@rendering SD-1949 ghost list rendering imports without visual regressions', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.screenshotPages('rendering/sd-1949-ghost-list-rendering'); +});