diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts index 05facab2f2..eb681e9cf4 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.test.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts @@ -76,6 +76,29 @@ describe('engines-tabs computeTabStops', () => { expect(firstDefault?.source).toBe('default'); }); + it('generates no default grid and terminates when defaultTabInterval is 0 (#3944)', () => { + // Word writes when a user sets the default + // tab interval to 0 and opens such documents fine, with no automatic tab + // stops. Before the guard this looped forever, freezing the main thread. + const stops = computeTabStops({ + explicitStops: [], + defaultTabInterval: 0, + paragraphIndent: { left: 0 }, + }); + + expect(stops).toEqual([]); + }); + + it('keeps explicit stops but adds no default grid for a negative defaultTabInterval (#3944)', () => { + const stops = computeTabStops({ + explicitStops: [{ val: 'start', pos: 720, leader: 'none' }], + defaultTabInterval: -10, + paragraphIndent: { left: 0 }, + }); + + expect(stops).toEqual([{ val: 'start', pos: 720, leader: 'none', source: 'explicit' }]); + }); + it('adds an implicit left-margin stop when hanging indent starts before the margin', () => { const stops = computeTabStops({ explicitStops: [{ val: 'start', pos: -1440, leader: 'none' }], diff --git a/packages/layout-engine/contracts/src/engines/tabs.ts b/packages/layout-engine/contracts/src/engines/tabs.ts index fec9a3f32c..1f35ebd39c 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.ts @@ -186,28 +186,35 @@ export function computeTabStops(context: TabContext): TabStop[] { // - 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, maxExplicit) + 14400; // 14400 twips = 10 inches - - while (pos < targetLimit) { - pos += defaultTabInterval; - - // Don't add if there's already a stop OR a cleared position at this position - const hasExistingStop = stops.some((s) => Math.abs(s.pos - pos) < TAB_POSITION_TOLERANCE_TWIPS); - const hasClearStop = clearPositions.some((clearPos) => Math.abs(clearPos - pos) < TAB_POSITION_TOLERANCE_TWIPS); - - // Default stops must be >= leftIndent (for body text alignment) - const isValidDefault = pos >= leftIndent; - - if (!hasExistingStop && !hasClearStop && isValidDefault) { - stops.push({ - val: 'start', - pos, - leader: 'none', - source: 'default', - }); + // w:defaultTabStop may be 0 or negative — Word writes 0 when a user sets the + // default tab interval to 0, opens such documents fine, and simply generates + // no automatic tab-stop grid. Without this guard the grid loop below never + // advances and freezes the main thread forever (issue #3944). + const hasUsableDefaultInterval = Number.isFinite(defaultTabInterval) && defaultTabInterval > 0; + if (hasUsableDefaultInterval) { + const seedDefaultsFromZero = !hasStartAlignedExplicit; + const defaultStart = seedDefaultsFromZero ? 0 : Math.max(maxExplicit, leftIndent); + let pos = defaultStart; + const targetLimit = Math.max(defaultStart, leftIndent, maxExplicit) + 14400; // 14400 twips = 10 inches + + while (pos < targetLimit) { + pos += defaultTabInterval; + + // Don't add if there's already a stop OR a cleared position at this position + const hasExistingStop = stops.some((s) => Math.abs(s.pos - pos) < TAB_POSITION_TOLERANCE_TWIPS); + const hasClearStop = clearPositions.some((clearPos) => Math.abs(clearPos - pos) < TAB_POSITION_TOLERANCE_TWIPS); + + // Default stops must be >= leftIndent (for body text alignment) + const isValidDefault = pos >= leftIndent; + + if (!hasExistingStop && !hasClearStop && isValidDefault) { + stops.push({ + val: 'start', + pos, + leader: 'none', + source: 'default', + }); + } } }