Skip to content
15 changes: 12 additions & 3 deletions packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
TextRun,
TabStop,
ParagraphIndent,
LeaderDecoration,
} from '@superdoc/contracts';
import { Engines } from '@superdoc/contracts';
import type { WordParagraphLayoutOutput } from '@superdoc/word-layout';
Expand Down Expand Up @@ -798,12 +799,14 @@ const applyTabLayoutToLines = (
const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target;
const relativeTarget = clampedTarget - effectiveIndent;
lineWidth = Math.max(lineWidth, relativeTarget);
let currentLeader: LeaderDecoration | null = null;

// Add leader if specified
if (stop?.leader && stop.leader !== 'none') {
const from = Math.min(originX, relativeTarget);
const to = Math.max(originX, relativeTarget);
leaders.push({ from, to, style: stop.leader });
const from = Math.min(originX + effectiveIndent, clampedTarget);
const to = Math.max(originX + effectiveIndent, clampedTarget);
currentLeader = { from, to, style: stop.leader };
leaders.push(currentLeader);
}

// Handle alignment types
Expand All @@ -820,6 +823,12 @@ const applyTabLayoutToLines = (
const beforeDecimal = groupMeasure.beforeDecimalWidth ?? groupMeasure.totalWidth;
groupStartX = Math.max(0, relativeTarget - beforeDecimal);
}

// Update current leader "to" ensuring leaders end where right-aligned content begins
if (currentLeader) {
currentLeader.to = groupStartX + effectiveIndent;
}

pendingTabAlignStartX = groupStartX;
} else {
cursorX = Math.max(cursorX, relativeTarget);
Expand Down
30 changes: 30 additions & 0 deletions packages/layout-engine/layout-bridge/test/remeasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,36 @@ describe('remeasureParagraph', () => {
expect(measure.lines[0].leaders).toBeDefined();
expect(measure.lines[0].leaders?.length).toBeGreaterThan(0);
expect(measure.lines[0].leaders?.[0].style).toBe('dot');

const leader = measure.lines[0].leaders?.[0];

if (leader) {
expect(leader.from).toBeGreaterThanOrEqual(0);
expect(leader.to).toBeGreaterThan(leader.from);
}
});

it.each([
{ label: 'without indent', indentLeft: 0 },
{ label: 'with indent', indentLeft: 36 },
])('leader from/to use absolute coordinates for right-aligned tab $label', ({ indentLeft }) => {
const tabStop: TabStop = { pos: pxToTwips(300), val: 'end', leader: 'dot' };
const block = createBlock([textRun('Chapter 1'), tabRun(), textRun('42')], {
tabs: [tabStop],
...(indentLeft > 0 && { indent: { left: indentLeft } }),
});
const measure = remeasureParagraph(block, 1000);

expect(measure.lines).toHaveLength(1);
const leaders = measure.lines[0].leaders;
expect(leaders).toHaveLength(1);
const leader = leaders![0];

const textWidth = 'Chapter 1'.length * CHAR_WIDTH;
const pageNumWidth = '42'.length * CHAR_WIDTH;

expect(leader.from).toBeCloseTo(textWidth + indentLeft, 0);
expect(leader.to).toBeCloseTo(300 - pageNumWidth, 0);
});

it('handles tab with hyphen leader', () => {
Expand Down
53 changes: 53 additions & 0 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,59 @@ describe('measureBlock', () => {
}
});

it.each([
{ label: 'TabRun without indent', indentLeft: 0, useInlineTab: false },
{ label: 'TabRun with indent', indentLeft: 36, useInlineTab: false },
{ label: 'inline tab without indent', indentLeft: 0, useInlineTab: true },
{ label: 'inline tab with indent', indentLeft: 36, useInlineTab: true },
])('positions leader from/to correctly for right-aligned tab $label', async ({ indentLeft, useInlineTab }) => {
const textBlock: FlowBlock = {
kind: 'paragraph',
id: '0-paragraph',
runs: [{ text: 'Chapter 1', fontFamily: 'Arial', fontSize: 16 }],
attrs: {},
};
const pageNumBlock: FlowBlock = {
kind: 'paragraph',
id: '1-paragraph',
runs: [{ text: '42', fontFamily: 'Arial', fontSize: 16 }],
attrs: {},
};

const runs = useInlineTab
? [{ text: 'Chapter 1\t42', fontFamily: 'Arial', fontSize: 16 }]
: [
{ text: 'Chapter 1', fontFamily: 'Arial', fontSize: 16 },
{ kind: 'tab', leader: 'dot', text: '\t', pmStart: 9, pmEnd: 10 },
{ text: '42', fontFamily: 'Arial', fontSize: 16 },
];

const block: FlowBlock = {
kind: 'paragraph',
id: '2-paragraph',
runs,
attrs: {
tabs: [{ pos: 4500, val: 'end', leader: 'dot' }],
...(indentLeft > 0 && { indent: { left: indentLeft } }),
},
};

const textMeasure = expectParagraphMeasure(await measureBlock(textBlock, 1000));
const textWidth = textMeasure.lines[0].width;
const pageNumMeasure = expectParagraphMeasure(await measureBlock(pageNumBlock, 1000));
const pageNumWidth = pageNumMeasure.lines[0].width;

const measure = expectParagraphMeasure(await measureBlock(block, 1000));
expect(measure.lines).toHaveLength(1);

const leaders = measure.lines[0].leaders;
Comment thread
caio-pizzol marked this conversation as resolved.
expect(leaders).toHaveLength(1);

const leader = leaders![0];
expect(leader.from).toBeCloseTo(textWidth + indentLeft, 0);
expect(leader.to).toBeCloseTo(300 - pageNumWidth, 0);
});

it('preserves trailing spaces after tabs when line breaks', async () => {
const block: FlowBlock = {
kind: 'paragraph',
Expand Down
50 changes: 42 additions & 8 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
type TableBorders,
type TableBorderValue,
effectiveTableCellSpacing,
LeaderDecoration,
} from '@superdoc/contracts';
import type { WordParagraphLayoutOutput } from '@superdoc/word-layout';
import {
Expand Down Expand Up @@ -1093,6 +1094,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
let hasSeenTextRun = false;
let tabStopCursor = 0;
let pendingTabAlignment: { target: number; val: TabStop['val'] } | null = null;
let pendingLeader: LeaderDecoration | null = null;
let pendingRunSpacing = 0;
// Remember the last applied tab alignment so we can clamp end-aligned
// segments to the exact target after measuring to avoid 1px drift.
Expand Down Expand Up @@ -1168,10 +1170,19 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
startX = Math.max(0, target);
}

// Update pending leader to end where aligned content begins
if (pendingLeader) {
const effectiveIndent = lines.length === 0 ? indentLeft + rawFirstLineOffset : indentLeft;
pendingLeader.to = startX + effectiveIndent;
pendingLeader = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line sets pendingLeader to null, but it gets set to null again 7 lines down anyway. safe to remove.

}

currentLine.width = roundValue(startX);
// Track alignment used for post-segment clamping
lastAppliedTabAlign = { target, val };
pendingTabAlignment = null;
pendingLeader = null;

return startX;
};

Expand Down Expand Up @@ -1321,6 +1332,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
}
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;
pendingRunSpacing = 0;
continue;
Expand Down Expand Up @@ -1377,6 +1389,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;
pendingRunSpacing = 0;
continue;
Expand All @@ -1386,6 +1399,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
if (isTabRun(run)) {
// Clear any previous tab group when we encounter a new tab
activeTabGroup = null;
pendingLeader = null;

// Initialize line if needed
if (!currentLine) {
Expand Down Expand Up @@ -1419,15 +1433,16 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
currentLine.maxFontSize = Math.max(currentLine.maxFontSize, 12);
currentLine.toRun = runIndex;
currentLine.toChar = 1; // tab is a single character
let currentLeader: LeaderDecoration | null = null;

// Emit leader decoration if requested
if (stop && stop.leader && stop.leader !== 'none') {
const leaderStyle: 'heavy' | 'dot' | 'hyphen' | 'underscore' | 'middleDot' = stop.leader;
const relativeTarget = clampedTarget - effectiveIndent;
const from = Math.min(originX, relativeTarget);
const to = Math.max(originX, relativeTarget);
const from = Math.min(originX + effectiveIndent, clampedTarget);
const to = Math.max(originX + effectiveIndent, clampedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
currentLeader = { from, to, style: leaderStyle };
currentLine.leaders.push(currentLeader);
}

if (stop) {
Expand Down Expand Up @@ -1455,6 +1470,11 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
groupStartX = Math.max(0, relativeTarget - beforeDecimal);
}

// Update current leader "to" ensuring leaders end where right-aligned content begins
if (currentLeader) {
currentLeader.to = groupStartX + effectiveIndent;
}

// Set up active tab group for subsequent run processing
activeTabGroup = {
measure: groupMeasure,
Expand All @@ -1471,12 +1491,14 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P

// Don't set pendingTabAlignment - we're using activeTabGroup instead
pendingTabAlignment = null;
pendingLeader = null;
} else {
// For start-aligned tabs, use the existing pendingTabAlignment mechanism
pendingTabAlignment = { target: clampedTarget - effectiveIndent, val: stop.val };
}
} else {
pendingTabAlignment = null;
pendingLeader = null;
}
pendingRunSpacing = 0;
continue;
Expand Down Expand Up @@ -1553,6 +1575,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;
activeTabGroup = null;

Expand Down Expand Up @@ -1704,6 +1727,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;

// Start new line with the annotation (body line, so use bodyContentWidth for hanging indent)
Expand Down Expand Up @@ -1808,6 +1832,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;

// Body line, so use bodyContentWidth for hanging indent
Expand Down Expand Up @@ -1922,6 +1947,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
lastAppliedTabAlign = null;
activeTabGroup = null;

Expand Down Expand Up @@ -2005,6 +2031,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
currentLine = null;
}

Expand Down Expand Up @@ -2072,6 +2099,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
currentLine = null;
}
} else if (isLastChunk) {
Expand Down Expand Up @@ -2218,6 +2246,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;

// Body line, so use bodyContentWidth for hanging indent
currentLine = {
Expand Down Expand Up @@ -2280,6 +2309,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
lines.push(completedLine);
tabStopCursor = 0;
pendingTabAlignment = null;
pendingLeader = null;
currentLine = null;
// advance past space
charPosInRun = wordEndNoSpace + 1;
Expand Down Expand Up @@ -2341,6 +2371,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P

if (!isLastSegment) {
pendingTabAlignment = null;
pendingLeader = null;

if (!currentLine) {
currentLine = {
fromRun: runIndex,
Expand Down Expand Up @@ -2376,16 +2408,18 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
pendingTabAlignment = { target: clampedTarget - effectiveIndent, val: stop.val };
} else {
pendingTabAlignment = null;
pendingLeader = null;
}

// Emit leader decoration if requested
if (stop && stop.leader && stop.leader !== 'none' && stop.leader !== 'middleDot') {
const leaderStyle: 'heavy' | 'dot' | 'hyphen' | 'underscore' = stop.leader;
const relativeTarget = clampedTarget - effectiveIndent;
const from = Math.min(originX, relativeTarget);
const to = Math.max(originX, relativeTarget);
const from = Math.min(originX + effectiveIndent, clampedTarget);
const to = Math.max(originX + effectiveIndent, clampedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
const leader: LeaderDecoration = { from, to, style: leaderStyle };
currentLine.leaders.push(leader);
pendingLeader = leader;
}
}
}
Expand Down
Loading