Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/layout-engine/contracts/src/engines/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <w:defaultTabStop w:val="0"/> 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' }],
Expand Down
51 changes: 29 additions & 22 deletions packages/layout-engine/contracts/src/engines/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
}
}
}

Expand Down
Loading