Skip to content

Commit b3c7d00

Browse files
authored
fix: render manual line breaks correctly (#2807)
* fix: render manual line breaks correctly * fix: improve line break preserving strategy * fix: address comments and add tests
1 parent 3e8ad7f commit b3c7d00

4 files changed

Lines changed: 172 additions & 9 deletions

File tree

packages/layout-engine/layout-bridge/src/remeasure.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -994,12 +994,12 @@ const applyTabLayoutToLines = (
994994
* @returns Line height in pixels (fontSize * 1.2 of the largest font in the range).
995995
* For example: 16px font returns 19.2px line height, 24px font returns 28.8px.
996996
*/
997-
function lineHeightForRuns(runs: Run[], fromRun: number, toRun: number): number {
997+
function lineHeightForRuns(runs: Run[], fromRun: number, toRun: number, fallbackFontSize: number = 16): number {
998998
let maxSize = 0;
999999
for (let i = fromRun; i <= toRun; i += 1) {
10001000
const run = runs[i];
10011001
const textRun = run && isTextRun(run) ? run : null;
1002-
const size = textRun?.fontSize ?? 16;
1002+
const size = textRun?.fontSize ?? 0;
10031003
if (size > maxSize) maxSize = size;
10041004
}
10051005
// Calculate line height as 120% of the maximum font size (maxSize * 1.2).
@@ -1014,7 +1014,8 @@ function lineHeightForRuns(runs: Run[], fromRun: number, toRun: number): number
10141014
// Note: This is a simplified calculation. Full typography measurement
10151015
// (in measuring/dom) uses actual font metrics (ascent, descent, lineGap)
10161016
// for more accurate line heights.
1017-
return maxSize * 1.2;
1017+
const resolvedSize = maxSize > 0 ? maxSize : fallbackFontSize;
1018+
return resolvedSize * 1.2;
10181019
}
10191020

10201021
/**
@@ -1180,6 +1181,10 @@ export function remeasureParagraph(
11801181

11811182
let currentRun = 0;
11821183
let currentChar = 0;
1184+
// Match measuring/dom behavior: explicit line breaks without text should use
1185+
// the most recent text font size (or first text run size for leading breaks).
1186+
const firstTextRunWithSize = runs.find((run): run is TextRun => isTextRun(run) && typeof run.fontSize === 'number');
1187+
let lastMeasuredFontSize = firstTextRunWithSize?.fontSize ?? 16;
11831188

11841189
while (currentRun < runs.length) {
11851190
const isFirstLine = lines.length === 0;
@@ -1199,11 +1204,23 @@ export function remeasureParagraph(
11991204
let endChar = currentChar;
12001205
let tabStopCursor = 0;
12011206
let didBreakInThisLine = false;
1207+
let explicitLineBreakRun = -1;
12021208
let resumeRun = -1;
12031209
let resumeChar = 0;
1210+
let lineMaxTextFontSize = 0;
12041211

12051212
for (let r = currentRun; r < runs.length; r += 1) {
12061213
const run = runs[r];
1214+
if (isLineBreakRun(run)) {
1215+
explicitLineBreakRun = r;
1216+
if (startRun === r && startChar === 0 && width === 0) {
1217+
// Preserve leading/manual explicit break as an empty line.
1218+
endRun = r;
1219+
endChar = 0;
1220+
}
1221+
didBreakInThisLine = true;
1222+
break;
1223+
}
12071224
if (run.kind === 'tab') {
12081225
const absCurrentX = width + effectiveIndent;
12091226
const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
@@ -1257,6 +1274,9 @@ export function remeasureParagraph(
12571274
if (r === resumeRun) {
12581275
resumeRun = -1;
12591276
}
1277+
if (text.length > 0 && isTextRun(run)) {
1278+
lineMaxTextFontSize = Math.max(lineMaxTextFontSize, run.fontSize ?? 16);
1279+
}
12601280
for (let c = start; c < text.length; c += 1) {
12611281
const ch = text[c];
12621282
if (ch === '\t') {
@@ -1345,7 +1365,7 @@ export function remeasureParagraph(
13451365
}
13461366

13471367
// If we didn't consume any chars (e.g., very long single char), force one char
1348-
if (startRun === endRun && startChar === endChar) {
1368+
if (explicitLineBreakRun < 0 && startRun === endRun && startChar === endChar) {
13491369
endRun = startRun;
13501370
endChar = startChar + 1;
13511371
}
@@ -1358,18 +1378,43 @@ export function remeasureParagraph(
13581378
width,
13591379
ascent: 0,
13601380
descent: 0,
1361-
lineHeight: lineHeightForRuns(runs, startRun, endRun),
1381+
lineHeight: lineHeightForRuns(runs, startRun, endRun, lastMeasuredFontSize),
13621382
maxWidth: effectiveMaxWidth,
13631383
};
13641384
lines.push(line);
1385+
if (lineMaxTextFontSize > 0) {
1386+
lastMeasuredFontSize = lineMaxTextFontSize;
1387+
}
13651388

13661389
// Advance to next line start
1367-
currentRun = endRun;
1368-
currentChar = endChar;
1390+
if (explicitLineBreakRun >= 0) {
1391+
// Preserve trailing/manual break boundaries:
1392+
// - If this line started on the break, we've already emitted its empty-line boundary,
1393+
// so advance past it.
1394+
// - If this line ended before the break (text + break), keep the break for the next
1395+
// iteration only when the remaining tail is all breaks (trailing break chain).
1396+
// This avoids creating an extra empty line for [text, break, break, text].
1397+
const emittedBreakBoundary =
1398+
startRun === explicitLineBreakRun && startChar === 0 && endRun === explicitLineBreakRun && endChar === 0;
1399+
if (emittedBreakBoundary) {
1400+
currentRun = explicitLineBreakRun + 1;
1401+
} else {
1402+
let nextNonBreakRun = explicitLineBreakRun + 1;
1403+
while (nextNonBreakRun < runs.length && isLineBreakRun(runs[nextNonBreakRun])) {
1404+
nextNonBreakRun += 1;
1405+
}
1406+
const preserveBoundaryForNextIteration = nextNonBreakRun >= runs.length;
1407+
currentRun = preserveBoundaryForNextIteration ? explicitLineBreakRun : explicitLineBreakRun + 1;
1408+
}
1409+
currentChar = 0;
1410+
} else {
1411+
currentRun = endRun;
1412+
currentChar = endChar;
1413+
}
13691414
if (currentRun >= runs.length) {
13701415
break;
13711416
}
1372-
if (currentChar >= runText(runs[currentRun]).length) {
1417+
if (!isLineBreakRun(runs[currentRun]) && currentChar >= runText(runs[currentRun]).length) {
13731418
currentRun += 1;
13741419
currentChar = 0;
13751420
}

packages/layout-engine/layout-bridge/test/remeasure.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,111 @@ describe('remeasureParagraph', () => {
892892
const block = createBlock([textRun('Hello'), { kind: 'lineBreak' } as Run, textRun('World')]);
893893
const measure = remeasureParagraph(block, 200);
894894

895-
expect(measure.lines.length).toBeGreaterThanOrEqual(1);
895+
expect(measure.lines).toHaveLength(2);
896+
expect(measure.lines[0].fromRun).toBe(0);
897+
expect(measure.lines[0].toRun).toBe(0);
898+
expect(measure.lines[1].fromRun).toBe(2);
899+
expect(measure.lines[1].toRun).toBe(2);
900+
});
901+
902+
it('creates an empty line for leading lineBreak at start of paragraph', () => {
903+
const block = createBlock([{ kind: 'lineBreak' } as Run, textRun('Text')]);
904+
const measure = remeasureParagraph(block, 200);
905+
906+
expect(measure.lines).toHaveLength(2);
907+
expect(measure.lines[0].fromRun).toBe(0);
908+
expect(measure.lines[0].toRun).toBe(0);
909+
expect(measure.lines[0].toChar).toBe(0);
910+
expect(measure.lines[1].fromRun).toBe(1);
911+
expect(measure.lines[1].toRun).toBe(1);
912+
});
913+
914+
it('preserves multiple explicit lineBreak boundaries', () => {
915+
const block = createBlock([
916+
textRun('One'),
917+
{ kind: 'lineBreak' } as Run,
918+
textRun('Two'),
919+
{ kind: 'lineBreak' } as Run,
920+
textRun('Three'),
921+
]);
922+
const measure = remeasureParagraph(block, 200);
923+
924+
expect(measure.lines).toHaveLength(3);
925+
expect(measure.lines[0].fromRun).toBe(0);
926+
expect(measure.lines[0].toRun).toBe(0);
927+
expect(measure.lines[1].fromRun).toBe(2);
928+
expect(measure.lines[1].toRun).toBe(2);
929+
expect(measure.lines[2].fromRun).toBe(4);
930+
expect(measure.lines[2].toRun).toBe(4);
931+
});
932+
933+
it('preserves trailing explicit lineBreak as final empty line', () => {
934+
const block = createBlock([textRun('Hello'), { kind: 'lineBreak' } as Run]);
935+
const measure = remeasureParagraph(block, 200);
936+
937+
expect(measure.lines).toHaveLength(2);
938+
expect(measure.lines[0].fromRun).toBe(0);
939+
expect(measure.lines[0].toRun).toBe(0);
940+
// Final empty line should be anchored to trailing break run.
941+
expect(measure.lines[1].fromRun).toBe(1);
942+
expect(measure.lines[1].toRun).toBe(1);
943+
expect(measure.lines[1].toChar).toBe(0);
944+
});
945+
946+
it('handles a single explicit lineBreak run as the only paragraph content', () => {
947+
const block = createBlock([{ kind: 'lineBreak' } as Run]);
948+
const measure = remeasureParagraph(block, 200);
949+
950+
expect(measure.lines).toHaveLength(1);
951+
expect(measure.lines[0].fromRun).toBe(0);
952+
expect(measure.lines[0].toRun).toBe(0);
953+
expect(measure.lines[0].fromChar).toBe(0);
954+
expect(measure.lines[0].toChar).toBe(0);
955+
});
956+
957+
it('uses previous text font size for trailing explicit lineBreak empty line height', () => {
958+
const block = createBlock([textRun('Heading', { fontSize: 24 }), { kind: 'lineBreak' } as Run]);
959+
const measure = remeasureParagraph(block, 200);
960+
961+
expect(measure.lines).toHaveLength(2);
962+
expect(measure.lines[0].lineHeight).toBe(24 * 1.2);
963+
expect(measure.lines[1].fromRun).toBe(1);
964+
expect(measure.lines[1].toRun).toBe(1);
965+
expect(measure.lines[1].lineHeight).toBe(24 * 1.2);
966+
});
967+
968+
it('preserves multiple trailing explicit lineBreak runs as multiple empty lines', () => {
969+
const block = createBlock([textRun('Hello'), { kind: 'lineBreak' } as Run, { kind: 'lineBreak' } as Run]);
970+
const measure = remeasureParagraph(block, 200);
971+
972+
expect(measure.lines).toHaveLength(3);
973+
expect(measure.lines[0].fromRun).toBe(0);
974+
expect(measure.lines[0].toRun).toBe(0);
975+
expect(measure.lines[1].fromRun).toBe(1);
976+
expect(measure.lines[1].toRun).toBe(1);
977+
expect(measure.lines[1].toChar).toBe(0);
978+
expect(measure.lines[2].fromRun).toBe(2);
979+
expect(measure.lines[2].toRun).toBe(2);
980+
expect(measure.lines[2].toChar).toBe(0);
981+
});
982+
983+
it('matches measureParagraphBlock for text + break + break + text', () => {
984+
const block = createBlock([
985+
textRun('A'),
986+
{ kind: 'lineBreak' } as Run,
987+
{ kind: 'lineBreak' } as Run,
988+
textRun('B'),
989+
]);
990+
const measure = remeasureParagraph(block, 200);
991+
992+
expect(measure.lines).toHaveLength(3);
993+
expect(measure.lines[0].fromRun).toBe(0);
994+
expect(measure.lines[0].toRun).toBe(0);
995+
expect(measure.lines[1].fromRun).toBe(2);
996+
expect(measure.lines[1].toRun).toBe(2);
997+
expect(measure.lines[1].toChar).toBe(0);
998+
expect(measure.lines[2].fromRun).toBe(3);
999+
expect(measure.lines[2].toRun).toBe(3);
8961000
});
8971001

8981002
it('handles tabs followed immediately by line break', () => {

packages/layout-engine/measuring/dom/src/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ describe('measureBlock', () => {
542542
expect(measure.lines).toHaveLength(2);
543543
expect(measure.lines[0].width).toBeGreaterThan(0);
544544
expect(measure.lines[1].width).toBeGreaterThan(0);
545+
expect(measure.lines[1].fromRun).toBe(2);
546+
expect(measure.lines[1].toRun).toBe(2);
545547
});
546548

547549
it('creates an empty line for leading lineBreak at start of paragraph', async () => {

packages/layout-engine/measuring/dom/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
10841084
leaders?: Line['leaders'];
10851085
/** Count of breakable spaces already included on this line (for justify-aware fitting) */
10861086
spaceCount: number;
1087+
/** Internal marker for an empty line seeded by an explicit line break. */
1088+
isLineBreakPlaceholder?: boolean;
10871089
} | null = null;
10881090

10891091
// Helper to calculate effective available width based on current line count.
@@ -1459,6 +1461,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
14591461
maxWidth: nextLineMaxWidth,
14601462
segments: [],
14611463
spaceCount: 0,
1464+
isLineBreakPlaceholder: true,
14621465
};
14631466
tabStopCursor = 0;
14641467
pendingTabAlignment = null;
@@ -1468,6 +1471,15 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
14681471
continue;
14691472
}
14701473

1474+
// When a text/tab/atomic run follows an explicit lineBreak, currentLine is a
1475+
// placeholder line seeded with the break run index. Re-anchor it so line ranges
1476+
// start at the first visible run on the new line.
1477+
if (currentLine?.isLineBreakPlaceholder) {
1478+
currentLine.fromRun = runIndex;
1479+
currentLine.toRun = runIndex;
1480+
currentLine.isLineBreakPlaceholder = false;
1481+
}
1482+
14711483
// Handle tab runs specially
14721484
if (isTabRun(run)) {
14731485
// Clear any previous tab group when we encounter a new tab

0 commit comments

Comments
 (0)