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:
parent
1ee0f86af3
commit
ad96b1c654
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export {
|
|||
isFontLoaded,
|
||||
markFontLoaded,
|
||||
ensureNodeFont,
|
||||
ensureCJKFallback,
|
||||
getCJKFallbackFamily,
|
||||
styleToWeight,
|
||||
weightToStyle
|
||||
} from './fonts'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue