* perf(text): measure each text once per shaping and layout width Layout asked for the same text at the same width hundreds of times per build, and each request built and laid out a new paragraph. Sizes are now cached beside glyph coverage, keyed by the same shaping inputs, so layout resizing the box keeps them. * refactor(text): reuse Size for measured text * perf(text): keep measurements when a paragraph rebuilds for a new box The paragraph cache dropped coverage and sizes whenever its own inputs changed, including a resize that reaches drawing without a graph update. Shaping compares its own inputs, so it is kept there.
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import type { CanvasKit, Paragraph } from 'canvaskit-wasm'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
import { buildParagraph, nodeFontReadiness } from '#core/canvas/text'
|
|
import type { ParagraphNode } from '#core/canvas/text/paragraph-inputs'
|
|
import { TextPreparationCache } from '#core/canvas/text/preparation-cache'
|
|
import { fontManager } from '#core/text/fonts'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
async function fixture(maxEntries = 8, maxUnits = 1000) {
|
|
const ck = await initCanvasKit()
|
|
const provider = ck.TypefaceFontProvider.Make()
|
|
const graph = new SceneGraph()
|
|
const node = graph.createNode('TEXT', graph.getPages()[0].id, {
|
|
text: 'Prepared text',
|
|
width: 200,
|
|
height: 50
|
|
})
|
|
const cache = new TextPreparationCache(maxEntries, maxUnits)
|
|
const built: Paragraph[] = []
|
|
function build() {
|
|
const builder = ck.ParagraphBuilder.MakeFromFontProvider(
|
|
new ck.ParagraphStyle({ textStyle: { fontSize: 16 } }),
|
|
provider
|
|
)
|
|
builder.addText(node.text)
|
|
const paragraph = builder.build()
|
|
builder.delete()
|
|
paragraph.layout(node.width)
|
|
built.push(paragraph)
|
|
return paragraph
|
|
}
|
|
function use(variant = 'draw', generation = 1) {
|
|
return cache.use(node, variant, generation, provider, build, ({ paragraph }) =>
|
|
paragraph.getHeight()
|
|
)
|
|
}
|
|
return {
|
|
ck,
|
|
provider,
|
|
graph,
|
|
node,
|
|
cache,
|
|
built,
|
|
build,
|
|
use,
|
|
dispose() {
|
|
cache.clear()
|
|
provider.delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('text preparation cache', () => {
|
|
test('does not repeatedly shape coverage when a dense scan exceeds paragraph capacity', async () => {
|
|
const f = await fixture(8)
|
|
const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
|
f.provider.registerFont(data, 'Inter')
|
|
fontManager.markLoaded('Inter', 'Regular', data)
|
|
let builds = 0
|
|
const ck: CanvasKit = {
|
|
...f.ck,
|
|
ParagraphBuilder: {
|
|
...f.ck.ParagraphBuilder,
|
|
MakeFromFontProvider(
|
|
...args: Parameters<typeof f.ck.ParagraphBuilder.MakeFromFontProvider>
|
|
) {
|
|
builds++
|
|
return f.ck.ParagraphBuilder.MakeFromFontProvider(...args)
|
|
}
|
|
}
|
|
}
|
|
const renderer = {
|
|
ck,
|
|
fontProvider: f.provider,
|
|
fontsLoaded: true,
|
|
textPreparationCache: f.cache
|
|
}
|
|
const nodes = Array.from({ length: 6 }, (_, i) =>
|
|
f.graph.createNode('TEXT', expectDefined(f.node.parentId, 'text node parent'), {
|
|
text: `Label ${i}`,
|
|
fontFamily: 'Inter',
|
|
fontWeight: 400,
|
|
width: 100,
|
|
height: 30
|
|
})
|
|
)
|
|
function draw() {
|
|
for (const node of nodes) {
|
|
expect(nodeFontReadiness(renderer, node)).toBe('ready')
|
|
f.cache.use(
|
|
node,
|
|
'draw',
|
|
fontManager.generation(),
|
|
f.provider,
|
|
() => buildParagraph(renderer, node),
|
|
({ paragraph }) => paragraph.getHeight()
|
|
)
|
|
}
|
|
}
|
|
try {
|
|
draw()
|
|
expect(builds).toBe(12)
|
|
draw()
|
|
const warmBuilds = builds
|
|
draw()
|
|
expect(builds).toBe(warmBuilds)
|
|
f.cache.deleteNode(nodes[0].id)
|
|
draw()
|
|
expect(builds).toBe(warmBuilds + 2)
|
|
f.graph.updateNodePreview(nodes[0].id, { text: 'Edited label' })
|
|
draw()
|
|
expect(builds).toBe(warmBuilds + 4)
|
|
f.cache.clear()
|
|
draw()
|
|
expect(builds).toBe(warmBuilds + 16)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('scopes successful coverage to fonts, inputs and explicit invalidation', async () => {
|
|
const f = await fixture(2)
|
|
const otherProvider = f.ck.TypefaceFontProvider.Make()
|
|
try {
|
|
f.use('coverage', 1)
|
|
f.cache.recordGlyphCoverage(f.node)
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
|
expect(f.cache.hasGlyphCoverage(f.node, 2, f.provider)).toBe(false)
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, otherProvider)).toBe(false)
|
|
f.graph.updateNodePreview(f.node.id, { x: 50, rotation: 20 })
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
|
// Layout resizing the box shapes the same glyphs, unless truncation hides some of them.
|
|
f.graph.updateNodePreview(f.node.id, { width: 80 })
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
|
f.cache.deleteNode(f.node.id, { keepShaping: true })
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
|
f.graph.updateNodePreview(f.node.id, { textTruncation: 'ENDING' })
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
|
f.cache.recordGlyphCoverage(f.node)
|
|
f.graph.updateNodePreview(f.node.id, { width: 60 })
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
|
f.graph.updateNodePreview(f.node.id, { textTruncation: 'DISABLED' })
|
|
f.cache.recordGlyphCoverage(f.node)
|
|
f.cache.deleteNode(f.node.id)
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
|
f.use('coverage', 1)
|
|
f.cache.recordGlyphCoverage(f.node)
|
|
f.cache.deleteNode('unrelated')
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
|
|
f.cache.deleteNode('another')
|
|
f.cache.deleteNode('overflow')
|
|
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
|
|
f.cache.recordGlyphCoverage(f.node)
|
|
f.use('draw', 2)
|
|
expect(f.cache.hasGlyphCoverage(f.node, 2, f.provider)).toBe(false)
|
|
} finally {
|
|
otherProvider.delete()
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('measures a text once per shaping and layout width', async () => {
|
|
const f = await fixture()
|
|
let measured = 0
|
|
const measure = (width: number, generation = 1) =>
|
|
f.cache.measure(f.node, width, generation, f.provider, () => {
|
|
measured++
|
|
return { width, height: f.node.text.length }
|
|
})
|
|
try {
|
|
measure(200)
|
|
measure(200)
|
|
measure(120)
|
|
expect(measured).toBe(2)
|
|
// Layout resizing the box changes neither the shaping nor the sizes at a given width.
|
|
f.graph.updateNodePreview(f.node.id, { width: 80, height: 30 })
|
|
f.cache.deleteNode(f.node.id, { keepShaping: true })
|
|
measure(200)
|
|
expect(measured).toBe(2)
|
|
// Drawing rebuilds its paragraph for the new box without dropping the measurements.
|
|
f.use()
|
|
f.graph.updateNodePreview(f.node.id, { width: 90 })
|
|
f.use()
|
|
expect(f.built).toHaveLength(2)
|
|
measure(200)
|
|
expect(measured).toBe(2)
|
|
f.graph.updateNodePreview(f.node.id, { text: 'Edited text' })
|
|
expect(measure(200).height).toBe('Edited text'.length)
|
|
expect(measured).toBe(3)
|
|
f.cache.deleteNode(f.node.id)
|
|
measure(200)
|
|
expect(measured).toBe(4)
|
|
measure(200, 2)
|
|
expect(measured).toBe(5)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('reuses native paragraphs during movement and invalidates text layout inputs', async () => {
|
|
const f = await fixture()
|
|
try {
|
|
f.use()
|
|
f.graph.updateNodePreview(f.node.id, { x: 30, y: 40, rotation: 25 })
|
|
f.use()
|
|
expect(f.built).toHaveLength(1)
|
|
// Exhaustive against the same input type accepted by paragraph construction.
|
|
const mutations: ParagraphNode = {
|
|
text: 'Different text',
|
|
fontFamily: 'Other',
|
|
fontSize: 20,
|
|
fontWeight: 700,
|
|
italic: true,
|
|
fontFeatures: [{ tag: 'liga', enabled: false }],
|
|
fontVariations: [{ axis: 'wght', value: 700 }],
|
|
textLanguage: 'ar',
|
|
textDirection: 'RTL',
|
|
textCase: 'UPPER',
|
|
letterSpacing: 2,
|
|
lineHeight: 30,
|
|
textAlignHorizontal: 'CENTER',
|
|
leadingTrim: 'CAP_HEIGHT',
|
|
textDecoration: 'UNDERLINE',
|
|
textDecorationStyle: 'WAVY',
|
|
textDecorationThickness: 2,
|
|
textDecorationFills: [
|
|
{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
|
],
|
|
textTruncation: 'ENDING',
|
|
maxLines: 2,
|
|
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
width: 100,
|
|
height: 80,
|
|
styleRuns: [{ start: 0, length: 4, style: { fontSize: 24 } }]
|
|
}
|
|
for (const [key, value] of Object.entries(mutations)) {
|
|
const previous = f.built.at(-1)
|
|
f.graph.updateNodePreview(f.node.id, { [key]: value })
|
|
f.use()
|
|
expect(previous?.isDeleted()).toBe(true)
|
|
}
|
|
expect(f.built).toHaveLength(1 + Object.keys(mutations).length)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('separates drawing/coverage variants and expires observations on font changes', async () => {
|
|
const f = await fixture()
|
|
const otherProvider = f.ck.TypefaceFontProvider.Make()
|
|
try {
|
|
const observe = (generation: number) =>
|
|
f.cache.use(f.node, 'coverage', generation, f.provider, f.build, (prepared) => {
|
|
const previous = prepared.missingGlyphs
|
|
prepared.missingGlyphs = []
|
|
return previous
|
|
})
|
|
expect(observe(1)).toBeUndefined()
|
|
expect(observe(1)).toEqual([])
|
|
f.use('draw:black')
|
|
f.use('draw:blue')
|
|
expect(f.built).toHaveLength(3)
|
|
expect(observe(2)).toBeUndefined()
|
|
expect(f.built.slice(0, 3).every((p) => p.isDeleted())).toBe(true)
|
|
f.cache.use(f.node, 'coverage', 2, otherProvider, f.build, (prepared) => {
|
|
expect(prepared.missingGlyphs).toBeUndefined()
|
|
})
|
|
expect(f.built[3].isDeleted()).toBe(true)
|
|
} finally {
|
|
f.dispose()
|
|
otherProvider.delete()
|
|
}
|
|
})
|
|
|
|
test('bounds native resource ownership, removes deleted nodes and clears idempotently', async () => {
|
|
const f = await fixture(2)
|
|
try {
|
|
f.use('a')
|
|
f.use('b')
|
|
f.use('a')
|
|
f.use('c')
|
|
expect(f.built[0].isDeleted()).toBe(false)
|
|
expect(f.built[1].isDeleted()).toBe(true)
|
|
f.cache.deleteNode(f.node.id)
|
|
expect(f.built.every((p) => p.isDeleted())).toBe(true)
|
|
f.use('a')
|
|
f.cache.clear()
|
|
f.cache.clear()
|
|
expect(f.built.every((p) => p.isDeleted())).toBe(true)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('evicts by aggregate text budget before reaching the paragraph count limit', async () => {
|
|
const f = await fixture(8, 20)
|
|
try {
|
|
f.use('a')
|
|
f.use('b')
|
|
expect(f.built).toHaveLength(2)
|
|
expect(f.built[0].isDeleted()).toBe(true)
|
|
expect(f.built[1].isDeleted()).toBe(false)
|
|
f.use('b')
|
|
expect(f.built).toHaveLength(2)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('does not retain oversized text and deletes uncached paragraphs after errors', async () => {
|
|
const f = await fixture(8, 2)
|
|
try {
|
|
f.use()
|
|
f.use()
|
|
expect(f.built).toHaveLength(2)
|
|
expect(f.built.every((p) => p.isDeleted())).toBe(true)
|
|
expect(() =>
|
|
f.cache.use(f.node, 'draw', 1, f.provider, f.build, () => {
|
|
throw new Error('draw failed')
|
|
})
|
|
).toThrow('draw failed')
|
|
expect(f.built.every((p) => p.isDeleted())).toBe(true)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
})
|