From 768b527155abb730a8187806f16b243d8b579856 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 5 May 2026 21:35:12 +0300 Subject: [PATCH] feat(core): add font fallback manifest --- CHANGELOG.md | 1 + packages/core/src/index.ts | 9 +++ packages/core/src/text/fallbacks.ts | 56 ++++++++++++++ packages/core/src/text/fonts.ts | 114 +++++++++++++--------------- packages/core/src/text/index.ts | 1 + src/app/editor/fonts.ts | 4 + tests/engine/fonts.test.ts | 28 ++++++- 7 files changed, 152 insertions(+), 61 deletions(-) create mode 100644 packages/core/src/text/fallbacks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b75b5189..185e409a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ ### Performance - Cache downloaded remote fonts in app-local storage so reopened documents can reuse them without repeated network fetches. +- Add a curated fallback font manifest for CJK and Arabic font downloads. - Cache instance override resolution and lazily populate opened `.fig` pages to reduce load time for large community files. - Reduce zoom and overlay rendering work by separating scene rendering from rulers, selection, labels, and input overlays. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41e7aa3dc..d7f0ee493 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -175,6 +175,15 @@ export { type FontInfo, type LocalFontAccessState } from './text/fonts' +export { + ARABIC_LOCAL_FALLBACK_FAMILIES, + ARABIC_REMOTE_FALLBACK_FAMILIES, + cjkLocalFallbackFamilies, + fontFallbackEntry, + fontFallbackManifest, + type FontFallbackManifestEntry, + type FontFallbackScript +} from './text/fallbacks' export { parseColor, normalizeColor, diff --git a/packages/core/src/text/fallbacks.ts b/packages/core/src/text/fallbacks.ts new file mode 100644 index 000000000..c7f4bb98f --- /dev/null +++ b/packages/core/src/text/fallbacks.ts @@ -0,0 +1,56 @@ +import { + CJK_FALLBACK_FAMILIES_LINUX, + CJK_FALLBACK_FAMILIES_MACOS, + CJK_FALLBACK_FAMILIES_WINDOWS, + CJK_GOOGLE_FONTS +} from '#core/constants' + +export type FontFallbackScript = 'cjk' | 'arabic' + +export interface FontFallbackManifestEntry { + script: FontFallbackScript + localFamilies: string[] + remoteFamilies: string[] +} + +export const ARABIC_LOCAL_FALLBACK_FAMILIES = [ + 'Noto Naskh Arabic', + 'Noto Sans Arabic', + 'Geeza Pro', + 'Arial', + 'Tahoma', + 'Amiri' +] + +export const ARABIC_REMOTE_FALLBACK_FAMILIES = ['Noto Naskh Arabic', 'Noto Sans Arabic'] + +export function cjkLocalFallbackFamilies(userAgent?: string): string[] { + if (!userAgent) return [...CJK_FALLBACK_FAMILIES_LINUX] + if (userAgent.includes('Mac')) return [...CJK_FALLBACK_FAMILIES_MACOS] + if (userAgent.includes('Windows')) return [...CJK_FALLBACK_FAMILIES_WINDOWS] + return [...CJK_FALLBACK_FAMILIES_LINUX] +} + +export function fontFallbackManifest( + userAgent?: string +): Record { + return { + cjk: { + script: 'cjk', + localFamilies: cjkLocalFallbackFamilies(userAgent), + remoteFamilies: [...CJK_GOOGLE_FONTS] + }, + arabic: { + script: 'arabic', + localFamilies: [...ARABIC_LOCAL_FALLBACK_FAMILIES], + remoteFamilies: [...ARABIC_REMOTE_FALLBACK_FAMILIES] + } + } +} + +export function fontFallbackEntry( + script: FontFallbackScript, + userAgent?: string +): FontFallbackManifestEntry { + return fontFallbackManifest(userAgent)[script] +} diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index b8374222c..97faf01cc 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -1,14 +1,12 @@ import { DEFAULT_FONT_FAMILY, IS_BROWSER, - CJK_FALLBACK_FAMILIES_MACOS, - CJK_FALLBACK_FAMILIES_WINDOWS, - CJK_FALLBACK_FAMILIES_LINUX, - CJK_GOOGLE_FONTS, GOOGLE_FONTS_API_KEY } from '#core/constants' +import { fontFallbackEntry } from '#core/text/fallbacks' import type { SceneGraph } from '#core/scene-graph' +import type { FontFallbackScript } from '#core/text/fallbacks' import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' export interface FontInfo { @@ -93,20 +91,13 @@ export function weightToStyle(weight: number, italic = false): string { return italic ? `${label} Italic` : label } -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 -} - export class FontManager { private loadedFamilies = new Map() private fontProvider: TypefaceFontProvider | null = null private localFonts: FontInfo[] | null = null private localFontAccessState: LocalFontAccessState = IS_BROWSER ? 'prompt' : 'unsupported' private downloadedFontCache: DownloadedFontCache | null = null + private fallbackUserAgent: string | undefined private googleFontsCache = new Map>() private googleFontsFailed = new Set() private cjkFallbackFamilies: string[] = [] @@ -138,6 +129,10 @@ export class FontManager { this.downloadedFontCache = cache } + setFallbackUserAgent(userAgent: string | undefined): void { + this.fallbackUserAgent = userAgent + } + async loadCachedFont(family: string, style = 'Regular'): Promise { const cached = await this.readDownloadedFont(family, style) if (!cached) return null @@ -275,31 +270,9 @@ export class FontManager { if (this.cjkFallbackFamilies.length > 0) return this.cjkFallbackFamilies if (this.cjkFallbackPromise) return this.cjkFallbackPromise - this.cjkFallbackPromise = (async () => { - for (const family of getCJKCandidates()) { - const buffer = await this.findLocalFont(family, undefined, { allowVariable: true }) - if (buffer && this.registerAndCache(family, 'Regular', buffer)) { - this.cjkFallbackFamilies.push(family) - } - } - - if (this.cjkFallbackFamilies.length === 0) { - const results = await Promise.allSettled( - CJK_GOOGLE_FONTS.map(async (family) => { - const data = await this.loadFont(family, 'Regular') - return data ? family : null - }) - ) - for (const result of results) { - if (result.status === 'fulfilled' && result.value) { - this.cjkFallbackFamilies.push(result.value) - } - } - } - - return this.cjkFallbackFamilies - })() - + this.cjkFallbackPromise = this.ensureFallbackFamilies('cjk', this.cjkFallbackFamilies, { + allowVariableLocalFonts: true + }) return this.cjkFallbackPromise } @@ -317,32 +290,22 @@ export class FontManager { if (this.arabicFallbackFamilies.length > 0) return this.arabicFallbackFamilies if (this.arabicFallbackPromise) return this.arabicFallbackPromise - this.arabicFallbackPromise = (async () => { - for (const family of [ - 'Noto Naskh Arabic', - 'Noto Sans Arabic', - 'Geeza Pro', - 'Arial', - 'Tahoma', - 'Amiri' - ]) { - const buffer = await this.findLocalFont(family) - if (buffer && this.registerAndCache(family, 'Regular', buffer)) { - this.arabicFallbackFamilies.push(family) - } - } - - if (this.arabicFallbackFamilies.length === 0) { - const data = await this.loadFont('Noto Naskh Arabic', 'Regular') - if (data) this.arabicFallbackFamilies.push('Noto Naskh Arabic') - } - - return this.arabicFallbackFamilies - })() - + this.arabicFallbackPromise = this.ensureFallbackFamilies('arabic', this.arabicFallbackFamilies) return this.arabicFallbackPromise } + async ensureFallbackPack( + scripts: FontFallbackScript[] = ['cjk', 'arabic'] + ): Promise> { + const result: Record = { cjk: [], arabic: [] } + await Promise.all( + scripts.map(async (script) => { + result[script] = script === 'cjk' ? await this.ensureCJKFallback() : await this.ensureArabicFallback() + }) + ) + return result + } + getArabicFallbackFamilies(): string[] { return this.arabicFallbackFamilies } @@ -353,6 +316,37 @@ export class FontManager { } } + private async ensureFallbackFamilies( + script: FontFallbackScript, + targetFamilies: string[], + options: { allowVariableLocalFonts?: boolean } = {} + ): Promise { + const manifest = fontFallbackEntry(script, this.fallbackUserAgent) + + for (const family of manifest.localFamilies) { + const buffer = await this.findLocalFont(family, undefined, { + allowVariable: options.allowVariableLocalFonts + }) + if (buffer && this.registerAndCache(family, 'Regular', buffer)) { + targetFamilies.push(family) + } + } + + if (targetFamilies.length === 0) { + const results = await Promise.allSettled( + manifest.remoteFamilies.map(async (family) => { + const data = await this.loadFont(family, 'Regular') + return data ? family : null + }) + ) + for (const result of results) { + if (result.status === 'fulfilled' && result.value) targetFamilies.push(result.value) + } + } + + return targetFamilies + } + private async readDownloadedFont(family: string, style: string): Promise { if (!this.downloadedFontCache) return null try { diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts index a96c96c4c..ff39a74d9 100644 --- a/packages/core/src/text/index.ts +++ b/packages/core/src/text/index.ts @@ -2,3 +2,4 @@ export * from './editor' export * from './style-runs' export * from './direction' export * from './fonts' +export * from './fallbacks' diff --git a/src/app/editor/fonts.ts b/src/app/editor/fonts.ts index 263c103a5..8f7c5bc03 100644 --- a/src/app/editor/fonts.ts +++ b/src/app/editor/fonts.ts @@ -3,6 +3,10 @@ import { fontManager, styleToWeight, type LocalFontAccessState } from '@open-pen import { createTauriDownloadedFontCache } from '@/app/editor/font-cache' +if (typeof navigator !== 'undefined') { + fontManager.setFallbackUserAgent(navigator.userAgent) +} + if (IS_TAURI) { fontManager.setDownloadedFontCache(createTauriDownloadedFontCache()) } diff --git a/tests/engine/fonts.test.ts b/tests/engine/fonts.test.ts index 60a4a82cd..ed50ff54e 100644 --- a/tests/engine/fonts.test.ts +++ b/tests/engine/fonts.test.ts @@ -8,7 +8,10 @@ import { styleToWeight, weightToStyle, FontManager, - SceneGraph + SceneGraph, + cjkLocalFallbackFamilies, + fontFallbackEntry, + fontFallbackManifest } from '@open-pencil/core' import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' @@ -460,6 +463,29 @@ describe('isVariableFont', () => { }) }) +describe('font fallback manifest', () => { + test('selects platform CJK local candidates', () => { + expect(cjkLocalFallbackFamilies('Mozilla/5.0 (Macintosh)')).toContain('PingFang SC') + expect(cjkLocalFallbackFamilies('Mozilla/5.0 (Windows NT 10.0)')).toContain( + 'Microsoft YaHei UI' + ) + expect(cjkLocalFallbackFamilies('X11; Linux x86_64')).toContain('Noto Sans CJK SC') + }) + + test('defines remote fallback families for CJK and Arabic', () => { + const manifest = fontFallbackManifest('X11; Linux x86_64') + expect(manifest.cjk.remoteFamilies).toContain('Noto Sans SC') + expect(manifest.arabic.remoteFamilies).toContain('Noto Naskh Arabic') + }) + + test('returns entries by script', () => { + expect(fontFallbackEntry('arabic').localFamilies).toContain('Geeza Pro') + expect(fontFallbackEntry('cjk', 'Mozilla/5.0 (Macintosh)').localFamilies).toContain( + 'PingFang SC' + ) + }) +}) + describe('fetchBundledFont', () => { test('loads Inter-Regular.ttf from assets in headless', async () => { const buffer = await fontManager.fetchBundledFont('/Inter-Regular.ttf')