fix(text): verify visible international font rendering

- Register CanvasKit faces under their source family names so CJK and Arabic glyphs paint instead of only shaping metrics
- Add deterministic CJK, Arabic, mixed-script, first-paint, and interactive fallback image oracles
- Cover typed and tool-created text parity with a repository-owned Noto CJK subset

Fixes #324
Fixes #291
This commit is contained in:
Danila Poyarkov 2026-07-17 16:19:18 +03:00
parent 47fe43c3ec
commit 9919d8710a
13 changed files with 469 additions and 178 deletions

View file

@ -36,6 +36,7 @@
- Make canvas text rendering demand missing font faces and verify CJK/Arabic fallback coverage from CanvasKit shaping results instead of coarse script predictions.
- Resolve fonts before loaded, pasted, imported, and tool-created nodes render; invalidate generation-stale text caches and use baked `.fig` glyphs only after live font resolution is exhausted.
- Load character-specific remote font subsets without Latin-only assumptions, preserve cumulative subset coverage, and reject unavailable desktop font styles instead of substituting the first family face.
- Render downloaded CJK and Arabic faces under their source family names so CanvasKit produces visible glyphs, with first-paint and interactive fallback snapshots covering CJK, Arabic, and mixed scripts.
- Fix live CLI and MCP automation drifting to the wrong open document or page when multiple files are open.
- Improve Chinese, Japanese, and Korean text rendering with glyph-aware fallback fonts and outline rendering when needed.
- Preserve imported Figma text sizing more accurately, especially auto-sized text inside auto-layout frames.

View file

@ -8,7 +8,6 @@
"ignoreWorkspaces": ["packages/acp", "packages/demos"],
"ignoreUnresolved": [
"~icons/*",
"/packages/core/src/text/fonts.ts",
"/src/app/editor/fonts/index.ts",
"#core/*"
],

View file

@ -3,7 +3,6 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { DEFAULT_FONT_FAMILY, IS_BROWSER } from '#core/constants'
import { fontFaceRenderFamily } from '#core/text/face'
import {
chooseLocalFontMatch,
isVariableFont,
@ -52,9 +51,6 @@ export class FontManager {
private fallbackUserAgent: string | undefined
private hostFontLoader: HostFontLoader | null = null
private webFonts = new WebFontResolver()
private registeredRenderFamilies = new Set<string>()
private renderFamilyAliases = new Map<string, string>()
private renderFamilyRevisions = new Map<string, number>()
private cjkFallbackFamilies: string[] = []
private cjkFallbackPromise: Promise<string[]> | null = null
private arabicFallbackFamilies: string[] = []
@ -64,18 +60,13 @@ export class FontManager {
this.fontProvider = provider
this.registrationGeneration++
this.providerRegistrations.clear()
this.registeredRenderFamilies.clear()
this.renderFamilyAliases.clear()
this.renderFamilyRevisions.clear()
for (const [cacheKey, data] of this.loadedFamilies) {
const separator = cacheKey.indexOf('|')
const family = cacheKey.slice(0, separator)
const style = cacheKey.slice(separator + 1)
this.registerFontInCanvasKit(family, data)
for (const supplemental of this.supplementalFamilyData.get(cacheKey) ?? []) {
this.registerFontInCanvasKit(family, supplemental)
}
this.registerInitialRenderFamily(family, style, data)
}
}
@ -333,23 +324,12 @@ export class FontManager {
return this.loadedFamilies.get(`${family}|${style}`) ?? null
}
renderFamily(family: string, style: string): string {
const data = this.loadedData(family, style)
if (!data) return family
const key = `${family}|${style}`
const renderFamily = this.renderFamilyAliases.get(key) ?? fontFaceRenderFamily(family, style)
if (!this.registeredRenderFamilies.has(renderFamily)) {
if (this.registerFontInCanvasKit(renderFamily, data)) {
this.renderFamilyAliases.set(key, renderFamily)
this.renderFamilyRevisions.set(key, 1)
this.registeredRenderFamilies.add(renderFamily)
} else {
renderFamily(family: string, _style: string): string {
// CanvasKit can shape metrics but paint no glyphs for some CJK/Arabic faces registered under a
// synthetic alias. Keep every shard under the font's source family; character-aware remote
// requests already fetch cumulative coverage before replacing the primary buffer.
return family
}
}
return renderFamily
}
collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> {
return collectGraphFontKeys(graph, nodeIds)
@ -535,31 +515,10 @@ export class FontManager {
if (existing) this.registerSupplemental(family, style, existing)
this.loadedFamilies.set(key, buffer)
this.registerFontInCanvasKit(family, buffer)
const currentRenderFamily = this.renderFamilyAliases.get(key)
if (currentRenderFamily) {
const revision = (this.renderFamilyRevisions.get(key) ?? 1) + 1
const renderFamily = `${fontFaceRenderFamily(family, style)}__${revision}`
if (this.registerFontInCanvasKit(renderFamily, buffer)) {
this.renderFamilyAliases.set(key, renderFamily)
this.renderFamilyRevisions.set(key, revision)
this.registeredRenderFamilies.add(renderFamily)
}
} else {
this.registerInitialRenderFamily(family, style, buffer)
}
this.registerFontInBrowser(family, style, buffer)
return buffer
}
private registerInitialRenderFamily(family: string, style: string, data: ArrayBuffer): void {
const key = `${family}|${style}`
const renderFamily = fontFaceRenderFamily(family, style)
if (!this.registerFontInCanvasKit(renderFamily, data)) return
this.renderFamilyAliases.set(key, renderFamily)
this.renderFamilyRevisions.set(key, 1)
this.registeredRenderFamilies.add(renderFamily)
}
private registerFontInCanvasKit(family: string, data: ArrayBuffer): boolean {
if (!this.fontProvider || data.byteLength < 4) return false
const registeredData = this.providerRegistrations.get(family)

View file

@ -1,5 +1,7 @@
import { test, expect } from '@playwright/test'
import type { FontManager } from '#core/text/fonts'
import { CanvasHelper } from '#tests/helpers/canvas'
test('tool-created CJK text requests fallback through app font loading', async ({ page }) => {
@ -13,13 +15,20 @@ test('tool-created CJK text requests fallback through app font loading', async (
const { ensureGraphFonts, loadFont } = await import('/src/app/editor/fonts/index.ts')
await loadFont('Inter', 'Regular')
const { fontManager } = await import('/packages/core/src/text/fonts.ts')
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager)
let requestedScripts: string[] = []
fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => {
requestedScripts = [...scripts]
return { cjk: ['Regression CJK Fallback'], arabic: [] }
return { cjk: ['Noto Sans CJK SC'], arabic: [] }
}
const pageNode = store.graph.getNode(store.state.currentPageId)
@ -66,9 +75,16 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({
const store = window.openPencil?.getStore?.()
if (!store?.renderer) throw new Error('OpenPencil renderer not initialized')
const renderer = store.renderer
const response = await fetch('/tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const response = await fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf')
const fallbackData = await response.arrayBuffer()
const { fontManager } = await import('/packages/core/src/text/fonts.ts')
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] }
const originalFamilies = [...manager.cjkFallbackFamilies]
const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager)
@ -84,9 +100,9 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({
const originalRender = renderer.renderFromEditorState.bind(renderer)
fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => {
await fallbackGate
fontManager.markLoaded('Regression CJK Fallback', 'Regular', fallbackData)
fontManager.setCJKFallbackFamily('Regression CJK Fallback')
return Object.fromEntries(scripts.map((script) => [script, ['Regression CJK Fallback']]))
fontManager.markLoaded('Noto Sans CJK SC', 'Regular', fallbackData)
fontManager.setCJKFallbackFamily('Noto Sans CJK SC')
return Object.fromEntries(scripts.map((script) => [script, ['Noto Sans CJK SC']]))
}
renderer.renderFromEditorState = (
...args: Parameters<typeof renderer.renderFromEditorState>
@ -101,7 +117,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({
y: 80,
width: 300,
height: 60,
text: '上班打卡App',
text: '你好世界App',
textLanguage: 'zh-Hans',
fontSize: 32,
fontFamily: 'Inter',
@ -131,6 +147,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({
return {
loadedBeforeFallback,
loadedAfterFallback: renderer.isNodeFontLoaded(text),
readiness: renderer.nodeFontReadiness(text),
fallbackRenderCount,
renderCount
}
@ -142,6 +159,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({
})
expect(result.loadedBeforeFallback).toBe(false)
expect(result.readiness).toBe('ready')
expect(result.loadedAfterFallback).toBe(true)
expect(result.fallbackRenderCount).toBe(1)
expect(result.renderCount).toBeGreaterThan(0)

View file

@ -1,121 +1,358 @@
import { existsSync, readFileSync } from 'node:fs'
import { Buffer } from 'node:buffer'
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
import type { Page } from '@playwright/test'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
const appleGothic = '/System/Library/Fonts/Supplemental/AppleGothic.ttf'
import type { FontManager } from '#core/text/fonts'
test.skip(
process.platform !== 'darwin' || !existsSync(appleGothic),
'CJK visual snapshot uses macOS AppleGothic for Hangul coverage'
)
import { expect, test } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
async function expectCanvas(name: string) {
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.canvas.screenshot()
expect(buffer).toMatchSnapshot(`${name}.png`)
}
test('renders Simplified Chinese, Traditional Chinese, Japanese, and Korean fallback text', async () => {
await editor.page.route('**/__test-fonts/apple-gothic.ttf', async (route) => {
await route.fulfill({ body: readFileSync(appleGothic), contentType: 'font/ttf' })
})
const smoke = await editor.page.evaluate(async () => {
async function openEditor(page: Page): Promise<CanvasHelper> {
await page.goto('/?test&no-chrome&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const { fontManager } = await import('/packages/core/src/text/fonts.ts')
const [notoResponse, appleGothicResponse] = await Promise.all([
fetch('/tests/fixtures/fonts/NotoSansSC-Regular.ttf'),
fetch('/__test-fonts/apple-gothic.ttf')
])
if (!notoResponse.ok) throw new Error(`Failed to load Noto Sans SC: ${notoResponse.status}`)
if (!appleGothicResponse.ok) {
throw new Error(`Failed to load AppleGothic: ${appleGothicResponse.status}`)
}
fontManager.markLoaded('Noto Sans SC', 'Regular', await notoResponse.arrayBuffer())
fontManager.markLoaded('AppleGothic', 'Regular', await appleGothicResponse.arrayBuffer())
fontManager.setCJKFallbackFamily('Noto Sans SC')
fontManager.setCJKFallbackFamily('AppleGothic')
const lines = [
{ label: 'Simplified Chinese', text: '你好世界' },
{ label: 'Traditional Chinese', text: '繁體中文' },
{ label: 'Japanese', text: '日本語かなカナ' },
{ label: 'Korean', text: '안녕하세요' }
]
store.graph.createNode('FRAME', pageId, {
name: 'CJK rendering visual backdrop',
x: 64,
y: 52,
width: 720,
height: 312,
cornerRadius: 20,
fills: [
{ type: 'SOLID', color: { r: 0.97, g: 0.98, b: 1, a: 1 }, visible: true, opacity: 1 }
],
strokes: [
{
color: { r: 0.78, g: 0.84, b: 0.95, a: 1 },
weight: 1,
visible: true,
opacity: 1,
align: 'INSIDE'
}
]
const pageNode = store.graph.getNode(store.state.currentPageId)
const childIds = pageNode?.childIds.slice() ?? []
for (const id of childIds) store.graph.deleteNode(id)
store.clearSelection()
store.renderer?.invalidateAllPictures()
store.requestRender()
store.renderer?.renderFromEditorState(
store.state,
store.graph,
store.textEditor,
window.innerWidth,
window.innerHeight,
false,
'full'
)
})
await canvas.waitForRender()
return canvas
}
for (const [index, line] of lines.entries()) {
const y = 84 + index * 64
async function expectCanvas(canvas: CanvasHelper, name: string): Promise<void> {
await canvas.page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve))
})
)
canvas.assertNoErrors()
expect(await canvas.canvas.screenshot()).toMatchSnapshot(`${name}.png`)
}
test('international text is correct on its first visible paint', async ({ page }) => {
await openEditor(page)
const result = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store?.renderer) throw new Error('OpenPencil renderer not initialized')
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
const pageId = store.state.currentPageId
const samples = [
{
label: 'Simplified Chinese',
text: '你好世界',
language: 'zh-Hans',
family: 'Noto Sans CJK SC'
},
{
label: 'Traditional Chinese',
text: '繁體中文',
language: 'zh-Hant',
family: 'Noto Sans CJK SC'
},
{ label: 'Japanese', text: '日本語かなカナ', language: 'ja', family: 'Noto Sans CJK SC' },
{ label: 'Korean', text: '안녕하세요', language: 'ko', family: 'Noto Sans CJK SC' },
{ label: 'Arabic', text: 'مرحبا بالعالم', language: 'ar', family: 'Noto Naskh Arabic' },
{
label: 'Mixed scripts',
text: 'OpenPencil · 你好 · مرحبا',
language: 'en',
family: 'Noto Sans CJK SC'
}
]
const sampleIds = samples.map((sample, index) => {
const y = 70 + index * 62
store.graph.createNode('TEXT', pageId, {
name: `${line.label} label`,
x: 96,
y,
width: 210,
name: `${sample.label} label`,
x: 88,
y: y + 4,
width: 220,
height: 28,
text: line.label,
text: sample.label,
fontFamily: 'Inter',
fontSize: 17,
fontSize: 16,
fontWeight: 600,
fills: [
{ type: 'SOLID', color: { r: 0.25, g: 0.32, b: 0.45, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('TEXT', pageId, {
name: `${line.label} sample`,
x: 328,
y: y - 4,
width: 360,
height: 42,
text: line.text,
fontFamily: 'Inter',
fontSize: 32,
textAutoResize: 'HEIGHT',
return store.graph.createNode('TEXT', pageId, {
name: `${sample.label} sample`,
x: 330,
y,
width: 430,
height: 44,
text: sample.text,
textLanguage: sample.language,
fontFamily: sample.family,
fontSize: 30,
textAutoResize: 'NONE',
styleRuns:
sample.label === 'Mixed scripts'
? [
{
start: sample.text.indexOf('مرحبا'),
length: 'مرحبا'.length,
style: { fontFamily: 'Noto Naskh Arabic', textLanguage: 'ar' }
}
]
: [],
fills: [
{ type: 'SOLID', color: { r: 0.04, g: 0.06, b: 0.12, a: 1 }, visible: true, opacity: 1 }
]
}).id
})
fontManager.blockNodesUntilFontsResolve(sampleIds)
const blockedBeforeFonts = sampleIds.every((id) => fontManager.isNodeBlocked(id))
const [cjk, arabic] = await Promise.all([
fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf').then((response) =>
response.arrayBuffer()
),
fetch('/tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf').then((response) =>
response.arrayBuffer()
)
])
fontManager.markLoaded('Noto Sans CJK SC', 'Regular', cjk)
fontManager.markLoaded('Noto Naskh Arabic', 'Regular', arabic)
fontManager.setCJKFallbackFamily('Noto Sans CJK SC')
fontManager.setArabicFallbackFamily('Noto Naskh Arabic')
await store.loadFontsForNodes(sampleIds)
fontManager.unblockNodes(sampleIds)
for (const id of sampleIds) {
const node = store.graph.getNode(id)
if (node?.type === 'TEXT') store.graph.updateNode(id, { text: node.text })
}
store.clearSelection()
store.renderer.invalidateAllPictures()
store.requestRender()
store.renderer.renderFromEditorState(
store.state,
store.graph,
store.textEditor,
window.innerWidth,
window.innerHeight,
false,
'full'
)
const pageNode = store.graph.getNode(pageId)
const image = await store.renderExportImage(pageNode?.childIds ?? [], 2, 'PNG')
if (!image) throw new Error('First-paint export failed')
return {
blockedBeforeFonts,
image: Array.from(image),
readiness: sampleIds.map((id) => {
const node = store.graph.getNode(id)
return {
name: node?.name,
state: node ? store.renderer?.nodeFontReadiness(node) : 'missing'
}
}),
unblockedAfterFonts: sampleIds.every((id) => !fontManager.isNodeBlocked(id))
}
})
const { image, ...state } = result
expect(state).toEqual({
blockedBeforeFonts: true,
readiness: [
{ name: 'Simplified Chinese sample', state: 'ready' },
{ name: 'Traditional Chinese sample', state: 'ready' },
{ name: 'Japanese sample', state: 'ready' },
{ name: 'Korean sample', state: 'ready' },
{ name: 'Arabic sample', state: 'ready' },
{ name: 'Mixed scripts sample', state: 'ready' }
],
unblockedAfterFonts: true
})
expect(Buffer.from(image)).toMatchSnapshot('international-text-first-paint.png')
})
test('typed and tool-created text repaint with the same resolved fallbacks', async ({ page }) => {
const canvas = await openEditor(page)
const ids = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const labels = ['Tool-created', 'Interactively typed']
for (const [index, label] of labels.entries()) {
store.graph.createNode('TEXT', pageId, {
name: `${label} label`,
x: 88,
y: 112 + index * 92,
width: 190,
height: 28,
text: label,
fontFamily: 'Inter',
fontSize: 16,
fontWeight: 600,
fills: [
{ type: 'SOLID', color: { r: 0.25, g: 0.32, b: 0.45, a: 1 }, visible: true, opacity: 1 }
]
})
}
await store.loadFontsForNodes(store.graph.getPages().flatMap((page) => page.childIds))
store.clearSelection()
store.requestRender()
return {
loaded: {
noto: fontManager.isStyleLoaded('Noto Sans SC', 'Regular'),
appleGothic: fontManager.isStyleLoaded('AppleGothic', 'Regular')
},
fallbacks: fontManager.getCJKFallbackFamilies()
const common = {
width: 450,
height: 52,
textLanguage: 'zh-Hans',
fontFamily: 'Inter',
fontSize: 30,
textAutoResize: 'NONE' as const,
fills: [
{
type: 'SOLID' as const,
color: { r: 0.04, g: 0.06, b: 0.12, a: 1 },
visible: true,
opacity: 1
}
]
}
const tool = store.graph.createNode('TEXT', pageId, {
...common,
name: 'Tool-created mixed script',
x: 300,
y: 104,
text: 'OpenPencil · 你好 · مرحبا'
})
const typed = store.graph.createNode('TEXT', pageId, {
...common,
name: 'Interactively typed mixed script',
x: 300,
y: 196,
text: ''
})
const nodeIds = [tool.id, typed.id]
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
fontManager.blockNodesUntilFontsResolve(nodeIds)
store.select([typed.id])
store.startTextEditing(typed.id)
store.requestRender()
return { nodeIds, toolId: tool.id, typedId: typed.id }
})
expect(smoke.loaded).toEqual({ noto: true, appleGothic: true })
expect(smoke.fallbacks).toEqual(expect.arrayContaining(['Noto Sans SC', 'AppleGothic']))
await editor.canvas.waitForRender()
await expectCanvas('cjk-fallback-text-rendering')
await page.locator('textarea[aria-hidden="true"]').fill('OpenPencil · 你好 · مرحبا')
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store?.renderer) throw new Error('OpenPencil renderer not initialized')
store.renderer.invalidateAllPictures()
store.renderer.renderFromEditorState(
store.state,
store.graph,
store.textEditor,
window.innerWidth,
window.innerHeight,
false,
'full'
)
})
await canvas.waitForRender()
const pending = await page.evaluate(async (nodeIds) => {
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
return nodeIds.every((id) => fontManager.isNodeBlocked(id))
}, ids.nodeIds)
expect(pending).toBe(true)
await expectCanvas(canvas, 'interactive-font-fallback-pending')
const resolved = await page.evaluate(async ({ nodeIds, toolId, typedId }) => {
const store = window.openPencil?.getStore?.()
if (!store?.renderer) throw new Error('OpenPencil renderer not initialized')
const fontModuleUrl = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/fonts.ts'))
if (!fontModuleUrl) throw new Error('Active font manager module not found')
const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as {
fontManager: FontManager
}
const [cjk, arabic] = await Promise.all([
fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf').then((response) =>
response.arrayBuffer()
),
fetch('/tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf').then((response) =>
response.arrayBuffer()
)
])
fontManager.markLoaded('Noto Sans CJK SC', 'Regular', cjk)
fontManager.markLoaded('Noto Naskh Arabic', 'Regular', arabic)
fontManager.setCJKFallbackFamily('Noto Sans CJK SC')
fontManager.setArabicFallbackFamily('Noto Naskh Arabic')
store.commitTextEdit()
await store.loadFontsForNodes(nodeIds)
fontManager.unblockNodes(nodeIds)
for (const id of nodeIds) {
const node = store.graph.getNode(id)
if (node?.type === 'TEXT') store.graph.updateNode(id, { text: node.text })
}
store.clearSelection()
store.renderer.invalidateAllPictures()
store.requestRender()
store.renderer.renderFromEditorState(
store.state,
store.graph,
store.textEditor,
window.innerWidth,
window.innerHeight,
false,
'full'
)
const tool = store.graph.getNode(toolId)
const typed = store.graph.getNode(typedId)
const pageNode = store.graph.getNode(store.state.currentPageId)
const image = await store.renderExportImage(pageNode?.childIds ?? [], 2, 'PNG')
if (!image) throw new Error('Interactive export failed')
return {
image: Array.from(image),
textsMatch: tool?.type === 'TEXT' && typed?.type === 'TEXT' && tool.text === typed.text,
toolReady: tool ? store.renderer.isNodeFontLoaded(tool) : false,
typedReady: typed ? store.renderer.isNodeFontLoaded(typed) : false,
unblocked: nodeIds.every((id) => !fontManager.isNodeBlocked(id))
}
}, ids)
const { image, ...state } = resolved
expect(state).toEqual({ textsMatch: true, toolReady: true, typedReady: true, unblocked: true })
expect(Buffer.from(image)).toMatchSnapshot('interactive-font-fallback-resolved.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -25,10 +25,10 @@ describe('font lifecycle', () => {
expect(providerGeneration).toBeGreaterThan(0)
expect(registrationGeneration).toBeGreaterThan(providerGeneration)
expect(manager.generation()).toBe(registrationGeneration)
expect(registrations).toEqual(['Generation Test', '__op_font__Generation_Test__Regular'])
expect(registrations).toEqual(['Generation Test'])
})
test('moves replaced subset fonts to a fresh render family', () => {
test('keeps cumulative subset registrations under the source family', () => {
const manager = new FontManager()
const registrations: string[] = []
const provider = {
@ -39,13 +39,12 @@ describe('font lifecycle', () => {
manager.attachProvider({} as CanvasKit, provider)
manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(8))
const firstRenderFamily = manager.renderFamily('Subset Font', 'Regular')
const firstGeneration = manager.generation()
manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(12))
const secondRenderFamily = manager.renderFamily('Subset Font', 'Regular')
expect(firstRenderFamily).toBe('__op_font__Subset_Font__Regular')
expect(secondRenderFamily).toBe('__op_font__Subset_Font__Regular__2')
expect(registrations).toContain(secondRenderFamily)
expect(manager.renderFamily('Subset Font', 'Regular')).toBe('Subset Font')
expect(manager.generation()).toBeGreaterThan(firstGeneration)
expect(registrations).toEqual(['Subset Font', 'Subset Font'])
})
test('tracks nodes gated by pre-render font resolution', () => {

View file

@ -132,16 +132,10 @@ describe('FontManager loaded font cache', () => {
manager.attachProvider(canvasKit, first.provider)
manager.markLoaded('ProviderLifecycle', 'Regular', new ArrayBuffer(12))
expect(first.registrations).toEqual([
{ family: 'ProviderLifecycle', byteLength: 12 },
{ family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 }
])
expect(first.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }])
manager.attachProvider(canvasKit, second.provider)
expect(second.registrations).toEqual([
{ family: 'ProviderLifecycle', byteLength: 12 },
{ family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 }
])
expect(second.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }])
manager.detachProvider(first.provider)
expect(manager.provider()).toBe(second.provider)
@ -149,7 +143,7 @@ describe('FontManager loaded font cache', () => {
expect(manager.provider()).toBeNull()
})
test('registers loaded faces under exact render families', () => {
test('renders loaded faces under their source families', () => {
const manager = new FontManager()
const recording = createRecordingProvider()
@ -158,13 +152,10 @@ describe('FontManager loaded font cache', () => {
const renderFamily = manager.renderFamily('Inter', 'SemiBold')
expect(renderFamily).toBe('__op_font__Inter__SemiBold')
expect(recording.registrations).toEqual([
{ family: 'Inter', byteLength: 12 },
{ family: '__op_font__Inter__SemiBold', byteLength: 12 }
])
expect(renderFamily).toBe('Inter')
expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: 12 }])
expect(manager.renderFamily('Inter', 'SemiBold')).toBe(renderFamily)
expect(recording.registrations).toHaveLength(2)
expect(recording.registrations).toHaveLength(1)
})
test('prefers local font data before downloaded cache', async () => {
@ -206,10 +197,7 @@ describe('FontManager loaded font cache', () => {
})
await expect(manager.loadFont('DownloadedCache', 'Regular')).resolves.toBe(data)
expect(recording.registrations).toEqual([
{ family: 'DownloadedCache', byteLength: 16 },
{ family: '__op_font__DownloadedCache__Regular', byteLength: 16 }
])
expect(recording.registrations).toEqual([{ family: 'DownloadedCache', byteLength: 16 }])
expect(writes).toBe(0)
})
@ -226,10 +214,7 @@ describe('FontManager loaded font cache', () => {
const data = await manager.loadFont('Inter', 'ExtraBold')
expect(data?.byteLength).toBeGreaterThan(0)
expect(recording.registrations).toEqual([
{ family: 'Inter', byteLength: data?.byteLength },
{ family: '__op_font__Inter__ExtraBold', byteLength: data?.byteLength }
])
expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: data?.byteLength }])
} finally {
globalThis.fetch = originalFetch
}

Binary file not shown.

93
tests/fixtures/fonts/OFL.txt vendored Normal file
View file

@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.