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 000000000..bc30c07f4 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/unicode-section-labels-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/cjk-fallback.spec.ts b/tests/e2e/fonts/cjk-fallback.spec.ts index 0d7f321fa..39268c211 100644 --- a/tests/e2e/fonts/cjk-fallback.spec.ts +++ b/tests/e2e/fonts/cjk-fallback.spec.ts @@ -3,10 +3,15 @@ import { test, expect } from '@playwright/test' import type { FontManager } from '#core/text/fonts' import { CanvasHelper } from '#tests/helpers/canvas' +import { trackFontModuleResources } from '#tests/helpers/fonts/runtime' + +test.beforeEach(async ({ page }) => { + 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 + ) +}