fix(text): load CJK fallback only when needed
This commit is contained in:
parent
de230f2f70
commit
5a89b520b2
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
51
packages/core/src/text/coverage.ts
Normal file
51
packages/core/src/text/coverage.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
42
tests/engine/app/font-loading.test.ts
Normal file
42
tests/engine/app/font-loading.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue