fix(text): load CJK fallback only when needed

This commit is contained in:
Danila Poyarkov 2026-07-01 22:06:19 +03:00
parent de230f2f70
commit 5a89b520b2
13 changed files with 287 additions and 24 deletions

View file

@ -9,6 +9,7 @@
"ignoreUnresolved": [
"~icons/*",
"/packages/core/src/text/fonts.ts",
"/src/app/editor/fonts/index.ts",
"#core/*"
],
"ignoreBinaries": ["figma-use", "identify", "magick", "open", "osascript"]

View file

@ -14,6 +14,7 @@ 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 { textNeedsFallbackScript } from '#core/text/coverage'
import { resolveNodeTextDirection } from '#core/text/direction'
import { fontManager, weightToStyle } from '#core/text/fonts'
@ -41,14 +42,19 @@ export interface ClipboardShapedText {
logicalIndexToCharacterOffsetMap: number[]
}
const CJK_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u
const ARABIC_RE = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u
const FONT_FAMILY_CACHE_LIMIT = 256
const fontFamilyCache = new Map<string, string[]>()
function hasRequiredFallbackFonts(text: string): boolean {
if (CJK_RE.test(text) && fontManager.getCJKFallbackFamilies().length === 0) return false
if (ARABIC_RE.test(text) && fontManager.getArabicFallbackFamilies().length === 0) return false
function hasRequiredFallbackFonts(node: SceneNode): boolean {
if (textNeedsFallbackScript(node, 'cjk') && fontManager.getCJKFallbackFamilies().length === 0) {
return false
}
if (
textNeedsFallbackScript(node, 'arabic') &&
fontManager.getArabicFallbackFamilies().length === 0
) {
return false
}
return true
}
@ -65,7 +71,7 @@ export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean {
if (!fontManager.isStyleLoaded(family, weightToStyle(weight, italic))) return false
}
return hasRequiredFallbackFonts(node.text)
return hasRequiredFallbackFonts(node)
}
export function measureTextNode(

View file

@ -0,0 +1,51 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { DEFAULT_FONT_FAMILY } from '#core/constants'
import type { FontFallbackScript } from '#core/text/fallbacks'
import { weightToStyle } from '#core/text/fonts'
import { fontGlyphCoverageSync } from '#core/text/opentype'
const CJK_CHAR_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u
const ARABIC_CHAR_RE = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u
function scriptCharRegex(script: FontFallbackScript): RegExp {
return script === 'cjk' ? CJK_CHAR_RE : ARABIC_CHAR_RE
}
function styleForCharacter(node: SceneNode, index: number): { family: string; style: string } {
const baseFamily = node.fontFamily || DEFAULT_FONT_FAMILY
let family = baseFamily
let weight = node.fontWeight
let italic = node.italic
const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length)
if (run) {
family = run.style.fontFamily ?? family
weight = run.style.fontWeight ?? weight
italic = run.style.italic ?? italic
}
return { family, style: weightToStyle(weight, italic) }
}
/**
* Returns true only when a loaded, parseable primary font is known to miss a script glyph.
* Unknown coverage is treated as renderable so we do not degrade fonts CanvasKit may handle.
*/
export function textNeedsFallbackScript(node: SceneNode, script: FontFallbackScript): boolean {
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
}
return false
}
export function textNeededFallbackScripts(node: SceneNode): FontFallbackScript[] {
return (['cjk', 'arabic'] as const).filter((script) => textNeedsFallbackScript(node, script))
}

View file

@ -29,6 +29,8 @@ export interface DownloadedFontCache {
write(family: string, style: string, data: ArrayBuffer): Promise<void>
}
export type HostFontLoader = (family: string, style: string) => Promise<ArrayBuffer | null>
type FindLocalFontOptions = { allowVariable?: boolean }
type LocalFontMatch = Pick<FontInfo, 'family' | 'style'>
@ -137,6 +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 webFonts = new WebFontResolver()
private registeredRenderFamilies = new Set<string>()
private cjkFallbackFamilies: string[] = []
@ -173,6 +176,10 @@ export class FontManager {
this.fallbackUserAgent = userAgent
}
setHostFallbackFontLoader(loader: HostFontLoader | null): void {
this.hostFallbackFontLoader = loader
}
setOnlineFontProviders(settings: Partial<Record<WebFontProviderId, boolean>>): void {
this.webFonts.setEnabled(settings)
}
@ -429,9 +436,11 @@ export class FontManager {
const manifest = fontFallbackEntry(script, this.fallbackUserAgent)
for (const family of manifest.localFamilies) {
const buffer = await this.findLocalFont(family, undefined, {
allowVariable: options.allowVariableLocalFonts
})
const buffer =
(await this.loadHostFallbackFont(family, 'Regular')) ??
(await this.findLocalFont(family, undefined, {
allowVariable: options.allowVariableLocalFonts
}))
if (buffer && this.registerAndCache(family, 'Regular', buffer)) {
targetFamilies.push(family)
}
@ -452,6 +461,16 @@ export class FontManager {
return targetFamilies
}
private async loadHostFallbackFont(family: string, style: string): Promise<ArrayBuffer | null> {
if (!this.hostFallbackFontLoader) return null
try {
return await this.hostFallbackFontLoader(family, style)
} catch (e) {
console.warn(`Host fallback font load failed for "${family}" ${style}:`, e)
return null
}
}
private async readDownloadedFont(family: string, style: string): Promise<ArrayBuffer | null> {
if (!this.downloadedFontCache) return null
try {

View file

@ -1,6 +1,7 @@
export * from './editor'
export * from './style-runs'
export * from './direction'
export * from './coverage'
export * from './fonts'
export * from './fallbacks'
export * from './web-fonts'

View file

@ -91,10 +91,22 @@ export interface GlyphOutlineMetrics {
advance: number
}
export function fontHasGlyphSync(family: string, style: string, char: string): boolean {
export type FontGlyphCoverage = 'has' | 'missing' | 'unknown'
export function fontGlyphCoverageSync(
family: string,
style: string,
char: string
): FontGlyphCoverage {
const bytes = fontManager.loadedData(family, style)
if (!bytes) return 'unknown'
const font = getParsedFont(family, style)
if (!font) return false
return font.charToGlyphIndex(char) !== 0
if (!font) return 'unknown'
return font.charToGlyphIndex(char) !== 0 ? 'has' : 'missing'
}
export function fontHasGlyphSync(family: string, style: string, char: string): boolean {
return fontGlyphCoverageSync(family, style, char) === 'has'
}
export function getGlyphOutlineMetricsSync(

View file

@ -394,7 +394,7 @@ export class SceneGraph {
if (node.type === 'TEXT') {
const textChanged = Object.keys(changes).some((k) => TEXT_PICTURE_KEYS.has(k))
if (node.textPicture && textChanged) node.textPicture = null
if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null
if (node.figmaDerivedTextGlyphs && textChanged) node.figmaDerivedTextGlyphs = null
}
const entries = Object.entries(changes) as Array<[string, unknown]>
changes = Object.fromEntries(

View file

@ -73,12 +73,10 @@ export function updateNodePreview(
}
const affectsLayout = Object.keys(changes).some((key) => LAYOUT_AFFECTING_KEYS.has(key))
if (affectsLayout) graph.clearAbsPosCache()
if (
node.type === 'TEXT' &&
node.textPicture &&
Object.keys(changes).some((key) => TEXT_PICTURE_KEYS.has(key))
) {
node.textPicture = null
if (node.type === 'TEXT') {
const textChanged = Object.keys(changes).some((key) => TEXT_PICTURE_KEYS.has(key))
if (node.textPicture && textChanged) node.textPicture = null
if (node.figmaDerivedTextGlyphs && textChanged) node.figmaDerivedTextGlyphs = null
}
const normalizedChanges = changes.vectorNetwork
? { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }

View file

@ -6,6 +6,7 @@ import {
WEB_FONT_PROVIDER_IDS,
fontManager,
styleToWeight,
textNeededFallbackScripts,
type FontFamilyOption,
type LocalFontAccessState,
type WebFontProviderId
@ -66,6 +67,7 @@ function configureTauriFontCache() {
tauriFontCacheConfigured = true
fontManager.setDownloadedFontCache(createTauriDownloadedFontCache())
fontManager.setWebFontFetch(tauriFetch)
fontManager.setHostFallbackFontLoader(loadFont)
}
configureTauriFontCache()
@ -162,12 +164,30 @@ export async function listFonts(): Promise<TauriFontFamily[]> {
export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise<boolean> {
const fontKeys = fontManager.collectFontKeys(graph, nodeIds)
const missing = fontKeys.filter(([family, style]) => !fontManager.isStyleLoaded(family, style))
if (missing.length === 0) return false
const results = await Promise.all(missing.map(([family, style]) => loadFont(family, style)))
const loaded = results.some((result) => result !== null)
if (loaded) clearTextPictures(graph)
return loaded
const fallbackScripts = neededFallbackScriptsForNodes(graph, nodeIds)
if (fallbackScripts.length > 0) {
const fallbacks = await fontManager.ensureFallbackPack(fallbackScripts)
if (Object.values(fallbacks).some((families) => families.length > 0)) clearTextPictures(graph)
} else if (loaded) {
clearTextPictures(graph)
}
return loaded || fallbackScripts.length > 0
}
function neededFallbackScriptsForNodes(graph: SceneGraph, nodeIds: string[]) {
const scripts = new Set<ReturnType<typeof textNeededFallbackScripts>[number]>()
const collect = (id: string) => {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'TEXT') {
for (const script of textNeededFallbackScripts(node)) scripts.add(script)
}
for (const childId of node.childIds) collect(childId)
}
for (const id of nodeIds) collect(id)
return [...scripts]
}
function clearTextPictures(graph: SceneGraph): void {

View file

@ -2,6 +2,61 @@ import { test, expect } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
test('tool-created CJK text requests fallback through app font loading', async ({ page }) => {
const canvas = new CanvasHelper(page)
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
await canvas.waitForInit()
const result = await page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const { ensureGraphFonts, loadFont } = await import('/src/app/editor/fonts/index.ts')
await loadFont('Inter', 'Regular')
const { fontManager } = await import('/packages/core/src/text/fonts.ts')
const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager)
let requestedScripts: string[] = []
fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => {
requestedScripts = [...scripts]
return { cjk: ['Regression CJK Fallback'], arabic: [] }
}
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const text = store.graph.createNode('TEXT', pageNode.id, {
name: 'Tool CJK Regression',
x: 80,
y: 80,
width: 300,
height: 60,
text: '你好世界',
fontSize: 32,
fontFamily: 'Inter',
textPicture: new Uint8Array([1, 2, 3]),
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
try {
const changed = await ensureGraphFonts(store.graph, [text.id])
return {
changed,
requestedScripts,
textPictureCleared: store.graph.getNode(text.id)?.textPicture === null
}
} finally {
fontManager.ensureFallbackPack = originalEnsureFallbackPack
}
})
expect(result).toEqual({
changed: true,
requestedScripts: ['cjk'],
textPictureCleared: true
})
canvas.assertNoErrors()
})
test('CJK text waits for fallback fonts and repaints after they load', async ({ page }) => {
const canvas = new CanvasHelper(page)
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')

View file

@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test'
import { fontManager, type FontFallbackScript } from '@open-pencil/core/text'
import { SceneGraph } from '@open-pencil/scene-graph'
import { ensureGraphFonts } from '@/app/editor/fonts'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
describe('app font loading', () => {
test('ensureGraphFonts loads fallback packs when loaded primary font misses CJK glyphs', async () => {
const interData = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', interData)
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
text: '你好世界',
fontFamily: 'Inter',
fontSize: 32,
textPicture: new Uint8Array([1, 2, 3])
})
const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager)
let requestedScripts: FontFallbackScript[] = []
fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => {
requestedScripts = [...scripts]
return { cjk: ['Regression CJK Fallback'], arabic: [] }
}
try {
const changed = await ensureGraphFonts(graph, [text.id])
expect(changed).toBe(true)
expect(requestedScripts).toEqual(['cjk'])
expect(expectDefined(graph.getNode(text.id), 'text node').textPicture).toBeNull()
} finally {
fontManager.ensureFallbackPack = originalEnsureFallbackPack
}
})
})

View file

@ -11,7 +11,7 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import { initCanvasKit } from '#cli/headless'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText } from '#core/canvas/scene'
import { buildParagraph } from '#core/canvas/text'
import { buildParagraph, isNodeFontLoaded } from '#core/canvas/text'
import { fontManager } from '#core/text/fonts'
import { expectDefined } from '#tests/helpers/assert'
@ -238,6 +238,26 @@ describe('renderText headless visual', () => {
expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
})
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()
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] }
const originalFallbacks = [...manager.cjkFallbackFamilies]
manager.cjkFallbackFamilies = []
try {
const loaded = isNodeFontLoaded(
{ fontProvider: {}, fontsLoaded: true } as never,
textNode({ text: '你好世界', fontFamily: 'Noto Sans SC', fontWeight: 400 })
)
expect(loaded).toBe(true)
} finally {
manager.cjkFallbackFamilies = originalFallbacks
}
})
test('renders CJK text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()

View file

@ -186,6 +186,44 @@ describe('updateNode', () => {
expect(expectDefined(afterUpdate, 'updated node').textPicture).toBeNull()
})
test('figmaDerivedTextGlyphs are nulled when text rendering properties change on TEXT node', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const textId = graph.createNode('TEXT', page, {
name: 'T',
x: 0,
y: 0,
width: 100,
height: 20
}).id
const glyphs = [{ commandsBlob: new Uint8Array([1, 2, 3]), x: 0, y: 10, fontSize: 14 }]
const textNode = expectDefined(graph.getNode(textId), 'text node')
textNode.figmaDerivedTextGlyphs = glyphs
graph.updateNode(textId, { fontFamily: 'Noto Sans SC' })
expect(expectDefined(graph.getNode(textId), 'updated node').figmaDerivedTextGlyphs).toBeNull()
})
test('figmaDerivedTextGlyphs survive non-text property change on TEXT node', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const textId = graph.createNode('TEXT', page, {
name: 'T',
x: 0,
y: 0,
width: 100,
height: 20
}).id
const glyphs = [{ commandsBlob: new Uint8Array([4, 5, 6]), x: 0, y: 10, fontSize: 14 }]
const textNode = expectDefined(graph.getNode(textId), 'text node')
textNode.figmaDerivedTextGlyphs = glyphs
graph.updateNode(textId, { opacity: 0.5 })
expect(expectDefined(graph.getNode(textId), 'updated node').figmaDerivedTextGlyphs).toBe(glyphs)
})
test('textPicture survives non-text property change on TEXT node', () => {
const graph = new SceneGraph()
const page = pageId(graph)