diff --git a/packages/layout-engine/layout-bridge/src/text-measurement.ts b/packages/layout-engine/layout-bridge/src/text-measurement.ts index d7561f4f5d..9618b1cb38 100644 --- a/packages/layout-engine/layout-bridge/src/text-measurement.ts +++ b/packages/layout-engine/layout-bridge/src/text-measurement.ts @@ -41,6 +41,22 @@ const isWordChar = (char: string): boolean => { return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || char === "'"; }; +const lineContainsManualTabWithoutSegments = (block: FlowBlock, line: Line): boolean => { + if (block.kind !== 'paragraph') return false; + if (line.segments?.some((seg) => seg.x !== undefined)) { + return false; + } + const fromRun = line.fromRun ?? 0; + const toRun = line.toRun ?? fromRun; + for (let runIndex = fromRun; runIndex <= toRun; runIndex += 1) { + const run = block.runs[runIndex]; + if (run && isTabRun(run)) { + return true; + } + } + return false; +}; + const capitalizeText = (text: string): string => { if (!text) return text; let result = ''; @@ -390,8 +406,17 @@ export function measureCharacterX( line.maxWidth ?? // Fallback: if no maxWidth, approximate available width as line width (no slack) line.width; + const manualTabWithoutSegments = lineContainsManualTabWithoutSegments(block, line); // Pass availableWidth to justify calculation to match painter's word-spacing - const justify = getJustifyAdjustment(block, line, availableWidth, alignmentOverride); + const justify = getJustifyAdjustment( + block, + line, + availableWidth, + alignmentOverride, + undefined, + undefined, + manualTabWithoutSegments, + ); const alignment = alignmentOverride ?? (block.kind === 'paragraph' ? block.attrs?.alignment : undefined); // For justify alignment, the line is stretched to fill available width (slack distributed across spaces) // For center/right alignment, the line keeps its natural width and is positioned within the available space @@ -722,7 +747,16 @@ export function findCharacterAtX( // Fallback: approximate with line width when no maxWidth is present line.width; // Pass availableWidth to justify calculation to match painter's word-spacing - const justify = getJustifyAdjustment(block, line, availableWidth, alignmentOverride); + const manualTabWithoutSegments = lineContainsManualTabWithoutSegments(block, line); + const justify = getJustifyAdjustment( + block, + line, + availableWidth, + alignmentOverride, + undefined, + undefined, + manualTabWithoutSegments, + ); const alignment = alignmentOverride ?? (block.kind === 'paragraph' ? block.attrs?.alignment : undefined); // For justify alignment, the line is stretched to fill available width (slack distributed across spaces) // For center/right alignment, the line keeps its natural width and is positioned within the available space diff --git a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts index a6131acb30..7124decaf9 100644 --- a/packages/layout-engine/layout-bridge/test/text-measurement.test.ts +++ b/packages/layout-engine/layout-bridge/test/text-measurement.test.ts @@ -570,6 +570,54 @@ describe('text measurement utility', () => { // Last line should NOT be justified (same width) expect(lastX).toBe(lastXNormal); }); + + it('skips justify spacing for manual tabs without explicit segments', () => { + const trailingText = 'Item body'; + const tabWidth = 48; + const block = createBlock([ + { text: '1 ', fontFamily: 'Arial', fontSize: 16 }, + { kind: 'tab', text: '\t', width: tabWidth }, + { text: trailingText, fontFamily: 'Arial', fontSize: 16 }, + ]); + (block as any).attrs = { alignment: 'justify' }; + const line = baseLine({ + fromRun: 0, + toRun: 2, + toChar: trailingText.length, + width: (2 + trailingText.length) * CHAR_WIDTH + tabWidth, + maxWidth: 300, + }); + + const targetCharOffset = 7; + const baseX = measureCharacterX(block, line, targetCharOffset, line.width); + const wideX = measureCharacterX(block, line, targetCharOffset, 300); + expect(wideX).toBe(baseX); + + const hitResult = findCharacterAtX(block, line, baseX, 0, 300); + expect(hitResult.charOffset).toBe(targetCharOffset); + }); + + it('still applies justify to lines without any tab runs', () => { + // Two runs so the first line is not derived as the last line of the paragraph + const block = createBlock([ + { text: 'hello world foo', fontFamily: 'Arial', fontSize: 16 }, + { text: 'bar baz qux', fontFamily: 'Arial', fontSize: 16 }, + ]); + (block as any).attrs = { alignment: 'justify' }; + const line = baseLine({ + fromRun: 0, + toRun: 0, + toChar: 15, + width: 15 * CHAR_WIDTH, + maxWidth: 300, + }); + + const targetCharOffset = 7; + const baseX = measureCharacterX(block, line, targetCharOffset, line.width); + const wideX = measureCharacterX(block, line, targetCharOffset, 300); + // No tabs — justify should apply, so wider available width produces different position + expect(wideX).not.toBe(baseX); + }); }); describe('center and right alignment', () => { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 2b83a3d8fa..40bd30b39e 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -441,6 +441,76 @@ describe('DomPainter', () => { expect(lines[1].style.wordSpacing).toBe(''); }); + it('skips justify for lines with manual tab runs but no explicit segment positions', () => { + const tabBlock: FlowBlock = { + kind: 'paragraph', + id: 'tab-justify-block', + runs: [ + { text: '1.', fontFamily: 'Arial', fontSize: 16 }, + { kind: 'tab', text: '\t', width: 48 }, + { text: 'a b c d', fontFamily: 'Arial', fontSize: 16 }, + ], + attrs: { alignment: 'justify' }, + }; + + const tabMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 2, + toChar: 7, + width: 60, + maxWidth: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + // No segments with x — this is the "manual tab without segments" case + }, + { + fromRun: 2, + fromChar: 7, + toRun: 2, + toChar: 7, + width: 0, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const tabLayout: Layout = { + pageSize: { w: 200, h: 200 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'tab-justify-block', + fromLine: 0, + toLine: 2, + x: 0, + y: 0, + width: 100, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [tabBlock], measures: [tabMeasure] }); + painter.paint(tabLayout, mount); + + const lines = Array.from(mount.querySelectorAll('.superdoc-line')) as HTMLElement[]; + expect(lines.length).toBeGreaterThanOrEqual(1); + // Manual tab without explicit segment positions should skip justify + expect(lines[0].style.wordSpacing).toBe(''); + }); + it('justifies last visible line when paragraph ends with lineBreak', () => { // When a paragraph ends with (lineBreak), the visible text before the break // should still be justified because the "last line" is the empty line after the break. diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c2a6bede71..1d01335d31 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5663,6 +5663,8 @@ export class DomPainter { // Check if any segments have explicit X positioning (from tab stops) const hasExplicitPositioning = line.segments?.some((seg) => seg.x !== undefined); + const lineContainsTabRun = runsForLine.some((run) => run.kind === 'tab'); + const manualTabWithoutSegments = lineContainsTabRun && !hasExplicitPositioning; const availableWidth = availableWidthOverride ?? line.maxWidth ?? line.width; const justifyShouldApply = shouldApplyJustify({ @@ -5671,7 +5673,7 @@ export class DomPainter { // Caller already folds last-line + trailing lineBreak behavior into skipJustify. isLastLineOfParagraph: false, paragraphEndsWithLineBreak: false, - skipJustifyOverride: skipJustify, + skipJustifyOverride: skipJustify || manualTabWithoutSegments, }); const countSpaces = (text: string): number => {