feat(core): add font fallback manifest

This commit is contained in:
Danila Poyarkov 2026-05-05 21:35:12 +03:00
parent c1b2678533
commit 768b527155
7 changed files with 152 additions and 61 deletions

View file

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

View file

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

View file

@ -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<FontFallbackScript, FontFallbackManifestEntry> {
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]
}

View file

@ -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<string, ArrayBuffer>()
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<string, Record<string, string>>()
private googleFontsFailed = new Set<string>()
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<ArrayBuffer | null> {
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<Record<FontFallbackScript, string[]>> {
const result: Record<FontFallbackScript, string[]> = { 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<string[]> {
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<ArrayBuffer | null> {
if (!this.downloadedFontCache) return null
try {

View file

@ -2,3 +2,4 @@ export * from './editor'
export * from './style-runs'
export * from './direction'
export * from './fonts'
export * from './fallbacks'

View file

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

View file

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