feat(text): add demand-driven font resolver

- Resolve exact faces through registered, local, cache, and remote sources with deduplicated state transitions\n- Use CanvasKit notdef glyphs to request script fallbacks and repaint after resolution\n- Make CJK coverage analysis codepoint-safe across supplementary characters and style runs
This commit is contained in:
Danila Poyarkov 2026-07-17 13:00:09 +03:00
parent a62cec00f8
commit 4b3d97ce0d
16 changed files with 703 additions and 98 deletions

View file

@ -32,6 +32,7 @@
### Fixes
- Make canvas text rendering demand missing font faces and verify CJK/Arabic fallback coverage from CanvasKit shaping results instead of coarse script predictions.
- Fix live CLI and MCP automation drifting to the wrong open document or page when multiple files are open.
- Improve Chinese, Japanese, and Korean text rendering with glyph-aware fallback fonts and outline rendering when needed.
- Preserve imported Figma text sizing more accurately, especially auto-sized text inside auto-layout frames.

View file

@ -78,6 +78,7 @@ export class SkiaRenderer {
fontMgr: FontMgr | null = null
fontProvider: TypefaceFontProvider | null = null
fontsLoaded = false
onFontResolutionSettled: (() => void) | undefined
imageCache = new Map<string, CKImage>()
vectorPathCache = new Map<string, Path[]>()
vectorStrokePathCache = new Map<string, Path[]>()

View file

@ -20,6 +20,11 @@ export async function loadFonts(
onFallbackFontsLoaded?: () => void
): Promise<void> {
if (r.isDestroyed()) return
r.onFontResolutionSettled = () => {
if (r.isDestroyed()) return
r.invalidateAllPictures()
onFallbackFontsLoaded?.()
}
r.fontProvider?.delete()
r.fontProvider = r.ck.TypefaceFontProvider.Make()

View file

@ -14,11 +14,25 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import { getCanvasKit } from '#core/canvaskit'
import { resolveRGBAForPreview } from '#core/color/management'
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants'
import { textNeededFallbackScripts } from '#core/text/coverage'
import { fontFallbackScriptForCharacter } from '#core/text/coverage'
import { resolveNodeTextDirection } from '#core/text/direction'
import type { FontFallbackScript } from '#core/text/fallbacks'
import { fontManager, weightToStyle } from '#core/text/fonts'
import {
fontCoverageDemand,
fontFaceDemand,
fontResolver,
missingGlyphCharacters
} from '#core/text/resolver'
interface TextRenderer {
interface FontReadinessRenderer {
ck?: CanvasKit
fontProvider?: TypefaceFontProvider | null
fontsLoaded?: boolean
onFontResolutionSettled?: () => void
}
interface TextRenderer extends FontReadinessRenderer {
ck: CanvasKit
fontProvider: TypefaceFontProvider | null
fontsLoaded: boolean
@ -45,28 +59,67 @@ export interface ClipboardShapedText {
const FONT_FAMILY_CACHE_LIMIT = 256
const fontFamilyCache = new Map<string, string[]>()
function hasRequiredFallbackFonts(node: SceneNode): boolean {
for (const script of textNeededFallbackScripts(node)) {
if (script === 'arabic' && fontManager.getArabicFallbackFamilies().length === 0) return false
if (script !== 'arabic' && fontManager.getCJKFallbackFamilies().length === 0) return false
}
return true
function demandFace(r: FontReadinessRenderer, family: string, style: string): boolean {
if (fontManager.isStyleLoaded(family, style)) return true
void fontResolver.demand(fontFaceDemand(family, style), r.onFontResolutionSettled)
return false
}
export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean {
function hasRequiredFaces(r: FontReadinessRenderer, node: SceneNode): boolean {
const baseFamily = node.fontFamily || DEFAULT_FONT_FAMILY
if (!fontManager.isStyleLoaded(baseFamily, weightToStyle(node.fontWeight, node.italic))) {
return false
}
let ready = demandFace(r, baseFamily, weightToStyle(node.fontWeight, node.italic))
for (const run of node.styleRuns) {
const family = run.style.fontFamily ?? baseFamily
const weight = run.style.fontWeight ?? node.fontWeight
const italic = run.style.italic ?? node.italic
if (!fontManager.isStyleLoaded(family, weightToStyle(weight, italic))) return false
if (!demandFace(r, family, weightToStyle(weight, italic))) ready = false
}
return ready
}
function hasObservedGlyphCoverage(r: TextRenderer, node: SceneNode): boolean {
const paragraph = buildParagraph(r, node)
paragraph.layout(resolveParagraphLayoutWidth(node))
const missingCharacters = missingGlyphCharacters(node.text, paragraph.getShapedLines())
paragraph.delete()
if (missingCharacters.length === 0) return true
const charactersByScript = new Map<FontFallbackScript, string[]>()
for (const character of missingCharacters) {
const script = fontFallbackScriptForCharacter(character)
if (!script) continue
const characters = charactersByScript.get(script) ?? []
characters.push(character)
charactersByScript.set(script, characters)
}
return hasRequiredFallbackFonts(node)
let ready = true
for (const [script, characters] of charactersByScript) {
const demand = fontCoverageDemand(script, characters)
const state = fontResolver.state(demand).state
if (state === 'loaded') {
fontResolver.exhaust(demand)
continue
}
if (state === 'exhausted') continue
ready = false
if (state === 'idle') {
void fontResolver.demand(demand, r.onFontResolutionSettled)
}
}
return ready
}
function canObserveGlyphCoverage(r: FontReadinessRenderer): r is TextRenderer {
return r.ck !== undefined && r.fontProvider != null && r.fontsLoaded !== undefined
}
export function isNodeFontLoaded(r: FontReadinessRenderer, node: SceneNode): boolean {
if (node.type !== 'TEXT') return true
if (!hasRequiredFaces(r, node)) return false
if (!node.text || !canObserveGlyphCoverage(r)) return true
return hasObservedGlyphCoverage(r, node)
}
export function measureTextNode(

View file

@ -5,10 +5,10 @@ import type { FontFallbackScript } from '#core/text/fallbacks'
import { weightToStyle } from '#core/text/fonts'
import { fontGlyphCoverageSync } from '#core/text/opentype'
const CJK_IDEOGRAPH_CHAR_RE = /[\u3400-\u9fff\uf900-\ufaff]/u
const CJK_IDEOGRAPH_CHAR_RE = /\p{Script=Han}/u
const CJK_HIRAGANA_KATAKANA_RE = /[\u3040-\u30ff]/u
const CJK_HANGUL_RE = /[\uac00-\ud7af]/u
const CJK_CHAR_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u
const CJK_CHAR_RE = /[\p{Script=Han}\u3040-\u30ff\uac00-\ud7af]/u
const ARABIC_CHAR_RE = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u
// Common Traditional-only characters. This is a heuristic for fallback order, not language ID.
@ -30,11 +30,13 @@ function scriptCharRegex(script: FontFallbackScript): RegExp {
}
}
function fallbackScriptForCJKChar(char: string): FontFallbackScript {
export function fontFallbackScriptForCharacter(char: string): FontFallbackScript | null {
if (ARABIC_CHAR_RE.test(char)) return 'arabic'
if (CJK_HANGUL_RE.test(char)) return 'cjk-kr'
if (CJK_HIRAGANA_KATAKANA_RE.test(char)) return 'cjk-jp'
if (TRADITIONAL_CJK_CHAR_RE.test(char)) return 'cjk-tc'
return 'cjk-sc'
if (CJK_IDEOGRAPH_CHAR_RE.test(char)) return 'cjk-sc'
return null
}
function styleForCharacter(node: SceneNode, index: number): { family: string; style: string } {
@ -61,11 +63,13 @@ export function textNeedsFallbackScript(node: SceneNode, script: FontFallbackScr
if (node.type !== 'TEXT' || !node.text) return false
const regex = scriptCharRegex(script)
for (let index = 0; index < node.text.length; index++) {
const char = node.text[index]
if (!char || !regex.test(char)) continue
const { family, style } = styleForCharacter(node, index)
if (fontGlyphCoverageSync(family, style, char) === 'missing') return true
let index = 0
for (const char of node.text) {
if (regex.test(char)) {
const { family, style } = styleForCharacter(node, index)
if (fontGlyphCoverageSync(family, style, char) === 'missing') return true
}
index += char.length
}
return false
@ -77,18 +81,21 @@ export function textNeededFallbackScripts(node: SceneNode): FontFallbackScript[]
let missingIdeograph = false
let missingTraditionalIdeograph = false
for (let index = 0; index < node.text.length; index++) {
const char = node.text[index]
if (!char || !CJK_CHAR_RE.test(char)) continue
const { family, style } = styleForCharacter(node, index)
if (fontGlyphCoverageSync(family, style, char) !== 'missing') continue
if (CJK_IDEOGRAPH_CHAR_RE.test(char)) {
missingIdeograph = true
if (TRADITIONAL_CJK_CHAR_RE.test(char)) missingTraditionalIdeograph = true
} else {
scripts.add(fallbackScriptForCJKChar(char))
let index = 0
for (const char of node.text) {
if (CJK_CHAR_RE.test(char)) {
const { family, style } = styleForCharacter(node, index)
if (fontGlyphCoverageSync(family, style, char) === 'missing') {
if (CJK_IDEOGRAPH_CHAR_RE.test(char)) {
missingIdeograph = true
if (TRADITIONAL_CJK_CHAR_RE.test(char)) missingTraditionalIdeograph = true
} else {
const script = fontFallbackScriptForCharacter(char)
if (script) scripts.add(script)
}
}
}
index += char.length
}
if (missingIdeograph) scripts.add(missingTraditionalIdeograph ? 'cjk-tc' : 'cjk-sc')

View file

@ -139,7 +139,7 @@ export class FontManager {
private localFontAccessState: LocalFontAccessState = IS_BROWSER ? 'prompt' : 'unsupported'
private downloadedFontCache: DownloadedFontCache | null = null
private fallbackUserAgent: string | undefined
private hostFallbackFontLoader: HostFontLoader | null = null
private hostFontLoader: HostFontLoader | null = null
private webFonts = new WebFontResolver()
private registeredRenderFamilies = new Set<string>()
private cjkFallbackFamilies: string[] = []
@ -176,8 +176,13 @@ export class FontManager {
this.fallbackUserAgent = userAgent
}
setHostFontLoader(loader: HostFontLoader | null): void {
this.hostFontLoader = loader
}
/** @deprecated Use setHostFontLoader. Scheduled for removal in v0.15. */
setHostFallbackFontLoader(loader: HostFontLoader | null): void {
this.hostFallbackFontLoader = loader
this.setHostFontLoader(loader)
}
setOnlineFontProviders(settings: Partial<Record<WebFontProviderId, boolean>>): void {
@ -277,46 +282,56 @@ export class FontManager {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
}
async loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
async loadLocalFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
const cacheKey = `${family}|${style}`
if (this.loadedFamilies.has(cacheKey)) {
const cached = this.loadedFamilies.get(cacheKey)
if (!cached) return null
this.registerFontInCanvasKit(family, cached)
return cached
const loaded = this.loadedFamilies.get(cacheKey)
if (loaded) {
this.registerFontInCanvasKit(family, loaded)
return loaded
}
const downloadedBuffer = await this.loadCachedFont(family, style)
if (downloadedBuffer) return downloadedBuffer
const localBuffer = await this.findLocalFont(family, style)
const localBuffer =
(await this.loadHostFont(family, style)) ?? (await this.findLocalFont(family, style))
if (localBuffer) return this.registerAndCache(family, style, localBuffer)
const bundledUrl = BUNDLED_FONTS[cacheKey]
if (bundledUrl) {
try {
const buffer = await this.fetchBundledFont(bundledUrl)
if (buffer && !isVariableFont(buffer)) return this.registerAndCache(family, style, buffer)
} catch (e) {
console.warn(`Bundled font load failed for "${family}" ${style}:`, e)
}
if (!bundledUrl) return null
try {
const buffer = await this.fetchBundledFont(bundledUrl)
return buffer && !isVariableFont(buffer) ? this.registerAndCache(family, style, buffer) : null
} catch (e) {
console.warn(`Bundled font load failed for "${family}" ${style}:`, e)
return null
}
}
async loadRemoteFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
if (typeof fetch === 'undefined') return null
try {
const normalized = normalizeFontFamily(family)
const families = normalized === family ? [family] : [family, normalized]
const buffer = await this.webFonts.fetchFont(families, style)
if (!buffer) return null
await this.writeDownloadedFont(family, style, buffer)
return this.registerAndCache(family, style, buffer)
} catch (e) {
console.warn(`Web font fetch failed for "${family}" ${style}:`, e)
return null
}
}
async loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
const loaded = this.loadedData(family, style)
if (loaded) {
this.registerFontInCanvasKit(family, loaded)
return loaded
}
if (typeof fetch !== 'undefined') {
try {
const normalized = normalizeFontFamily(family)
const families = normalized === family ? [family] : [family, normalized]
const buffer = await this.webFonts.fetchFont(families, style)
if (buffer) {
await this.writeDownloadedFont(family, style, buffer)
return this.registerAndCache(family, style, buffer)
}
} catch (e) {
console.warn(`Web font fetch failed for "${family}" ${style}:`, e)
}
}
return null
return (
(await this.loadLocalFont(family, style)) ??
(await this.loadCachedFont(family, style)) ??
(await this.loadRemoteFont(family, style))
)
}
async ensureNodeFont(family: string, weight: number): Promise<void> {
@ -438,7 +453,7 @@ export class FontManager {
for (const family of manifest.localFamilies) {
const buffer =
(await this.loadHostFallbackFont(family, 'Regular')) ??
(await this.loadHostFont(family, 'Regular')) ??
(await this.findLocalFont(family, undefined, {
allowVariable: options.allowVariableLocalFonts
}))
@ -462,10 +477,10 @@ export class FontManager {
return targetFamilies
}
private async loadHostFallbackFont(family: string, style: string): Promise<ArrayBuffer | null> {
if (!this.hostFallbackFontLoader) return null
private async loadHostFont(family: string, style: string): Promise<ArrayBuffer | null> {
if (!this.hostFontLoader) return null
try {
return await this.hostFallbackFontLoader(family, style)
return await this.hostFontLoader(family, style)
} catch (e) {
console.warn(`Host fallback font load failed for "${family}" ${style}:`, e)
return null

View file

@ -4,4 +4,5 @@ export * from './direction'
export * from './coverage'
export * from './fonts'
export * from './fallbacks'
export * from './resolver'
export * from './web-fonts'

View file

@ -0,0 +1,67 @@
import { fontFallbackScriptForCharacter } from '#core/text/coverage'
import type { FontFallbackScript } from '#core/text/fallbacks'
export interface ObservedShapedLine {
textRange: { last: number }
runs: Array<{ glyphs: Uint16Array; offsets: Uint32Array }>
}
interface CodePointSpan {
character: string
utf8Start: number
utf16Start: number
}
function codePointSpans(text: string): { spans: CodePointSpan[]; utf8Length: number } {
const spans: CodePointSpan[] = []
let utf8Start = 0
let utf16Start = 0
const encoder = new TextEncoder()
for (const character of text) {
spans.push({ character, utf8Start, utf16Start })
utf8Start += encoder.encode(character).byteLength
utf16Start += character.length
}
return { spans, utf8Length: utf8Start }
}
export function missingGlyphCharacters(
text: string,
lines: readonly ObservedShapedLine[]
): string[] {
if (!text || lines.length === 0) return []
const { spans, utf8Length } = codePointSpans(text)
const finalOffset = lines.at(-1)?.textRange.last
const offsetsAreUtf16 = finalOffset === text.length && utf8Length !== text.length
const spansByOffset = new Map<number, string>()
for (const span of spans) {
spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, span.character)
}
const missing = new Set<string>()
for (const line of lines) {
for (const run of line.runs) {
for (let index = 0; index < run.glyphs.length; index++) {
if (run.glyphs[index] !== 0) continue
const offset = run.offsets[index]
const character = spansByOffset.get(offset)
if (character) missing.add(character)
}
}
}
return [...missing]
}
export function missingGlyphScripts(
text: string,
lines: readonly ObservedShapedLine[]
): FontFallbackScript[] {
const scripts = new Set<FontFallbackScript>()
for (const character of missingGlyphCharacters(text, lines)) {
const script = fontFallbackScriptForCharacter(character)
if (script) scripts.add(script)
}
return [...scripts]
}

View file

@ -0,0 +1,68 @@
import type { FontFallbackScript } from '#core/text/fallbacks'
import { fontManager } from '#core/text/fonts'
import { FontResolver } from '#core/text/resolver/resolver'
import type {
FontResolutionCandidate,
FontResolutionDemand,
FontResolutionLoader
} from '#core/text/resolver/types'
export * from './coverage'
export * from './resolver'
export * from './types'
function faceCandidate(
family: string,
style: string,
source: FontResolutionCandidate['source']
): FontResolutionCandidate {
return { id: `${source}:${family}:${style}`, family, style, source }
}
export function fontFaceDemand(family: string, style: string): FontResolutionDemand {
return {
key: `face:${family.trim().toLocaleLowerCase()}:${style.toLocaleLowerCase()}`,
candidates: [
faceCandidate(family, style, 'registered'),
faceCandidate(family, style, 'local'),
faceCandidate(family, style, 'cache'),
faceCandidate(family, style, 'remote')
]
}
}
export function fontCoverageDemand(
script: FontFallbackScript,
characters: readonly string[] = []
): FontResolutionDemand {
const codePoints = characters.flatMap((character) => {
const codePoint = character.codePointAt(0)
return codePoint === undefined ? [] : [codePoint.toString(16)]
})
const coverageKey = [...new Set(codePoints)].sort((a, b) => a.localeCompare(b)).join(',')
return {
key: `coverage:${script}:${coverageKey}`,
candidates: [{ id: `fallback:${script}`, family: script, style: 'Regular', source: 'fallback' }]
}
}
const productionFontLoader: FontResolutionLoader = async (candidate) => {
switch (candidate.source) {
case 'registered':
return fontManager.isStyleLoaded(candidate.family, candidate.style)
case 'local':
return (await fontManager.loadLocalFont(candidate.family, candidate.style)) !== null
case 'cache':
return (await fontManager.loadCachedFont(candidate.family, candidate.style)) !== null
case 'remote':
return (await fontManager.loadRemoteFont(candidate.family, candidate.style)) !== null
case 'fallback': {
const script = candidate.family as FontFallbackScript
const families = await fontManager.ensureFallbackPack([script])
return (families[script]?.length ?? 0) > 0
}
}
return false
}
export const fontResolver = new FontResolver(productionFontLoader)

View file

@ -0,0 +1,133 @@
import type {
FontResolutionDemand,
FontResolutionLoader,
FontResolutionSettled,
FontResolutionSnapshot
} from '#core/text/resolver/types'
interface FontResolutionEntry {
demand: FontResolutionDemand
snapshot: FontResolutionSnapshot
promise: Promise<FontResolutionSnapshot>
callbacks: Set<FontResolutionSettled>
}
function idleSnapshot(key: string): FontResolutionSnapshot {
return { key, state: 'idle' }
}
export class FontResolver {
private readonly entries = new Map<string, FontResolutionEntry>()
constructor(private readonly load: FontResolutionLoader) {}
state(demand: FontResolutionDemand | string): FontResolutionSnapshot {
const key = typeof demand === 'string' ? demand : demand.key
return this.entries.get(key)?.snapshot ?? idleSnapshot(key)
}
demand(
demand: FontResolutionDemand,
onSettled?: FontResolutionSettled
): Promise<FontResolutionSnapshot> {
const current = this.entries.get(demand.key)
if (current) {
if (current.snapshot.state === 'loading' && onSettled) current.callbacks.add(onSettled)
return current.promise
}
const callbacks = new Set<FontResolutionSettled>()
if (onSettled) callbacks.add(onSettled)
const snapshot: FontResolutionSnapshot = { key: demand.key, state: 'loading' }
const entry: FontResolutionEntry = {
demand,
snapshot,
callbacks,
promise: Promise.resolve(snapshot)
}
this.entries.set(demand.key, entry)
entry.promise = this.resolve(entry)
return entry.promise
}
retry(
demand: FontResolutionDemand,
onSettled?: FontResolutionSettled
): Promise<FontResolutionSnapshot> {
if (this.state(demand).state !== 'failed') return this.demand(demand, onSettled)
this.entries.delete(demand.key)
return this.demand(demand, onSettled)
}
exhaust(demand: FontResolutionDemand): FontResolutionSnapshot {
const current = this.entries.get(demand.key)
if (current?.snapshot.state === 'loading') return current.snapshot
const snapshot: FontResolutionSnapshot = { key: demand.key, state: 'exhausted' }
if (current) {
current.snapshot = snapshot
current.promise = Promise.resolve(snapshot)
return snapshot
}
const entry: FontResolutionEntry = {
demand,
snapshot,
promise: Promise.resolve(snapshot),
callbacks: new Set()
}
this.entries.set(demand.key, entry)
return snapshot
}
reset(demand?: FontResolutionDemand | string): void {
if (demand === undefined) {
this.entries.clear()
return
}
this.entries.delete(typeof demand === 'string' ? demand : demand.key)
}
private async resolve(entry: FontResolutionEntry): Promise<FontResolutionSnapshot> {
for (const candidate of entry.demand.candidates) {
if (this.entries.get(entry.demand.key) !== entry) return idleSnapshot(entry.demand.key)
entry.snapshot = { key: entry.demand.key, state: 'loading', candidate }
try {
if (await this.load(candidate, entry.demand)) {
return this.settle(entry, {
key: entry.demand.key,
state: 'loaded',
candidate,
source: candidate.source
})
}
} catch (error) {
return this.settle(entry, {
key: entry.demand.key,
state: 'failed',
candidate,
source: candidate.source,
error
})
}
}
return this.settle(entry, { key: entry.demand.key, state: 'exhausted' })
}
private settle(
entry: FontResolutionEntry,
snapshot: FontResolutionSnapshot
): FontResolutionSnapshot {
if (this.entries.get(entry.demand.key) !== entry) return idleSnapshot(entry.demand.key)
entry.snapshot = snapshot
for (const callback of entry.callbacks) {
try {
callback(snapshot)
} catch (error) {
console.error('Font resolution callback failed:', error)
}
}
entry.callbacks.clear()
return snapshot
}
}

View file

@ -0,0 +1,30 @@
export type FontResolutionState = 'idle' | 'loading' | 'loaded' | 'failed' | 'exhausted'
export type FontCandidateSource = 'registered' | 'local' | 'cache' | 'remote' | 'fallback'
export interface FontResolutionCandidate {
id: string
family: string
style: string
source: FontCandidateSource
}
export interface FontResolutionDemand {
key: string
candidates: readonly FontResolutionCandidate[]
}
export interface FontResolutionSnapshot {
key: string
state: FontResolutionState
candidate?: FontResolutionCandidate
source?: FontCandidateSource
error?: unknown
}
export type FontResolutionLoader = (
candidate: FontResolutionCandidate,
demand: FontResolutionDemand
) => Promise<boolean>
export type FontResolutionSettled = (snapshot: FontResolutionSnapshot) => void

View file

@ -5,7 +5,6 @@ import {
DEFAULT_WEB_FONT_PROVIDER_SETTINGS,
WEB_FONT_PROVIDER_IDS,
fontManager,
styleToWeight,
textNeededFallbackScripts,
type FontFamilyOption,
type LocalFontAccessState,
@ -67,7 +66,7 @@ function configureTauriFontCache() {
tauriFontCacheConfigured = true
fontManager.setDownloadedFontCache(createTauriDownloadedFontCache())
fontManager.setWebFontFetch(tauriFetch)
fontManager.setHostFallbackFontLoader(loadFont)
fontManager.setHostFontLoader(loadSystemFont)
}
configureTauriFontCache()
@ -196,31 +195,19 @@ function clearTextPictures(graph: SceneGraph): void {
}
}
async function loadSystemFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
if (!isTauri()) return null
try {
const { invoke } = await import('@tauri-apps/api/core')
const data = await invoke<number[]>('load_system_font', { family, style })
return new Uint8Array(data).buffer
} catch {
return null
}
}
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
configureTauriFontCache()
if (isTauri()) {
const cached = await fontManager.loadCachedFont(family, style)
if (cached) return cached
try {
const { invoke } = await import('@tauri-apps/api/core')
const data = await invoke<number[]>('load_system_font', { family, style })
const buffer = new Uint8Array(data).buffer
fontManager.markLoaded(family, style, buffer)
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic') ? 'italic' : 'normal'
const face = new FontFace(family, buffer, { weight: String(weight), style: italic })
await face.load()
document.fonts.add(face)
return buffer
} catch {
return fontManager.loadFont(family, style)
}
}
const loaded = await fontManager.loadFont(family, style)
if (!loaded) showWebFontUnavailableToast()
return loaded

View file

@ -13,6 +13,7 @@ import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText } from '#core/canvas/scene'
import { buildParagraph, isNodeFontLoaded } from '#core/canvas/text'
import { fontManager } from '#core/text/fonts'
import { missingGlyphCharacters } from '#core/text/resolver'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
@ -238,6 +239,36 @@ describe('renderText headless visual', () => {
expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
})
test('observes CanvasKit notdef glyphs for unsupported CJK text', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] }
const originalFallbacks = [...manager.cjkFallbackFamilies]
manager.cjkFallbackFamilies = []
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
try {
const renderer = new SkiaRendererClass(ck, surface)
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const paragraph = buildParagraph(
renderer,
textNode({ text: 'A𠀀B', fontFamily: 'Inter', fontWeight: 400 })
)
paragraph.layout(200)
expect(missingGlyphCharacters('A𠀀B', paragraph.getShapedLines())).toEqual(['𠀀'])
paragraph.delete()
} finally {
manager.cjkFallbackFamilies = originalFallbacks
surface.delete()
}
})
test('does not require fallback families when the primary font covers CJK glyphs', async () => {
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const notoData = await Bun.file(notoPath).arrayBuffer()

View file

@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { fontManager, textNeededFallbackScripts } from '@open-pencil/core/text'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
describe('font fallback coverage indexing', () => {
test('detects supplementary-plane Han code points', async () => {
const family = `SupplementaryHan_${Date.now()}`
const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontManager.markLoaded(family, 'Regular', data)
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), {
text: 'A𠀀B',
fontFamily: family,
fontWeight: 400
})
expect(textNeededFallbackScripts(node)).toContain('cjk-sc')
})
test('uses UTF-16 style-run indices after a surrogate pair', async () => {
const cjkData = await Bun.file('tests/fixtures/fonts/NotoSansSC-Regular.ttf').arrayBuffer()
const latinData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
const cjkFamily = `CJKRunBase_${Date.now()}`
const latinFamily = `CJKRunOverride_${Date.now()}`
fontManager.markLoaded(cjkFamily, 'Regular', cjkData)
fontManager.markLoaded(latinFamily, 'Regular', latinData)
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), {
text: '😀你',
fontFamily: cjkFamily,
fontWeight: 400,
styleRuns: [
{
start: 2,
length: 1,
style: { fontFamily: latinFamily }
}
]
})
expect(textNeededFallbackScripts(node)).toContain('cjk-sc')
})
})

View file

@ -161,7 +161,29 @@ describe('FontManager loaded font cache', () => {
expect(recording.registrations).toHaveLength(2)
})
test('loads downloaded cache before other sources', async () => {
test('prefers local font data before downloaded cache', async () => {
const manager = new FontManager()
const recording = createRecordingProvider()
let cacheReads = 0
manager.attachProvider({} as CanvasKit, recording.provider)
manager.setHostFontLoader(async () => new ArrayBuffer(20))
manager.setDownloadedFontCache({
async read() {
cacheReads++
return new ArrayBuffer(16)
},
async write() {
return undefined
}
})
const data = await manager.loadFont('LocalPriority', 'Regular')
expect(data?.byteLength).toBe(20)
expect(cacheReads).toBe(0)
})
test('loads downloaded cache when local sources are unavailable', async () => {
const manager = new FontManager()
const recording = createRecordingProvider()
const data = new ArrayBuffer(16)

View file

@ -0,0 +1,136 @@
import { describe, expect, test } from 'bun:test'
import {
FontResolver,
fontCoverageDemand,
missingGlyphCharacters,
missingGlyphScripts,
type FontResolutionCandidate,
type FontResolutionDemand,
type ObservedShapedLine
} from '@open-pencil/core/text'
function candidate(source: FontResolutionCandidate['source']): FontResolutionCandidate {
return { id: source, family: 'Example', style: 'Regular', source }
}
function faceDemand(): FontResolutionDemand {
return {
key: 'face:example:regular',
candidates: ['registered', 'local', 'cache', 'remote'].map((source) =>
candidate(source as FontResolutionCandidate['source'])
)
}
}
function shapedLine(textLength: number, glyphs: number[], offsets: number[]): ObservedShapedLine {
return {
textRange: { last: textLength },
runs: [
{
glyphs: Uint16Array.from(glyphs),
offsets: Uint32Array.from(offsets)
}
]
}
}
describe('FontResolver', () => {
test('tries face candidates in source order', async () => {
const attempted: string[] = []
const resolver = new FontResolver(async (item) => {
attempted.push(item.source)
return item.source === 'remote'
})
const result = await resolver.demand(faceDemand())
expect(attempted).toEqual(['registered', 'local', 'cache', 'remote'])
expect(result.state).toBe('loaded')
expect(result.source).toBe('remote')
})
test('deduplicates concurrent demand and collects settle callbacks', async () => {
let release: ((loaded: boolean) => void) | undefined
let loads = 0
const resolver = new FontResolver(
() =>
new Promise<boolean>((resolve) => {
loads++
release = resolve
})
)
const settled: string[] = []
const demand = { key: 'shared', candidates: [candidate('local')] }
const first = resolver.demand(demand, () => settled.push('first'))
const second = resolver.demand(demand, () => settled.push('second'))
expect(first).toBe(second)
expect(loads).toBe(1)
expect(resolver.state(demand).state).toBe('loading')
release?.(true)
await first
expect(settled).toEqual(['first', 'second'])
})
test('exhausts after every candidate is unavailable', async () => {
const resolver = new FontResolver(async () => false)
const demand = faceDemand()
expect((await resolver.demand(demand)).state).toBe('exhausted')
expect(resolver.state(demand).state).toBe('exhausted')
})
test('records loader failures and retries explicitly', async () => {
let attempts = 0
const resolver = new FontResolver(async () => {
attempts++
if (attempts === 1) throw new Error('provider offline')
return true
})
const demand = { key: 'retry', candidates: [candidate('remote')] }
expect((await resolver.demand(demand)).state).toBe('failed')
expect((await resolver.retry(demand)).state).toBe('loaded')
expect(attempts).toBe(2)
})
test('reset returns a settled key to idle', async () => {
const resolver = new FontResolver(async () => true)
const demand = { key: 'reset', candidates: [candidate('local')] }
await resolver.demand(demand)
resolver.reset(demand)
expect(resolver.state(demand).state).toBe('idle')
})
})
describe('observed glyph coverage', () => {
test('maps CanvasKit UTF-8 offsets back to supplementary code points', () => {
const text = 'A𠀀B'
const lines = [shapedLine(6, [12, 0, 13], [0, 1, 5, 6])]
expect(missingGlyphCharacters(text, lines)).toEqual(['𠀀'])
expect(missingGlyphScripts(text, lines)).toEqual(['cjk-sc'])
})
test('supports UTF-16 offsets from mocked shapers', () => {
const text = 'éAB'
const lines = [shapedLine(text.length, [12, 13, 0], [0, 1, 2, 3])]
expect(missingGlyphCharacters(text, lines)).toEqual(['B'])
})
test('does not report non-zero glyphs as missing', () => {
const lines = [shapedLine(2, [12, 13], [0, 1, 2])]
expect(missingGlyphCharacters('你好', lines)).toEqual([])
})
test('creates distinct coverage keys for distinct observed characters', () => {
expect(fontCoverageDemand('cjk-sc', ['你']).key).not.toBe(
fontCoverageDemand('cjk-sc', ['𠀀']).key
)
})
})