From a3ba38a6dc1ac680379d6fb3843e5720ebb1373c Mon Sep 17 00:00:00 2001 From: barry <91018388+barry166@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:36:37 +0800 Subject: [PATCH] fix: align trailing collapsible space handling across all line-break paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countPreparedLinesSimple (used by layout()) correctly skips collapsible spaces that overflow the line edge — CSS white-space: normal behavior where trailing spaces hang without triggering a break. walkPreparedLinesSimple (used by layoutWithLines/walkLineRanges) and layoutNextLineRangeSimple (used by layoutNextLine) were missing this guard, causing canBreakAfter('space') to trigger spurious line breaks when word + space > maxWidth. Add the isSimpleCollapsibleSpace check before the canBreakAfter check in both walk paths to match the count path behavior. Add a regression test with crafted segment data that directly triggers the divergence condition (word=40 + space=5 > maxWidth=42, but word=40 + next=1 <= 42). Closes #11 --- src/layout.test.ts | 33 +++++++++++++++++++++++++++++++++ src/line-break.ts | 13 +++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/layout.test.ts b/src/layout.test.ts index 3b5d01bb..8d4d770a 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -624,4 +624,37 @@ describe('layout invariants', () => { } } }) + + test('trailing collapsible space hangs without breaking across count and walk paths', () => { + // Regression test for issue #11: countPreparedLinesSimple (used by layout()) + // and walkPreparedLinesSimple (used by layoutWithLines()) returned different + // lineCount when a trailing collapsible space overflowed the line edge. + // + // CSS white-space: normal behavior: trailing collapsible whitespace hangs + // past the line edge without triggering a line break. + // + // We craft internal data directly because the fake measurement system's + // proportions (space=0.33x, char>=0.4x) can't produce the divergence + // condition: word + space > maxWidth but word + nextSegment <= maxWidth. + const prepared = { + widths: [40, 5, 1], + lineEndFitAdvances: [40, 0, 1], + lineEndPaintAdvances: [40, 0, 1], + kinds: ['text' as const, 'space' as const, 'text' as const], + simpleLineWalkFastPath: true, + breakableWidths: [null, null, null], + breakablePrefixWidths: [null, null, null], + discretionaryHyphenWidth: 5, + tabStopAdvance: 0, + chunks: [{ startSegmentIndex: 0, endSegmentIndex: 3, consumedEndSegmentIndex: 3 }], + } + + // 40 + 5 = 45 > 42: space overflows + // 40 + 1 = 41 <= 42: next segment fits if space just hangs + const maxWidth = 42 + const counted = countPreparedLines(prepared, maxWidth) + const walked = walkPreparedLines(prepared, maxWidth) + expect(counted).toBe(1) + expect(walked).toBe(counted) + }) }) diff --git a/src/line-break.ts b/src/line-break.ts index 57fa1131..6a4c8d1f 100644 --- a/src/line-break.ts +++ b/src/line-break.ts @@ -333,6 +333,13 @@ function walkPreparedLinesSimple( const newW = lineW + w if (newW > maxWidth + lineFitEpsilon) { + // CSS behavior: trailing collapsible space hangs past the line edge + // without triggering a line break — matches countPreparedLinesSimple + if (isSimpleCollapsibleSpace(kind)) { + i++ + continue + } + if (canBreakAfter(kind)) { appendWholeSegment(i, w) emitCurrentLine(i + 1, 0, lineW - w) @@ -1029,6 +1036,12 @@ function layoutNextLineRangeSimple( const newW = lineW + w if (newW > maxWidth + lineFitEpsilon) { + // CSS behavior: trailing collapsible space hangs past the line edge + // without triggering a line break — matches countPreparedLinesSimple + if (isSimpleCollapsibleSpace(kind)) { + continue + } + if (canBreakAfter(kind)) { appendWholeSegment(i, w) return finishLine(i + 1, 0, lineW - w)