Skip to content
Draft
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
16 changes: 16 additions & 0 deletions packages/layout-engine/contracts/src/engines/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ describe('engines-tabs computeTabStops', () => {
expect(firstDefault?.val).toBe('start');
expect(firstDefault?.leader).toBe('none');
});

it('preserves explicit tab stops that fall before the left indent', () => {
const explicitPos = -360; // 0.25" before paragraph start
const stops = computeTabStops({
explicitStops: [
{ val: 'start', pos: explicitPos, leader: 'none' },
{ val: 'decimal', pos: 1440, leader: 'dot' },
],
defaultTabInterval: 720,
paragraphIndent: { left: 720 },
});

expect(stops.find((stop) => stop.pos === explicitPos)).toBeDefined();
// Default stops should still start at/after left indent
expect(stops.filter((stop) => stop.pos >= 720).length).toBeGreaterThan(0);
});
});

describe('engines-tabs layoutWithTabs', () => {
Expand Down
21 changes: 12 additions & 9 deletions packages/layout-engine/contracts/src/engines/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { ParagraphIndent } from './paragraph.js';
* Common conversions: 720 twips = 0.5", 1440 twips = 1"
*/
export interface TabStop {
val: 'start' | 'end' | 'center' | 'decimal' | 'bar' | 'clear';
val: 'start' | 'end' | 'center' | 'decimal' | 'bar' | 'num' | 'clear';
pos: number; // Twips from paragraph start (after left indent)
leader?: 'none' | 'dot' | 'hyphen' | 'heavy' | 'underscore' | 'middleDot';
}
Expand Down Expand Up @@ -115,35 +115,38 @@ export function computeTabStops(context: TabContext): TabStop[] {
// Extract cleared positions before filtering (OOXML: clear tabs suppress default stops)
const clearPositions = explicitStops.filter((stop) => stop.val === 'clear').map((stop) => stop.pos);

// Start with explicit stops (filter out 'clear' tabs - they remove default stops)
const stops = explicitStops.filter((stop) => stop.val !== 'clear');
// Separate explicit stops so we can preserve positions before the left indent.
const explicit = explicitStops.filter((stop) => stop.val !== 'clear');

// Find the rightmost explicit stop
const maxExplicit = stops.reduce((max, stop) => Math.max(max, stop.pos), 0);
const hasExplicit = stops.length > 0;
const maxExplicit = explicit.reduce((max, stop) => Math.max(max, stop.pos), 0);
const hasExplicit = explicit.length > 0;
const defaultStart = hasExplicit ? Math.max(maxExplicit, leftIndent ?? 0) : 0;

// Add default stops beyond the explicit range (if any)
let pos = hasExplicit ? defaultStart : 0;
const targetLimit = Math.max(defaultStart, leftIndent ?? 0) + 14400; // 14400 twips = 10 inches
const defaultStops: TabStop[] = [];
while (pos < targetLimit) {
pos += defaultTabInterval;

// Don't add if there's already an explicit stop OR a cleared position at this position
const hasExplicitStop = stops.some((s) => Math.abs(s.pos - pos) < 20);
const hasExplicitStop = explicit.some((s) => Math.abs(s.pos - pos) < 20);
const hasClearStop = clearPositions.some((clearPos) => Math.abs(clearPos - pos) < 20);

if (!hasExplicitStop && !hasClearStop) {
stops.push({
defaultStops.push({
val: 'start',
pos,
leader: 'none',
});
}
}

// Filter out stops before the left indent and sort
return stops.filter((stop) => stop.pos >= leftIndent).sort((a, b) => a.pos - b.pos);
// Default stops should not appear before the paragraph's left indent.
const filteredDefaults = defaultStops.filter((stop) => stop.pos >= (leftIndent ?? 0));

return [...explicit, ...filteredDefaults].sort((a, b) => a.pos - b.pos);
}

/**
Expand Down
86 changes: 86 additions & 0 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,40 @@ describe('measureBlock', () => {
expect(measure.lines[0].maxWidth).toBe(maxWidth - textStartPx);
});

it('respects hanging indent for manual numbering paragraphs', async () => {
const maxWidth = 400;
const block: FlowBlock = {
kind: 'paragraph',
id: 'manual-numbering',
runs: [
{
text: '(a)',
fontFamily: 'Times New Roman',
fontSize: 16,
},
{
kind: 'tab',
text: '\t',
pmStart: 3,
pmEnd: 4,
},
{
text: 'Specified Entity means in relation to Party A for the purpose of:',
fontFamily: 'Times New Roman',
fontSize: 16,
},
],
attrs: {
indent: { left: 48, hanging: 48 },
tabs: [{ pos: -48, align: 'left' }],
},
};

const measure = expectParagraphMeasure(await measureBlock(block, maxWidth));
// Negative first-line offsets extend into the hanging region, granting full width.
expect(measure.lines[0].maxWidth).toBe(maxWidth);
});

it('falls back to marker.textStartX when wordLayout.textStartPx is missing', async () => {
const maxWidth = 200;
const textStartX = 96; // First-line text start after marker + tab
Expand Down Expand Up @@ -1472,6 +1506,58 @@ describe('measureBlock', () => {
}
}
});

it('aligns first-line tabs relative to the paragraph start', async () => {
const firstLinePx = 96; // 1 inch
const tabPosPx = 144; // 1.5 inches from paragraph start
const block: FlowBlock = {
kind: 'paragraph',
id: 'first-line-tab',
runs: [
{
text: '(a)',
fontFamily: 'Arial',
fontSize: 16,
},
{
kind: 'tab',
text: '\t',
tabStops: [{ pos: tabPosPx, val: 'left' }],
tabIndex: 0,
pmStart: 3,
pmEnd: 4,
},
{
text: 'After tab text',
fontFamily: 'Arial',
fontSize: 16,
},
],
attrs: {
indent: {
firstLine: firstLinePx,
},
},
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));

const tabRun = block.runs[1];
expect(tabRun.kind).toBe('tab');
if (tabRun.kind === 'tab') {
expect(tabRun.width).toBeGreaterThan(0);
// Width should only cover the distance from the first-line indent to the tab stop.
expect(tabRun.width).toBeLessThan(tabPosPx - firstLinePx);
}

const firstLine = measure.lines[0];
expect(firstLine.segments).toBeDefined();
const segmentAfterTab = firstLine.segments?.find((seg) => seg.runIndex === 2 && typeof seg.x === 'number');
expect(segmentAfterTab).toBeDefined();
if (segmentAfterTab) {
expect(segmentAfterTab.x).toBeCloseTo(tabPosPx - firstLinePx, 5);
}
});
});

describe('space-only runs', () => {
Expand Down
77 changes: 56 additions & 21 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
const suppressFirstLine = (block.attrs as Record<string, unknown>)?.suppressFirstLineIndent === true;
const rawFirstLineOffset = suppressFirstLine ? 0 : firstLine - hanging;
// When wordLayout is present, the hanging region is occupied by the list marker/tab.
// Do not expand the first-line width; use the same content width as subsequent lines.
// Do not let hanging expand the available width; clamp negative offset to zero.
const clampedFirstLineOffset = Math.max(0, rawFirstLineOffset);
const firstLineOffset = isWordLayoutList ? 0 : clampedFirstLineOffset;
// For true Word lists we rely on marker/tabs to control alignment, so skip custom offsets.
const firstLineOffset = isWordLayoutList ? 0 : rawFirstLineOffset;
const contentWidth = Math.max(1, maxWidth - indentLeft - indentRight);
// Body lines use contentWidth (same as first line for most cases).
// The hanging indent affects WHERE body lines start (indentLeft), not their available width.
Expand Down Expand Up @@ -581,7 +579,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
// because it's consistent with marker.markerX positioning. Mismatched priority causes justify overflow.
const rawTextStartPx = (wordLayout as { textStartPx?: unknown } | undefined)?.textStartPx;
const markerTextStartX = (wordLayout as { marker?: { textStartX?: unknown } } | undefined)?.marker?.textStartX;
const textStartPx =
const explicitTextStartPx =
typeof markerTextStartX === 'number' && Number.isFinite(markerTextStartX)
? markerTextStartX
: typeof rawTextStartPx === 'number' && Number.isFinite(rawTextStartPx)
Expand All @@ -603,7 +601,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
return measureText(markerText, markerFont, ctx);
},
);
const effectiveTextStartPx = resolvedTextStartPx ?? textStartPx;
const effectiveTextStartPx = explicitTextStartPx ?? resolvedTextStartPx;

if (typeof effectiveTextStartPx === 'number' && effectiveTextStartPx > indentLeft) {
// textStartPx indicates where text actually starts on the first line (after marker + tab/space).
Expand Down Expand Up @@ -631,6 +629,17 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
}
};

/**
* Computes the active offset for tab calculations on the current line.
* Tabs in Word measure from the paragraph's left indent. When a positive
* first-line indent is present, the visible text is shifted by firstLineOffset.
* Paragraph layout handles this shift via CSS text-indent/padding, so tab math
* must subtract the offset to avoid double-counting.
*/
const getCurrentLineTabOffset = (): number => {
return lines.length === 0 ? firstLineOffset : 0;
};

// Drop cap handling: measure drop cap and calculate reserved space
const dropCapDescriptor = block.attrs?.dropCapDescriptor;
let dropCapMeasure: {
Expand Down Expand Up @@ -978,28 +987,41 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P

// Advance to next tab stop using the same logic as inline "\t" handling
const originX = currentLine.width;
const { target, nextIndex, stop } = getNextTabStopPx(currentLine.width, tabStops, tabStopCursor);
tabStopCursor = nextIndex;
const tabAdvance = Math.max(0, target - currentLine.width);
currentLine.width = roundValue(currentLine.width + tabAdvance);
const lineTabOffset = getCurrentLineTabOffset();
let absoluteTarget: number;
let stop: TabStopPx | undefined;
if (lineTabOffset < 0 && currentLine.width + lineTabOffset < 0 && tabStopCursor < tabStops.length) {
stop = tabStops[tabStopCursor];
absoluteTarget = stop?.pos ?? currentLine.width + lineTabOffset;
tabStopCursor += 1;
} else {
const result = getNextTabStopPx(currentLine.width + lineTabOffset, tabStops, tabStopCursor);
absoluteTarget = result.target;
tabStopCursor = result.nextIndex;
stop = result.stop;
}
const normalizedTarget = absoluteTarget - lineTabOffset;
const targetWidth = Math.max(0, normalizedTarget);
const tabAdvance = targetWidth - currentLine.width;
currentLine.width = roundValue(targetWidth);
// Persist measured tab width on the TabRun for downstream consumers/tests
(run as TabRun & { width?: number }).width = tabAdvance;
(run as TabRun & { width?: number }).width = Math.max(0, tabAdvance);

currentLine.maxFontSize = Math.max(currentLine.maxFontSize, 12);
currentLine.toRun = runIndex;
currentLine.toChar = 1; // tab is a single character
if (stop) {
validateTabStopVal(stop);
pendingTabAlignment = { target, val: stop.val };
pendingTabAlignment = { target: normalizedTarget, val: stop.val };
} else {
pendingTabAlignment = null;
}

// Emit leader decoration if requested
if (stop && stop.leader && stop.leader !== 'none') {
const leaderStyle: 'heavy' | 'dot' | 'hyphen' | 'underscore' | 'middleDot' = stop.leader;
const from = Math.min(originX, target);
const to = Math.max(originX, target);
const from = Math.min(originX, normalizedTarget);
const to = Math.max(originX, normalizedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
}
Expand Down Expand Up @@ -1786,10 +1808,23 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};
}
const originX = currentLine.width;
const { target, nextIndex, stop } = getNextTabStopPx(currentLine.width, tabStops, tabStopCursor);
tabStopCursor = nextIndex;
const tabAdvance = Math.max(0, target - currentLine.width);
currentLine.width = roundValue(currentLine.width + tabAdvance);
const lineTabOffset = getCurrentLineTabOffset();
let absoluteTarget: number;
let stop: TabStopPx | undefined;
if (lineTabOffset < 0 && currentLine.width + lineTabOffset < 0 && tabStopCursor < tabStops.length) {
stop = tabStops[tabStopCursor];
absoluteTarget = stop?.pos ?? currentLine.width + lineTabOffset;
tabStopCursor += 1;
} else {
const result = getNextTabStopPx(currentLine.width + lineTabOffset, tabStops, tabStopCursor);
absoluteTarget = result.target;
tabStopCursor = result.nextIndex;
stop = result.stop;
}
const normalizedTarget = absoluteTarget - lineTabOffset;
const targetWidth = Math.max(0, normalizedTarget);
const tabAdvance = targetWidth - currentLine.width;
currentLine.width = roundValue(targetWidth);

currentLine.maxFontInfo = updateMaxFontInfo(currentLine.maxFontSize, currentLine.maxFontInfo, run);
currentLine.maxFontSize = Math.max(currentLine.maxFontSize, run.fontSize);
Expand All @@ -1798,16 +1833,16 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
charPosInRun += 1;
if (stop) {
validateTabStopVal(stop);
pendingTabAlignment = { target, val: stop.val };
pendingTabAlignment = { target: normalizedTarget, val: stop.val };
} else {
pendingTabAlignment = 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 from = Math.min(originX, target);
const to = Math.max(originX, target);
const from = Math.min(originX, normalizedTarget);
const to = Math.max(originX, normalizedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
}
Expand Down
14 changes: 14 additions & 0 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,20 @@ describe('computeParagraphAttrs', () => {
expect(result?.tabs).toBeDefined();
expect(result?.tabs?.[0].leader).toBe('hyphen');
});

it('should preserve Word numbering tab alignment "num"', () => {
const para: PMNode = {
attrs: {
tabs: [{ val: 'num', pos: 1440 }],
},
};
const styleContext = createStyleContext();

const result = computeParagraphAttrs(para, styleContext);

expect(result?.tabs).toBeDefined();
expect(result?.tabs?.[0].val).toBe('num');
});
});

describe('framePr edge cases and validation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ const normalizeResolvedTabAlignment = (value: TabStop['val']): ResolvedTabStop['
case 'end':
case 'decimal':
case 'bar':
case 'num':
return value;
default:
return undefined;
Expand Down
10 changes: 10 additions & 0 deletions packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ describe('normalizeOoxmlTabs', () => {
{ val: 'end', pos: 2160 },
]);
});

it('should preserve Word numbering tab alignment "num"', () => {
const tabs = [{ val: 'num', pos: 96 }];
const result = normalizeOoxmlTabs(tabs);
expect(result).toEqual([{ val: 'num', pos: 1440 }]);
});
});

describe('property name fallbacks', () => {
Expand Down Expand Up @@ -221,6 +227,10 @@ describe('normalizeTabVal', () => {
it('should return "clear" for clear', () => {
expect(normalizeTabVal('clear')).toBe('clear');
});

it('should return "num" for num', () => {
expect(normalizeTabVal('num')).toBe('num');
});
});

describe('legacy mappings', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/pm-adapter/src/attributes/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export const normalizeTabVal = (value: unknown): TabStop['val'] | undefined => {
case 'end':
case 'decimal':
case 'bar':
case 'num':
case 'clear':
return value;
case 'left':
Expand Down