Skip to content
Merged
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
42 changes: 40 additions & 2 deletions packages/layout-engine/contracts/src/engines/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,49 @@ 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();
});

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', () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/layout-engine/contracts/src/engines/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 48 additions & 5 deletions packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
LineSegment,
Run,
TextRun,
TabRun,
TabStop,
ParagraphIndent,
LeaderDecoration,
Expand Down Expand Up @@ -781,6 +782,33 @@ const applyTabLayoutToLines = (
indentLeft: number,
rawFirstLineOffset: number,
): void => {
const totalTabRuns = runs.reduce((count, run) => (run.kind === 'tab' ? count + 1 : count), 0);
Comment thread
caio-pizzol marked this conversation as resolved.
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;
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;
Expand All @@ -797,11 +825,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);
Expand Down Expand Up @@ -853,7 +893,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;
}

Expand Down Expand Up @@ -889,7 +931,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;
}

Expand Down
22 changes: 21 additions & 1 deletion packages/layout-engine/layout-bridge/test/remeasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,77 @@ 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',
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: rightStopTwips }],
},
};

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');
// 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(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',
Expand Down
Loading
Loading