From abc1cbaec1f8aa17e5391ea1ad9589b972161a7b Mon Sep 17 00:00:00 2001 From: Leonid Kuznetsov Date: Mon, 31 Aug 2026 16:21:23 +0300 Subject: [PATCH] fix(layout): guard the default tab-stop grid against a non-positive interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DOCX whose word/settings.xml carries froze the main thread forever: computeTabStops generates the automatic tab-stop grid with `pos += defaultTabInterval` toward a fixed limit, so an interval of 0 (or a negative one) never advances and the loop never terminates. onReady/onException never fire and the page pegs the CPU until the tab is killed. The grid is pre-computed for every measured paragraph, so any document with text content triggers it — no tab characters required. Word writes defaultTabStop 0 when a user sets the default tab interval to 0, opens such documents fine, and simply generates no automatic tab stops. Do the same: skip the default grid for a non-positive (or non-finite) interval, keeping explicit stops and the implicit indent-driven stops intact. Mirrors the existing guard in word-layout's computeTabStops. Fixes #3944 Co-Authored-By: Claude Fable 5 --- .../contracts/src/engines/tabs.test.ts | 23 +++++++++ .../contracts/src/engines/tabs.ts | 51 +++++++++++-------- 2 files changed, 52 insertions(+), 22 deletions(-) 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', + }); + } } }