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.
This commit is contained in:
Danila Poyarkov 2026-09-15 23:08:53 +03:00
parent 11baf4f8f5
commit b458e3c3ae
17 changed files with 927 additions and 144 deletions

View file

@ -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.

View file

@ -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(

View file

@ -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<Size>
/** 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<string, LabelParagraphEntry>({
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<string, LabelParagraphEntry>
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<T>(
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<LabelParagraphEntry, 'width' | 'height'> {
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
}
}

View file

@ -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

View file

@ -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<SkiaRenderer, 'isDestroyed' | 'onFontResolutionSettled'>,
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()

View file

@ -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<string, string[]>({ 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
}

View file

@ -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<string, string[]>({ 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<FontFallbackScript, string[]>()
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<SceneNode, 'textAlignHorizontal' | 'textDirection' | 'text'>

View file

@ -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

View file

@ -32,6 +32,21 @@ export interface MissingGlyphOccurrence {
utf16Start: number
}
export function missingGlyphsByScript(
occurrences: readonly MissingGlyphOccurrence[],
languageAt?: (utf16Start: number) => string | null
): Map<FontFallbackScript, string[]> {
const groups = new Map<FontFallbackScript, string[]>()
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[],

View file

@ -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')

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -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 () => {

View file

@ -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<CanvasHelper> {
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()

View file

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

View file

@ -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<ReturnType<typeof initCanvasKit>>
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<T>(
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()
}
}
)
})

View file

@ -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) }
}

View file

@ -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<void> {
await page.addInitScript(
(capacity) => performance.setResourceTimingBufferSize(capacity),
FONT_MODULE_RESOURCE_CAPACITY
)
}