diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts index 427d1abc36..e9c8d0b223 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.test.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts @@ -161,11 +161,49 @@ describe('engines-tabs computeTabStops', () => { expect(stops.find((stop) => stop.pos === 340)).toBeDefined(); // Explicit stop at 709 should be preserved expect(stops.find((stop) => stop.pos === 709)).toBeDefined(); - // First default should be at 709 + 720 = 1429 + // First default should align with Word's 0.5" grid offset from leftIndent (709 + 720 = 1429). expect(stops.find((stop) => stop.pos === 1429)).toBeDefined(); - // No default at 720 (before leftIndent, and no explicit stop there) + // No duplicate default at 720 because explicit stop at 709 occupies that slot expect(stops.filter((stop) => stop.pos === 720).length).toBe(0); }); + + it('still generates default start tabs before explicit right tabs (TOC regression)', () => { + const stops = computeTabStops({ + explicitStops: [{ val: 'end', pos: 10593, leader: 'dot' }], // TOC1 style tab + defaultTabInterval: 720, + paragraphIndent: { left: 454, hanging: 454 }, // first line begins near 0" + }); + + const firstDefault = stops.find((stop) => stop.val === 'start' && stop.leader === 'none'); + expect(firstDefault).toBeDefined(); + expect(firstDefault?.pos).toBe(720); // Word default 0.5" tab stop + expect(firstDefault!.pos).toBeLessThan(10593); + expect(stops.find((stop) => stop.val === 'end' && stop.pos === 10593)).toBeDefined(); + }); + + it('preserves legacy defaults-after-rightmost behavior when a start stop is present', () => { + // Paragraphs with a start-aligned explicit stop (e.g. signature lines, invoice + // headers) must keep the pre-fix behavior: defaults begin after the rightmost + // explicit stop, not from zero. Regression guard for the hasStartAlignedExplicit + // branch added alongside the TOC fix. + const explicitStops = [ + { val: 'start' as const, pos: 500, leader: 'none' as const }, + { val: 'end' as const, pos: 5000, leader: 'dot' as const }, + ]; + const stops = computeTabStops({ + explicitStops, + defaultTabInterval: 720, + paragraphIndent: { left: 0 }, + }); + + const explicitPositions = new Set(explicitStops.map((s) => s.pos)); + // No *default* (non-explicit) stop should appear between 0 and the rightmost + // explicit stop (5000). Explicit stops themselves are allowed. + const generatedBelowEnd = stops.filter((stop) => stop.pos < 5000 && !explicitPositions.has(stop.pos)); + expect(generatedBelowEnd).toHaveLength(0); + // Defaults should resume at 5720 (5000 + 720 interval). + expect(stops.find((stop) => stop.val === 'start' && stop.pos === 5720)).toBeDefined(); + }); }); describe('engines-tabs layoutWithTabs', () => { diff --git a/packages/layout-engine/contracts/src/engines/tabs.ts b/packages/layout-engine/contracts/src/engines/tabs.ts index 9417ff5cb3..0a2ac0d884 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.ts @@ -129,18 +129,18 @@ export function computeTabStops(context: TabContext): TabStop[] { // Find the rightmost explicit stop (use original stops for this calculation) const maxExplicit = filteredExplicitStops.reduce((max, stop) => Math.max(max, stop.pos), 0); - const hasExplicit = filteredExplicitStops.length > 0; - // Collect all stops: start with filtered explicit stops const stops = [...filteredExplicitStops]; + const hasStartAlignedExplicit = filteredExplicitStops.some((stop) => stop.val === 'start'); // Generate default stops at regular intervals. - // When explicit stops exist, start after the rightmost explicit or leftIndent. - // When no explicit stops, generate from 0 to ensure we hit multiples that land at/near leftIndent. - // Then filter defaults by leftIndent (body text alignment). - const defaultStart = hasExplicit ? Math.max(maxExplicit, leftIndent) : 0; + // - When no explicit start tabs exist (e.g., TOC paragraphs with only right-aligned tabs), + // seed defaults from the origin so numbering/content still lands on the default grid. + // - Otherwise, preserve legacy behavior: defaults start after the rightmost explicit or left indent. + const seedDefaultsFromZero = !hasStartAlignedExplicit; + const defaultStart = seedDefaultsFromZero ? 0 : Math.max(maxExplicit, leftIndent); let pos = defaultStart; - const targetLimit = Math.max(defaultStart, leftIndent) + 14400; // 14400 twips = 10 inches + const targetLimit = Math.max(defaultStart, leftIndent, maxExplicit) + 14400; // 14400 twips = 10 inches while (pos < targetLimit) { pos += defaultTabInterval; diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index 5e7ae01e43..0462bbecd5 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -5,6 +5,7 @@ import type { LineSegment, Run, TextRun, + TabRun, TabStop, ParagraphIndent, LeaderDecoration, @@ -781,6 +782,33 @@ const applyTabLayoutToLines = ( indentLeft: number, rawFirstLineOffset: number, ): void => { + const totalTabRuns = runs.reduce((count, run) => (run.kind === 'tab' ? count + 1 : count), 0); + const alignmentTabStopsPx = tabStops + .map((stop, index) => ({ stop, index })) + .filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal'); + // Word-compat heuristic (not ECMA-376 17.3.3.32): the last N tab characters in a + // paragraph bind to the last N explicit end/center/decimal stops. Needed for TOC + // entries where a right-aligned dot-leader stop coexists with default grid stops. + // Mirrored in measuring/dom/src/index.ts. + const getAlignmentStopForOrdinal = (ordinal: number): { stop: TabStopPx; index: number } | null => { + if (alignmentTabStopsPx.length === 0 || totalTabRuns === 0 || !Number.isFinite(ordinal)) return null; + if (ordinal < 0 || ordinal >= totalTabRuns) return null; + const remainingTabs = totalTabRuns - ordinal - 1; + const targetIndex = alignmentTabStopsPx.length - 1 - remainingTabs; + if (targetIndex < 0 || targetIndex >= alignmentTabStopsPx.length) return null; + return alignmentTabStopsPx[targetIndex]; + }; + let sequentialTabIndex = 0; + const consumeTabOrdinal = (explicitIndex?: number): number => { + if (typeof explicitIndex === 'number' && Number.isFinite(explicitIndex)) { + sequentialTabIndex = Math.max(sequentialTabIndex, explicitIndex + 1); + return explicitIndex; + } + const ordinal = sequentialTabIndex; + sequentialTabIndex += 1; + return ordinal; + }; + lines.forEach((line, lineIndex) => { let cursorX = 0; let lineWidth = 0; @@ -797,11 +825,23 @@ const applyTabLayoutToLines = ( /** * Processes a tab character, calculating position and handling alignment. */ - const applyTab = (startRunIndex: number, startChar: number, run?: Run): void => { + const applyTab = (startRunIndex: number, startChar: number, run?: Run, tabOrdinal?: number): void => { const originX = cursorX; const absCurrentX = cursorX + effectiveIndent; - const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor); - tabStopCursor = nextIndex; + let stop: TabStopPx | undefined; + let target: number; + const forcedAlignment = + typeof tabOrdinal === 'number' && Number.isFinite(tabOrdinal) ? getAlignmentStopForOrdinal(tabOrdinal) : null; + if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) { + stop = forcedAlignment.stop; + target = forcedAlignment.stop.pos; + tabStopCursor = forcedAlignment.index + 1; + } else { + const next = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor); + stop = next.stop; + target = next.target; + tabStopCursor = next.nextIndex; + } const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target; const relativeTarget = clampedTarget - effectiveIndent; lineWidth = Math.max(lineWidth, relativeTarget); @@ -853,7 +893,9 @@ const applyTabLayoutToLines = ( const run = runs[runIndex]; if (!run) continue; if (run.kind === 'tab') { - applyTab(runIndex + 1, 0, run); + const tabRun = run as TabRun; + const ordinal = consumeTabOrdinal(tabRun.tabIndex); + applyTab(runIndex + 1, 0, run, ordinal); continue; } @@ -889,7 +931,8 @@ const applyTabLayoutToLines = ( lineWidth = Math.max(lineWidth, cursorX); segments.push(segment); } - applyTab(runIndex, i + 1); + const ordinal = consumeTabOrdinal(); + applyTab(runIndex, i + 1, undefined, ordinal); segmentStart = i + 1; } diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index f7e757e55a..66d5a2ee1a 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -445,6 +445,27 @@ describe('remeasureParagraph', () => { expect(measure.lines[0].segments?.length).toBeGreaterThan(0); }); + it('aligns trailing TOC-style tab to explicit right stop with leader', () => { + const rightStopPx = 300; + const block = createBlock( + [textRun('1.'), tabRun({ tabIndex: 0 }), textRun('Generalities'), tabRun({ tabIndex: 1 }), textRun('5')], + { + tabs: [{ pos: pxToTwips(rightStopPx), val: 'end', leader: 'dot' }], + indent: { left: 30, hanging: 30 }, + tabIntervalTwips: DEFAULT_TAB_INTERVAL_TWIPS, + }, + ); + + const measure = remeasureParagraph(block, 800); + expect(measure.lines).toHaveLength(1); + const leaders = measure.lines[0].leaders; + expect(leaders).toBeDefined(); + expect(leaders?.length).toBe(1); + const leader = leaders![0]; + expect(leader.style).toBe('dot'); + expect(leader.to).toBeCloseTo(rightStopPx - CHAR_WIDTH, 0); + }); + it('handles tab at various positions within text', () => { // Tab after some text should advance to next stop after current position const tabStop: TabStop = { pos: 720, val: 'start' }; // 48px @@ -481,7 +502,6 @@ describe('remeasureParagraph', () => { const tabStop: TabStop = { pos: 1440, val: 'start' }; const block = createBlock([textRun('A'), tabRun(), textRun('B')], { tabs: [tabStop] }); const measure = remeasureParagraph(block, 200); - expect(measure.lines).toHaveLength(1); // Tab should advance to 96px (1 inch) expect(measure.lines[0].width).toBeGreaterThan(96); diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index b46e43fe0a..a894b43594 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1619,6 +1619,77 @@ describe('measureBlock', () => { } }); + it('aligns trailing tabs to explicit right stops with dot leaders (TOC regression)', async () => { + const rightStopTwips = 10593; + const rightStopPx = rightStopTwips * (96 / 1440); // ~706px + const block: FlowBlock = { + kind: 'paragraph', + id: 'toc-paragraph', + runs: [ + { text: '1.', fontFamily: 'Arial', fontSize: 13.333 }, + { kind: 'tab', text: '\t', tabIndex: 0, pmStart: 2, pmEnd: 3 }, + { text: 'Generalities', fontFamily: 'Arial', fontSize: 13.333 }, + { kind: 'tab', text: '\t', tabIndex: 1, pmStart: 15, pmEnd: 16 }, + { text: '5', fontFamily: 'Arial', fontSize: 13.333 }, + ], + attrs: { + indent: { left: 30, right: 0, firstLine: 0, hanging: 30 }, + tabs: [{ val: 'end', leader: 'dot', pos: rightStopTwips }], + }, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 800)); + expect(measure.lines).toHaveLength(1); + const line = measure.lines[0]; + expect(line.leaders).toBeDefined(); + expect(line.leaders?.[0]?.style).toBe('dot'); + // Leader must end right before the page number — within ~20px of the right stop + // (page number "5" is a few px wide, not 100+ px wide). + expect(line.leaders?.[0]?.to).toBeLessThanOrEqual(rightStopPx); + expect(line.leaders?.[0]?.to).toBeGreaterThan(rightStopPx - 20); + // Leader must start AFTER the title text, not at "1." — proves the first tab + // fell on the default 0.5" grid, not on the end stop. + expect(line.leaders?.[0]?.from).toBeGreaterThan(100); + const trailingTab = block.runs[3]; + if (trailingTab.kind === 'tab') { + expect(trailingTab.width).toBeGreaterThan(50); + } + }); + + it('maps three trailing tabs to two explicit alignment stops (asymmetric case)', async () => { + const centerStopTwips = 5000; + const endStopTwips = 10000; + const block: FlowBlock = { + kind: 'paragraph', + id: 'asymmetric-tabs', + runs: [ + { text: 'A', fontFamily: 'Arial', fontSize: 13.333 }, + { kind: 'tab', text: '\t', tabIndex: 0, pmStart: 1, pmEnd: 2 }, + { text: 'B', fontFamily: 'Arial', fontSize: 13.333 }, + { kind: 'tab', text: '\t', tabIndex: 1, pmStart: 3, pmEnd: 4 }, + { text: 'C', fontFamily: 'Arial', fontSize: 13.333 }, + { kind: 'tab', text: '\t', tabIndex: 2, pmStart: 5, pmEnd: 6 }, + { text: 'D', fontFamily: 'Arial', fontSize: 13.333 }, + ], + attrs: { + indent: { left: 0, right: 0, firstLine: 0, hanging: 0 }, + tabs: [ + { val: 'center', pos: centerStopTwips }, + { val: 'end', pos: endStopTwips }, + ], + }, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 800)); + expect(measure.lines).toHaveLength(1); + // Three tabs, two alignment stops: last two tabs bind to center + end. + // The first tab must NOT bind to either alignment stop — it should fall on the + // default grid. The last tab ends near the end stop position. + const lineWidth = measure.lines[0].width; + const endStopPx = endStopTwips * (96 / 1440); + expect(lineWidth).toBeCloseTo(endStopPx, 0); + }); + it('handles multiple tabs in a row', async () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 83e050abd2..d756023eea 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -982,6 +982,9 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P block.attrs?.tabs as TabStop[], block.attrs?.tabIntervalTwips as number | undefined, ); + const alignmentTabStopsPx = tabStops + .map((stop, index) => ({ stop, index })) + .filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal'); const decimalSeparator = sanitizeDecimalSeparator(block.attrs?.decimalSeparator); // Extract bar tab stops for paragraph-level rendering (OOXML: bars on all lines) @@ -1230,7 +1233,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P }; // Expand runs to handle inline newlines as explicit break runs - const runsToProcess: Run[] = []; + let runsToProcess: Run[] = []; for (const run of normalizedRuns as Run[]) { if ((run as TextRun).text && typeof (run as TextRun).text === 'string' && (run as TextRun).text.includes('\n')) { const textRun = run as TextRun; @@ -1259,6 +1262,58 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P runsToProcess.push(run as Run); } } + if (runsToProcess.some((run) => isTextRun(run) && typeof run.text === 'string' && run.text.includes('\t'))) { + const expandedRuns: Run[] = []; + for (const run of runsToProcess) { + if (!isTextRun(run) || typeof run.text !== 'string' || !run.text.includes('\t')) { + expandedRuns.push(run); + continue; + } + const textRun = run as TextRun; + let buffer = ''; + let cursor = textRun.pmStart ?? 0; + const text = textRun.text; + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + if (char === '\t') { + if (buffer.length > 0) { + expandedRuns.push({ + ...textRun, + text: buffer, + pmStart: cursor - buffer.length, + pmEnd: cursor, + }); + buffer = ''; + } + const tabRun: TabRun = { + kind: 'tab', + text: '\t', + pmStart: cursor, + pmEnd: cursor + 1, + tabStops: block.attrs?.tabs as TabStop[] | undefined, + indent, + leader: (textRun as unknown as TabRun)?.leader ?? null, + sdt: textRun.sdt, + }; + expandedRuns.push(tabRun); + cursor += 1; + continue; + } + buffer += char; + cursor += 1; + } + if (buffer.length > 0) { + expandedRuns.push({ + ...textRun, + text: buffer, + pmStart: cursor - buffer.length, + pmEnd: cursor, + }); + } + } + runsToProcess = expandedRuns; + } + const totalTabRuns = runsToProcess.reduce((count, run) => (isTabRun(run) ? count + 1 : count), 0); /** * Trims trailing regular spaces from a line when it is finalized. @@ -1306,7 +1361,23 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P } }; - // Process each run + // Word-compat heuristic (not ECMA-376 17.3.3.32): the last N tab characters in a + // paragraph bind to the last N explicit end/center/decimal stops. Needed for TOC + // entries where a right-aligned dot-leader stop coexists with default grid stops — + // strict greedy next-stop resolution would land the trailing tab on a default stop + // instead of the leader stop. Mirrored in layout-bridge/src/remeasure.ts. + const getAlignmentStopForOrdinal = (ordinal: number): { stop: TabStopPx; index: number } | null => { + if (alignmentTabStopsPx.length === 0 || totalTabRuns === 0 || !Number.isFinite(ordinal)) { + return null; + } + if (ordinal < 0 || ordinal >= totalTabRuns) return null; + const remainingTabs = totalTabRuns - ordinal - 1; + const targetIndex = alignmentTabStopsPx.length - 1 - remainingTabs; + if (targetIndex < 0 || targetIndex >= alignmentTabStopsPx.length) return null; + return alignmentTabStopsPx[targetIndex]; + }; + + let sequentialTabIndex = 0; for (let runIndex = 0; runIndex < runsToProcess.length; runIndex++) { const run = runsToProcess[runIndex]; @@ -1419,13 +1490,32 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P }; } - // Advance to next tab stop using the same logic as inline "\t" handling + // Advance to the appropriate tab stop (explicit alignment stops take precedence for trailing tabs) const originX = currentLine.width; // Use first-line effective indent (accounts for hanging) on first line, body indent otherwise const effectiveIndent = lines.length === 0 ? indentLeft + rawFirstLineOffset : indentLeft; const absCurrentX = currentLine.width + effectiveIndent; - const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor); - tabStopCursor = nextIndex; + let stop: TabStopPx | undefined; + let target: number; + const resolvedTabIndex = + typeof (run as TabRun).tabIndex === 'number' && Number.isFinite((run as TabRun).tabIndex) + ? (run as TabRun).tabIndex! + : sequentialTabIndex; + // Keep the sequential counter in sync with explicit tabIndex values so mixed + // inputs (explicit + synthetic TabRuns) don't produce out-of-order ordinals. + // Mirrors consumeTabOrdinal() in layout-bridge/src/remeasure.ts. + sequentialTabIndex = Math.max(sequentialTabIndex, resolvedTabIndex + 1); + const forcedAlignment = getAlignmentStopForOrdinal(resolvedTabIndex); + if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) { + stop = forcedAlignment.stop; + target = forcedAlignment.stop.pos; + tabStopCursor = forcedAlignment.index + 1; + } else { + const nextStop = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor); + target = nextStop.target; + tabStopCursor = nextStop.nextIndex; + stop = nextStop.stop; + } const maxAbsWidth = currentLine.maxWidth + effectiveIndent; const clampedTarget = Math.min(target, maxAbsWidth); const tabAdvance = Math.max(0, clampedTarget - absCurrentX); diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts index e239d81850..065b52b128 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/layout-engine/pm-adapter/src/integration.test.ts @@ -19,6 +19,7 @@ import twoColumnFixture from './fixtures/two-column-two-page.json'; import tabsDecimalFixture from './fixtures/tabs-decimal.json'; import tabsCenterEndFixture from './fixtures/tabs-center-end.json'; import paragraphPPrVariationsFixture from './fixtures/paragraph_pPr_variations.json'; +import { twipsToPx } from './utilities.js'; const DEFAULT_CONVERTER_CONTEXT = { docx: {}, @@ -292,7 +293,12 @@ describe('PM → FlowBlock → Measure integration', () => { const decimalMeasure = expectParagraphMeasure(await measureBlock(blocks[0], 400)); const controlMeasure = expectParagraphMeasure(await measureBlock(controlBlocks[0], 400)); - expect(decimalMeasure.lines[0].width).toBeLessThanOrEqual(controlMeasure.lines[0].width); + const rightAlignedStopTwips = blocks[0].attrs?.tabs?.find((stop) => stop.val === 'end')?.pos; + if (typeof rightAlignedStopTwips === 'number') { + expect(decimalMeasure.lines[0].width).toBeCloseTo(twipsToPx(rightAlignedStopTwips), 2); + } + // Decimal-aligned measurement should reserve at least as much width as the control case + expect(decimalMeasure.lines[0].width).toBeGreaterThanOrEqual(controlMeasure.lines[0].width); }); it('derives default decimal separator from document language when not explicitly set', async () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js index 3ca33cca40..8141a51766 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js @@ -33,27 +33,30 @@ const encode = (params) => { const { nodes = [], nodeListHandler } = params || {}; const node = nodes[0]; - let processedContent = nodeListHandler.handler({ + const rawChildren = nodeListHandler.handler({ ...params, nodes: node.elements || [], }); - const hasParagraphBlocks = (processedContent || []).some((child) => child?.type === 'paragraph'); - if (!hasParagraphBlocks) { - processedContent = [ - { - type: 'paragraph', - content: processedContent.filter((child) => Boolean(child && child.type)), - }, - ]; - } - const attrs = { - instruction: node.attributes?.instruction || '', - }; - attrs.rightAlignPageNumbers = deriveRightAlignPageNumbers(processedContent); + // The tableOfContents schema requires paragraph* children. If the handler returned + // any non-paragraph children (e.g. stray inline runs from a malformed TOC field), + // wrap each inline child into its own paragraph so downstream consumers can rely + // on the invariant. This also covers the all-inline case. + const normalizedContent = (rawChildren || []).reduce((acc, child) => { + if (!child || !child.type) return acc; + if (child.type === 'paragraph') { + acc.push(child); + } else { + acc.push({ type: 'paragraph', content: [child] }); + } + return acc; + }, []); const processedNode = { type: 'tableOfContents', - attrs, - content: processedContent, + attrs: { + instruction: node.attributes?.instruction || '', + rightAlignPageNumbers: deriveRightAlignPageNumbers(normalizedContent), + }, + content: normalizedContent, }; return processedNode; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js index 272504b157..41eee2d401 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js @@ -114,29 +114,26 @@ describe('sd:tableOfContents translator', () => { }); }); - it('does not wrap when content already contains paragraph blocks', () => { + it('wraps mixed paragraph and inline children so every child is a paragraph', () => { const mockNodeListHandler = { handler: vi.fn(() => [ - { type: 'paragraph', content: [{ type: 'text', text: 'Para' }] }, - { type: 'text', text: 'trailing inline' }, + { type: 'paragraph', content: [{ type: 'text', text: 'Entry 1' }] }, + { type: 'text', text: 'stray inline' }, + { type: 'paragraph', content: [{ type: 'text', text: 'Entry 2' }] }, ]), }; const params = { - nodes: [ - { - name: 'sd:tableOfContents', - attributes: { instruction: 'TOC \\o "1-3"' }, - elements: [{ name: 'w:p', elements: [] }], - }, - ], + nodes: [{ name: 'sd:tableOfContents', attributes: { instruction: 'TOC' }, elements: [{ name: 'w:r' }] }], nodeListHandler: mockNodeListHandler, }; const result = config.encode(params); - expect(result.content).toEqual([ - { type: 'paragraph', content: [{ type: 'text', text: 'Para' }] }, - { type: 'text', text: 'trailing inline' }, - ]); + expect(result.content).toHaveLength(3); + expect(result.content.every((child) => child.type === 'paragraph')).toBe(true); + expect(result.content[1]).toEqual({ + type: 'paragraph', + content: [{ type: 'text', text: 'stray inline' }], + }); }); it('filters out null and typeless children when wrapping', () => { diff --git a/tests/behavior/tests/formatting/fixtures/sd-2447-toc-tab-alignment.docx b/tests/behavior/tests/formatting/fixtures/sd-2447-toc-tab-alignment.docx new file mode 100644 index 0000000000..c4c07dfe0b Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/sd-2447-toc-tab-alignment.docx differ diff --git a/tests/behavior/tests/formatting/sd-2447-toc-tab-alignment.spec.ts b/tests/behavior/tests/formatting/sd-2447-toc-tab-alignment.spec.ts new file mode 100644 index 0000000000..3ae6ab56f6 --- /dev/null +++ b/tests/behavior/tests/formatting/sd-2447-toc-tab-alignment.spec.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/sd-2447-toc-tab-alignment.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'TOC fixture missing'); + +// SD-2447: TOC paragraphs have a single right-aligned dot-leader tab stop. +// The bug was: on load, the first \t jumped straight to the leader, pushing the +// title to the right margin with no page-number alignment. The fix seeds the +// default 0.5" grid and binds the trailing tab to the right-aligned stop. +test('TOC entries render with aligned dot leaders and right-justified page numbers', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + + // TOC should render — not a blank editor + await expect(superdoc.page.locator('.superdoc-leader').first()).toBeVisible({ timeout: 15_000 }); + + // Collect leader geometry per TOC entry + const entries = await superdoc.page.evaluate(() => { + const leaders = Array.from(document.querySelectorAll('div.superdoc-leader')); + return leaders.map((leader) => { + const parent = leader.parentElement as HTMLElement; + const pageNum = Array.from(parent.querySelectorAll('a, span')).slice(-1)[0] as HTMLElement; + const leaderRect = leader.getBoundingClientRect(); + const pageNumRect = pageNum ? pageNum.getBoundingClientRect() : null; + const parentRect = parent.getBoundingClientRect(); + return { + text: (parent.textContent || '').slice(0, 80), + leaderFrom: leaderRect.x, + leaderTo: leaderRect.x + leaderRect.width, + pageNumRight: pageNumRect ? pageNumRect.x + pageNumRect.width : null, + parentRight: parentRect.x + parentRect.width, + }; + }); + }); + + expect(entries.length).toBeGreaterThan(0); + + // All leader endpoints should be consistent (within 5px) — the trailing tab is + // bound to the single right-aligned stop for every TOC entry. + const leaderEnds = entries.map((e) => e.leaderTo); + const minEnd = Math.min(...leaderEnds); + const maxEnd = Math.max(...leaderEnds); + expect(maxEnd - minEnd).toBeLessThan(5); + + // Page numbers should be right-aligned near the right margin — the page-number + // right edge should be within 30px of the parent's right edge for every entry. + for (const entry of entries) { + expect(entry.pageNumRight).not.toBeNull(); + expect(entry.parentRight - (entry.pageNumRight as number)).toBeLessThan(60); + } + + // Leader start position must vary with title length (short titles produce longer + // leaders). If every leader started at the same spot, the first tab would have + // been incorrectly bound to the end stop (the reported bug). + const leaderStarts = entries.map((e) => e.leaderFrom); + const startRange = Math.max(...leaderStarts) - Math.min(...leaderStarts); + expect(startRange).toBeGreaterThan(20); +});