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:
parent
ddf415ec30
commit
23039e0b99
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
116
tests/e2e/fonts/cjk-fallback.spec.ts
Normal file
116
tests/e2e/fonts/cjk-fallback.spec.ts
Normal 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()
|
||||
})
|
||||
Loading…
Reference in a new issue