diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts index 427d1abc36..66f54fecd1 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.test.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts @@ -161,11 +161,25 @@ 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(); + }); }); 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 b9c0134ecd..8b66329521 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, @@ -779,6 +780,29 @@ 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'); + 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; @@ -795,11 +819,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); @@ -851,7 +887,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; } @@ -887,7 +925,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 242df9b836..1c3156b057 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 c4d26ea236..01db77ade6 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1619,6 +1619,35 @@ describe('measureBlock', () => { } }); + it('aligns trailing tabs to explicit right stops with dot leaders (TOC regression)', async () => { + 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: 10593 }], + }, + }; + + 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'); + const trailingTab = block.runs[3]; + if (trailingTab.kind === 'tab') { + expect(trailingTab.width).toBeGreaterThan(0); + expect(trailingTab.width).toBeGreaterThan(50); + } + }); + 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 d3d16b7e65..91a6148277 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -981,6 +981,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) @@ -1229,7 +1232,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; @@ -1258,6 +1261,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,6 +1361,18 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P }; // Process each run + 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]; @@ -1417,13 +1484,29 @@ 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; + sequentialTabIndex += 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 21d94521aa..3ca33cca40 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,16 +33,26 @@ const encode = (params) => { const { nodes = [], nodeListHandler } = params || {}; const node = nodes[0]; - const processedContent = nodeListHandler.handler({ + let processedContent = 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); const processedNode = { type: 'tableOfContents', - attrs: { - instruction: node.attributes?.instruction || '', - rightAlignPageNumbers: deriveRightAlignPageNumbers(processedContent), - }, + attrs, content: processedContent, }; 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 f1d01a7001..8f27701ae3 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 @@ -90,6 +90,29 @@ describe('sd:tableOfContents translator', () => { const result = config.encode(params); expect(result.attrs.rightAlignPageNumbers).toBe(false); }); + + it('wraps inline children into a paragraph when parent accepts blocks', () => { + const mockNodeListHandler = { + handler: vi.fn(() => [{ type: 'text', text: 'Inline content' }]), + }; + const params = { + nodes: [ + { + name: 'sd:tableOfContents', + attributes: { instruction: 'TOC \\h' }, + elements: [{ name: 'w:r', elements: [] }], + }, + ], + nodeListHandler: mockNodeListHandler, + }; + + const result = config.encode(params); + expect(result).toEqual({ + type: 'tableOfContents', + attrs: { instruction: 'TOC \\h', rightAlignPageNumbers: true }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inline content' }] }], + }); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index bac9866d8a..4e879bdac0 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -274,6 +274,7 @@ export { TableCell, TableHeader, DocumentIndex, + TableOfContents, IndexEntry, TableOfContentsEntry, TocPageNumber,