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
This commit is contained in:
Danila Poyarkov 2026-03-05 09:19:42 +03:00
parent 1ee0f86af3
commit ad96b1c654
7 changed files with 135 additions and 21 deletions

View file

@ -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.

View file

@ -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'

View file

@ -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<Array
const blob: Blob = await match.blob()
const buffer = await blob.arrayBuffer()
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
if (registerFontInCanvasKit(family, buffer)) {
loadedFamilies.set(cacheKey, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
}
}
} catch {
/* fall through to bundled */
/* fall through to Google Fonts */
}
}
@ -136,9 +143,8 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
if (typeof fetch !== 'undefined') {
try {
const buffer = await fetchGoogleFont(family, style)
if (buffer) {
if (buffer && registerFontInCanvasKit(family, buffer)) {
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
}
@ -154,10 +160,11 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
const response = await fetch(bundledUrl)
const buffer = await response.arrayBuffer()
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
if (registerFontInCanvasKit(family, buffer)) {
loadedFamilies.set(cacheKey, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
}
} catch {
/* no bundled font available */
}
@ -166,9 +173,14 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
return null
}
function registerFontInCanvasKit(family: string, data: ArrayBuffer) {
if (!fontProvider) return
fontProvider.registerFont(data, family)
function registerFontInCanvasKit(family: string, data: ArrayBuffer): boolean {
if (!fontProvider || data.byteLength < 4) return false
try {
fontProvider.registerFont(data, family)
return true
} catch {
return false
}
}
function registerFontInBrowser(family: string, style: string, data: ArrayBuffer) {
@ -234,6 +246,63 @@ export function collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[st
.filter(([family]) => family !== DEFAULT_FONT_FAMILY)
}
let cjkFallbackFamily: string | null = null
let cjkFallbackPromise: Promise<string | null> | 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<ArrayBuffer | null> {
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<string | null> {
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'

View file

@ -63,6 +63,8 @@ export {
isFontLoaded,
markFontLoaded,
ensureNodeFont,
ensureCJKFallback,
getCJKFallbackFamily,
styleToWeight,
weightToStyle
} from './fonts'

View file

@ -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<void> {
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,

View file

@ -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)
}

View file

@ -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'