From 4b3d97ce0d125f6ef13e794bf72236f65fca780d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 13:00:09 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + packages/core/src/canvas/renderer.ts | 1 + packages/core/src/canvas/renderer/fonts.ts | 5 + packages/core/src/canvas/text.ts | 81 ++++++++++-- packages/core/src/text/coverage.ts | 47 ++++--- packages/core/src/text/fonts.ts | 91 +++++++------ packages/core/src/text/index.ts | 1 + packages/core/src/text/resolver/coverage.ts | 67 ++++++++++ packages/core/src/text/resolver/index.ts | 68 ++++++++++ packages/core/src/text/resolver/resolver.ts | 133 +++++++++++++++++++ packages/core/src/text/resolver/types.ts | 30 +++++ src/app/editor/fonts/index.ts | 37 ++---- tests/engine/render/canvas/text.test.ts | 31 +++++ tests/engine/text/coverage.test.ts | 48 +++++++ tests/engine/text/fonts/loading.test.ts | 24 +++- tests/engine/text/resolver.test.ts | 136 ++++++++++++++++++++ 16 files changed, 703 insertions(+), 98 deletions(-) create mode 100644 packages/core/src/text/resolver/coverage.ts create mode 100644 packages/core/src/text/resolver/index.ts create mode 100644 packages/core/src/text/resolver/resolver.ts create mode 100644 packages/core/src/text/resolver/types.ts create mode 100644 tests/engine/text/coverage.test.ts create mode 100644 tests/engine/text/resolver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b99e9b640..589ba7cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 3df299c87..058254bec 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -78,6 +78,7 @@ export class SkiaRenderer { fontMgr: FontMgr | null = null fontProvider: TypefaceFontProvider | null = null fontsLoaded = false + onFontResolutionSettled: (() => void) | undefined imageCache = new Map() vectorPathCache = new Map() vectorStrokePathCache = new Map() diff --git a/packages/core/src/canvas/renderer/fonts.ts b/packages/core/src/canvas/renderer/fonts.ts index a758971a6..6c5bb369f 100644 --- a/packages/core/src/canvas/renderer/fonts.ts +++ b/packages/core/src/canvas/renderer/fonts.ts @@ -20,6 +20,11 @@ export async function loadFonts( onFallbackFontsLoaded?: () => void ): Promise { if (r.isDestroyed()) return + r.onFontResolutionSettled = () => { + if (r.isDestroyed()) return + r.invalidateAllPictures() + onFallbackFontsLoaded?.() + } r.fontProvider?.delete() r.fontProvider = r.ck.TypefaceFontProvider.Make() diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index 971167c6c..481edb8a6 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -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() -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() + 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( diff --git a/packages/core/src/text/coverage.ts b/packages/core/src/text/coverage.ts index e6cb3f99c..4f312c447 100644 --- a/packages/core/src/text/coverage.ts +++ b/packages/core/src/text/coverage.ts @@ -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') diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 64ae3b7b7..bff89ef75 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -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() 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>): 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 { + async loadLocalFont(family: string, style = 'Regular'): Promise { 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 { + 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 { + 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 { @@ -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 { - if (!this.hostFallbackFontLoader) return null + private async loadHostFont(family: string, style: string): Promise { + 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 diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts index 29a729e52..ea35a6a0b 100644 --- a/packages/core/src/text/index.ts +++ b/packages/core/src/text/index.ts @@ -4,4 +4,5 @@ export * from './direction' export * from './coverage' export * from './fonts' export * from './fallbacks' +export * from './resolver' export * from './web-fonts' diff --git a/packages/core/src/text/resolver/coverage.ts b/packages/core/src/text/resolver/coverage.ts new file mode 100644 index 000000000..2465ba263 --- /dev/null +++ b/packages/core/src/text/resolver/coverage.ts @@ -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() + for (const span of spans) { + spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, span.character) + } + + const missing = new Set() + 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() + for (const character of missingGlyphCharacters(text, lines)) { + const script = fontFallbackScriptForCharacter(character) + if (script) scripts.add(script) + } + return [...scripts] +} diff --git a/packages/core/src/text/resolver/index.ts b/packages/core/src/text/resolver/index.ts new file mode 100644 index 000000000..a78089cb0 --- /dev/null +++ b/packages/core/src/text/resolver/index.ts @@ -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) diff --git a/packages/core/src/text/resolver/resolver.ts b/packages/core/src/text/resolver/resolver.ts new file mode 100644 index 000000000..be178906c --- /dev/null +++ b/packages/core/src/text/resolver/resolver.ts @@ -0,0 +1,133 @@ +import type { + FontResolutionDemand, + FontResolutionLoader, + FontResolutionSettled, + FontResolutionSnapshot +} from '#core/text/resolver/types' + +interface FontResolutionEntry { + demand: FontResolutionDemand + snapshot: FontResolutionSnapshot + promise: Promise + callbacks: Set +} + +function idleSnapshot(key: string): FontResolutionSnapshot { + return { key, state: 'idle' } +} + +export class FontResolver { + private readonly entries = new Map() + + 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 { + 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() + 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 { + 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 { + 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 + } +} diff --git a/packages/core/src/text/resolver/types.ts b/packages/core/src/text/resolver/types.ts new file mode 100644 index 000000000..a077013e2 --- /dev/null +++ b/packages/core/src/text/resolver/types.ts @@ -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 + +export type FontResolutionSettled = (snapshot: FontResolutionSnapshot) => void diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index cf6fc84e4..c5eca3523 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -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 { + if (!isTauri()) return null + try { + const { invoke } = await import('@tauri-apps/api/core') + const data = await invoke('load_system_font', { family, style }) + return new Uint8Array(data).buffer + } catch { + return null + } +} + export async function loadFont(family: string, style = 'Regular'): Promise { 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('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 diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index 5316c4b04..e28f4f9a8 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -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() diff --git a/tests/engine/text/coverage.test.ts b/tests/engine/text/coverage.test.ts new file mode 100644 index 000000000..18318b681 --- /dev/null +++ b/tests/engine/text/coverage.test.ts @@ -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') + }) +}) diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index 0c804b1e5..1b53a2afb 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -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) diff --git a/tests/engine/text/resolver.test.ts b/tests/engine/text/resolver.test.ts new file mode 100644 index 000000000..a199b9d2e --- /dev/null +++ b/tests/engine/text/resolver.test.ts @@ -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((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 + ) + }) +})