diff --git a/package.json b/package.json index bda5a13a..b97bba68 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,11 @@ "import": "./dist/layout.js", "default": "./dist/layout.js" }, + "./host": { + "types": "./dist/host.d.ts", + "import": "./dist/host.js", + "default": "./dist/host.js" + }, "./demos/*": "./pages/demos/*", "./assets/*": "./pages/assets/*", "./package.json": "./package.json" diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 00000000..a9cfccd7 --- /dev/null +++ b/src/host.ts @@ -0,0 +1,67 @@ +import { + clearCache, + layout, + layoutNextLine, + layoutWithLines, + prepare, + prepareWithSegments, + profilePrepare, + setLocale, + walkLineRanges, + type LayoutCursor, + type LayoutLine, + type LayoutLineRange, + type LayoutLinesResult, + type LayoutResult, + type PrepareOptions, + type PrepareProfile, + type PreparedText, + type PreparedTextWithSegments, +} from './layout.js' +import { withMeasurementHost, type MeasurementHost } from './measurement.js' + +export type PretextHostConfig = { + measurement: MeasurementHost +} + +export type PretextHostApi = { + profilePrepare(text: string, font: string, options?: PrepareOptions): PrepareProfile + prepare(text: string, font: string, options?: PrepareOptions): PreparedText + prepareWithSegments(text: string, font: string, options?: PrepareOptions): PreparedTextWithSegments + layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult + walkLineRanges( + prepared: PreparedTextWithSegments, + maxWidth: number, + onLine?: (line: LayoutLineRange) => void, + ): number + layoutNextLine( + prepared: PreparedTextWithSegments, + start: LayoutCursor, + maxWidth: number, + ): LayoutLine | null + layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): LayoutLinesResult + clearCache(): void + setLocale(locale?: string): void +} + +export { type MeasurementHost } from './measurement.js' + +export function createPretext(config: PretextHostConfig): PretextHostApi { + const bind = ( + fn: (...args: Args) => Result, + ): ((...args: Args) => Result) => { + return (...args: Args) => withMeasurementHost(config.measurement, () => fn(...args)) + } + + return { + profilePrepare: bind(profilePrepare), + prepare: bind(prepare), + prepareWithSegments: bind(prepareWithSegments), + layout: bind(layout), + walkLineRanges: bind(walkLineRanges), + layoutNextLine: bind(layoutNextLine), + layoutWithLines: bind(layoutWithLines), + clearCache: bind(clearCache), + setLocale, + } +} diff --git a/src/layout.test.ts b/src/layout.test.ts index 3b5d01bb..7ccd611f 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -625,3 +625,93 @@ describe('layout invariants', () => { } }) }) + +describe('host config', () => { + test('createPretext binds prepare/layout to an injected measurement host', async () => { + const hostMod = await import('./host.ts') as { + createPretext: (config: { + measurement: { + clearMeasurementCaches(): void + getSegmentMetrics(seg: string, cache: Map): { width: number, containsCJK: boolean } + getEngineProfile(): { + lineFitEpsilon: number + carryCJKAfterClosingQuote: boolean + preferPrefixWidthsForBreakableRuns: boolean + preferEarlySoftHyphenBreak: boolean + } + getCorrectedSegmentWidth( + seg: string, + metrics: { width: number, containsCJK: boolean }, + emojiCorrection: number, + ): number + getSegmentGraphemeWidths(): null + getSegmentGraphemePrefixWidths(): null + getFontMeasurementState(font: string, needsEmojiCorrection: boolean): { + cache: Map + fontSize: number + emojiCorrection: number + } + textMayContainEmoji(text: string): boolean + } + }) => { + prepare(text: string, font: string): ReturnType + layout(prepared: ReturnType, maxWidth: number, lineHeight: number): ReturnType + clearCache(): void + } + } + + let cleared = 0 + const measuredSegments: string[] = [] + const cache = new Map() + const api = hostMod.createPretext({ + measurement: { + clearMeasurementCaches() { + cleared++ + cache.clear() + }, + getSegmentMetrics(seg: string) { + measuredSegments.push(seg) + return { + width: measureWidth(seg, FONT), + containsCJK: seg.length > 0 && isWideCharacter(seg[0]!), + } + }, + getEngineProfile() { + return { + lineFitEpsilon: 0.005, + carryCJKAfterClosingQuote: false, + preferPrefixWidthsForBreakableRuns: false, + preferEarlySoftHyphenBreak: false, + } + }, + getCorrectedSegmentWidth(_seg, metrics) { + return metrics.width + }, + getSegmentGraphemeWidths() { + return null + }, + getSegmentGraphemePrefixWidths() { + return null + }, + getFontMeasurementState(font: string, _needsEmojiCorrection: boolean) { + return { + cache, + fontSize: parseFontSize(font), + emojiCorrection: 0, + } + }, + textMayContainEmoji() { + return false + }, + }, + }) + + const prepared = api.prepare('Hello world', FONT) + expect(api.layout(prepared, 60, LINE_HEIGHT).lineCount).toBeGreaterThan(0) + expect(measuredSegments).toContain('Hello') + expect(measuredSegments).toContain('world') + + api.clearCache() + expect(cleared).toBe(1) + }) +}) diff --git a/src/measurement.ts b/src/measurement.ts index b2fb6d57..32717a5f 100644 --- a/src/measurement.ts +++ b/src/measurement.ts @@ -15,9 +15,37 @@ export type EngineProfile = { preferEarlySoftHyphenBreak: boolean } +export type FontMeasurementState = { + cache: Map + fontSize: number + emojiCorrection: number +} + +export type MeasurementHost = { + clearMeasurementCaches(): void + getSegmentMetrics(seg: string, cache: Map): SegmentMetrics + getEngineProfile(): EngineProfile + getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number + getSegmentGraphemeWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getSegmentGraphemePrefixWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState + textMayContainEmoji(text: string): boolean +} + let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null const segmentMetricCaches = new Map>() let cachedEngineProfile: EngineProfile | null = null +let measurementHostOverride: MeasurementHost | null = null const emojiPresentationRe = /\p{Emoji_Presentation}/u const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u @@ -49,7 +77,7 @@ export function getSegmentMetricCache(font: string): Map return cache } -export function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics { +function browserGetSegmentMetrics(seg: string, cache: Map): SegmentMetrics { let metrics = cache.get(seg) if (metrics === undefined) { const ctx = getMeasureContext() @@ -62,7 +90,7 @@ export function getSegmentMetrics(seg: string, cache: Map, @@ -182,15 +214,15 @@ export function getSegmentGraphemeWidths( const widths: number[] = [] const graphemeSegmenter = getSharedGraphemeSegmenter() for (const gs of graphemeSegmenter.segment(seg)) { - const graphemeMetrics = getSegmentMetrics(gs.segment, cache) - widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection)) + const graphemeMetrics = browserGetSegmentMetrics(gs.segment, cache) + widths.push(browserGetCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection)) } metrics.graphemeWidths = widths.length > 1 ? widths : null return metrics.graphemeWidths } -export function getSegmentGraphemePrefixWidths( +function browserGetSegmentGraphemePrefixWidths( seg: string, metrics: SegmentMetrics, cache: Map, @@ -203,19 +235,15 @@ export function getSegmentGraphemePrefixWidths( let prefix = '' for (const gs of graphemeSegmenter.segment(seg)) { prefix += gs.segment - const prefixMetrics = getSegmentMetrics(prefix, cache) - prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection)) + const prefixMetrics = browserGetSegmentMetrics(prefix, cache) + prefixWidths.push(browserGetCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection)) } metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null return metrics.graphemePrefixWidths } -export function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): { - cache: Map - fontSize: number - emojiCorrection: number -} { +function browserGetFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState { const ctx = getMeasureContext() ctx.font = font const cache = getSegmentMetricCache(font) @@ -224,8 +252,75 @@ export function getFontMeasurementState(font: string, needsEmojiCorrection: bool return { cache, fontSize, emojiCorrection } } -export function clearMeasurementCaches(): void { +function browserClearMeasurementCaches(): void { segmentMetricCaches.clear() emojiCorrectionCache.clear() sharedGraphemeSegmenter = null } + +export const browserMeasurementHost: MeasurementHost = { + clearMeasurementCaches: browserClearMeasurementCaches, + getSegmentMetrics: browserGetSegmentMetrics, + getEngineProfile: browserGetEngineProfile, + getCorrectedSegmentWidth: browserGetCorrectedSegmentWidth, + getSegmentGraphemeWidths: browserGetSegmentGraphemeWidths, + getSegmentGraphemePrefixWidths: browserGetSegmentGraphemePrefixWidths, + getFontMeasurementState: browserGetFontMeasurementState, + textMayContainEmoji: browserTextMayContainEmoji, +} + +function getActiveMeasurementHost(): MeasurementHost { + return measurementHostOverride ?? browserMeasurementHost +} + +export function withMeasurementHost(measurementHost: MeasurementHost, fn: () => T): T { + const previousHost = measurementHostOverride + measurementHostOverride = measurementHost + try { + return fn() + } finally { + measurementHostOverride = previousHost + } +} + +export function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics { + return getActiveMeasurementHost().getSegmentMetrics(seg, cache) +} + +export function getEngineProfile(): EngineProfile { + return getActiveMeasurementHost().getEngineProfile() +} + +export function textMayContainEmoji(text: string): boolean { + return getActiveMeasurementHost().textMayContainEmoji(text) +} + +export function getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number { + return getActiveMeasurementHost().getCorrectedSegmentWidth(seg, metrics, emojiCorrection) +} + +export function getSegmentGraphemeWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, +): number[] | null { + return getActiveMeasurementHost().getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection) +} + +export function getSegmentGraphemePrefixWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, +): number[] | null { + return getActiveMeasurementHost().getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection) +} + +export function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState { + return getActiveMeasurementHost().getFontMeasurementState(font, needsEmojiCorrection) +} + +export function clearMeasurementCaches(): void { + getActiveMeasurementHost().clearMeasurementCaches() +}