From ad96b1c654e2e69cdbae5b7c7f4d405368349e2a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 5 Mar 2026 09:19:42 +0300 Subject: [PATCH] Add CJK font fallback for text rendering Load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK, etc.) at startup and pass it as a fallback in CanvasKit's fontFamilies array. Falls back to Google Fonts (Noto Sans SC) when no system font is available. Also fixes loadFont to only cache fonts that successfully register with CanvasKit's TypefaceFontProvider, preventing invalid font data from being treated as loaded. Closes #48 --- CHANGELOG.md | 2 + packages/core/src/constants.ts | 25 ++++++++ packages/core/src/fonts.ts | 99 ++++++++++++++++++++++++----- packages/core/src/index.ts | 2 + packages/core/src/renderer.ts | 16 +++-- src/composables/use-canvas-input.ts | 4 +- src/engine/fonts.ts | 8 ++- 7 files changed, 135 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fddc313fc..a4ab87959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### Fixes +- CJK text rendering — load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK) as fallback; falls back to Noto Sans SC from Google Fonts when no system font is available (#48) +- Font registration errors no longer cache invalid font data — `loadFont` only caches after successful CanvasKit registration - Fix hover highlighting nodes from internal component pages — scope hit-test to current page - Fix hit-testing on transparent frames and groups — empty containers without fills or strokes are now click-through, clipping parents reject hits outside their bounds, matching Figma behavior - Fix instance overrides on .fig import and clipboard paste — resolve guidPaths by overrideKey, handle component swaps (`overriddenSymbolID`), propagate through nested clone chains. Import and paste now share a single override engine. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6f1f7d534..e3a2e1bea 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -124,3 +124,28 @@ export const AI_MODELS: ModelOption[] = [ export const DEFAULT_AI_MODEL = AI_MODELS[0].id export const GOOGLE_FONTS_API_KEY = 'AIzaSyD1tYDR_dUEiV-Tw1vksEhZbUytgKW5pc8' + +export const CJK_FALLBACK_FAMILIES_MACOS = [ + 'PingFang SC', + 'Hiragino Sans', + 'Apple SD Gothic Neo', + 'Heiti SC' +] + +export const CJK_FALLBACK_FAMILIES_WINDOWS = [ + 'Microsoft YaHei', + 'Microsoft JhengHei', + 'Yu Gothic', + 'Malgun Gothic', + 'SimHei' +] + +export const CJK_FALLBACK_FAMILIES_LINUX = [ + 'Noto Sans CJK SC', + 'Noto Sans CJK JP', + 'Noto Sans CJK KR', + 'WenQuanYi Micro Hei', + 'Droid Sans Fallback' +] + +export const CJK_GOOGLE_FONT = 'Noto Sans SC' diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 0292d51ed..7244e6dbe 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -1,6 +1,12 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' -import { DEFAULT_FONT_FAMILY } from './constants' +import { + DEFAULT_FONT_FAMILY, + CJK_FALLBACK_FAMILIES_MACOS, + CJK_FALLBACK_FAMILIES_WINDOWS, + CJK_FALLBACK_FAMILIES_LINUX, + CJK_GOOGLE_FONT +} from './constants' import type { SceneGraph } from './scene-graph' export interface FontInfo { @@ -122,13 +128,14 @@ export async function loadFont(family: string, style = 'Regular'): Promise family !== DEFAULT_FONT_FAMILY) } +let cjkFallbackFamily: string | null = null +let cjkFallbackPromise: Promise | null = null + +function getCJKCandidates(): string[] { + if (typeof navigator === 'undefined') return [...CJK_FALLBACK_FAMILIES_LINUX] + const ua = navigator.userAgent + if (ua.includes('Mac')) return CJK_FALLBACK_FAMILIES_MACOS + if (ua.includes('Windows')) return CJK_FALLBACK_FAMILIES_WINDOWS + return CJK_FALLBACK_FAMILIES_LINUX +} + +async function tryLoadLocalFont(family: string): Promise { + if (typeof window === 'undefined' || !window.queryLocalFonts) return null + try { + const fonts = await window.queryLocalFonts() + const match = fonts.find((f: FontInfo) => f.family === family) + if (!match) return null + const blob: Blob = await match.blob() + const buffer = await blob.arrayBuffer() + if (!registerFontInCanvasKit(family, buffer)) return null + const cacheKey = `${family}|Regular` + loadedFamilies.set(cacheKey, buffer) + registerFontInBrowser(family, 'Regular', buffer) + return buffer + } catch { + return null + } +} + +export async function ensureCJKFallback(): Promise { + if (cjkFallbackFamily) return cjkFallbackFamily + if (cjkFallbackPromise) return cjkFallbackPromise + + cjkFallbackPromise = (async () => { + for (const family of getCJKCandidates()) { + if (await tryLoadLocalFont(family)) { + cjkFallbackFamily = family + return family + } + } + + const data = await loadFont(CJK_GOOGLE_FONT, 'Regular') + if (data) { + cjkFallbackFamily = CJK_GOOGLE_FONT + return CJK_GOOGLE_FONT + } + + return null + })() + + return cjkFallbackPromise +} + +export function getCJKFallbackFamily(): string | null { + return cjkFallbackFamily +} + export function weightToStyle(weight: number, italic = false): string { let label = 'Regular' if (weight <= 100) label = 'Thin' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c7a06eed..b9b5c2984 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -63,6 +63,8 @@ export { isFontLoaded, markFontLoaded, ensureNodeFont, + ensureCJKFallback, + getCJKFallbackFamily, styleToWeight, weightToStyle } from './fonts' diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 1c32bb90a..5485ca435 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -58,7 +58,7 @@ import { TEXT_CARET_WIDTH, DEFAULT_FONT_FAMILY } from './constants' -import { isFontLoaded } from './fonts' +import { isFontLoaded, getCJKFallbackFamily } from './fonts' import { vectorNetworkToPath, geometryBlobToPath } from './vector' import { RenderProfiler } from './profiler' @@ -311,7 +311,7 @@ export class SkiaRenderer { async loadFonts(): Promise { this.fontProvider = this.ck.TypefaceFontProvider.Make() - const { initFontService, loadFont } = await import('./fonts') + const { initFontService, loadFont, ensureCJKFallback } = await import('./fonts') initFontService(this.ck, this.fontProvider) const fontData = await loadFont(DEFAULT_FONT_FAMILY, 'Regular') @@ -335,6 +335,10 @@ export class SkiaRenderer { this.fontsLoaded = true this.invalidateAllPictures() + + ensureCJKFallback().then((family) => { + if (family) this.invalidateAllPictures() + }) } replaceSurface(surface: Surface): void { @@ -2331,6 +2335,7 @@ export class SkiaRenderer { const ck = this.ck const baseColor = color ?? ck.BLACK const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE + const cjkFallback = getCJKFallbackFamily() const truncateOpts: { maxLines?: number; ellipsis?: string } = {} if (node.textTruncation === 'ENDING') { @@ -2343,12 +2348,15 @@ export class SkiaRenderer { truncateOpts.ellipsis = '…' } + const fontFamilies = (primary: string) => + cjkFallback ? [primary, cjkFallback] : [primary] + const paraStyle = new ck.ParagraphStyle({ textAlign: this.getTextAlign(node.textAlignHorizontal), ...truncateOpts, textStyle: { color: baseColor, - fontFamilies: [node.fontFamily || DEFAULT_FONT_FAMILY], + fontFamilies: fontFamilies(node.fontFamily || DEFAULT_FONT_FAMILY), fontSize: baseFontSize, fontStyle: { weight: { value: node.fontWeight || 400 } as FontWeight, @@ -2377,7 +2385,7 @@ export class SkiaRenderer { builder.pushStyle( new ck.TextStyle({ color: baseColor, - fontFamilies: [s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)], + fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), fontSize: s.fontSize ?? baseFontSize, fontStyle: { weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight, diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 94abb5594..34a17c436 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -561,7 +561,9 @@ export function useCanvasInput( cursorOverride.value = cursor const hit = - hitTestSectionTitle(cx, cy) ?? hitTestComponentLabel(cx, cy) ?? store.graph.hitTest(cx, cy, store.state.currentPageId) + hitTestSectionTitle(cx, cy) ?? + hitTestComponentLabel(cx, cy) ?? + store.graph.hitTest(cx, cy, store.state.currentPageId) store.setHoveredNode(hit && !store.state.selectedIds.has(hit.id) ? hit.id : null) } diff --git a/src/engine/fonts.ts b/src/engine/fonts.ts index a6e121911..3053bf054 100644 --- a/src/engine/fonts.ts +++ b/src/engine/fonts.ts @@ -1,4 +1,10 @@ -export { initFontService, getFontProvider, ensureNodeFont } from '@open-pencil/core' +export { + initFontService, + getFontProvider, + ensureNodeFont, + ensureCJKFallback, + getCJKFallbackFamily +} from '@open-pencil/core' import { loadFont as loadFontCore, markFontLoaded, styleToWeight } from '@open-pencil/core'