openpencil/packages/core/src/fonts.ts
Danila Poyarkov 9e3a6c0e7f Extract collectFontKeys into core and add font tests
- Extract font key collection from editor store into
  collectFontKeys() in packages/core/src/fonts.ts
- 18 tests: styleToWeight, weightToStyle, markFontLoaded/isFontLoaded,
  collectFontKeys (nested nodes, style runs, deduplication, default
  font filtering, invalid IDs)
2026-03-04 10:16:26 +03:00

250 lines
7.7 KiB
TypeScript

import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
import { DEFAULT_FONT_FAMILY } from './constants'
import type { SceneGraph } from './scene-graph'
export interface FontInfo {
family: string
fullName: string
style: string
postscriptName: string
}
const loadedFamilies = new Map<string, ArrayBuffer>()
let fontProvider: TypefaceFontProvider | null = null
export function initFontService(_canvasKit: CanvasKit, provider: TypefaceFontProvider) {
fontProvider = provider
}
export function getFontProvider(): TypefaceFontProvider | null {
return fontProvider
}
export async function queryFonts(): Promise<FontInfo[]> {
if (typeof window === 'undefined' || !window.queryLocalFonts) return []
try {
const fonts = await window.queryLocalFonts()
const seen = new Set<string>()
const result: FontInfo[] = []
for (const f of fonts) {
const key = `${f.family}|${f.style}`
if (seen.has(key)) continue
seen.add(key)
result.push({
family: f.family,
fullName: f.fullName,
style: f.style,
postscriptName: f.postscriptName
})
}
return result
} catch {
return []
}
}
export async function listFamilies(): Promise<string[]> {
const fonts = await queryFonts()
return [...new Set(fonts.map((f) => f.family))].sort()
}
const BUNDLED_FONTS: Record<string, string> = {
'Inter|Regular': '/Inter-Regular.ttf'
}
import { GOOGLE_FONTS_API_KEY } from './constants'
const googleFontsCache = new Map<string, Record<string, string>>()
const googleFontsFailed = new Set<string>()
async function fetchGoogleFontFiles(family: string): Promise<Record<string, string> | null> {
if (googleFontsCache.has(family)) return googleFontsCache.get(family)!
if (googleFontsFailed.has(family)) return null
const url = `https://www.googleapis.com/webfonts/v1/webfonts?family=${encodeURIComponent(family)}&key=${GOOGLE_FONTS_API_KEY}`
const response = await fetch(url)
if (!response.ok) {
googleFontsFailed.add(family)
return null
}
const data = (await response.json()) as { items?: Array<{ files?: Record<string, string> }> }
const files = data.items?.[0]?.files
if (!files) {
googleFontsFailed.add(family)
return null
}
googleFontsCache.set(family, files)
return files
}
function styleToVariant(style: string): string {
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic')
if (weight === 400 && !italic) return 'regular'
if (weight === 400 && italic) return 'italic'
return italic ? `${weight}italic` : `${weight}`
}
async function fetchGoogleFont(family: string, style: string): Promise<ArrayBuffer | null> {
const files = await fetchGoogleFontFiles(family)
if (!files) return null
const variant = styleToVariant(style)
const ttfUrl = files[variant] ?? files['regular']
if (!ttfUrl) return null
const response = await fetch(ttfUrl)
if (!response.ok) return null
return response.arrayBuffer()
}
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
const cacheKey = `${family}|${style}`
if (loadedFamilies.has(cacheKey)) {
const cached = loadedFamilies.get(cacheKey)
if (!cached) return null
registerFontInCanvasKit(family, cached)
return cached
}
// Try local font access API first (browser only)
if (typeof window !== 'undefined' && window.queryLocalFonts) {
try {
const fonts = await window.queryLocalFonts()
const match =
fonts.find((f: FontInfo) => f.family === family && f.style === style) ??
fonts.find((f: FontInfo) => f.family === family)
if (match) {
const blob: Blob = await match.blob()
const buffer = await blob.arrayBuffer()
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
}
} catch {
/* fall through to bundled */
}
}
// Try Google Fonts
if (typeof fetch !== 'undefined') {
try {
const buffer = await fetchGoogleFont(family, style)
if (buffer) {
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
}
} catch {
/* fall through to bundled */
}
}
// Fall back to bundled font
const bundledUrl = BUNDLED_FONTS[cacheKey]
if (bundledUrl) {
try {
const response = await fetch(bundledUrl)
const buffer = await response.arrayBuffer()
loadedFamilies.set(cacheKey, buffer)
registerFontInCanvasKit(family, buffer)
registerFontInBrowser(family, style, buffer)
return buffer
} catch {
/* no bundled font available */
}
}
return null
}
function registerFontInCanvasKit(family: string, data: ArrayBuffer) {
if (!fontProvider) return
fontProvider.registerFont(data, family)
}
function registerFontInBrowser(family: string, style: string, data: ArrayBuffer) {
if (typeof document === 'undefined') return
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic') ? 'italic' : 'normal'
const face = new FontFace(family, data, {
weight: String(weight),
style: italic
})
face.load().then(() => document.fonts.add(face))
}
export function styleToWeight(style: string): number {
const s = style.toLowerCase().replace(/[\s-_]/g, '')
if (s.includes('thin') || s.includes('hairline')) return 100
if (s.includes('extralight') || s.includes('ultralight')) return 200
if (s.includes('light')) return 300
if (s.includes('medium')) return 500
if (s.includes('semibold') || s.includes('demibold')) return 600
if (s.includes('extrabold') || s.includes('ultrabold')) return 800
if (s.includes('black') || s.includes('heavy')) return 900
if (s.includes('bold')) return 700
return 400
}
export async function ensureNodeFont(family: string, weight: number): Promise<void> {
const style = weightToStyle(weight)
await loadFont(family, style)
}
export function markFontLoaded(family: string, style: string, data: ArrayBuffer): void {
const cacheKey = `${family}|${style}`
loadedFamilies.set(cacheKey, data)
registerFontInCanvasKit(family, data)
}
export function isFontLoaded(family: string): boolean {
return [...loadedFamilies.keys()].some((k) => k.startsWith(`${family}|`))
}
export function collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> {
const fontKeys = new Set<string>()
const collect = (id: string) => {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'TEXT') {
const family = node.fontFamily || DEFAULT_FONT_FAMILY
fontKeys.add(`${family}\0${weightToStyle(node.fontWeight || 400, node.italic)}`)
for (const run of node.styleRuns) {
const f = run.style.fontFamily ?? family
const w = run.style.fontWeight ?? node.fontWeight ?? 400
const i = run.style.italic ?? node.italic
fontKeys.add(`${f}\0${weightToStyle(w, i)}`)
}
}
for (const childId of node.childIds) collect(childId)
}
for (const id of nodeIds) collect(id)
return [...fontKeys]
.map((k) => k.split('\0') as [string, string])
.filter(([family]) => family !== DEFAULT_FONT_FAMILY)
}
export function weightToStyle(weight: number, italic = false): string {
let label = 'Regular'
if (weight <= 100) label = 'Thin'
else if (weight <= 200) label = 'ExtraLight'
else if (weight <= 300) label = 'Light'
else if (weight <= 500) label = 'Medium'
else if (weight <= 600) label = 'SemiBold'
else if (weight <= 700) label = 'Bold'
else if (weight <= 800) label = 'ExtraBold'
else if (weight >= 900) label = 'Black'
if (italic) label += ' Italic'
return label
}