From ac0874bd599a01a63464dea771c6ab4feab98b7a Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Thu, 2 Apr 2026 13:00:08 -0300 Subject: [PATCH 01/11] fix: toc not loading on paragraphs --- .../v1/core/super-converter/exporter.js | 1 + .../v3/handlers/helpers/is-inline-node.js | 1 + .../tableOfContents-translator.js | 55 ++++++++---- .../tableOfContents-translator.test.js | 84 +++++++++++++++++++ .../p/helpers/legacy-handle-paragraph-node.js | 1 + .../src/editors/v1/extensions/index.js | 6 +- .../table-of-contents/table-of-contents.js | 38 +++++++++ .../v1/extensions/types/node-attributes.ts | 7 ++ packages/super-editor/src/editors/v1/types.ts | 1 + 9 files changed, 177 insertions(+), 17 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index 257982a614..74522f4cf5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -209,6 +209,7 @@ export function exportSchemaToJson(params) { sequenceField: sdSequenceFieldTranslator, documentStatField: sdDocumentStatFieldTranslator, tableOfContents: sdTableOfContentsTranslator, + tableOfContentsInline: sdTableOfContentsTranslator, index: sdIndexTranslator, indexEntry: sdIndexEntryTranslator, mathBlock: translatePassthroughNode, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js index 32f754de9b..0175bdbd4f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -35,6 +35,7 @@ const INLINE_FALLBACK_TYPES = new Set([ 'sequenceField', 'indexEntry', 'tableOfContentsEntry', + 'tableOfContentsInline', ]); export function isInlineNode(node, schema) { 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..0cdaa041cf 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,30 @@ const encode = (params) => { const { nodes = [], nodeListHandler } = params || {}; const node = nodes[0]; - const processedContent = nodeListHandler.handler({ + let processedContent = nodeListHandler.handler({ ...params, nodes: node.elements || [], }); + const parentAcceptsBlocks = params?.extraParams?.parentAcceptsBlocks !== false; + const hasParagraphBlocks = (processedContent || []).some((child) => child?.type === 'paragraph'); + if (parentAcceptsBlocks && !hasParagraphBlocks) { + processedContent = [ + { + type: 'paragraph', + content: processedContent.filter((child) => Boolean(child && child.type)), + }, + ]; + } + const inlineField = !parentAcceptsBlocks; + const attrs = { + instruction: node.attributes?.instruction || '', + }; + if (!inlineField) { + attrs.rightAlignPageNumbers = deriveRightAlignPageNumbers(processedContent); + } const processedNode = { - type: 'tableOfContents', - attrs: { - instruction: node.attributes?.instruction || '', - rightAlignPageNumbers: deriveRightAlignPageNumbers(processedContent), - }, + type: inlineField ? 'tableOfContentsInline' : 'tableOfContents', + attrs, content: processedContent, }; @@ -56,8 +70,14 @@ const encode = (params) => { */ const decode = (params) => { const { node } = params; + const isInlineNode = node.type === 'tableOfContentsInline'; const tocContent = Array.isArray(node.content) ? node.content : []; - const contentNodes = tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); + const inlineContentNodes = tocContent.flatMap((n) => { + const exported = exportSchemaToJson({ ...params, node: n }); + if (!exported) return []; + return Array.isArray(exported) ? exported : [exported]; + }); + const blockContentNodes = isInlineNode ? [] : tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); // Inject the fldChar begin, instrText and fldChar separate into the first child (after any existing pPr) const tocBeginElements = [ @@ -77,9 +97,16 @@ const decode = (params) => { }, { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, ]; + const tocEndElements = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, + ]; + + if (isInlineNode) { + return [...tocBeginElements, ...inlineContentNodes, ...tocEndElements]; + } - if (contentNodes.length > 0) { - const firstParagraph = contentNodes[0]; + if (blockContentNodes.length > 0) { + const firstParagraph = blockContentNodes[0]; let insertIndex = 0; if (firstParagraph.elements) { const pPrIndex = firstParagraph.elements.findIndex((el) => el.name === 'w:pPr'); @@ -91,24 +118,20 @@ const decode = (params) => { firstParagraph.elements.splice(insertIndex, 0, ...tocBeginElements); } else { // If there are no paragraphs, create one with the TOC begin elements - contentNodes.push({ + blockContentNodes.push({ name: 'w:p', elements: tocBeginElements, }); } - // Inject the fldChar end into the last child - const tocEndElements = [ - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, - ]; - const lastParagraph = contentNodes[contentNodes.length - 1]; + const lastParagraph = blockContentNodes[blockContentNodes.length - 1]; if (lastParagraph.elements) { lastParagraph.elements.push(...tocEndElements); } else { lastParagraph.elements = [...tocEndElements]; } - return contentNodes; + return blockContentNodes; }; /** @type {import('@translator').NodeTranslatorConfig} */ 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..41714b44da 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,53 @@ describe('sd:tableOfContents translator', () => { const result = config.encode(params); expect(result.attrs.rightAlignPageNumbers).toBe(false); }); + + it('encodes inline tableOfContents when block parents are not allowed', () => { + 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, + extraParams: { parentAcceptsBlocks: false }, + }; + + const result = config.encode(params); + expect(result).toEqual({ + type: 'tableOfContentsInline', + attrs: { instruction: 'TOC \\h' }, + content: [{ type: 'text', text: 'Inline content' }], + }); + }); + + 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', () => { @@ -172,5 +219,42 @@ describe('sd:tableOfContents translator', () => { expect(result[0].name).toBe('w:p'); expect(result[0].elements).toEqual([...expectedBeginElements, ...expectedEndElements]); }); + + it('should decode inline TOC nodes into run content', () => { + const inlineParams = { + node: { + type: 'tableOfContentsInline', + attrs: { instruction: 'TOC \\h' }, + content: [{ type: 'text', text: 'Inline result' }], + }, + }; + vi.mocked(exportSchemaToJson).mockReturnValue({ + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ text: 'Inline result' }] }], + }); + + const result = config.decode(inlineParams); + + const inlineBegin = [ + { ...expectedBeginElements[0] }, + { + name: 'w:r', + elements: [ + { + name: 'w:instrText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ text: 'TOC \\h', type: 'text', name: '#text', elements: [] }], + }, + ], + }, + { ...expectedBeginElements[2] }, + ]; + + expect(result).toEqual([ + ...inlineBegin, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: 'Inline result' }] }] }, + ...expectedEndElements, + ]); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 796b1e3f75..0175c83800 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -149,6 +149,7 @@ export const handleParagraphNode = (params) => { paragraphProperties: resolvedParagraphProperties, inlineParagraphProperties, numberingDefinedInline: Boolean(inlineParagraphProperties.numberingProperties), + parentAcceptsBlocks: false, }, path: [...(params.path || []), node], }; diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index bac9866d8a..84c31507ec 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -45,7 +45,7 @@ import { ShapeContainer } from './shape-container/index.js'; import { ShapeTextbox } from './shape-textbox/index.js'; import { ContentBlock } from './content-block/index.js'; import { BlockNode } from './block-node/index.js'; -import { TableOfContents, TocPageNumber } from './table-of-contents/index.js'; +import { TableOfContents, TableOfContentsInline, TocPageNumber } from './table-of-contents/index.js'; import { DocumentIndex } from './document-index/index.js'; import { VectorShape } from './vector-shape/index.js'; import { ShapeGroup } from './shape-group/index.js'; @@ -108,6 +108,7 @@ const getRichTextExtensions = () => { Link, Paragraph, TableOfContents, + TableOfContentsInline, DocumentIndex, Strike, Text, @@ -165,6 +166,7 @@ const getStarterExtensions = () => { Strike, TabNode, TableOfContents, + TableOfContentsInline, TocPageNumber, DocumentIndex, Text, @@ -274,6 +276,8 @@ export { TableCell, TableHeader, DocumentIndex, + TableOfContents, + TableOfContentsInline, IndexEntry, TableOfContentsEntry, TocPageNumber, diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js index 05b74847c2..1067954a76 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js @@ -196,3 +196,41 @@ export const TableOfContents = Node.create({ }; }, }); + +export const TableOfContentsInline = Node.create({ + name: 'tableOfContentsInline', + + group: 'inline', + + inline: true, + + content: 'inline*', + + selectable: true, + + addOptions() { + return { + htmlAttributes: { + 'data-id': 'table-of-contents-inline', + 'aria-label': 'Inline Table of Contents', + }, + }; + }, + + parseDOM() { + return [{ tag: 'span[data-id="table-of-contents-inline"]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; + }, + + addAttributes() { + return { + instruction: { + default: null, + rendered: false, + }, + }; + }, +}); diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 541d254bec..51c114bb1c 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -1008,6 +1008,12 @@ export interface TableOfContentsAttrs extends BlockNodeAttributes { sdBlockId?: string | null; } +/** Inline table of contents node attributes */ +export interface TableOfContentsInlineAttrs extends InlineNodeAttributes { + /** Field instruction */ + instruction?: string | null; +} + // ============================================ // DOCUMENT INDEX // ============================================ @@ -1217,6 +1223,7 @@ declare module '../../core/types/NodeAttributesMap.js' { // Content blocks contentBlock: ContentBlockAttrs; tableOfContents: TableOfContentsAttrs; + tableOfContentsInline: TableOfContentsInlineAttrs; index: DocumentIndexAttrs; indexEntry: IndexEntryAttrs; diff --git a/packages/super-editor/src/editors/v1/types.ts b/packages/super-editor/src/editors/v1/types.ts index 00d304604f..6606dd2787 100644 --- a/packages/super-editor/src/editors/v1/types.ts +++ b/packages/super-editor/src/editors/v1/types.ts @@ -170,6 +170,7 @@ export type { ContentBlockSize, ContentBlockMarginOffset, TableOfContentsAttrs, + TableOfContentsInlineAttrs, StructuredContentBlockAttrs, DocumentPartObjectAttrs, } from './extensions/types/node-attributes.js'; From 2529a1bc6fd3fcdd3268e9167ac468c253b5fa19 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 7 Apr 2026 13:49:46 -0300 Subject: [PATCH 02/11] fix: tab stop computation and alignment for TOC styles --- .../contracts/src/engines/tabs.test.ts | 18 +++- .../contracts/src/engines/tabs.ts | 14 +-- .../layout-bridge/src/remeasure.ts | 49 +++++++++- .../layout-bridge/test/remeasure.test.ts | 22 ++++- .../measuring/dom/src/index.test.ts | 29 ++++++ .../layout-engine/measuring/dom/src/index.ts | 91 ++++++++++++++++++- 6 files changed, 204 insertions(+), 19 deletions(-) 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); From 64e40c9ccad48d59e234bc9ff5b6192d3f30ae8f Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 7 Apr 2026 14:32:43 -0300 Subject: [PATCH 03/11] fix: tests --- packages/layout-engine/pm-adapter/src/integration.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 () => { From 2e6aabd3543898396216f553c659ee73aaf73a81 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:41:08 -0300 Subject: [PATCH 04/11] fix: don't decode every child twice Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> --- .../sd/tableOfContents/tableOfContents-translator.js | 7 +++++++ 1 file changed, 7 insertions(+) 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 0cdaa041cf..fae8488486 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 @@ -77,6 +77,13 @@ const decode = (params) => { if (!exported) return []; return Array.isArray(exported) ? exported : [exported]; }); + const inlineContentNodes = isInlineNode + ? tocContent.flatMap((n) => { + const exported = exportSchemaToJson({ ...params, node: n }); + if (!exported) return []; + return Array.isArray(exported) ? exported : [exported]; + }) + : []; const blockContentNodes = isInlineNode ? [] : tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); // Inject the fldChar begin, instrText and fldChar separate into the first child (after any existing pPr) From e4d3419c262b524b2e0671685d5a5f3d957f385d Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Wed, 8 Apr 2026 14:48:30 -0300 Subject: [PATCH 05/11] fix: build --- .../sd/tableOfContents/tableOfContents-translator.js | 5 ----- 1 file changed, 5 deletions(-) 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 fae8488486..cf4ad4394f 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 @@ -72,11 +72,6 @@ const decode = (params) => { const { node } = params; const isInlineNode = node.type === 'tableOfContentsInline'; const tocContent = Array.isArray(node.content) ? node.content : []; - const inlineContentNodes = tocContent.flatMap((n) => { - const exported = exportSchemaToJson({ ...params, node: n }); - if (!exported) return []; - return Array.isArray(exported) ? exported : [exported]; - }); const inlineContentNodes = isInlineNode ? tocContent.flatMap((n) => { const exported = exportSchemaToJson({ ...params, node: n }); From 8e31a4f31b9d195331038cfae3e9b8c34ea19413 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Thu, 9 Apr 2026 12:43:15 -0300 Subject: [PATCH 06/11] refactor: removed unused code --- .../v1/core/super-converter/exporter.js | 1 - .../v3/handlers/helpers/is-inline-node.js | 1 - .../tableOfContents-translator.js | 24 ++------ .../tableOfContents-translator.test.js | 61 ------------------- .../src/editors/v1/extensions/index.js | 5 +- .../table-of-contents/table-of-contents.js | 38 ------------ .../v1/extensions/types/node-attributes.ts | 7 --- packages/super-editor/src/editors/v1/types.ts | 1 - 8 files changed, 5 insertions(+), 133 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index 74522f4cf5..257982a614 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -209,7 +209,6 @@ export function exportSchemaToJson(params) { sequenceField: sdSequenceFieldTranslator, documentStatField: sdDocumentStatFieldTranslator, tableOfContents: sdTableOfContentsTranslator, - tableOfContentsInline: sdTableOfContentsTranslator, index: sdIndexTranslator, indexEntry: sdIndexEntryTranslator, mathBlock: translatePassthroughNode, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js index 0175bdbd4f..32f754de9b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -35,7 +35,6 @@ const INLINE_FALLBACK_TYPES = new Set([ 'sequenceField', 'indexEntry', 'tableOfContentsEntry', - 'tableOfContentsInline', ]); export function isInlineNode(node, schema) { 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 cf4ad4394f..fc7acecd57 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 @@ -37,9 +37,8 @@ const encode = (params) => { ...params, nodes: node.elements || [], }); - const parentAcceptsBlocks = params?.extraParams?.parentAcceptsBlocks !== false; const hasParagraphBlocks = (processedContent || []).some((child) => child?.type === 'paragraph'); - if (parentAcceptsBlocks && !hasParagraphBlocks) { + if (!hasParagraphBlocks) { processedContent = [ { type: 'paragraph', @@ -47,15 +46,12 @@ const encode = (params) => { }, ]; } - const inlineField = !parentAcceptsBlocks; const attrs = { instruction: node.attributes?.instruction || '', }; - if (!inlineField) { - attrs.rightAlignPageNumbers = deriveRightAlignPageNumbers(processedContent); - } + attrs.rightAlignPageNumbers = deriveRightAlignPageNumbers(processedContent); const processedNode = { - type: inlineField ? 'tableOfContentsInline' : 'tableOfContents', + type: 'tableOfContents', attrs, content: processedContent, }; @@ -70,16 +66,8 @@ const encode = (params) => { */ const decode = (params) => { const { node } = params; - const isInlineNode = node.type === 'tableOfContentsInline'; const tocContent = Array.isArray(node.content) ? node.content : []; - const inlineContentNodes = isInlineNode - ? tocContent.flatMap((n) => { - const exported = exportSchemaToJson({ ...params, node: n }); - if (!exported) return []; - return Array.isArray(exported) ? exported : [exported]; - }) - : []; - const blockContentNodes = isInlineNode ? [] : tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); + const blockContentNodes = tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); // Inject the fldChar begin, instrText and fldChar separate into the first child (after any existing pPr) const tocBeginElements = [ @@ -103,10 +91,6 @@ const decode = (params) => { { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, ]; - if (isInlineNode) { - return [...tocBeginElements, ...inlineContentNodes, ...tocEndElements]; - } - if (blockContentNodes.length > 0) { const firstParagraph = blockContentNodes[0]; let insertIndex = 0; 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 41714b44da..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 @@ -91,30 +91,6 @@ describe('sd:tableOfContents translator', () => { expect(result.attrs.rightAlignPageNumbers).toBe(false); }); - it('encodes inline tableOfContents when block parents are not allowed', () => { - 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, - extraParams: { parentAcceptsBlocks: false }, - }; - - const result = config.encode(params); - expect(result).toEqual({ - type: 'tableOfContentsInline', - attrs: { instruction: 'TOC \\h' }, - content: [{ type: 'text', text: 'Inline content' }], - }); - }); - it('wraps inline children into a paragraph when parent accepts blocks', () => { const mockNodeListHandler = { handler: vi.fn(() => [{ type: 'text', text: 'Inline content' }]), @@ -219,42 +195,5 @@ describe('sd:tableOfContents translator', () => { expect(result[0].name).toBe('w:p'); expect(result[0].elements).toEqual([...expectedBeginElements, ...expectedEndElements]); }); - - it('should decode inline TOC nodes into run content', () => { - const inlineParams = { - node: { - type: 'tableOfContentsInline', - attrs: { instruction: 'TOC \\h' }, - content: [{ type: 'text', text: 'Inline result' }], - }, - }; - vi.mocked(exportSchemaToJson).mockReturnValue({ - name: 'w:r', - elements: [{ name: 'w:t', elements: [{ text: 'Inline result' }] }], - }); - - const result = config.decode(inlineParams); - - const inlineBegin = [ - { ...expectedBeginElements[0] }, - { - name: 'w:r', - elements: [ - { - name: 'w:instrText', - attributes: { 'xml:space': 'preserve' }, - elements: [{ text: 'TOC \\h', type: 'text', name: '#text', elements: [] }], - }, - ], - }, - { ...expectedBeginElements[2] }, - ]; - - expect(result).toEqual([ - ...inlineBegin, - { name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: 'Inline result' }] }] }, - ...expectedEndElements, - ]); - }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index 84c31507ec..4e879bdac0 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -45,7 +45,7 @@ import { ShapeContainer } from './shape-container/index.js'; import { ShapeTextbox } from './shape-textbox/index.js'; import { ContentBlock } from './content-block/index.js'; import { BlockNode } from './block-node/index.js'; -import { TableOfContents, TableOfContentsInline, TocPageNumber } from './table-of-contents/index.js'; +import { TableOfContents, TocPageNumber } from './table-of-contents/index.js'; import { DocumentIndex } from './document-index/index.js'; import { VectorShape } from './vector-shape/index.js'; import { ShapeGroup } from './shape-group/index.js'; @@ -108,7 +108,6 @@ const getRichTextExtensions = () => { Link, Paragraph, TableOfContents, - TableOfContentsInline, DocumentIndex, Strike, Text, @@ -166,7 +165,6 @@ const getStarterExtensions = () => { Strike, TabNode, TableOfContents, - TableOfContentsInline, TocPageNumber, DocumentIndex, Text, @@ -277,7 +275,6 @@ export { TableHeader, DocumentIndex, TableOfContents, - TableOfContentsInline, IndexEntry, TableOfContentsEntry, TocPageNumber, diff --git a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js index 1067954a76..05b74847c2 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js +++ b/packages/super-editor/src/editors/v1/extensions/table-of-contents/table-of-contents.js @@ -196,41 +196,3 @@ export const TableOfContents = Node.create({ }; }, }); - -export const TableOfContentsInline = Node.create({ - name: 'tableOfContentsInline', - - group: 'inline', - - inline: true, - - content: 'inline*', - - selectable: true, - - addOptions() { - return { - htmlAttributes: { - 'data-id': 'table-of-contents-inline', - 'aria-label': 'Inline Table of Contents', - }, - }; - }, - - parseDOM() { - return [{ tag: 'span[data-id="table-of-contents-inline"]' }]; - }, - - renderDOM({ htmlAttributes }) { - return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; - }, - - addAttributes() { - return { - instruction: { - default: null, - rendered: false, - }, - }; - }, -}); diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 51c114bb1c..541d254bec 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -1008,12 +1008,6 @@ export interface TableOfContentsAttrs extends BlockNodeAttributes { sdBlockId?: string | null; } -/** Inline table of contents node attributes */ -export interface TableOfContentsInlineAttrs extends InlineNodeAttributes { - /** Field instruction */ - instruction?: string | null; -} - // ============================================ // DOCUMENT INDEX // ============================================ @@ -1223,7 +1217,6 @@ declare module '../../core/types/NodeAttributesMap.js' { // Content blocks contentBlock: ContentBlockAttrs; tableOfContents: TableOfContentsAttrs; - tableOfContentsInline: TableOfContentsInlineAttrs; index: DocumentIndexAttrs; indexEntry: IndexEntryAttrs; diff --git a/packages/super-editor/src/editors/v1/types.ts b/packages/super-editor/src/editors/v1/types.ts index 6606dd2787..00d304604f 100644 --- a/packages/super-editor/src/editors/v1/types.ts +++ b/packages/super-editor/src/editors/v1/types.ts @@ -170,7 +170,6 @@ export type { ContentBlockSize, ContentBlockMarginOffset, TableOfContentsAttrs, - TableOfContentsInlineAttrs, StructuredContentBlockAttrs, DocumentPartObjectAttrs, } from './extensions/types/node-attributes.js'; From a8d623dac528649f853abef89f6587482ae157e9 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Thu, 9 Apr 2026 12:46:59 -0300 Subject: [PATCH 07/11] chore: small code tweaks --- .../tableOfContents-translator.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 fc7acecd57..41b877fab6 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 @@ -67,7 +67,7 @@ const encode = (params) => { const decode = (params) => { const { node } = params; const tocContent = Array.isArray(node.content) ? node.content : []; - const blockContentNodes = tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); + const contentNodes = tocContent.map((n) => exportSchemaToJson({ ...params, node: n })); // Inject the fldChar begin, instrText and fldChar separate into the first child (after any existing pPr) const tocBeginElements = [ @@ -87,12 +87,9 @@ const decode = (params) => { }, { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' }, elements: [] }] }, ]; - const tocEndElements = [ - { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, - ]; - if (blockContentNodes.length > 0) { - const firstParagraph = blockContentNodes[0]; + if (contentNodes.length > 0) { + const firstParagraph = contentNodes[0]; let insertIndex = 0; if (firstParagraph.elements) { const pPrIndex = firstParagraph.elements.findIndex((el) => el.name === 'w:pPr'); @@ -104,20 +101,23 @@ const decode = (params) => { firstParagraph.elements.splice(insertIndex, 0, ...tocBeginElements); } else { // If there are no paragraphs, create one with the TOC begin elements - blockContentNodes.push({ + contentNodes.push({ name: 'w:p', elements: tocBeginElements, }); } - const lastParagraph = blockContentNodes[blockContentNodes.length - 1]; + const tocEndElements = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, + ]; + const lastParagraph = contentNodes[contentNodes.length - 1]; if (lastParagraph.elements) { lastParagraph.elements.push(...tocEndElements); } else { lastParagraph.elements = [...tocEndElements]; } - return blockContentNodes; + return contentNodes; }; /** @type {import('@translator').NodeTranslatorConfig} */ From d935aa07d935112f866b49c50f29abdd9bc2f613 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Thu, 9 Apr 2026 12:47:30 -0300 Subject: [PATCH 08/11] chore: small code tweaks --- .../v3/handlers/sd/tableOfContents/tableOfContents-translator.js | 1 + 1 file changed, 1 insertion(+) 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 41b877fab6..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 @@ -107,6 +107,7 @@ const decode = (params) => { }); } + // Inject the fldChar end into the last child const tocEndElements = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' }, elements: [] }] }, ]; From 139b17c36e0c0aaea2a77e81d5a601e5800e4094 Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Thu, 9 Apr 2026 12:53:06 -0300 Subject: [PATCH 09/11] chore: removed unused code --- .../v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 0175c83800..796b1e3f75 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -149,7 +149,6 @@ export const handleParagraphNode = (params) => { paragraphProperties: resolvedParagraphProperties, inlineParagraphProperties, numberingDefinedInline: Boolean(inlineParagraphProperties.numberingProperties), - parentAcceptsBlocks: false, }, path: [...(params.path || []), node], }; From 0aa81e28c57eda1e1a80d0256280532cbc7d8eb3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:47:58 -0300 Subject: [PATCH 10/11] fix(layout-engine): tab-alignment parity, TOC schema invariant, regression tests Follow-ups from PR review: - measuring/dom: sync sequentialTabIndex with explicit tabIndex values (mirror remeasure.ts). - Document getAlignmentStopForOrdinal as a Word-compat heuristic, not ECMA 17.3.3.32 behavior. - tableOfContents encoder: normalize every non-paragraph child into its own paragraph so the schema invariant (paragraph*) holds for mixed inline + paragraph input, not just the all-inline case. - Add regression tests: 3-tabs + 2-alignment-stops asymmetric case, mixed start+end stops preserving legacy defaults-after-rightmost behavior, tightened TOC leader assertion. --- .../contracts/src/engines/tabs.test.ts | 24 ++++++++++ .../layout-bridge/src/remeasure.ts | 4 ++ .../measuring/dom/src/index.test.ts | 46 ++++++++++++++++++- .../layout-engine/measuring/dom/src/index.ts | 11 ++++- .../tableOfContents-translator.js | 35 +++++++------- .../tableOfContents-translator.test.js | 22 +++++++++ 6 files changed, 122 insertions(+), 20 deletions(-) diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts index 66f54fecd1..e9c8d0b223 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.test.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts @@ -180,6 +180,30 @@ describe('engines-tabs computeTabStops', () => { 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/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index 8b66329521..ee6ed991b0 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -784,6 +784,10 @@ const applyTabLayoutToLines = ( 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; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 01db77ade6..7bea4e9b75 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -1620,6 +1620,8 @@ 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', @@ -1632,7 +1634,7 @@ describe('measureBlock', () => { ], attrs: { indent: { left: 30, right: 0, firstLine: 0, hanging: 30 }, - tabs: [{ val: 'end', leader: 'dot', pos: 10593 }], + tabs: [{ val: 'end', leader: 'dot', pos: rightStopTwips }], }, }; @@ -1641,13 +1643,53 @@ describe('measureBlock', () => { 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(0); 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 91a6148277..811db19157 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -1360,7 +1360,11 @@ 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; @@ -1495,7 +1499,10 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P typeof (run as TabRun).tabIndex === 'number' && Number.isFinite((run as TabRun).tabIndex) ? (run as TabRun).tabIndex! : sequentialTabIndex; - sequentialTabIndex += 1; + // 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; 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 8f27701ae3..070fabd9f5 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 @@ -113,6 +113,28 @@ describe('sd:tableOfContents translator', () => { content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inline content' }] }], }); }); + + it('wraps mixed paragraph and inline children so every child is a paragraph', () => { + const mockNodeListHandler = { + handler: vi.fn(() => [ + { 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' }, elements: [{ name: 'w:r' }] }], + nodeListHandler: mockNodeListHandler, + }; + + const result = config.encode(params); + 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' }], + }); + }); }); describe('decode', () => { From 1d776c60fb9508a0c9a610d1c0f1a33f2db25ac5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:56:55 -0300 Subject: [PATCH 11/11] test(behavior): add TOC tab-alignment regression for SD-2447 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright spec that loads a TOC1 fixture and asserts leader endpoints align, page numbers right-justify, and leader start positions vary with title length (proving the first tab falls on the default grid, not the right stop). Fixture is a Word-native 14KB DOCX with a right-aligned dot-leader tab stop on TOC1 style — the same paragraph shape as the reported Keyper document. --- .../fixtures/sd-2447-toc-tab-alignment.docx | Bin 0 -> 14694 bytes .../sd-2447-toc-tab-alignment.spec.ts | 62 ++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/behavior/tests/formatting/fixtures/sd-2447-toc-tab-alignment.docx create mode 100644 tests/behavior/tests/formatting/sd-2447-toc-tab-alignment.spec.ts 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 0000000000000000000000000000000000000000..c4c07dfe0b5612624e1f4265b174dd223b56ffc7 GIT binary patch literal 14694 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 literal 0 HcmV?d00001 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); +});