diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts
index b6ce830e72..ded9faf947 100644
--- a/packages/layout-engine/layout-bridge/src/remeasure.ts
+++ b/packages/layout-engine/layout-bridge/src/remeasure.ts
@@ -1781,6 +1781,55 @@ export function remeasureParagraph(
}
let lastMeasuredFontSize = firstRunFontSize ?? 16;
+ /**
+ * Word never wraps a line at a trailing space that only precedes an explicit
+ * break () or the paragraph end: such spaces are not measured for line
+ * fitting and hang past the text margin instead of opening a new (empty) line.
+ * Mirrors spacesHangBeforeBreak in measuring/dom (issue #3946).
+ *
+ * Only true line breaks qualify here (unlike the primary measurer's mirror):
+ * remeasurement treats page/column 'break' runs as zero-width passthroughs
+ * that do not close the line, so a space before one is mid-line and must keep
+ * counting toward the fit. Computed lazily in one backward pass —
+ * suffixHangs[i] answers "do runs i.. contribute no non-space content before
+ * a line break or the paragraph end?", and tailAllSpaceFrom[i] is the first
+ * index after run i's last non-space character — so repeated per-character
+ * lookups stay O(1) regardless of how many trailing-space runs there are.
+ */
+ let trailingSpacesHangCache: { suffixHangs: boolean[]; tailAllSpaceFrom: number[] } | null = null;
+ const computeTrailingSpacesHangCache = (): { suffixHangs: boolean[]; tailAllSpaceFrom: number[] } => {
+ const suffixHangs: boolean[] = new Array(runs.length + 1);
+ const tailAllSpaceFrom: number[] = new Array(runs.length);
+ suffixHangs[runs.length] = true;
+ for (let i = runs.length - 1; i >= 0; i -= 1) {
+ const run = runs[i];
+ if (isTextRun(run)) {
+ let lastNonSpace = run.text.length - 1;
+ while (lastNonSpace >= 0 && run.text[lastNonSpace] === ' ') lastNonSpace -= 1;
+ tailAllSpaceFrom[i] = lastNonSpace + 1;
+ } else {
+ tailAllSpaceFrom[i] = 0;
+ }
+ if (isLineBreakRun(run)) {
+ suffixHangs[i] = true;
+ continue;
+ }
+ if (isVanishedRun(run)) {
+ suffixHangs[i] = suffixHangs[i + 1];
+ continue;
+ }
+ suffixHangs[i] = isTextRun(run) && tailAllSpaceFrom[i] === 0 ? suffixHangs[i + 1] : false;
+ }
+ return { suffixHangs, tailAllSpaceFrom };
+ };
+ const trailingSpacesHang = (runIdx: number, fromChar: number): boolean => {
+ trailingSpacesHangCache ??= computeTrailingSpacesHangCache();
+ const { suffixHangs, tailAllSpaceFrom } = trailingSpacesHangCache;
+ const current = runs[runIdx];
+ if (isTextRun(current) && fromChar < tailAllSpaceFrom[runIdx]) return false;
+ return suffixHangs[runIdx + 1];
+ };
+
while (currentRun < runs.length) {
const isFirstLine = lines.length === 0;
// For first line, reduce available width by textStart/first-line offset (e.g., for in-flow list markers)
@@ -2030,6 +2079,14 @@ export function remeasureParagraph(
: effectiveMaxWidth - WIDTH_FUDGE_PX;
if (width + w > fitThreshold && width > 0) {
if (ch === ' ') {
+ // A trailing space right before an explicit break (or the paragraph
+ // end) never forces a wrap: consume it at zero charged width and keep
+ // scanning so the break closes this line instead of an empty one.
+ if (trailingSpacesHang(r, chEnd)) {
+ endRun = r;
+ endChar = chEnd;
+ continue;
+ }
// The space is only a wrap delimiter. Consume it so the next line
// starts at the following word, but do not charge its width to the
// completed line.
diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts
index 0aca57eaae..c7c813eb65 100644
--- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts
+++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts
@@ -1392,6 +1392,27 @@ describe('remeasureParagraph', () => {
expect(measure.lines[2].toRun).toBe(4);
});
+ it('does not create a spurious empty line when a trailing space overflows before a lineBreak (#3946)', () => {
+ // 'Helloworld' = 100px at CHAR_WIDTH 10; with the trailing space it is
+ // 110px. At maxWidth 105 the space overflows the measure, but because it
+ // only precedes an explicit break it must hang instead of wrapping onto
+ // its own (visually empty) line — Word renders two lines here.
+ const block = createBlock([textRun('Helloworld '), { kind: 'lineBreak' } as Run, textRun('Next')]);
+ const measure = remeasureParagraph(block, 10 * CHAR_WIDTH + 5);
+
+ expect(measure.lines).toHaveLength(2);
+ expect(measure.lines[0].fromRun).toBe(0);
+ expect(measure.lines[1].fromRun).toBe(2);
+ });
+
+ it('does not wrap a whole-run trailing space that overflows before a lineBreak (#3946)', () => {
+ const block = createBlock([textRun('Helloworld'), textRun(' '), { kind: 'lineBreak' } as Run, textRun('Next')]);
+ const measure = remeasureParagraph(block, 10 * CHAR_WIDTH + 5);
+
+ expect(measure.lines).toHaveLength(2);
+ expect(measure.lines[1].fromRun).toBe(3);
+ });
+
it('preserves trailing explicit lineBreak as final empty line', () => {
const block = createBlock([textRun('Hello'), { kind: 'lineBreak' } as Run]);
const measure = remeasureParagraph(block, 200);
diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts
index fa47848c7e..d77cad7e88 100644
--- a/packages/layout-engine/measuring/dom/src/index.test.ts
+++ b/packages/layout-engine/measuring/dom/src/index.test.ts
@@ -5125,6 +5125,92 @@ describe('measureBlock', () => {
expect(measure.lines[0].toChar).toBe(5);
});
+ it('does not create a spurious empty line when a trailing space overflows before a hard break (#3946)', async () => {
+ // Measure the text without the trailing space to find its natural width.
+ const probe: FlowBlock = {
+ kind: 'paragraph',
+ id: 'probe-paragraph',
+ runs: [{ text: 'Lorem ipsum dolor', fontFamily: 'Arial', fontSize: 16 }],
+ attrs: {},
+ };
+ const probeMeasure = expectParagraphMeasure(await measureBlock(probe, 1000));
+ const textWidth = probeMeasure.lines[0].width;
+
+ // Same text + trailing space + explicit line break + more text, with
+ // maxWidth chosen so the text fits but text+space slightly exceeds it.
+ // Word renders TWO lines here: the trailing space hangs past the measure
+ // instead of wrapping onto its own (visually empty) line.
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'bug-paragraph',
+ runs: [
+ { text: 'Lorem ipsum dolor ', fontFamily: 'Arial', fontSize: 16 },
+ { kind: 'lineBreak' },
+ { text: 'Second line', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ attrs: { alignment: 'justify' },
+ };
+ const measure = expectParagraphMeasure(await measureBlock(block, textWidth + 2));
+
+ expect(measure.lines.length).toBe(2);
+ expect(measure.lines[1].fromRun).toBe(2);
+ });
+
+ it('hangs a trailing space before a page/column break run too (#3946)', async () => {
+ // The run loop closes the current line for every 'break' kind, so a
+ // space that only precedes a page/column break is line-trailing as well.
+ const probe: FlowBlock = {
+ kind: 'paragraph',
+ id: 'probe3-paragraph',
+ runs: [{ text: 'Lorem ipsum dolor', fontFamily: 'Arial', fontSize: 16 }],
+ attrs: {},
+ };
+ const probeMeasure = expectParagraphMeasure(await measureBlock(probe, 1000));
+ const textWidth = probeMeasure.lines[0].width;
+
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'bug3-paragraph',
+ runs: [
+ { text: 'Lorem ipsum dolor ', fontFamily: 'Arial', fontSize: 16 },
+ { kind: 'break' },
+ { text: 'Second line', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ attrs: {},
+ };
+ const measure = expectParagraphMeasure(await measureBlock(block, textWidth + 2));
+
+ expect(measure.lines.length).toBe(2);
+ expect(measure.lines[1].fromRun).toBe(2);
+ });
+
+ it('does not wrap a whole-run trailing space that overflows before a hard break (#3946)', async () => {
+ const probe: FlowBlock = {
+ kind: 'paragraph',
+ id: 'probe2-paragraph',
+ runs: [{ text: 'Lorem ipsum dolor', fontFamily: 'Arial', fontSize: 16 }],
+ attrs: {},
+ };
+ const probeMeasure = expectParagraphMeasure(await measureBlock(probe, 1000));
+ const textWidth = probeMeasure.lines[0].width;
+
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'bug2-paragraph',
+ runs: [
+ { text: 'Lorem ipsum dolor', fontFamily: 'Arial', fontSize: 16 },
+ { text: ' ', fontFamily: 'Arial', fontSize: 16 },
+ { kind: 'lineBreak' },
+ { text: 'Second line', fontFamily: 'Arial', fontSize: 16 },
+ ],
+ attrs: { alignment: 'justify' },
+ };
+ const measure = expectParagraphMeasure(await measureBlock(block, textWidth + 2));
+
+ expect(measure.lines.length).toBe(2);
+ expect(measure.lines[1].fromRun).toBe(3);
+ });
+
it('includes space width when mid-line', async () => {
const blockNoSpace: FlowBlock = {
kind: 'paragraph',
diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts
index 5d7c5b0754..9f437ddc29 100644
--- a/packages/layout-engine/measuring/dom/src/index.ts
+++ b/packages/layout-engine/measuring/dom/src/index.ts
@@ -2760,6 +2760,47 @@ async function measureParagraphBlock(
}
};
+ /**
+ * Word never wraps a line at a trailing space that is immediately followed by an
+ * explicit break () or the end of the paragraph: trailing spaces are not
+ * measured for line fitting and simply hang past the text margin. Without this,
+ * a space that overflows the measure right before a hard break wraps onto its
+ * own line, and the break then closes that (visually empty) line — producing a
+ * spurious blank line (three lines instead of two). Returns true when every run
+ * after `startRunIndex` up to the next explicit break (or the paragraph end)
+ * contributes no non-space content, i.e. the current space(s) are line-trailing.
+ */
+ // Any 'break' kind counts, not just breakType 'line': the run loop below
+ // closes the current line for every break run, so a space that only precedes
+ // a page/column break is line-trailing here too. Computed lazily in one
+ // backward pass (suffixHangs[i] answers "do runs i.. contribute no non-space
+ // content before a line-closing break or the paragraph end?"), so repeated
+ // lookups stay O(1) even with many trailing-space runs.
+ let spacesHangCache: boolean[] | null = null;
+ const computeSpacesHangCache = (): boolean[] => {
+ const suffixHangs: boolean[] = new Array(runsToProcess.length + 1);
+ suffixHangs[runsToProcess.length] = true;
+ for (let i = runsToProcess.length - 1; i >= 0; i--) {
+ const run = runsToProcess[i] as Run;
+ if (run.kind === 'lineBreak' || run.kind === 'break') {
+ suffixHangs[i] = true;
+ continue;
+ }
+ if (isVanishedRun(run)) {
+ suffixHangs[i] = suffixHangs[i + 1];
+ continue;
+ }
+ const isPlainTextRun = !run.kind || run.kind === 'text';
+ const text = (run as TextRun).text;
+ suffixHangs[i] = isPlainTextRun && typeof text === 'string' && /^[ ]*$/.test(text) ? suffixHangs[i + 1] : false;
+ }
+ return suffixHangs;
+ };
+ const spacesHangBeforeBreak = (startRunIndex: number): boolean => {
+ spacesHangCache ??= computeSpacesHangCache();
+ return spacesHangCache[startRunIndex + 1];
+ };
+
// Per-line-segment tab counts. The heuristic below binds the last N tabs of a
// segment to the last N alignment stops; segments are delimited by explicit
// runs because pPr/tabs apply per line, not per paragraph.
@@ -3613,7 +3654,8 @@ async function measureParagraphBlock(
const boundarySpacing = resolveBoundarySpacing(currentLine.width, isRunStart, run as TextRun);
if (
currentLine.width + boundarySpacing + spacesWidth > currentLine.maxWidth - WIDTH_FUDGE_PX &&
- currentLine.width > 0
+ currentLine.width > 0 &&
+ !(isLastSegment && spacesHangBeforeBreak(runIndex))
) {
trimTrailingWrapSpaces(currentLine);
const completedLine: Line = closeLineWithMetrics(currentLine);
@@ -3733,7 +3775,8 @@ async function measureParagraphBlock(
const boundarySpacing = resolveBoundarySpacing(currentLine.width, isRunStart, run as TextRun);
if (
currentLine.width + boundarySpacing + singleSpaceWidth > currentLine.maxWidth - WIDTH_FUDGE_PX &&
- currentLine.width > 0
+ currentLine.width > 0 &&
+ !(isLastSegment && wordIndex > lastNonEmptyWordIndex && spacesHangBeforeBreak(runIndex))
) {
// Space doesn't fit - finish current line and start new one with the space
trimTrailingWrapSpaces(currentLine);