diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts index c402f1c1a..7f6c91b09 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.test.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts @@ -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', () => { diff --git a/packages/layout-engine/contracts/src/engines/tabs.ts b/packages/layout-engine/contracts/src/engines/tabs.ts index 6c2ad2a05..a5c574440 100644 --- a/packages/layout-engine/contracts/src/engines/tabs.ts +++ b/packages/layout-engine/contracts/src/engines/tabs.ts @@ -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'; } @@ -115,26 +115,27 @@ 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', @@ -142,8 +143,10 @@ export function computeTabStops(context: TabContext): TabStop[] { } } - // 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); } /** diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 05b57ba95..fa4b27b20 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -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 @@ -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', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 7f6540136..6f358fd8b 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -547,10 +547,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const suppressFirstLine = (block.attrs as Record)?.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. @@ -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) @@ -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). @@ -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: { @@ -978,19 +987,32 @@ 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; } @@ -998,8 +1020,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // 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 }); } @@ -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); @@ -1798,7 +1833,7 @@ 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; } @@ -1806,8 +1841,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // 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 }); } diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index 8cb8019f0..ac722cf26 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -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', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 22d0d35ee..64d321bc7 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -657,6 +657,7 @@ const normalizeResolvedTabAlignment = (value: TabStop['val']): ResolvedTabStop[' case 'end': case 'decimal': case 'bar': + case 'num': return value; default: return undefined; diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts b/packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts index 4788272c8..c407f2d35 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/tabs.test.ts @@ -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', () => { @@ -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', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts index 69852ba68..47e1d03c3 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts @@ -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':