fix(canvas): wait for CJK fallback fonts

- Skip CJK and Arabic text until fallback font families are ready
- Re-render after async fallback font loading completes
- Add Playwright coverage for delayed CJK fallback loading
This commit is contained in:
Danila Poyarkov 2026-05-12 16:54:55 +03:00
parent ddf415ec30
commit 23039e0b99
6 changed files with 146 additions and 8 deletions

View file

@ -343,8 +343,8 @@ export class SkiaRenderer {
return this.destroyed
}
async loadFonts(): Promise<void> {
await RendererFonts.loadFonts(this)
async loadFonts(onFallbackFontsLoaded?: () => void): Promise<void> {
await RendererFonts.loadFonts(this, onFallbackFontsLoaded)
}
async prepareForExport(

View file

@ -14,7 +14,10 @@ export function getFontProvider(r: SkiaRenderer) {
return r.isDestroyed() || !r.fontProvider ? null : r.fontProvider
}
export async function loadFonts(r: SkiaRenderer): Promise<void> {
export async function loadFonts(
r: SkiaRenderer,
onFallbackFontsLoaded?: () => void
): Promise<void> {
if (r.isDestroyed()) return
r.fontProvider?.delete()
r.fontProvider = r.ck.TypefaceFontProvider.Make()
@ -46,10 +49,16 @@ export async function loadFonts(r: SkiaRenderer): Promise<void> {
r.invalidateAllPictures()
void fontManager.ensureCJKFallback().then((families) => {
if (!r.isDestroyed() && families.length > 0) r.invalidateAllPictures()
if (!r.isDestroyed() && families.length > 0) {
r.invalidateAllPictures()
onFallbackFontsLoaded?.()
}
})
void fontManager.ensureArabicFallback().then((families) => {
if (!r.isDestroyed() && families.length > 0) r.invalidateAllPictures()
if (!r.isDestroyed() && families.length > 0) {
r.invalidateAllPictures()
onFallbackFontsLoaded?.()
}
})
}

View file

@ -545,6 +545,10 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil
}
const paragraphY = -1
if (!r.isNodeFontLoaded(node)) {
canvas.restore()
return
}
if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) {
canvas.restore()
return

View file

@ -30,13 +30,22 @@ export interface ClipboardShapedText {
logicalIndexToCharacterOffsetMap: number[]
}
const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u
const ARABIC_RE = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u
function hasRequiredFallbackFonts(text: string): boolean {
if (CJK_RE.test(text) && fontManager.getCJKFallbackFamilies().length === 0) return false
if (ARABIC_RE.test(text) && fontManager.getArabicFallbackFamilies().length === 0) return false
return true
}
export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean {
const families = new Set<string>()
families.add(node.fontFamily || DEFAULT_FONT_FAMILY)
for (const run of node.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}
return [...families].every((f) => fontManager.isLoaded(f))
return hasRequiredFallbackFonts(node.text) && [...families].every((f) => fontManager.isLoaded(f))
}
export function measureTextNode(

View file

@ -65,7 +65,7 @@ export function createCanvasSurfaceManager({
// has cleared the module-level fontProvider — the new renderer must reload.
// On initial mount, kit-loader.init() handles loadFonts, so skip here.
if (reloadFonts && !isDestroyed()) {
void state.renderer.loadFonts().then(() => {
void state.renderer.loadFonts(renderNow).then(() => {
if (!isDestroyed()) renderNow()
})
}
@ -145,7 +145,7 @@ export function useCanvasSurfaceLifecycle({
lifecycle,
setCanvasKit,
createSurface: surface.createSurface,
loadFonts: () => surface.getRenderer()?.loadFonts(),
loadFonts: () => surface.getRenderer()?.loadFonts(surface.renderNow),
renderNow: surface.renderNow,
onReady
})

View file

@ -0,0 +1,116 @@
import { test, expect } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
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')
await canvas.waitForInit()
const result = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
const { fontManager } = await import('/packages/core/src/text/fonts.ts')
const manager = fontManager as typeof fontManager & {
cjkFallbackFamilies: string[]
cjkFallbackPromise: Promise<string[]> | null
arabicFallbackFamilies: string[]
arabicFallbackPromise: Promise<string[]> | null
}
const originalCJKFamilies = [...manager.cjkFallbackFamilies]
const originalCJKPromise = manager.cjkFallbackPromise
const originalArabicFamilies = [...manager.arabicFallbackFamilies]
const originalArabicPromise = manager.arabicFallbackPromise
const originalEnsureCJKFallback = fontManager.ensureCJKFallback.bind(fontManager)
const originalEnsureArabicFallback = fontManager.ensureArabicFallback.bind(fontManager)
let releaseCJKFallback: (() => void) | null = null
const fallbackGate = new Promise<void>((resolve) => {
releaseCJKFallback = resolve
})
manager.cjkFallbackFamilies = []
manager.cjkFallbackPromise = null
manager.arabicFallbackFamilies = []
manager.arabicFallbackPromise = null
let fallbackRenderCount = 0
let renderCount = 0
const originalRender = renderer.renderFromEditorState.bind(renderer)
fontManager.ensureCJKFallback = async () => {
await fallbackGate
fontManager.setCJKFallbackFamily('Regression CJK Fallback')
return ['Regression CJK Fallback']
}
fontManager.ensureArabicFallback = async () => []
renderer.renderFromEditorState = (
...args: Parameters<typeof renderer.renderFromEditorState>
) => {
renderCount += 1
return originalRender(...args)
}
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const text = store.graph.createNode('TEXT', pageNode.id, {
name: 'CJK Regression',
x: 80,
y: 80,
width: 300,
height: 60,
text: '上班打卡App',
fontSize: 32,
fontFamily: 'Inter',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
try {
await renderer.loadFonts(() => {
fallbackRenderCount += 1
renderer.renderFromEditorState(
store.state,
store.graph,
store.textEditor,
800,
600,
false,
'full'
)
})
const loadedBeforeFallback = renderer.isNodeFontLoaded(text)
const beforeFallbackRenderCount = fallbackRenderCount
releaseCJKFallback?.()
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise(requestAnimationFrame)
return {
loadedBeforeFallback,
loadedAfterFallback: renderer.isNodeFontLoaded(text),
beforeFallbackRenderCount,
fallbackRenderCount,
renderCount
}
} finally {
manager.cjkFallbackFamilies = originalCJKFamilies
manager.cjkFallbackPromise = originalCJKPromise
manager.arabicFallbackFamilies = originalArabicFamilies
manager.arabicFallbackPromise = originalArabicPromise
fontManager.ensureCJKFallback = originalEnsureCJKFallback
fontManager.ensureArabicFallback = originalEnsureArabicFallback
renderer.renderFromEditorState = originalRender
}
})
expect(result.loadedBeforeFallback).toBe(false)
expect(result.loadedAfterFallback).toBe(true)
expect(result.beforeFallbackRenderCount).toBe(0)
expect(result.fallbackRenderCount).toBe(1)
expect(result.renderCount).toBeGreaterThan(0)
canvas.assertNoErrors()
})