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
18 changes: 16 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,25 @@ 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();
});
});

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
49 changes: 44 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 @@ -779,6 +780,29 @@ const applyTabLayoutToLines = (
indentLeft: number,
rawFirstLineOffset: number,
): void => {
const totalTabRuns = runs.reduce((count, run) => (run.kind === 'tab' ? count + 1 : count), 0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Count inline tab characters when mapping alignment stops

applyTabLayoutToLines() now maps the last tab ordinals to explicit end/center/decimal stops, but totalTabRuns only counts runs with kind === 'tab'. In paragraphs where a tab remains embedded in a text run (the TOC/PAGEREF case this commit targets), consumeTabOrdinal() still advances for "\t" characters, so later ordinals are treated as out-of-range and the explicit right-aligned stop is applied to the wrong tab (or not applied at all). This leaves remeasurement output inconsistent with the DOM measurer and keeps TOC page-number alignment incorrect for mixed tab-run/text-tab input.

Useful? React with 👍 / 👎.

const alignmentTabStopsPx = tabStops
.map((stop, index) => ({ stop, index }))
.filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal');
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 @@ -795,11 +819,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 @@ -851,7 +887,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 @@ -887,7 +925,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
29 changes: 29 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,35 @@ describe('measureBlock', () => {
}
});

it('aligns trailing tabs to explicit right stops with dot leaders (TOC regression)', async () => {
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: 10593 }],
},
};

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');
const trailingTab = block.runs[3];
if (trailingTab.kind === 'tab') {
expect(trailingTab.width).toBeGreaterThan(0);
expect(trailingTab.width).toBeGreaterThan(50);
}
});

it('handles multiple tabs in a row', async () => {
const block: FlowBlock = {
kind: 'paragraph',
Expand Down
91 changes: 87 additions & 4 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,9 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
block.attrs?.tabs as TabStop[],
block.attrs?.tabIntervalTwips as number | undefined,
);
const alignmentTabStopsPx = tabStops
.map((stop, index) => ({ stop, index }))
.filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal');
const decimalSeparator = sanitizeDecimalSeparator(block.attrs?.decimalSeparator);

// Extract bar tab stops for paragraph-level rendering (OOXML: bars on all lines)
Expand Down Expand Up @@ -1229,7 +1232,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};

// Expand runs to handle inline newlines as explicit break runs
const runsToProcess: Run[] = [];
let runsToProcess: Run[] = [];
for (const run of normalizedRuns as Run[]) {
if ((run as TextRun).text && typeof (run as TextRun).text === 'string' && (run as TextRun).text.includes('\n')) {
const textRun = run as TextRun;
Expand Down Expand Up @@ -1258,6 +1261,58 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
runsToProcess.push(run as Run);
}
}
if (runsToProcess.some((run) => isTextRun(run) && typeof run.text === 'string' && run.text.includes('\t'))) {
const expandedRuns: Run[] = [];
for (const run of runsToProcess) {
if (!isTextRun(run) || typeof run.text !== 'string' || !run.text.includes('\t')) {
expandedRuns.push(run);
continue;
}
const textRun = run as TextRun;
let buffer = '';
let cursor = textRun.pmStart ?? 0;
const text = textRun.text;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (char === '\t') {
if (buffer.length > 0) {
expandedRuns.push({
...textRun,
text: buffer,
pmStart: cursor - buffer.length,
pmEnd: cursor,
});
buffer = '';
}
const tabRun: TabRun = {
kind: 'tab',
text: '\t',
pmStart: cursor,
pmEnd: cursor + 1,
tabStops: block.attrs?.tabs as TabStop[] | undefined,
indent,
leader: (textRun as unknown as TabRun)?.leader ?? null,
sdt: textRun.sdt,
};
expandedRuns.push(tabRun);
cursor += 1;
continue;
}
buffer += char;
cursor += 1;
}
if (buffer.length > 0) {
expandedRuns.push({
...textRun,
text: buffer,
pmStart: cursor - buffer.length,
pmEnd: cursor,
});
}
}
runsToProcess = expandedRuns;
}
const totalTabRuns = runsToProcess.reduce((count, run) => (isTabRun(run) ? count + 1 : count), 0);

/**
* Trims trailing regular spaces from a line when it is finalized.
Expand Down Expand Up @@ -1306,6 +1361,18 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};

// Process each run
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;
for (let runIndex = 0; runIndex < runsToProcess.length; runIndex++) {
const run = runsToProcess[runIndex];

Expand Down Expand Up @@ -1417,13 +1484,29 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};
}

// Advance to next tab stop using the same logic as inline "\t" handling
// Advance to the appropriate tab stop (explicit alignment stops take precedence for trailing tabs)
const originX = currentLine.width;
// Use first-line effective indent (accounts for hanging) on first line, body indent otherwise
const effectiveIndent = lines.length === 0 ? indentLeft + rawFirstLineOffset : indentLeft;
const absCurrentX = currentLine.width + effectiveIndent;
const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
tabStopCursor = nextIndex;
let stop: TabStopPx | undefined;
let target: number;
const resolvedTabIndex =
typeof (run as TabRun).tabIndex === 'number' && Number.isFinite((run as TabRun).tabIndex)
? (run as TabRun).tabIndex!
: sequentialTabIndex;
sequentialTabIndex += 1;
const forcedAlignment = getAlignmentStopForOrdinal(resolvedTabIndex);
if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) {
stop = forcedAlignment.stop;
target = forcedAlignment.stop.pos;
tabStopCursor = forcedAlignment.index + 1;
} else {
const nextStop = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
target = nextStop.target;
tabStopCursor = nextStop.nextIndex;
stop = nextStop.stop;
}
const maxAbsWidth = currentLine.maxWidth + effectiveIndent;
const clampedTarget = Math.min(target, maxAbsWidth);
const tabAdvance = Math.max(0, clampedTarget - absCurrentX);
Expand Down
8 changes: 7 additions & 1 deletion packages/layout-engine/pm-adapter/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import twoColumnFixture from './fixtures/two-column-two-page.json';
import tabsDecimalFixture from './fixtures/tabs-decimal.json';
import tabsCenterEndFixture from './fixtures/tabs-center-end.json';
import paragraphPPrVariationsFixture from './fixtures/paragraph_pPr_variations.json';
import { twipsToPx } from './utilities.js';

const DEFAULT_CONVERTER_CONTEXT = {
docx: {},
Expand Down Expand Up @@ -292,7 +293,12 @@ describe('PM → FlowBlock → Measure integration', () => {
const decimalMeasure = expectParagraphMeasure(await measureBlock(blocks[0], 400));
const controlMeasure = expectParagraphMeasure(await measureBlock(controlBlocks[0], 400));

expect(decimalMeasure.lines[0].width).toBeLessThanOrEqual(controlMeasure.lines[0].width);
const rightAlignedStopTwips = blocks[0].attrs?.tabs?.find((stop) => stop.val === 'end')?.pos;
if (typeof rightAlignedStopTwips === 'number') {
expect(decimalMeasure.lines[0].width).toBeCloseTo(twipsToPx(rightAlignedStopTwips), 2);
}
// Decimal-aligned measurement should reserve at least as much width as the control case
expect(decimalMeasure.lines[0].width).toBeGreaterThanOrEqual(controlMeasure.lines[0].width);
});

it('derives default decimal separator from document language when not explicitly set', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,26 @@ const encode = (params) => {
const { nodes = [], nodeListHandler } = params || {};
const node = nodes[0];

const processedContent = nodeListHandler.handler({
let processedContent = nodeListHandler.handler({
...params,
nodes: node.elements || [],
});
const hasParagraphBlocks = (processedContent || []).some((child) => child?.type === 'paragraph');
if (!hasParagraphBlocks) {
processedContent = [
{
type: 'paragraph',
content: processedContent.filter((child) => Boolean(child && child.type)),
},
];
}
const attrs = {
instruction: node.attributes?.instruction || '',
};
attrs.rightAlignPageNumbers = deriveRightAlignPageNumbers(processedContent);
const processedNode = {
type: 'tableOfContents',
attrs: {
instruction: node.attributes?.instruction || '',
rightAlignPageNumbers: deriveRightAlignPageNumbers(processedContent),
},
attrs,
content: processedContent,
};

Expand Down
Loading
Loading