diff --git a/knip.json b/knip.json index fd411a08e..7d4224ab6 100644 --- a/knip.json +++ b/knip.json @@ -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"] diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index aa0e2587b..2b0035223 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -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() -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( diff --git a/packages/core/src/text/coverage.ts b/packages/core/src/text/coverage.ts new file mode 100644 index 000000000..a04d51a7f --- /dev/null +++ b/packages/core/src/text/coverage.ts @@ -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)) +} diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index c35ec319a..6be65d20b 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -29,6 +29,8 @@ export interface DownloadedFontCache { write(family: string, style: string, data: ArrayBuffer): Promise } +export type HostFontLoader = (family: string, style: string) => Promise + type FindLocalFontOptions = { allowVariable?: boolean } type LocalFontMatch = Pick @@ -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() private cjkFallbackFamilies: string[] = [] @@ -173,6 +176,10 @@ export class FontManager { this.fallbackUserAgent = userAgent } + setHostFallbackFontLoader(loader: HostFontLoader | null): void { + this.hostFallbackFontLoader = loader + } + setOnlineFontProviders(settings: Partial>): 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 { + 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 { if (!this.downloadedFontCache) return null try { diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts index 5b67d991c..29a729e52 100644 --- a/packages/core/src/text/index.ts +++ b/packages/core/src/text/index.ts @@ -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' diff --git a/packages/core/src/text/opentype.ts b/packages/core/src/text/opentype.ts index d4703a20b..ba24204c8 100644 --- a/packages/core/src/text/opentype.ts +++ b/packages/core/src/text/opentype.ts @@ -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( diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 8b101534b..18c448073 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -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( diff --git a/packages/scene-graph/src/preview.ts b/packages/scene-graph/src/preview.ts index 1ff3e912f..e81564519 100644 --- a/packages/scene-graph/src/preview.ts +++ b/packages/scene-graph/src/preview.ts @@ -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) } diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 17bc1ba48..cf6fc84e4 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -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 { export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise { 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[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 { diff --git a/tests/e2e/fonts/cjk-fallback.spec.ts b/tests/e2e/fonts/cjk-fallback.spec.ts index 7dc7f4e3a..5e876c704 100644 --- a/tests/e2e/fonts/cjk-fallback.spec.ts +++ b/tests/e2e/fonts/cjk-fallback.spec.ts @@ -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') diff --git a/tests/engine/app/font-loading.test.ts b/tests/engine/app/font-loading.test.ts new file mode 100644 index 000000000..9ed045bbf --- /dev/null +++ b/tests/engine/app/font-loading.test.ts @@ -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 + } + }) +}) diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index a387e7691..5316c4b04 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -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() diff --git a/tests/engine/scene-graph/basic/update-node.test.ts b/tests/engine/scene-graph/basic/update-node.test.ts index 0c4971b4d..0cb5e7c7f 100644 --- a/tests/engine/scene-graph/basic/update-node.test.ts +++ b/tests/engine/scene-graph/basic/update-node.test.ts @@ -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)