- Resolve exact faces through registered, local, cache, and remote sources with deduplicated state transitions\n- Use CanvasKit notdef glyphs to request script fallbacks and repaint after resolution\n- Make CJK coverage analysis codepoint-safe across supplementary characters and style runs
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import {
|
|
FontResolver,
|
|
fontCoverageDemand,
|
|
missingGlyphCharacters,
|
|
missingGlyphScripts,
|
|
type FontResolutionCandidate,
|
|
type FontResolutionDemand,
|
|
type ObservedShapedLine
|
|
} from '@open-pencil/core/text'
|
|
|
|
function candidate(source: FontResolutionCandidate['source']): FontResolutionCandidate {
|
|
return { id: source, family: 'Example', style: 'Regular', source }
|
|
}
|
|
|
|
function faceDemand(): FontResolutionDemand {
|
|
return {
|
|
key: 'face:example:regular',
|
|
candidates: ['registered', 'local', 'cache', 'remote'].map((source) =>
|
|
candidate(source as FontResolutionCandidate['source'])
|
|
)
|
|
}
|
|
}
|
|
|
|
function shapedLine(textLength: number, glyphs: number[], offsets: number[]): ObservedShapedLine {
|
|
return {
|
|
textRange: { last: textLength },
|
|
runs: [
|
|
{
|
|
glyphs: Uint16Array.from(glyphs),
|
|
offsets: Uint32Array.from(offsets)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
describe('FontResolver', () => {
|
|
test('tries face candidates in source order', async () => {
|
|
const attempted: string[] = []
|
|
const resolver = new FontResolver(async (item) => {
|
|
attempted.push(item.source)
|
|
return item.source === 'remote'
|
|
})
|
|
|
|
const result = await resolver.demand(faceDemand())
|
|
|
|
expect(attempted).toEqual(['registered', 'local', 'cache', 'remote'])
|
|
expect(result.state).toBe('loaded')
|
|
expect(result.source).toBe('remote')
|
|
})
|
|
|
|
test('deduplicates concurrent demand and collects settle callbacks', async () => {
|
|
let release: ((loaded: boolean) => void) | undefined
|
|
let loads = 0
|
|
const resolver = new FontResolver(
|
|
() =>
|
|
new Promise<boolean>((resolve) => {
|
|
loads++
|
|
release = resolve
|
|
})
|
|
)
|
|
const settled: string[] = []
|
|
const demand = { key: 'shared', candidates: [candidate('local')] }
|
|
|
|
const first = resolver.demand(demand, () => settled.push('first'))
|
|
const second = resolver.demand(demand, () => settled.push('second'))
|
|
|
|
expect(first).toBe(second)
|
|
expect(loads).toBe(1)
|
|
expect(resolver.state(demand).state).toBe('loading')
|
|
release?.(true)
|
|
await first
|
|
expect(settled).toEqual(['first', 'second'])
|
|
})
|
|
|
|
test('exhausts after every candidate is unavailable', async () => {
|
|
const resolver = new FontResolver(async () => false)
|
|
const demand = faceDemand()
|
|
|
|
expect((await resolver.demand(demand)).state).toBe('exhausted')
|
|
expect(resolver.state(demand).state).toBe('exhausted')
|
|
})
|
|
|
|
test('records loader failures and retries explicitly', async () => {
|
|
let attempts = 0
|
|
const resolver = new FontResolver(async () => {
|
|
attempts++
|
|
if (attempts === 1) throw new Error('provider offline')
|
|
return true
|
|
})
|
|
const demand = { key: 'retry', candidates: [candidate('remote')] }
|
|
|
|
expect((await resolver.demand(demand)).state).toBe('failed')
|
|
expect((await resolver.retry(demand)).state).toBe('loaded')
|
|
expect(attempts).toBe(2)
|
|
})
|
|
|
|
test('reset returns a settled key to idle', async () => {
|
|
const resolver = new FontResolver(async () => true)
|
|
const demand = { key: 'reset', candidates: [candidate('local')] }
|
|
await resolver.demand(demand)
|
|
|
|
resolver.reset(demand)
|
|
|
|
expect(resolver.state(demand).state).toBe('idle')
|
|
})
|
|
})
|
|
|
|
describe('observed glyph coverage', () => {
|
|
test('maps CanvasKit UTF-8 offsets back to supplementary code points', () => {
|
|
const text = 'A𠀀B'
|
|
const lines = [shapedLine(6, [12, 0, 13], [0, 1, 5, 6])]
|
|
|
|
expect(missingGlyphCharacters(text, lines)).toEqual(['𠀀'])
|
|
expect(missingGlyphScripts(text, lines)).toEqual(['cjk-sc'])
|
|
})
|
|
|
|
test('supports UTF-16 offsets from mocked shapers', () => {
|
|
const text = 'éAB'
|
|
const lines = [shapedLine(text.length, [12, 13, 0], [0, 1, 2, 3])]
|
|
|
|
expect(missingGlyphCharacters(text, lines)).toEqual(['B'])
|
|
})
|
|
|
|
test('does not report non-zero glyphs as missing', () => {
|
|
const lines = [shapedLine(2, [12, 13], [0, 1, 2])]
|
|
expect(missingGlyphCharacters('你好', lines)).toEqual([])
|
|
})
|
|
|
|
test('creates distinct coverage keys for distinct observed characters', () => {
|
|
expect(fontCoverageDemand('cjk-sc', ['你']).key).not.toBe(
|
|
fontCoverageDemand('cjk-sc', ['𠀀']).key
|
|
)
|
|
})
|
|
})
|