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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions src/host.ts
Original file line number Diff line number Diff line change
@@ -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 = <Args extends unknown[], Result>(
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,
}
}
90 changes: 90 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): { 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<string, unknown>
fontSize: number
emojiCorrection: number
}
textMayContainEmoji(text: string): boolean
}
}) => {
prepare(text: string, font: string): ReturnType<LayoutModule['prepare']>
layout(prepared: ReturnType<LayoutModule['prepare']>, maxWidth: number, lineHeight: number): ReturnType<LayoutModule['layout']>
clearCache(): void
}
}

let cleared = 0
const measuredSegments: string[] = []
const cache = new Map<string, unknown>()
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)
})
})
127 changes: 111 additions & 16 deletions src/measurement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,37 @@ export type EngineProfile = {
preferEarlySoftHyphenBreak: boolean
}

export type FontMeasurementState = {
cache: Map<string, SegmentMetrics>
fontSize: number
emojiCorrection: number
}

export type MeasurementHost = {
clearMeasurementCaches(): void
getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics
getEngineProfile(): EngineProfile
getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number
getSegmentGraphemeWidths(
seg: string,
metrics: SegmentMetrics,
cache: Map<string, SegmentMetrics>,
emojiCorrection: number,
): number[] | null
getSegmentGraphemePrefixWidths(
seg: string,
metrics: SegmentMetrics,
cache: Map<string, SegmentMetrics>,
emojiCorrection: number,
): number[] | null
getFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState
textMayContainEmoji(text: string): boolean
}

let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null
const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()
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
Expand Down Expand Up @@ -49,7 +77,7 @@ export function getSegmentMetricCache(font: string): Map<string, SegmentMetrics>
return cache
}

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
function browserGetSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
let metrics = cache.get(seg)
if (metrics === undefined) {
const ctx = getMeasureContext()
Expand All @@ -62,7 +90,7 @@ export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics
return metrics
}

export function getEngineProfile(): EngineProfile {
function browserGetEngineProfile(): EngineProfile {
if (cachedEngineProfile !== null) return cachedEngineProfile

if (typeof navigator === 'undefined') {
Expand Down Expand Up @@ -116,7 +144,7 @@ function isEmojiGrapheme(g: string): boolean {
return emojiPresentationRe.test(g) || g.includes('\uFE0F')
}

export function textMayContainEmoji(text: string): boolean {
function browserTextMayContainEmoji(text: string): boolean {
return maybeEmojiRe.test(text)
}

Expand Down Expand Up @@ -166,12 +194,16 @@ function getEmojiCount(seg: string, metrics: SegmentMetrics): number {
return metrics.emojiCount
}

export function getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number {
function browserGetCorrectedSegmentWidth(
seg: string,
metrics: SegmentMetrics,
emojiCorrection: number,
): number {
if (emojiCorrection === 0) return metrics.width
return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

export function getSegmentGraphemeWidths(
function browserGetSegmentGraphemeWidths(
seg: string,
metrics: SegmentMetrics,
cache: Map<string, SegmentMetrics>,
Expand All @@ -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<string, SegmentMetrics>,
Expand All @@ -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<string, SegmentMetrics>
fontSize: number
emojiCorrection: number
} {
function browserGetFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState {
const ctx = getMeasureContext()
ctx.font = font
const cache = getSegmentMetricCache(font)
Expand All @@ -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<T>(measurementHost: MeasurementHost, fn: () => T): T {
const previousHost = measurementHostOverride
measurementHostOverride = measurementHost
try {
return fn()
} finally {
measurementHostOverride = previousHost
}
}

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): 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<string, SegmentMetrics>,
emojiCorrection: number,
): number[] | null {
return getActiveMeasurementHost().getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection)
}

export function getSegmentGraphemePrefixWidths(
seg: string,
metrics: SegmentMetrics,
cache: Map<string, SegmentMetrics>,
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()
}