diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 808a93390..f02716377 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -343,8 +343,8 @@ export class SkiaRenderer { return this.destroyed } - async loadFonts(): Promise { - await RendererFonts.loadFonts(this) + async loadFonts(onFallbackFontsLoaded?: () => void): Promise { + await RendererFonts.loadFonts(this, onFallbackFontsLoaded) } async prepareForExport( diff --git a/packages/core/src/canvas/renderer/fonts.ts b/packages/core/src/canvas/renderer/fonts.ts index 80372352a..7baa6247c 100644 --- a/packages/core/src/canvas/renderer/fonts.ts +++ b/packages/core/src/canvas/renderer/fonts.ts @@ -14,7 +14,10 @@ export function getFontProvider(r: SkiaRenderer) { return r.isDestroyed() || !r.fontProvider ? null : r.fontProvider } -export async function loadFonts(r: SkiaRenderer): Promise { +export async function loadFonts( + r: SkiaRenderer, + onFallbackFontsLoaded?: () => void +): Promise { if (r.isDestroyed()) return r.fontProvider?.delete() r.fontProvider = r.ck.TypefaceFontProvider.Make() @@ -46,10 +49,16 @@ export async function loadFonts(r: SkiaRenderer): Promise { 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?.() + } }) } diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index d47808629..80bef6eb0 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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 diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index aaa1b3deb..d9f57951c 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -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() 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( diff --git a/packages/vue/src/canvas/surface/lifecycle.ts b/packages/vue/src/canvas/surface/lifecycle.ts index e2cbe54b1..f3c046ae8 100644 --- a/packages/vue/src/canvas/surface/lifecycle.ts +++ b/packages/vue/src/canvas/surface/lifecycle.ts @@ -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 }) diff --git a/tests/e2e/fonts/cjk-fallback.spec.ts b/tests/e2e/fonts/cjk-fallback.spec.ts new file mode 100644 index 000000000..c75af0e33 --- /dev/null +++ b/tests/e2e/fonts/cjk-fallback.spec.ts @@ -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 | null + arabicFallbackFamilies: string[] + arabicFallbackPromise: Promise | 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((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 + ) => { + 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() +})