diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index 0dc783db3..f853daefd 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -134,6 +134,12 @@ function demandRemoteCoverage(r: TextRenderer, node: SceneNode, characters: stri } function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadiness { + if ( + r.fontProvider && + r.textPreparationCache?.hasGlyphCoverage(node, fontManager.generation(), r.fontProvider) + ) + return 'ready' + const missingOccurrences = withPreparedText( r, node, @@ -151,7 +157,10 @@ function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadi return prepared.missingGlyphs } ) - if (missingOccurrences.length === 0) return 'ready' + if (missingOccurrences.length === 0) { + r.textPreparationCache?.recordGlyphCoverage(node) + return 'ready' + } const charactersByScript = new Map() for (const { character, utf16Start } of missingOccurrences) { diff --git a/packages/core/src/canvas/text/preparation-cache.ts b/packages/core/src/canvas/text/preparation-cache.ts index a89b0db19..3eafa39f5 100644 --- a/packages/core/src/canvas/text/preparation-cache.ts +++ b/packages/core/src/canvas/text/preparation-cache.ts @@ -27,6 +27,10 @@ interface Entry extends PreparedText { export class TextPreparationCache { private readonly entries = new Map() private readonly nodeKeys = new Map>() + // A successful coverage check contains no native resources. Keep that result + // independently of paragraph LRU eviction, weakly owned by the source node. + private glyphCoverage = new WeakMap() + private readonly invalidatedCoverage = new Set() private units = 0 private generation = -1 private provider: TypefaceFontProvider | null = null @@ -91,7 +95,36 @@ export class TextPreparationCache { return consume(entry) } + hasGlyphCoverage(node: SceneNode, generation: number, provider: TypefaceFontProvider): boolean { + if (this.generation !== generation || this.provider !== provider) return false + if (this.invalidatedCoverage.delete(node.id)) { + this.glyphCoverage.delete(node) + return false + } + const inputs = this.glyphCoverage.get(node) + if (!inputs) return false + if (PARAGRAPH_INPUT_KEYS.every((prop, index) => inputs[index] === node[prop])) return true + this.glyphCoverage.delete(node) + return false + } + + /** Call only after observing complete coverage with this cache's current font scope. */ + recordGlyphCoverage(node: SceneNode): void { + this.invalidatedCoverage.delete(node.id) + this.glyphCoverage.set( + node, + PARAGRAPH_INPUT_KEYS.map((prop) => node[prop]) + ) + } + deleteNode(id: string): void { + // Invalidation arrives by ID; don't add strong node ownership just to find + // weak observations. Bound pending IDs and conservatively reset on overflow. + this.invalidatedCoverage.add(id) + if (this.invalidatedCoverage.size > this.maxEntries) { + this.glyphCoverage = new WeakMap() + this.invalidatedCoverage.clear() + } const keys = this.nodeKeys.get(id) if (keys) for (const key of keys) this.remove(key) } @@ -100,6 +133,8 @@ export class TextPreparationCache { for (const entry of this.entries.values()) entry.paragraph.delete() this.entries.clear() this.nodeKeys.clear() + this.glyphCoverage = new WeakMap() + this.invalidatedCoverage.clear() this.units = 0 } diff --git a/tests/e2e/canvas/dense-text-preview.spec.ts b/tests/e2e/canvas/dense-text-preview.spec.ts new file mode 100644 index 000000000..f20014842 --- /dev/null +++ b/tests/e2e/canvas/dense-text-preview.spec.ts @@ -0,0 +1,101 @@ +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' +import { probeParagraphBuilds } from '#tests/helpers/canvas/text-preparation' + +const editor = useEditorSetupWithClear('/?test&no-rulers&navigation-benchmark') + +test('dense text keeps exact pixels without reshaping unchanged labels during previews', async () => { + const interaction = await editor.page.evaluateHandle(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + return store.beginInteractiveEdit() + }) + try { + const previewId = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + const page = store.state.currentPageId + const group = store.graph.createNode('FRAME', page, { + name: 'Dense text', + width: 8000, + height: 3600, + fills: [] + }) + // Coverage and drawing formerly needed 1,200 entries in a 1,024-paragraph cache. + for (let i = 0; i < 600; i++) { + store.graph.createNode('TEXT', group.id, { + x: (i % 20) * 400, + y: Math.floor(i / 20) * 120, + width: 380, + height: 110, + fontFamily: 'Inter', + fontWeight: 400, + fontSize: 48, + text: `Label ${i}`, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + } + const marker = store.graph.createNode('RECTANGLE', page, { + x: -10000, + y: -10000, + width: 10, + height: 10 + }) + store.clearSelection() + store.setZoomAroundPoint(0.06, 0, 0) + store.pan(-store.state.panX, -store.state.panY) + store.requestRender() + return marker.id + }) + await editor.page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + await editor.page.evaluate((id) => { + const store = window.openPencil?.getStore?.() + if (!store?.renderer) throw new Error('Renderer unavailable') + store.renderer.textPreparationCache.clear() + store.graph.updateNodePreview(id, { x: -10004 }) + store.requestRepaint() + }, previewId) + await editor.canvas.waitForRender() + const reference = await editor.canvas.screenshotCanvasRegion(520, 240) + expect(reference).toMatchSnapshot('dense-text-preview.png', { threshold: 0, maxDiffPixels: 0 }) + + // One additional pass lets drawing paragraphs occupy the cache after coverage + // observations have been memoized. Subsequent preview frames must not shape text. + await editor.page.evaluate((id) => { + const store = window.openPencil?.getStore?.() + store?.graph.updateNodePreview(id, { x: -10003 }) + store?.requestRepaint() + }, previewId) + await editor.canvas.waitForRender() + const probe = await probeParagraphBuilds(editor.page) + try { + for (const x of [-10002, -10001, -10000]) { + await editor.page.evaluate( + ({ id, x }) => { + const store = window.openPencil?.getStore?.() + store?.graph.updateNodePreview(id, { x }) + store?.requestRepaint() + }, + { id: previewId, x } + ) + await editor.canvas.waitForRender() + } + expect(await probe.evaluate((probe) => probe.readiness())).toBeGreaterThanOrEqual(1800) + expect(await probe.evaluate((probe) => probe.count())).toBe(0) + const actual = await editor.canvas.screenshotCanvasRegion(520, 240) + expect(actual.equals(reference)).toBe(true) + editor.canvas.assertNoErrors() + } finally { + try { + await probe.evaluate((probe) => probe.restore()) + } finally { + await probe.dispose() + } + } + } finally { + try { + await interaction.evaluate((end) => end()) + } finally { + await interaction.dispose() + } + } +}) diff --git a/tests/e2e/canvas/dense-text-preview.spec.ts-snapshots/dense-text-preview-openpencil-darwin.png b/tests/e2e/canvas/dense-text-preview.spec.ts-snapshots/dense-text-preview-openpencil-darwin.png new file mode 100644 index 000000000..676a89643 Binary files /dev/null and b/tests/e2e/canvas/dense-text-preview.spec.ts-snapshots/dense-text-preview-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/typography-depth-visual.spec.ts b/tests/e2e/canvas/typography-depth-visual.spec.ts index 4b49b5bdb..130d774e1 100644 --- a/tests/e2e/canvas/typography-depth-visual.spec.ts +++ b/tests/e2e/canvas/typography-depth-visual.spec.ts @@ -1,4 +1,5 @@ import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' +import { probeParagraphBuilds } from '#tests/helpers/canvas/text-preparation' const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') @@ -88,23 +89,7 @@ test('text case vertical alignment and ending truncation', async () => { }, previewId) await editor.canvas.waitForRender() const reference = await editor.canvas.screenshotCanvasRegion() - const preparations = await editor.page.evaluateHandle(() => { - const renderer = window.openPencil?.getStore?.().renderer - if (!renderer) throw new Error('Renderer unavailable') - const builder = renderer.ck.ParagraphBuilder - const original = builder.MakeFromFontProvider - let count = 0 - builder.MakeFromFontProvider = (...args) => { - count++ - return original.apply(builder, args) - } - return { - count: () => count, - restore: () => { - builder.MakeFromFontProvider = original - } - } - }) + const preparations = await probeParagraphBuilds(editor.page) try { for (let step = 0; step < 3; step++) { await editor.page.evaluate( diff --git a/tests/engine/render/canvas/text-preparation-cache.test.ts b/tests/engine/render/canvas/text-preparation-cache.test.ts index c40e5f30b..5070afc6b 100644 --- a/tests/engine/render/canvas/text-preparation-cache.test.ts +++ b/tests/engine/render/canvas/text-preparation-cache.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from 'bun:test' -import type { Paragraph } from 'canvaskit-wasm' +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' async function fixture(maxEntries = 8, maxUnits = 1000) { const ck = await initCanvasKit() @@ -53,6 +55,102 @@ async function fixture(maxEntries = 8, maxUnits = 1000) { } 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 + ) { + 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', f.node.parentId, { + 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) + f.graph.updateNodePreview(f.node.id, { width: 80 }) + 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('reuses native paragraphs during movement and invalidates text layout inputs', async () => { const f = await fixture() try { diff --git a/tests/helpers/canvas/text-preparation.ts b/tests/helpers/canvas/text-preparation.ts new file mode 100644 index 000000000..341332034 --- /dev/null +++ b/tests/helpers/canvas/text-preparation.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' + +export function probeParagraphBuilds(page: Page) { + return page.evaluateHandle(() => { + const renderers = window.openPencil?.getStore?.().canvasRenderers + if (!renderers?.length) throw new Error('Renderer unavailable') + const restores: Array<() => void> = [] + let count = 0 + let readiness = 0 + for (const builder of new Set(renderers.map((renderer) => renderer.ck.ParagraphBuilder))) { + const original = builder.MakeFromFontProvider + builder.MakeFromFontProvider = (...args) => { + count++ + return original.apply(builder, args) + } + restores.push(() => { + builder.MakeFromFontProvider = original + }) + } + for (const renderer of renderers) { + const original = renderer.nodeFontReadiness + renderer.nodeFontReadiness = (node) => { + readiness++ + return original.call(renderer, node) + } + restores.push(() => { + renderer.nodeFontReadiness = original + }) + } + return { + count: () => count, + readiness: () => readiness, + restore: () => { + for (const restore of restores) restore() + } + } + }) +}