feat(core): add font fallback manifest
This commit is contained in:
parent
c1b2678533
commit
768b527155
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
packages/core/src/text/fallbacks.ts
Normal file
56
packages/core/src/text/fallbacks.ts
Normal 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]
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './editor'
|
|||
export * from './style-runs'
|
||||
export * from './direction'
|
||||
export * from './fonts'
|
||||
export * from './fallbacks'
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue