openpencil/tests/engine/render/canvas/labels/fonts.test.ts
Danila Poyarkov b458e3c3ae perf(canvas): reuse labels with shared font fallback
Acquire section paragraphs once, reuse proven fitting layouts, and bound retained text with borrowed native-resource lifetimes. Share document text family selection and Arabic/CJK coverage resolution rather than preserving missing glyphs. Validate exact zoom parity with real existing font fixtures.
2026-09-15 23:08:53 +03:00

148 lines
4.6 KiB
TypeScript

import { beforeAll, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { initCanvasKit } from '#cli/headless'
import { LabelParagraphCache } from '#core/canvas/labels/paragraph-cache'
import { resolveLabelFontCoverage } from '#core/canvas/renderer/fonts'
import { resolveParagraphFontFamilies } from '#core/canvas/text/font-families'
import { FontResolver, missingGlyphOccurrences } from '#core/text/resolver'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
test.each([
{
text: 'مرحبا بالعالم',
family: 'Noto Naskh Arabic',
path: 'tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf',
arabic: true
},
{
text: '你好世界',
family: 'Noto Sans SC',
path: 'tests/fixtures/fonts/NotoSansSC-Regular.ttf',
arabic: false
}
])('labels reuse shared fallback loading and render real $family glyphs', async (fixture) => {
const provider = ck.TypefaceFontProvider.Make()
provider.registerFont(readFileSync(repoPath('public/Inter-Regular.ttf')), 'Inter')
const release = Promise.withResolvers<undefined>()
const settled = Promise.withResolvers<undefined>()
let generation = 1
let loads = 0
let loaded = false
const resolver = new FontResolver(async () => {
loads++
await release.promise
provider.registerFont(readFileSync(repoPath(fixture.path)), fixture.family)
loaded = true
return true
})
const owner = {
isDestroyed: () => false,
onFontResolutionSettled: () => {
generation++
settled.resolve(undefined)
}
}
const families = () =>
resolveParagraphFontFamilies(
'Inter',
'Regular',
loaded && fixture.arabic ? [fixture.family] : [],
loaded && !fixture.arabic ? [fixture.family] : []
)
const fresh = new LabelParagraphCache(0, undefined, { families })
const cache = new LabelParagraphCache(undefined, undefined, {
families,
onMissingGlyphs: (missing) => resolveLabelFontCoverage(owner, missing, resolver)
})
const surface = expectDefined(ck.MakeSurface(320, 64), 'label font surface')
try {
cache.use(ck, provider, fixture.text, 24, 300, ck.BLACK, generation, ({ paragraph }) => {
expect(
missingGlyphOccurrences(fixture.text, paragraph.getShapedLines()).length
).toBeGreaterThan(0)
})
cache.measure(ck, provider, fixture.text, 24, 300, ck.BLACK, generation)
expect(loads).toBe(1)
release.resolve(undefined)
await settled.promise
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
cache.use(ck, provider, fixture.text, 24, 300, ck.BLACK, generation, ({ paragraph }) => {
expect(missingGlyphOccurrences(fixture.text, paragraph.getShapedLines())).toEqual([])
canvas.drawParagraph(paragraph, 2, 2)
})
surface.flush()
const image = surface.makeImageSnapshot()
try {
const pixels = expectDefined(
image.readPixels(0, 0, {
width: 320,
height: 64,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'label font pixels'
)
let ink = 0
for (let i = 0; i < pixels.length; i += 4) if (pixels[i] < 128 && pixels[i + 3] > 0) ink++
expect(ink).toBeGreaterThan(40)
} finally {
image.delete()
}
const intrinsic = cache.measure(ck, provider, fixture.text, 24, 300, ck.BLACK, generation).width
const shape = (candidate: LabelParagraphCache, width: number, weight: number) =>
candidate.use(
ck,
provider,
fixture.text,
24,
width,
ck.BLACK,
generation,
({ paragraph, metrics }) => ({
metrics,
runs: paragraph.getShapedLines().map((line) =>
line.runs.map((run) => ({
glyphs: [...run.glyphs],
positions: [...run.positions],
offsets: [...run.offsets]
}))
)
}),
weight
)
for (const weight of [400, 600]) {
for (const width of [
300,
intrinsic - 1,
intrinsic,
intrinsic + 0.01,
intrinsic + 0.25,
intrinsic + 0.75,
intrinsic + 1,
intrinsic + 2,
300
]) {
expect(shape(cache, width, weight)).toEqual(shape(fresh, width, weight))
}
}
expect(loads).toBe(1)
} finally {
release.resolve(undefined)
if (loads > 0) await settled.promise
cache.clear()
fresh.clear()
surface.delete()
provider.delete()
}
})