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'