Skip to content
38 changes: 36 additions & 2 deletions packages/layout-engine/layout-bridge/src/text-measurement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/layout-engine/layout-bridge/test/text-measurement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
70 changes: 70 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <w:br/> (lineBreak), the visible text before the break
// should still be justified because the "last line" is the empty line after the break.
Expand Down
4 changes: 3 additions & 1 deletion packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 => {
Expand Down
Loading