diff --git a/src/layout.test.ts b/src/layout.test.ts index dc609d76..ef12f64d 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -12,6 +12,8 @@ type LayoutModule = typeof import('./layout.ts') type LineBreakModule = typeof import('./line-break.ts') type RichInlineModule = typeof import('./rich-inline.ts') type AnalysisModule = typeof import('./analysis.ts') +type MeasurementModule = typeof import('./measurement.ts') +type SegmentMetrics = ReturnType let prepare: LayoutModule['prepare'] let prepareWithSegments: LayoutModule['prepareWithSegments'] @@ -32,6 +34,7 @@ let materializeRichInlineLineRange: RichInlineModule['materializeRichInlineLineR let measureRichInlineStats: RichInlineModule['measureRichInlineStats'] let walkRichInlineLineRanges: RichInlineModule['walkRichInlineLineRanges'] let isCJK: AnalysisModule['isCJK'] +let getSegmentBreakableFitAdvances: MeasurementModule['getSegmentBreakableFitAdvances'] const emojiPresentationRe = /\p{Emoji_Presentation}/u const punctuationRe = /[.,!?;:%)\]}'"”’»›…—-]/u @@ -262,11 +265,12 @@ class TestOffscreenCanvas { beforeAll(async () => { Reflect.set(globalThis, 'OffscreenCanvas', TestOffscreenCanvas) - const [analysisMod, mod, lineBreakMod, richInlineMod] = await Promise.all([ + const [analysisMod, mod, lineBreakMod, richInlineMod, measurementMod] = await Promise.all([ import('./analysis.ts'), import('./layout.ts'), import('./line-break.ts'), import('./rich-inline.ts'), + import('./measurement.ts'), ]) ;({ isCJK } = analysisMod) ;({ @@ -283,6 +287,7 @@ beforeAll(async () => { } = mod) ;({ countPreparedLines, measurePreparedLineGeometry, stepPreparedLineGeometry, walkPreparedLines } = lineBreakMod) ;({ prepareRichInline, materializeRichInlineLineRange, measureRichInlineStats, walkRichInlineLineRanges } = richInlineMod) + ;({ getSegmentBreakableFitAdvances } = measurementMod) }) beforeEach(() => { @@ -290,6 +295,25 @@ beforeEach(() => { clearCache() }) +describe('measurement invariants', () => { + test('breakable fit advance cache is keyed by fit mode', () => { + const metrics: SegmentMetrics = { width: 80, containsCJK: false } + const cache: Map = new Map([ + ['a', { width: 10, containsCJK: false }], + ['b', { width: 20, containsCJK: false }], + ['c', { width: 30, containsCJK: false }], + ['ab', { width: 35, containsCJK: false }], + ['bc', { width: 60, containsCJK: false }], + ['abc', metrics], + ]) + + expect(getSegmentBreakableFitAdvances('abc', metrics, cache, 0, 'sum-graphemes')).toEqual([10, 20, 30]) + expect(getSegmentBreakableFitAdvances('abc', metrics, cache, 0, 'pair-context')).toEqual([10, 25, 40]) + expect(getSegmentBreakableFitAdvances('abc', metrics, cache, 0, 'segment-prefixes')).toEqual([10, 25, 45]) + expect(getSegmentBreakableFitAdvances('abc', metrics, cache, 0, 'sum-graphemes')).toEqual([10, 20, 30]) + }) +}) + describe('prepare invariants', () => { test('whitespace-only input stays empty', () => { const prepared = prepare(' \t\n ', FONT) diff --git a/src/measurement.ts b/src/measurement.ts index b7d09c54..7b12a81e 100644 --- a/src/measurement.ts +++ b/src/measurement.ts @@ -4,8 +4,7 @@ export type SegmentMetrics = { width: number containsCJK: boolean emojiCount?: number - breakableFitMode?: BreakableFitMode - breakableFitAdvances?: number[] | null + breakableFitAdvancesByMode?: Partial> } export type EngineProfile = { @@ -204,32 +203,41 @@ export function getSegmentBreakableFitAdvances( emojiCorrection: number, mode: BreakableFitMode, ): number[] | null { - if (metrics.breakableFitAdvances !== undefined && metrics.breakableFitMode === mode) { - return metrics.breakableFitAdvances - } - metrics.breakableFitMode = mode - const graphemeSegmenter = getSharedGraphemeSegmenter() const graphemes: string[] = [] for (const gs of graphemeSegmenter.segment(seg)) { graphemes.push(gs.segment) } + + const cacheMode = + mode === 'segment-prefixes' && graphemes.length > MAX_PREFIX_FIT_GRAPHEMES + ? 'pair-context' + : mode + const cached = metrics.breakableFitAdvancesByMode?.[cacheMode] + if (cached !== undefined) { + return cached + } + + function cacheAdvances(advances: number[] | null): number[] | null { + const byMode = metrics.breakableFitAdvancesByMode ??= {} + byMode[cacheMode] = advances + return advances + } + if (graphemes.length <= 1) { - metrics.breakableFitAdvances = null - return metrics.breakableFitAdvances + return cacheAdvances(null) } - if (mode === 'sum-graphemes') { + if (cacheMode === 'sum-graphemes') { const advances: number[] = [] for (const grapheme of graphemes) { const graphemeMetrics = getSegmentMetrics(grapheme, cache) advances.push(getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection)) } - metrics.breakableFitAdvances = advances - return metrics.breakableFitAdvances + return cacheAdvances(advances) } - if (mode === 'pair-context' || graphemes.length > MAX_PREFIX_FIT_GRAPHEMES) { + if (cacheMode === 'pair-context') { const advances: number[] = [] let previousGrapheme: string | null = null let previousWidth = 0 @@ -250,8 +258,7 @@ export function getSegmentBreakableFitAdvances( previousWidth = currentWidth } - metrics.breakableFitAdvances = advances - return metrics.breakableFitAdvances + return cacheAdvances(advances) } const advances: number[] = [] @@ -266,8 +273,7 @@ export function getSegmentBreakableFitAdvances( prefixWidth = nextPrefixWidth } - metrics.breakableFitAdvances = advances - return metrics.breakableFitAdvances + return cacheAdvances(advances) } export function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): {