Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,71 @@ 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('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
Expand Down
49 changes: 33 additions & 16 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -274,6 +275,7 @@ function measureAnalysis(
font: string,
includeSegments: boolean,
wordBreak: WordBreakMode,
letterSpacing: number = 0,
): InternalPreparedText | PreparedTextWithSegments {
const engineProfile = getEngineProfile()
const { cache, emojiCorrection } = getFontMeasurementState(
Expand All @@ -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[] = []
Expand Down Expand Up @@ -328,27 +338,31 @@ 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 : countGraphemes(text)
: 0
const width = gc > 0 ? baseWidth + gc * letterSpacing : baseWidth
if (gc > 0 && graphemeWidths !== null) {
graphemeWidths = graphemeWidths.map((w, i) => w + (i < gc - 1 ? letterSpacing : 0))
}
if (gc > 0 && graphemePrefixWidths !== null) {
graphemePrefixWidths = graphemePrefixWidths.map((w, i) => w + i * letterSpacing)
}
pushMeasuredSegment(
text,
width,
lineEndFitAdvance,
lineEndPaintAdvance,
isTrailingTrimmed ? 0 : width - (gc > 0 ? letterSpacing : 0),
paintZero ? 0 : width,
kind,
start,
graphemeWidths,
Expand All @@ -357,11 +371,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,
Expand Down Expand Up @@ -494,8 +510,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,
Expand Down