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:
parent
a62cec00f8
commit
4b3d97ce0d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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[]>()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export * from './direction'
|
|||
export * from './coverage'
|
||||
export * from './fonts'
|
||||
export * from './fallbacks'
|
||||
export * from './resolver'
|
||||
export * from './web-fonts'
|
||||
|
|
|
|||
67
packages/core/src/text/resolver/coverage.ts
Normal file
67
packages/core/src/text/resolver/coverage.ts
Normal 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]
|
||||
}
|
||||
68
packages/core/src/text/resolver/index.ts
Normal file
68
packages/core/src/text/resolver/index.ts
Normal 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)
|
||||
133
packages/core/src/text/resolver/resolver.ts
Normal file
133
packages/core/src/text/resolver/resolver.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
packages/core/src/text/resolver/types.ts
Normal file
30
packages/core/src/text/resolver/types.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
48
tests/engine/text/coverage.test.ts
Normal file
48
tests/engine/text/coverage.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
136
tests/engine/text/resolver.test.ts
Normal file
136
tests/engine/text/resolver.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue