From 2acfbf497c58af99e635b6e3d9a0f26a3ba223f5 Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 5 Apr 2026 21:30:08 +0200 Subject: [PATCH 1/2] feat: letterSpacing option in prepare() --- src/layout.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++ src/layout.ts | 47 +++++++++++++++++++++++++-------------- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/layout.test.ts b/src/layout.test.ts index d9b83327..bec12b11 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -727,6 +727,61 @@ describe('rich-inline invariants', () => { }) describe('layout invariants', () => { + test('letterSpacing increases segment widths by graphemeCount * spacing', () => { + const base = prepareWithSegments('Hello World', FONT) + const spaced = prepareWithSegments('Hello World', FONT, { letterSpacing: 2 }) + + // "Hello" = 5 graphemes, " " = 1 grapheme, "World" = 5 graphemes + expect(spaced.widths[0]).toBeCloseTo(base.widths[0]! + 5 * 2, 5) + expect(spaced.widths[1]).toBeCloseTo(base.widths[1]! + 1 * 2, 5) + expect(spaced.widths[2]).toBeCloseTo(base.widths[2]! + 5 * 2, 5) + }) + + test('letterSpacing zero produces identical widths to no option', () => { + const base = prepareWithSegments('Hello World', FONT) + const zero = prepareWithSegments('Hello World', FONT, { letterSpacing: 0 }) + + expect(zero.widths).toEqual(base.widths) + }) + + test('letterSpacing causes more line breaks at narrow widths', () => { + const base = prepare('The quick brown fox', FONT) + const spaced = prepare('The quick brown fox', FONT, { letterSpacing: 5 }) + + const baseLines = layout(base, 200, LINE_HEIGHT).lineCount + const spacedLines = layout(spaced, 200, LINE_HEIGHT).lineCount + expect(spacedLines).toBeGreaterThanOrEqual(baseLines) + }) + + test('negative letterSpacing tightens text and reduces line count', () => { + const base = prepare('The quick brown fox jumps over', FONT) + const tight = prepare('The quick brown fox jumps over', FONT, { letterSpacing: -1 }) + + const baseLines = layout(base, 120, LINE_HEIGHT).lineCount + const tightLines = layout(tight, 120, LINE_HEIGHT).lineCount + expect(tightLines).toBeLessThanOrEqual(baseLines) + }) + + test('letterSpacing applies to CJK text', () => { + const base = prepareWithSegments('春天到了', FONT) + const spaced = prepareWithSegments('春天到了', FONT, { letterSpacing: 4 }) + + let baseTotal = 0 + let spacedTotal = 0 + for (let i = 0; i < base.widths.length; i++) baseTotal += base.widths[i]! + for (let i = 0; i < spaced.widths.length; i++) spacedTotal += spaced.widths[i]! + expect(spacedTotal).toBeGreaterThan(baseTotal) + }) + + test('letterSpacing works with pre-wrap mode', () => { + const base = prepare('Hello\nWorld', FONT, { whiteSpace: 'pre-wrap' }) + const spaced = prepare('Hello\nWorld', FONT, { whiteSpace: 'pre-wrap', letterSpacing: 3 }) + + const baseLines = layout(base, 200, LINE_HEIGHT).lineCount + const spacedLines = layout(spaced, 200, LINE_HEIGHT).lineCount + expect(spacedLines).toBeGreaterThanOrEqual(baseLines) + }) + test('line count grows monotonically as width shrinks', () => { const prepared = prepare('The quick brown fox jumps over the lazy dog', FONT) let previous = 0 diff --git a/src/layout.ts b/src/layout.ts index 3a9524e3..43c44d70 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -149,6 +149,7 @@ export type WordBreakMode = AnalysisWordBreakMode export type PrepareOptions = { whiteSpace?: WhiteSpaceMode wordBreak?: WordBreakMode + letterSpacing?: number } // Internal hard-break chunk hint for the line walker. Not public because @@ -274,6 +275,7 @@ function measureAnalysis( font: string, includeSegments: boolean, wordBreak: WordBreakMode, + letterSpacing: number = 0, ): InternalPreparedText | PreparedTextWithSegments { const engineProfile = getEngineProfile() const { cache, emojiCorrection } = getFontMeasurementState( @@ -284,6 +286,14 @@ function measureAnalysis( const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection) const tabStopAdvance = spaceWidth * 8 + const graphemeSegmenter = letterSpacing !== 0 ? getSharedGraphemeSegmenter() : null + function countGraphemes(text: string): number { + if (graphemeSegmenter === null) return 0 + let count = 0 + for (const _ of graphemeSegmenter.segment(text)) count++ + return count + } + if (analysis.len === 0) return createEmptyPrepared(includeSegments) const widths: number[] = [] @@ -328,27 +338,29 @@ function measureAnalysis( allowOverflowBreaks: boolean, ): void { const textMetrics = getSegmentMetrics(text, cache) - const width = getCorrectedSegmentWidth(text, textMetrics, emojiCorrection) - const lineEndFitAdvance = - kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break' - ? 0 - : width - const lineEndPaintAdvance = - kind === 'space' || kind === 'zero-width-break' - ? 0 - : width + const baseWidth = getCorrectedSegmentWidth(text, textMetrics, emojiCorrection) + const isTrailingTrimmed = kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break' + const paintZero = kind === 'space' || kind === 'zero-width-break' if (allowOverflowBreaks && wordLike && text.length > 1) { - const graphemeWidths = getSegmentGraphemeWidths(text, textMetrics, cache, emojiCorrection) - const graphemePrefixWidths = + let graphemeWidths = getSegmentGraphemeWidths(text, textMetrics, cache, emojiCorrection) + let graphemePrefixWidths = engineProfile.preferPrefixWidthsForBreakableRuns || isNumericRunSegment(text) ? getSegmentGraphemePrefixWidths(text, textMetrics, cache, emojiCorrection) : null + const gc = letterSpacing !== 0 && graphemeWidths !== null ? graphemeWidths.length : 0 + const width = gc > 0 ? baseWidth + gc * letterSpacing : baseWidth + if (gc > 0 && graphemeWidths !== null) { + graphemeWidths = graphemeWidths.map(w => w + letterSpacing) + } + if (gc > 0 && graphemePrefixWidths !== null) { + graphemePrefixWidths = graphemePrefixWidths.map((w, i) => w + (i + 1) * letterSpacing) + } pushMeasuredSegment( text, width, - lineEndFitAdvance, - lineEndPaintAdvance, + isTrailingTrimmed ? 0 : width - (gc > 0 ? letterSpacing : 0), + paintZero ? 0 : width, kind, start, graphemeWidths, @@ -357,11 +369,13 @@ function measureAnalysis( return } + const gc = letterSpacing !== 0 ? countGraphemes(text) : 0 + const width = gc > 0 ? baseWidth + gc * letterSpacing : baseWidth pushMeasuredSegment( text, width, - lineEndFitAdvance, - lineEndPaintAdvance, + isTrailingTrimmed ? 0 : width - (gc > 0 ? letterSpacing : 0), + paintZero ? 0 : width, kind, start, null, @@ -494,8 +508,9 @@ function prepareInternal( options?: PrepareOptions, ): InternalPreparedText | PreparedTextWithSegments { const wordBreak = options?.wordBreak ?? 'normal' + const letterSpacing = options?.letterSpacing ?? 0 const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace, wordBreak) - return measureAnalysis(analysis, font, includeSegments, wordBreak) + return measureAnalysis(analysis, font, includeSegments, wordBreak, letterSpacing) } // Prepare text for layout. Segments the text, measures each segment via canvas, From 934503053ae1f138a3f92c186e89f67d7fdbbcda Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 5 Apr 2026 22:17:15 +0200 Subject: [PATCH 2/2] fix: trailing letter-spacing trim for grapheme breaks and single-grapheme segments --- src/layout.test.ts | 10 ++++++++++ src/layout.ts | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/layout.test.ts b/src/layout.test.ts index bec12b11..e65e61fd 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -782,6 +782,16 @@ describe('layout invariants', () => { expect(spacedLines).toBeGreaterThanOrEqual(baseLines) }) + test('letterSpacing trims trailing spacing at wrapped CJK line ends', () => { + const options = { letterSpacing: 4 } + const singleLine = layoutWithLines(prepareWithSegments('春天', FONT, options), 200, LINE_HEIGHT) + const wrapWidth = singleLine.lines[0]!.width + + const wrapped = layoutWithLines(prepareWithSegments('春天到了', FONT, options), wrapWidth, LINE_HEIGHT) + expect(wrapped.lineCount).toBe(2) + expect(wrapped.lines[0]!.text).toBe('春天') + }) + test('line count grows monotonically as width shrinks', () => { const prepared = prepare('The quick brown fox jumps over the lazy dog', FONT) let previous = 0 diff --git a/src/layout.ts b/src/layout.ts index 43c44d70..d3902549 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -348,13 +348,15 @@ function measureAnalysis( engineProfile.preferPrefixWidthsForBreakableRuns || isNumericRunSegment(text) ? getSegmentGraphemePrefixWidths(text, textMetrics, cache, emojiCorrection) : null - const gc = letterSpacing !== 0 && graphemeWidths !== null ? graphemeWidths.length : 0 + const gc = letterSpacing !== 0 + ? graphemeWidths !== null ? graphemeWidths.length : countGraphemes(text) + : 0 const width = gc > 0 ? baseWidth + gc * letterSpacing : baseWidth if (gc > 0 && graphemeWidths !== null) { - graphemeWidths = graphemeWidths.map(w => w + letterSpacing) + graphemeWidths = graphemeWidths.map((w, i) => w + (i < gc - 1 ? letterSpacing : 0)) } if (gc > 0 && graphemePrefixWidths !== null) { - graphemePrefixWidths = graphemePrefixWidths.map((w, i) => w + (i + 1) * letterSpacing) + graphemePrefixWidths = graphemePrefixWidths.map((w, i) => w + i * letterSpacing) } pushMeasuredSegment( text,