From b458e3c3aea015ab3ff1d1aef7d09b8dc0d7df30 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 23:08:53 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 4 +- packages/core/src/canvas/labels/draw.ts | 75 +++-- .../core/src/canvas/labels/paragraph-cache.ts | 246 +++++++++++++---- packages/core/src/canvas/renderer.ts | 4 +- packages/core/src/canvas/renderer/fonts.ts | 25 +- .../core/src/canvas/text/font-families.ts | 31 +++ packages/core/src/canvas/text/index.ts | 46 +--- packages/core/src/text/coverage.ts | 4 + packages/core/src/text/resolver/coverage.ts | 15 + tests/e2e/canvas/label-anatomy.spec.ts | 123 +++++++++ ...icode-section-labels-openpencil-darwin.png | Bin 0 -> 33976 bytes tests/e2e/fonts/cjk-fallback.spec.ts | 9 +- tests/e2e/fonts/cjk-rendering-visual.spec.ts | 5 + .../engine/render/canvas/labels/fonts.test.ts | 147 ++++++++++ .../canvas/labels/paragraph-reuse.test.ts | 257 ++++++++++++++++++ tests/helpers/fonts/fontsource.ts | 69 +++++ tests/helpers/fonts/runtime.ts | 11 + 17 files changed, 927 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/canvas/text/font-families.ts create mode 100644 tests/e2e/canvas/label-anatomy.spec.ts-snapshots/unicode-section-labels-openpencil-darwin.png create mode 100644 tests/engine/render/canvas/labels/fonts.test.ts create mode 100644 tests/engine/render/canvas/labels/paragraph-reuse.test.ts create mode 100644 tests/helpers/fonts/fontsource.ts create mode 100644 tests/helpers/fonts/runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 727b8af82..9832c6222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ - Match page-list density to the layer tree and add subtle, reduced-motion-aware dialog transitions. - Fade in streaming Markdown list items and code lines without animating completed responses. - Vertically center shaped section titles and allow renaming a section by double-clicking its canvas label. -- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography. +- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with Inter typography and shared Arabic/CJK font fallback. - Upgrade CanvasKit to 0.41 and use immutable renderer paths through `PathBuilder`. - Upgrade direct model chat providers and transports to AI SDK 7 while retaining the local ACP execution path. - Localize file, clipboard, collaboration, chat, vectorization, storage, recovery, and component-library notifications in every supported language. @@ -129,7 +129,7 @@ ### Performance - Reduce pauses after repeated frame creation without leaving hidden property edits or popups active. -- Reduce repeated text shaping and scene invalidation while moving and resizing objects. +- Reduce repeated text shaping and scene invalidation while moving and resizing objects, and reuse fitting canvas labels during zoom. - Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation. - Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots. - Show the FIG page list from a lightweight Kiwi scan before materializing the full document. diff --git a/packages/core/src/canvas/labels/draw.ts b/packages/core/src/canvas/labels/draw.ts index e5aba9bd4..3033a4fa1 100644 --- a/packages/core/src/canvas/labels/draw.ts +++ b/packages/core/src/canvas/labels/draw.ts @@ -35,54 +35,47 @@ function drawSectionTitle( nested: boolean, overlays?: RenderOverlays ): void { - let layout = labelLayout('section', node.width * r.zoom, nested) - if (!layout) return + const initialLayout = labelLayout('section', node.width * r.zoom, nested) + if (!initialLayout) return const { background, foreground, border, hover } = sectionLabelColors(r, graph, node) - const metrics = r.labelParagraphCache.measure( + r.labelParagraphCache.use( r.ck, provider, node.name, - layout.fontSize, - layout.maxTextWidth, + initialLayout.fontSize, + initialLayout.maxTextWidth, foreground, r.fontGeneration, - layout.fontWeight + ({ paragraph, metrics }) => { + const layout = labelLayout('section', node.width * r.zoom, nested, metrics) + if (!layout) return + canvas.save() + try { + canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r)) + r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a)) + const { x, y, width, height } = layout.bounds + const bounds = r.ck.RRectXY( + r.ck.LTRBRect(x, y, x + width, y + height), + SECTION_TITLE_RADIUS, + SECTION_TITLE_RADIUS + ) + canvas.drawRRect(bounds, r.auxFill) + if (overlays?.hoveredNodeId === node.id) { + r.auxFill.setColor(hover) + canvas.drawRRect(bounds, r.auxFill) + } + r.auxStroke.setColor(border) + r.auxStroke.setStrokeWidth(1) + r.auxStroke.setPathEffect(null) + canvas.drawRRect(bounds, r.auxStroke) + r.auxFill.setColor(foreground) + canvas.drawParagraph(paragraph, layout.text.x, layout.text.y) + } finally { + canvas.restore() + } + }, + initialLayout.fontWeight ) - layout = labelLayout('section', node.width * r.zoom, nested, metrics) - if (!layout) return - canvas.save() - canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r)) - r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a)) - const { x, y, width, height } = layout.bounds - const bounds = r.ck.RRectXY( - r.ck.LTRBRect(x, y, x + width, y + height), - SECTION_TITLE_RADIUS, - SECTION_TITLE_RADIUS - ) - canvas.drawRRect(bounds, r.auxFill) - if (overlays?.hoveredNodeId === node.id) { - r.auxFill.setColor(hover) - canvas.drawRRect(bounds, r.auxFill) - } - r.auxStroke.setColor(border) - r.auxStroke.setStrokeWidth(1) - r.auxStroke.setPathEffect(null) - canvas.drawRRect(bounds, r.auxStroke) - r.auxFill.setColor(foreground) - r.labelParagraphCache.draw( - r.ck, - canvas, - provider, - node.name, - layout.fontSize, - layout.maxTextWidth, - foreground, - r.fontGeneration, - layout.text.x, - layout.text.y, - layout.fontWeight - ) - canvas.restore() } export function drawComponentLabels( diff --git a/packages/core/src/canvas/labels/paragraph-cache.ts b/packages/core/src/canvas/labels/paragraph-cache.ts index 12d74c524..ccde57a33 100644 --- a/packages/core/src/canvas/labels/paragraph-cache.ts +++ b/packages/core/src/canvas/labels/paragraph-cache.ts @@ -7,15 +7,40 @@ import type { TypefaceFontProvider } from 'canvaskit-wasm' -import { ResourceCache } from '#core/cache/resource' +import type { Size } from '@open-pencil/scene-graph' -interface LabelParagraphEntry { - paragraph: Paragraph - width: number - height: number +import { ResourceCache } from '#core/cache/resource' +import { resolveParagraphFontFamilies } from '#core/canvas/text/font-families' +import { DEFAULT_FONT_FAMILY } from '#core/constants' +import { textHasFallbackScript } from '#core/text/coverage' +import { detectTextDirection } from '#core/text/direction' +import { weightToStyle } from '#core/text/fonts' +import { missingGlyphOccurrences, type MissingGlyphOccurrence } from '#core/text/resolver' + +export type LabelParagraphMetrics = Readonly + +/** Borrowed only for the synchronous use() callback; do not delete or retain the paragraph. */ +export interface BorrowedLabelParagraph { + readonly paragraph: Paragraph + readonly metrics: LabelParagraphMetrics +} + +export interface LabelFontOptions { + families?: (weight: number) => string[] + onMissingGlyphs?: (missing: readonly MissingGlyphOccurrence[]) => void +} + +interface LabelParagraphEntry extends BorrowedLabelParagraph { + units: number + layoutWidth: number + reuseFrom: number | null + users: number + retired: boolean } const MAX_LABEL_PARAGRAPHS = 512 +const MAX_LABEL_TEXT_UNITS = 262_144 +const HARD_BREAK = /[\r\n\f\v\u0085\u2028\u2029]/u export function measureGlyphWidth(font: Font, text: string): number { let width = 0 @@ -23,12 +48,138 @@ export function measureGlyphWidth(font: Font, text: string): number { return width } -export class LabelParagraphCache { - private readonly entries = new ResourceCache({ - maxEntries: MAX_LABEL_PARAGRAPHS, - dispose: (entry) => entry.paragraph.delete() +function retire(entry: LabelParagraphEntry): void { + entry.retired = true + if (entry.users === 0) entry.paragraph.delete() +} + +function buildEntry( + ck: CanvasKit, + provider: TypefaceFontProvider, + text: string, + fontSize: number, + width: number, + color: Float32Array, + fontWeight: number, + fonts: LabelFontOptions +): LabelParagraphEntry { + const style = new ck.ParagraphStyle({ + maxLines: 1, + ellipsis: '…', + textDirection: + detectTextDirection(text) === 'RTL' ? ck.TextDirection.RTL : ck.TextDirection.LTR, + textAlign: ck.TextAlign.Left, + textStyle: { + color, + fontFamilies: + fonts.families?.(fontWeight) ?? + resolveParagraphFontFamilies(DEFAULT_FONT_FAMILY, weightToStyle(fontWeight)), + fontSize, + fontStyle: { weight: { value: fontWeight } as FontWeight } + } }) + const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider) + let paragraph: Paragraph + try { + builder.addText(text) + paragraph = builder.build() + } finally { + builder.delete() + } + try { + paragraph.layout(width) + if (fonts.onMissingGlyphs && textHasFallbackScript(text)) { + fonts.onMissingGlyphs(missingGlyphOccurrences(text, paragraph.getShapedLines())) + } + const longest = paragraph.getLongestLine() + let reuseFrom: number | null = null + // Labels stay physically left-aligned, including RTL text; unwrapped text keeps its origin. + // Never speculate with an extra unconstrained layout, or reuse hard-break/ellipsized text. + if (!HARD_BREAK.test(text) && !paragraph.didExceedMaxLines()) { + const intrinsic = Math.max(paragraph.getMaxIntrinsicWidth(), longest) + if (Number.isFinite(intrinsic) && intrinsic <= width) reuseFrom = intrinsic + } + return { + paragraph, + metrics: { width: Math.min(longest, width), height: paragraph.getHeight() }, + units: text.length, + layoutWidth: width, + reuseFrom, + users: 0, + retired: false + } + } catch (error) { + paragraph.delete() + throw error + } +} + +export class LabelParagraphCache { + private readonly entries: ResourceCache private fontGeneration = -1 + private provider: TypefaceFontProvider | null = null + + constructor( + maxEntries = MAX_LABEL_PARAGRAPHS, + private readonly maxTextUnits = MAX_LABEL_TEXT_UNITS, + private readonly fonts: LabelFontOptions = {} + ) { + this.entries = new ResourceCache({ + maxEntries, + maxWeight: maxTextUnits, + weight: (entry) => entry.units, + dispose: retire + }) + } + + use( + ck: CanvasKit, + provider: TypefaceFontProvider, + text: string, + fontSize: number, + maxWidth: number, + color: Float32Array, + generation: number, + consume: (entry: BorrowedLabelParagraph) => T, + fontWeight = 400 + ): T { + if (generation !== this.fontGeneration || provider !== this.provider) { + this.clear() + this.fontGeneration = generation + this.provider = provider + } + const width = Math.max(1, maxWidth) + const base = + text.length <= this.maxTextUnits + ? `${fontSize}\0${fontWeight}\0${color[0]},${color[1]},${color[2]},${color[3]}\0${text}` + : null + const fitKey = `fit\0${base}` + const exactKey = `${width}\0${base}` + let entry: LabelParagraphEntry | undefined + if (base !== null) { + const fitting = this.entries.peek(fitKey) + // SkParagraph floors its SkScalar (float32) layout width before line breaking. + // This guards reuse only; pass the original width unchanged to native layout. + const fits = + fitting && + fitting.reuseFrom !== null && + (width === fitting.layoutWidth || Math.floor(Math.fround(width)) > fitting.reuseFrom) + entry = this.entries.get(fits ? fitKey : exactKey) + } + const cached = entry !== undefined + entry ??= buildEntry(ck, provider, text, fontSize, width, color, fontWeight, this.fonts) + entry.users++ + try { + if (!cached) { + const key = entry.reuseFrom === null ? exactKey : fitKey + if (base === null || !this.entries.set(key, entry)) entry.retired = true + } + return consume(entry) + } finally { + entry.users-- + if (entry.retired && entry.users === 0) entry.paragraph.delete() + } + } measure( ck: CanvasKit, @@ -39,8 +190,18 @@ export class LabelParagraphCache { color: Float32Array, generation: number, fontWeight = 400 - ): Pick { - return this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight) + ): LabelParagraphMetrics { + return this.use( + ck, + provider, + text, + fontSize, + maxWidth, + color, + generation, + (entry) => entry.metrics, + fontWeight + ) } draw( @@ -56,59 +217,32 @@ export class LabelParagraphCache { y: number, fontWeight = 400 ): number { - const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight) - canvas.drawParagraph(entry.paragraph, x, y) - return entry.width + return this.use( + ck, + provider, + text, + fontSize, + maxWidth, + color, + generation, + (entry) => { + canvas.drawParagraph(entry.paragraph, x, y) + return entry.metrics.width + }, + fontWeight + ) } clear(): void { + this.provider = null + this.fontGeneration = -1 this.entries.clear() } size(): number { return this.entries.size } - - private entry( - ck: CanvasKit, - provider: TypefaceFontProvider, - text: string, - fontSize: number, - maxWidth: number, - color: Float32Array, - generation: number, - fontWeight: number - ): LabelParagraphEntry { - if (generation !== this.fontGeneration) { - this.clear() - this.fontGeneration = generation - } - const boundedWidth = Math.max(1, maxWidth) - const key = `${fontSize}\0${fontWeight}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}` - let entry = this.entries.get(key) - if (!entry) { - const style = new ck.ParagraphStyle({ - maxLines: 1, - ellipsis: '…', - textStyle: { - color, - fontFamilies: ['Inter'], - fontSize, - fontStyle: { weight: { value: fontWeight } as FontWeight } - } - }) - const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider) - builder.addText(text) - const paragraph = builder.build() - builder.delete() - paragraph.layout(boundedWidth) - entry = { - paragraph, - width: Math.min(paragraph.getLongestLine(), boundedWidth), - height: paragraph.getHeight() - } - this.entries.set(key, entry) - } - return entry + textUnits(): number { + return this.entries.weight } } diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index eb3b66615..1ac2a7bbe 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -138,7 +138,9 @@ export class SkiaRenderer { subtreePictureCachePositionPreviewVersion = -1 subtreePictureCacheFontGeneration = -1 readonly labelCache = new LabelCache() - readonly labelParagraphCache = new LabelParagraphCache() + readonly labelParagraphCache = new LabelParagraphCache(undefined, undefined, { + onMissingGlyphs: (missing) => RendererFonts.resolveLabelFontCoverage(this, missing) + }) readonly textPreparationCache = new TextPreparationCache() readonly tiledScene = new TiledSceneController() readonly profiler: RenderProfiler diff --git a/packages/core/src/canvas/renderer/fonts.ts b/packages/core/src/canvas/renderer/fonts.ts index d4b61cca2..cd30f7678 100644 --- a/packages/core/src/canvas/renderer/fonts.ts +++ b/packages/core/src/canvas/renderer/fonts.ts @@ -11,7 +11,30 @@ import { } from '#core/constants' import { fontManager } from '#core/text/fonts' import { prepareGraphFonts } from '#core/text/prepare' -import type { FontResolutionSnapshot } from '#core/text/resolver' +import { + fontCoverageDemand, + fontResolver, + missingGlyphsByScript, + type MissingGlyphOccurrence, + type FontResolutionSnapshot +} from '#core/text/resolver' + +export function resolveLabelFontCoverage( + r: Pick, + missing: readonly MissingGlyphOccurrence[], + resolver = fontResolver +): void { + if (r.isDestroyed()) return + for (const [script, characters] of missingGlyphsByScript(missing)) { + const demand = fontCoverageDemand(script, characters) + const state = resolver.state(demand).state + if (state === 'loaded') resolver.exhaust(demand) + else if (state === 'idle' || state === 'loading') { + // No fake TEXT node: the shared settlement callback refreshes font generation and repaints. + void resolver.demand(demand, r.onFontResolutionSettled) + } + } +} export function syncFontGeneration(r: SkiaRenderer): void { r.fontGeneration = fontManager.generation() diff --git a/packages/core/src/canvas/text/font-families.ts b/packages/core/src/canvas/text/font-families.ts new file mode 100644 index 000000000..e483047f5 --- /dev/null +++ b/packages/core/src/canvas/text/font-families.ts @@ -0,0 +1,31 @@ +import { uniq } from 'es-toolkit/array' + +import { ResourceCache } from '#core/cache/resource' +import { DEFAULT_FONT_FAMILY } from '#core/constants' +import { fontManager } from '#core/text/fonts' + +const familiesCache = new ResourceCache({ maxEntries: 256 }) + +export function resolveParagraphFontFamilies( + primary: string, + style: string, + arabicFallbacks: readonly string[] = fontManager.getArabicFallbackFamilies(), + cjkFallbacks: readonly string[] = fontManager.getCJKFallbackFamilies() +): string[] { + const renderPrimary = fontManager.renderFamily(primary, style) + const renderArabicFallbacks = arabicFallbacks.map((family) => + fontManager.renderFamily(family, 'Regular') + ) + const renderCJKFallbacks = cjkFallbacks.map((family) => + fontManager.renderFamily(family, 'Regular') + ) + const key = `${renderPrimary}\0${renderArabicFallbacks.join('\0')}\0${renderCJKFallbacks.join('\0')}` + const cached = familiesCache.peek(key) + if (cached) return cached + const families = [renderPrimary] + if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY) + families.push(...renderArabicFallbacks, ...renderCJKFallbacks) + const resolved = uniq(families) + familiesCache.set(key, resolved) + return resolved +} diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index bd7ff1a77..169f9ef00 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -6,17 +6,13 @@ import type { TextFontVariations, TypefaceFontProvider } from 'canvaskit-wasm' -import { uniq } from 'es-toolkit/array' import type { SceneNode } from '@open-pencil/scene-graph' -import { ResourceCache } from '#core/cache/resource' import { resolveRGBAForPreview } from '#core/color/management' import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants' import { transformTextCase } from '#core/text/case' -import { fontFallbackScriptForCharacter } from '#core/text/coverage' import { resolveNodeTextDirection } from '#core/text/direction' -import type { FontFallbackScript } from '#core/text/fallbacks' import { fontManager, weightToStyle } from '#core/text/fonts' import { fontCoverageDemand, @@ -24,9 +20,11 @@ import { fontRemoteCoverageDemand, fontResolver, missingGlyphOccurrences, + missingGlyphsByScript, type FontResolutionSettled } from '#core/text/resolver' +import { resolveParagraphFontFamilies } from './font-families' import type { ParagraphNode } from './paragraph-inputs' import type { PreparedText, TextPreparationCache } from './preparation-cache' @@ -45,9 +43,6 @@ interface TextRenderer extends FontReadinessRenderer { fontsLoaded: boolean } -const FONT_FAMILY_CACHE_LIMIT = 256 -const fontFamilyCache = new ResourceCache({ maxEntries: FONT_FAMILY_CACHE_LIMIT }) - function demandFace( r: FontReadinessRenderer, node: SceneNode, @@ -163,14 +158,9 @@ function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadi return 'ready' } - const charactersByScript = new Map() - for (const { character, utf16Start } of missingOccurrences) { - const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, utf16Start)) - if (!script) continue - const characters = charactersByScript.get(script) ?? [] - characters.push(character) - charactersByScript.set(script, characters) - } + const charactersByScript = missingGlyphsByScript(missingOccurrences, (offset) => + languageForCharacter(node, offset) + ) let pending = false let exhausted = charactersByScript.size === 0 @@ -277,32 +267,6 @@ function buildTruncateOpts( return opts } -function resolveParagraphFontFamilies( - primary: string, - style: string, - arabicFallbacks: readonly string[], - cjkFallbacks: readonly string[] -): string[] { - const renderPrimary = fontManager.renderFamily(primary, style) - const renderArabicFallbacks = arabicFallbacks.map((family) => - fontManager.renderFamily(family, 'Regular') - ) - const renderCJKFallbacks = cjkFallbacks.map((family) => - fontManager.renderFamily(family, 'Regular') - ) - const key = `${renderPrimary}\0${renderArabicFallbacks.join('\0')}\0${renderCJKFallbacks.join('\0')}` - const cached = fontFamilyCache.peek(key) - if (cached) return cached - - const families = [renderPrimary] - if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY) - families.push(...renderArabicFallbacks, ...renderCJKFallbacks) - - const resolved = uniq(families) - fontFamilyCache.set(key, resolved) - return resolved -} - function getParagraphTextAlign( ck: CanvasKit, node: Pick diff --git a/packages/core/src/text/coverage.ts b/packages/core/src/text/coverage.ts index ab4ffbe79..473b8d695 100644 --- a/packages/core/src/text/coverage.ts +++ b/packages/core/src/text/coverage.ts @@ -30,6 +30,10 @@ function scriptCharRegex(script: FontFallbackScript): RegExp { } } +export function textHasFallbackScript(text: string): boolean { + return ARABIC_CHAR_RE.test(text) || CJK_CHAR_RE.test(text) +} + export function fontFallbackScriptForCharacter( char: string, language?: string | null diff --git a/packages/core/src/text/resolver/coverage.ts b/packages/core/src/text/resolver/coverage.ts index 5a7ed0a73..f2b7ab3e2 100644 --- a/packages/core/src/text/resolver/coverage.ts +++ b/packages/core/src/text/resolver/coverage.ts @@ -32,6 +32,21 @@ export interface MissingGlyphOccurrence { utf16Start: number } +export function missingGlyphsByScript( + occurrences: readonly MissingGlyphOccurrence[], + languageAt?: (utf16Start: number) => string | null +): Map { + const groups = new Map() + for (const { character, utf16Start } of occurrences) { + const script = fontFallbackScriptForCharacter(character, languageAt?.(utf16Start)) + if (!script) continue + const characters = groups.get(script) ?? [] + characters.push(character) + groups.set(script, characters) + } + return groups +} + export function missingGlyphOccurrences( text: string, lines: readonly ObservedShapedLine[], diff --git a/tests/e2e/canvas/label-anatomy.spec.ts b/tests/e2e/canvas/label-anatomy.spec.ts index 42a0bebcf..1ba461320 100644 --- a/tests/e2e/canvas/label-anatomy.spec.ts +++ b/tests/e2e/canvas/label-anatomy.spec.ts @@ -1,11 +1,134 @@ +import { readFileSync } from 'node:fs' + import type { SceneNode } from '@open-pencil/scene-graph' import { expect, test } from '#tests/e2e/fixtures' import { expectDefined } from '#tests/helpers/assert' import { CanvasHelper } from '#tests/helpers/canvas' +import { mockFontsource } from '#tests/helpers/fonts/fontsource' test.use({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 }) +test('reused Unicode section labels match fresh paragraphs across zoom reversals', async ({ + page +}) => { + const fonts = await mockFontsource(page, [ + { + family: 'Noto Naskh Arabic', + subset: 'arabic', + data: readFileSync('public/NotoNaskhArabic-Regular.ttf') + }, + { + family: 'Noto Sans SC', + subset: 'chinese-simplified', + data: readFileSync('tests/fixtures/fonts/NotoSansCJK-Test.otf'), + format: 'otf' + } + ]) + try { + await page.goto('/?test&no-rulers&navigation-benchmark') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + const names = [ + 'Button', + 'AV To ffi', + 'Привет', + 'Ελληνικά', + 'écho café', + 'مرحبا', + '你好', + 'Trail ', + 'one\ntwo' + ] + for (const [index, name] of names.entries()) { + store.graph.createNode('SECTION', store.state.currentPageId, { + name, + x: 80, + y: 70 + index * 70, + width: 130, + height: 40, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], + strokes: [ + { + color: { r: 0.7, g: 0.7, b: 0.7, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'INSIDE' + } + ] + }) + } + store.clearSelection() + store.requestRender() + }) + await expect + .poll(() => + page.evaluate(() => { + const renderer = window.openPencil + ?.getStore?.() + .canvasRenderers.find((r) => r.fontsLoaded && r.fontProvider) + const provider = renderer?.fontProvider + if (!renderer || !provider) return false + return ['مرحبا', '你好'].every((text) => + renderer.labelParagraphCache.use( + renderer.ck, + provider, + text, + 11, + 300, + renderer.ck.BLACK, + renderer.fontGeneration, + ({ paragraph }) => { + const runs = paragraph.getShapedLines().flatMap((line) => line.runs) + return ( + runs.length > 0 && + runs.every( + (run) => run.glyphs.length > 0 && run.glyphs.every((glyph) => glyph !== 0) + ) + ) + }, + 600 + ) + ) + }) + ) + .toBe(true) + expect(fonts.counts.downloads).toBeGreaterThanOrEqual(2) + await canvas.waitForRender() + for (const zoom of [1.25, 0.6, 1.1, 0.75, 1]) { + await page.evaluate((scale) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + store.setZoomAroundPoint(scale, 0, 0) + }, zoom) + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + await canvas.waitForRender() + const reused = await canvas.screenshotCanvasRegion(520, 840) + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store?.canvasRenderers.length) throw new Error('Renderer unavailable') + for (const renderer of store.canvasRenderers) renderer.labelParagraphCache.clear() + store.requestRepaint() + }) + await canvas.waitForRender() + const fresh = await canvas.screenshotCanvasRegion(520, 840) + expect(reused.equals(fresh), `label pixels at ${zoom} zoom`).toBe(true) + if (zoom === 1) + expect(fresh).toMatchSnapshot('unicode-section-labels.png', { + threshold: 0, + maxDiffPixels: 0 + }) + } + canvas.assertNoErrors() + } finally { + await fonts.dispose() + } +}) + for (const rotation of [-145, -40, 50]) { test(`frame titles and dimensions remain readable at ${rotation} degrees`, async ({ page }) => { await page.goto('/?test&no-rulers') diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/unicode-section-labels-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/unicode-section-labels-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..bc30c07f49aa0f85bd3d8fa172c300e449264154 GIT binary patch literal 33976 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 { + await trackFontModuleResources(page) +}) test('tool-created CJK text requests fallback through app font loading', async ({ page }) => { const canvas = new CanvasHelper(page) - await page.goto('http://localhost:1420/?test&no-chrome&no-rulers') + await page.goto('/?test&no-chrome&no-rulers') await canvas.waitForInit() const result = await page.evaluate(async () => { @@ -68,7 +73,7 @@ test('tool-created CJK text requests fallback through app font loading', async ( test('CJK text waits for fallback fonts and repaints after they load', async ({ page }) => { const canvas = new CanvasHelper(page) - await page.goto('http://localhost:1420/?test&no-chrome&no-rulers') + await page.goto('/?test&no-chrome&no-rulers') await canvas.waitForInit() const result = await page.evaluate(async () => { diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts b/tests/e2e/fonts/cjk-rendering-visual.spec.ts index fe9f14ef9..5d28beef1 100644 --- a/tests/e2e/fonts/cjk-rendering-visual.spec.ts +++ b/tests/e2e/fonts/cjk-rendering-visual.spec.ts @@ -6,8 +6,13 @@ import type { FontManager } from '#core/text/fonts' import { expect, test } from '#tests/e2e/fixtures' import { CanvasHelper } from '#tests/helpers/canvas' +import { mockFontsource } from '#tests/helpers/fonts/fontsource' +import { trackFontModuleResources } from '#tests/helpers/fonts/runtime' async function openEditor(page: Page): Promise { + await trackFontModuleResources(page) + // These tests install fixture fonts explicitly; remote arrivals must not race those fixtures. + await mockFontsource(page, []) await page.goto('/?test&no-chrome&no-rulers') const canvas = new CanvasHelper(page) await canvas.waitForInit() diff --git a/tests/engine/render/canvas/labels/fonts.test.ts b/tests/engine/render/canvas/labels/fonts.test.ts new file mode 100644 index 000000000..79c8b882e --- /dev/null +++ b/tests/engine/render/canvas/labels/fonts.test.ts @@ -0,0 +1,147 @@ +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> +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() + const settled = Promise.withResolvers() + 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() + } +}) diff --git a/tests/engine/render/canvas/labels/paragraph-reuse.test.ts b/tests/engine/render/canvas/labels/paragraph-reuse.test.ts new file mode 100644 index 000000000..0eee692a9 --- /dev/null +++ b/tests/engine/render/canvas/labels/paragraph-reuse.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +import type { CanvasKit, Paragraph, ParagraphBuilder, TypefaceFontProvider } from 'canvaskit-wasm' + +import { initCanvasKit } from '#cli/headless' +import { LabelParagraphCache } from '#core/canvas/labels/paragraph-cache' + +import { expectDefined } from '#tests/helpers/assert' +import { repoPath } from '#tests/helpers/paths' + +let ck: Awaited> +let provider: TypefaceFontProvider +beforeAll(async () => { + ck = await initCanvasKit() + provider = ck.TypefaceFontProvider.Make() + provider.registerFont(readFileSync(repoPath('public/Inter-Regular.ttf')), 'Inter') + provider.registerFont(readFileSync(repoPath('public/Inter-SemiBold.ttf')), 'Inter') +}) +afterAll(() => provider.delete()) + +function withLabel( + cache: LabelParagraphCache, + text: string, + width: number, + consume: (paragraph: Paragraph) => T +): T { + return cache.use(ck, provider, text, 11, width, ck.BLACK, 1, ({ paragraph }) => + consume(paragraph) + ) +} + +describe('label paragraph reuse', () => { + test('reuses fitting widths without re-layout and keeps ellipsis variants separate', () => { + const cache = new LabelParagraphCache() + try { + withLabel(cache, 'Button', 300, (first) => { + for (const width of [200, 100, 180, 301]) { + withLabel(cache, 'Button', width, (next) => expect(next).toBe(first)) + } + withLabel(cache, 'Button', 5, (clipped) => { + expect(clipped).not.toBe(first) + expect(clipped.didExceedMaxLines()).toBe(true) + }) + withLabel(cache, 'Button', 100, (next) => expect(next).toBe(first)) + }) + expect(cache.size()).toBe(2) + } finally { + cache.clear() + } + }) + + test('pins borrowed native paragraphs across nested eviction, clear and exceptions', () => { + const cache = new LabelParagraphCache(1) + let borrowed: Paragraph | undefined + try { + expect(() => + withLabel(cache, 'First', 200, (first) => { + borrowed = first + withLabel(cache, 'Second', 200, (second) => { + cache.clear() + expect(first.isDeleted()).toBe(false) + expect(second.isDeleted()).toBe(false) + }) + expect(first.isDeleted()).toBe(false) + throw new Error('consumer failed') + }) + ).toThrow('consumer failed') + expect(borrowed?.isDeleted()).toBe(true) + expect(cache.size()).toBe(0) + } finally { + cache.clear() + } + }) + + test('bounds text units and disposes oversized paragraphs after consumption', () => { + const cache = new LabelParagraphCache(8, 10) + let transient: Paragraph | undefined + try { + withLabel(cache, 'First', 200, (paragraph) => expect(paragraph.isDeleted()).toBe(false)) + withLabel(cache, 'Second', 200, (paragraph) => expect(paragraph.isDeleted()).toBe(false)) + expect(cache.textUnits()).toBe(6) + withLabel(cache, 'Oversized label', 200, (paragraph) => { + transient = paragraph + expect(paragraph.isDeleted()).toBe(false) + expect(cache.textUnits()).toBe(6) + }) + expect(transient?.isDeleted()).toBe(true) + expect(cache.size()).toBe(1) + } finally { + cache.clear() + } + }) + + test('invalidates on provider identity even when generation is unchanged', () => { + const other = ck.TypefaceFontProvider.Make() + const cache = new LabelParagraphCache() + try { + withLabel(cache, 'Provider', 200, (first) => { + cache.use(ck, other, 'Provider', 11, 200, ck.BLACK, 1, ({ paragraph }) => { + expect(paragraph).not.toBe(first) + expect(first.isDeleted()).toBe(false) + }) + }) + expect(cache.size()).toBe(1) + } finally { + cache.clear() + other.delete() + } + }) + + test.each(['addText', 'layout'])('releases native resources when %s throws', (stage) => { + const cache = new LabelParagraphCache() + let builder: ParagraphBuilder | undefined + let paragraph: Paragraph | undefined + const fail = () => { + throw new Error('native failure') + } + const kit: CanvasKit = { + ...ck, + ParagraphBuilder: { + ...ck.ParagraphBuilder, + MakeFromFontProvider: (...args) => { + const created = ck.ParagraphBuilder.MakeFromFontProvider(...args) + builder = created + if (stage === 'addText') created.addText = fail + else { + const build = created.build.bind(created) + created.build = () => { + const result = build() + paragraph = result + result.layout = fail + return result + } + } + return created + } + } + } + try { + expect(() => cache.measure(kit, provider, 'Failure', 11, 200, ck.BLACK, 1)).toThrow( + 'native failure' + ) + expect(builder?.isDeleted()).toBe(true) + if (stage === 'layout') expect(paragraph?.isDeleted()).toBe(true) + expect(cache.size()).toBe(0) + } finally { + cache.clear() + if (builder && !builder.isDeleted()) builder.delete() + if (paragraph && !paragraph.isDeleted()) paragraph.delete() + } + }) + + test.each([400, 600])( + 'matches fresh layouts at Unicode/wrap boundaries (weight %i)', + (fontWeight) => { + const cached = new LabelParagraphCache() + const fresh = new LabelParagraphCache(0) + const surface = expectDefined(ck.MakeSurface(1024, 40), 'label comparison surface') + const canvas = surface.getCanvas() + const paint = (cache: LabelParagraphCache, text: string, width: number) => { + canvas.clear(ck.WHITE) + const metrics = cache.use( + ck, + provider, + text, + 11, + width, + ck.BLACK, + 1, + ({ paragraph, metrics }) => { + canvas.drawParagraph(paragraph, 2, 2) + return metrics + }, + fontWeight + ) + surface.flush() + const image = surface.makeImageSnapshot() + try { + return { + metrics, + pixels: expectDefined( + image.readPixels(0, 0, { + width: 1024, + height: 40, + colorType: ck.ColorType.RGBA_8888, + alphaType: ck.AlphaType.Unpremul, + colorSpace: ck.ColorSpace.SRGB + }), + 'label pixels' + ) + } + } finally { + image.delete() + } + } + try { + for (const text of [ + 'Button', + 'AV To ffi', + '漢字', + 'مرحبا بالعالم', + 'אבג 123', + '👩🏽‍💻 🇯🇵', + 'é', + 'Trail ', + ' Lead', + 'a\tb', + 'one\ntwo', + 'one\r\ntwo', + 'a\u2028b', + 'a\u2029b', + 'a\u0085b', + '\u202Babc\u202C', + '\u200Fabc', + 'zero\u200Bwidth', + '', + ' ' + ]) { + const intrinsic = fresh.use( + ck, + provider, + text, + 11, + 1000, + ck.BLACK, + 1, + ({ paragraph }) => paragraph.getMaxIntrinsicWidth(), + fontWeight + ) + for (const width of [ + 1000, + intrinsic + 1, + intrinsic + 1 / 64, + intrinsic, + intrinsic - 1 / 64, + intrinsic - 1, + 1, + 0, + -1, + 120, + 300 + ]) { + const actual = paint(cached, text, width) + const expected = paint(fresh, text, width) + expect(actual.metrics, `${JSON.stringify(text)} at ${width}`).toEqual(expected.metrics) + expect(actual.pixels, `${JSON.stringify(text)} at ${width}`).toEqual(expected.pixels) + } + } + } finally { + cached.clear() + fresh.clear() + surface.delete() + } + } + ) +}) diff --git a/tests/helpers/fonts/fontsource.ts b/tests/helpers/fonts/fontsource.ts new file mode 100644 index 000000000..2a62ac1f4 --- /dev/null +++ b/tests/helpers/fonts/fontsource.ts @@ -0,0 +1,69 @@ +import type { Page, Route } from '@playwright/test' + +interface FontsourceFixture { + family: string + subset: string + data: Buffer + format?: 'ttf' | 'otf' +} + +/** Use real font bytes through the browser-enabled provider, without external network requests. */ +export async function mockFontsource(page: Page, fixtures: readonly FontsourceFixture[]) { + const counts = { downloads: 0 } + const entries = new Map(fixtures.map((fixture, index) => [String(index), fixture])) + const pattern = + /^https:\/\/(api\.fontsource\.org\/v1\/fonts(?:\/|$)|cdn\.jsdelivr\.net\/fontsource\/fonts\/openpencil-fixture-)/ + const headers = { 'access-control-allow-origin': '*' } + async function handle(route: Route) { + const url = new URL(route.request().url()) + if (url.hostname === 'cdn.jsdelivr.net') { + const fixture = entries.get( + url.pathname.slice('/fontsource/fonts/openpencil-fixture-'.length, -4) + ) + if (!fixture) throw new Error('Unknown fixture font') + counts.downloads++ + await route.fulfill({ + body: fixture.data, + contentType: `font/${fixture.format ?? 'ttf'}`, + headers + }) + } else if (url.pathname === '/v1/fonts') { + await route.fulfill({ + headers, + json: [...entries].map(([id, fixture]) => ({ + id, + family: fixture.family, + variable: false, + weights: [400], + styles: ['normal'], + subsets: [fixture.subset], + defSubset: fixture.subset + })) + }) + } else { + const id = url.pathname.split('/').at(-1) + const fixture = id === undefined ? undefined : entries.get(id) + if (!fixture) throw new Error('Unknown fixture font metadata') + await route.fulfill({ + headers, + json: { + unicodeRange: {}, + variants: { + '400': { + normal: { + [fixture.subset]: { + url: { + [fixture.format ?? 'ttf']: + `https://cdn.jsdelivr.net/fontsource/fonts/openpencil-fixture-${id}.${fixture.format ?? 'ttf'}` + } + } + } + } + } + } + }) + } + } + await page.route(pattern, handle) + return { counts, dispose: () => page.unroute(pattern, handle) } +} diff --git a/tests/helpers/fonts/runtime.ts b/tests/helpers/fonts/runtime.ts new file mode 100644 index 000000000..f55a0d996 --- /dev/null +++ b/tests/helpers/fonts/runtime.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test' + +const FONT_MODULE_RESOURCE_CAPACITY = 4096 + +/** Preserve the loaded font module URL even when the dev module graph exceeds the browser's default buffer. */ +export async function trackFontModuleResources(page: Page): Promise { + await page.addInitScript( + (capacity) => performance.setResourceTimingBufferSize(capacity), + FONT_MODULE_RESOURCE_CAPACITY + ) +}