diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc07455b..77440eb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Make canvas text rendering demand missing font faces and verify CJK/Arabic fallback coverage from CanvasKit shaping results instead of coarse script predictions. - Resolve fonts before loaded, pasted, imported, and tool-created nodes render; invalidate generation-stale text caches and use baked `.fig` glyphs only after live font resolution is exhausted. - Load character-specific remote font subsets without Latin-only assumptions, preserve cumulative subset coverage, and reject unavailable desktop font styles instead of substituting the first family face. +- Render downloaded CJK and Arabic faces under their source family names so CanvasKit produces visible glyphs, with first-paint and interactive fallback snapshots covering CJK, Arabic, and mixed scripts. - Fix live CLI and MCP automation drifting to the wrong open document or page when multiple files are open. - Improve Chinese, Japanese, and Korean text rendering with glyph-aware fallback fonts and outline rendering when needed. - Preserve imported Figma text sizing more accurately, especially auto-sized text inside auto-layout frames. diff --git a/knip.json b/knip.json index 7d4224ab6..59a0d24c1 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,6 @@ "ignoreWorkspaces": ["packages/acp", "packages/demos"], "ignoreUnresolved": [ "~icons/*", - "/packages/core/src/text/fonts.ts", "/src/app/editor/fonts/index.ts", "#core/*" ], diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 6127be85a..f26fe3c6e 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -3,7 +3,6 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' import { DEFAULT_FONT_FAMILY, IS_BROWSER } from '#core/constants' -import { fontFaceRenderFamily } from '#core/text/face' import { chooseLocalFontMatch, isVariableFont, @@ -52,9 +51,6 @@ export class FontManager { private fallbackUserAgent: string | undefined private hostFontLoader: HostFontLoader | null = null private webFonts = new WebFontResolver() - private registeredRenderFamilies = new Set() - private renderFamilyAliases = new Map() - private renderFamilyRevisions = new Map() private cjkFallbackFamilies: string[] = [] private cjkFallbackPromise: Promise | null = null private arabicFallbackFamilies: string[] = [] @@ -64,18 +60,13 @@ export class FontManager { this.fontProvider = provider this.registrationGeneration++ this.providerRegistrations.clear() - this.registeredRenderFamilies.clear() - this.renderFamilyAliases.clear() - this.renderFamilyRevisions.clear() for (const [cacheKey, data] of this.loadedFamilies) { const separator = cacheKey.indexOf('|') const family = cacheKey.slice(0, separator) - const style = cacheKey.slice(separator + 1) this.registerFontInCanvasKit(family, data) for (const supplemental of this.supplementalFamilyData.get(cacheKey) ?? []) { this.registerFontInCanvasKit(family, supplemental) } - this.registerInitialRenderFamily(family, style, data) } } @@ -333,22 +324,11 @@ export class FontManager { return this.loadedFamilies.get(`${family}|${style}`) ?? null } - renderFamily(family: string, style: string): string { - const data = this.loadedData(family, style) - if (!data) return family - - const key = `${family}|${style}` - const renderFamily = this.renderFamilyAliases.get(key) ?? fontFaceRenderFamily(family, style) - if (!this.registeredRenderFamilies.has(renderFamily)) { - if (this.registerFontInCanvasKit(renderFamily, data)) { - this.renderFamilyAliases.set(key, renderFamily) - this.renderFamilyRevisions.set(key, 1) - this.registeredRenderFamilies.add(renderFamily) - } else { - return family - } - } - return renderFamily + renderFamily(family: string, _style: string): string { + // CanvasKit can shape metrics but paint no glyphs for some CJK/Arabic faces registered under a + // synthetic alias. Keep every shard under the font's source family; character-aware remote + // requests already fetch cumulative coverage before replacing the primary buffer. + return family } collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> { @@ -535,31 +515,10 @@ export class FontManager { if (existing) this.registerSupplemental(family, style, existing) this.loadedFamilies.set(key, buffer) this.registerFontInCanvasKit(family, buffer) - const currentRenderFamily = this.renderFamilyAliases.get(key) - if (currentRenderFamily) { - const revision = (this.renderFamilyRevisions.get(key) ?? 1) + 1 - const renderFamily = `${fontFaceRenderFamily(family, style)}__${revision}` - if (this.registerFontInCanvasKit(renderFamily, buffer)) { - this.renderFamilyAliases.set(key, renderFamily) - this.renderFamilyRevisions.set(key, revision) - this.registeredRenderFamilies.add(renderFamily) - } - } else { - this.registerInitialRenderFamily(family, style, buffer) - } this.registerFontInBrowser(family, style, buffer) return buffer } - private registerInitialRenderFamily(family: string, style: string, data: ArrayBuffer): void { - const key = `${family}|${style}` - const renderFamily = fontFaceRenderFamily(family, style) - if (!this.registerFontInCanvasKit(renderFamily, data)) return - this.renderFamilyAliases.set(key, renderFamily) - this.renderFamilyRevisions.set(key, 1) - this.registeredRenderFamilies.add(renderFamily) - } - private registerFontInCanvasKit(family: string, data: ArrayBuffer): boolean { if (!this.fontProvider || data.byteLength < 4) return false const registeredData = this.providerRegistrations.get(family) diff --git a/tests/e2e/fonts/cjk-fallback.spec.ts b/tests/e2e/fonts/cjk-fallback.spec.ts index c2f64c630..561e755c9 100644 --- a/tests/e2e/fonts/cjk-fallback.spec.ts +++ b/tests/e2e/fonts/cjk-fallback.spec.ts @@ -1,5 +1,7 @@ import { test, expect } from '@playwright/test' +import type { FontManager } from '#core/text/fonts' + import { CanvasHelper } from '#tests/helpers/canvas' test('tool-created CJK text requests fallback through app font loading', async ({ page }) => { @@ -13,13 +15,20 @@ test('tool-created CJK text requests fallback through app font loading', async ( 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 fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager + } const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager) let requestedScripts: string[] = [] fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => { requestedScripts = [...scripts] - return { cjk: ['Regression CJK Fallback'], arabic: [] } + return { cjk: ['Noto Sans CJK SC'], arabic: [] } } const pageNode = store.graph.getNode(store.state.currentPageId) @@ -66,9 +75,16 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ const store = window.openPencil?.getStore?.() if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') const renderer = store.renderer - const response = await fetch('/tests/fixtures/fonts/NotoSansSC-Regular.ttf') + const response = await fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf') const fallbackData = await response.arrayBuffer() - const { fontManager } = await import('/packages/core/src/text/fonts.ts') + const fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager + } const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] } const originalFamilies = [...manager.cjkFallbackFamilies] const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager) @@ -84,9 +100,9 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ const originalRender = renderer.renderFromEditorState.bind(renderer) fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => { await fallbackGate - fontManager.markLoaded('Regression CJK Fallback', 'Regular', fallbackData) - fontManager.setCJKFallbackFamily('Regression CJK Fallback') - return Object.fromEntries(scripts.map((script) => [script, ['Regression CJK Fallback']])) + fontManager.markLoaded('Noto Sans CJK SC', 'Regular', fallbackData) + fontManager.setCJKFallbackFamily('Noto Sans CJK SC') + return Object.fromEntries(scripts.map((script) => [script, ['Noto Sans CJK SC']])) } renderer.renderFromEditorState = ( ...args: Parameters @@ -101,7 +117,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ y: 80, width: 300, height: 60, - text: '上班打卡App', + text: '你好世界App', textLanguage: 'zh-Hans', fontSize: 32, fontFamily: 'Inter', @@ -131,6 +147,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ return { loadedBeforeFallback, loadedAfterFallback: renderer.isNodeFontLoaded(text), + readiness: renderer.nodeFontReadiness(text), fallbackRenderCount, renderCount } @@ -142,6 +159,7 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ }) expect(result.loadedBeforeFallback).toBe(false) + expect(result.readiness).toBe('ready') expect(result.loadedAfterFallback).toBe(true) expect(result.fallbackRenderCount).toBe(1) expect(result.renderCount).toBeGreaterThan(0) diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts b/tests/e2e/fonts/cjk-rendering-visual.spec.ts index 410953303..aa7124b49 100644 --- a/tests/e2e/fonts/cjk-rendering-visual.spec.ts +++ b/tests/e2e/fonts/cjk-rendering-visual.spec.ts @@ -1,121 +1,358 @@ -import { existsSync, readFileSync } from 'node:fs' +import { Buffer } from 'node:buffer' -import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' +import type { Page } from '@playwright/test' -const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') -const appleGothic = '/System/Library/Fonts/Supplemental/AppleGothic.ttf' +import type { FontManager } from '#core/text/fonts' -test.skip( - process.platform !== 'darwin' || !existsSync(appleGothic), - 'CJK visual snapshot uses macOS AppleGothic for Hangul coverage' -) +import { expect, test } from '#tests/e2e/fixtures' +import { CanvasHelper } from '#tests/helpers/canvas' -async function expectCanvas(name: string) { - editor.canvas.assertNoErrors() - const buffer = await editor.canvas.canvas.screenshot() - expect(buffer).toMatchSnapshot(`${name}.png`) -} - -test('renders Simplified Chinese, Traditional Chinese, Japanese, and Korean fallback text', async () => { - await editor.page.route('**/__test-fonts/apple-gothic.ttf', async (route) => { - await route.fulfill({ body: readFileSync(appleGothic), contentType: 'font/ttf' }) - }) - - const smoke = await editor.page.evaluate(async () => { +async function openEditor(page: Page): Promise { + await page.goto('/?test&no-chrome&no-rulers') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + await page.evaluate(() => { const store = window.openPencil?.getStore?.() if (!store) throw new Error('OpenPencil store not initialized') - const pageId = store.state.currentPageId - const { fontManager } = await import('/packages/core/src/text/fonts.ts') + const pageNode = store.graph.getNode(store.state.currentPageId) + const childIds = pageNode?.childIds.slice() ?? [] + for (const id of childIds) store.graph.deleteNode(id) + store.clearSelection() + store.renderer?.invalidateAllPictures() + store.requestRender() + store.renderer?.renderFromEditorState( + store.state, + store.graph, + store.textEditor, + window.innerWidth, + window.innerHeight, + false, + 'full' + ) + }) + await canvas.waitForRender() + return canvas +} - const [notoResponse, appleGothicResponse] = await Promise.all([ - fetch('/tests/fixtures/fonts/NotoSansSC-Regular.ttf'), - fetch('/__test-fonts/apple-gothic.ttf') - ]) - if (!notoResponse.ok) throw new Error(`Failed to load Noto Sans SC: ${notoResponse.status}`) - if (!appleGothicResponse.ok) { - throw new Error(`Failed to load AppleGothic: ${appleGothicResponse.status}`) +async function expectCanvas(canvas: CanvasHelper, name: string): Promise { + await canvas.page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)) + }) + ) + canvas.assertNoErrors() + expect(await canvas.canvas.screenshot()).toMatchSnapshot(`${name}.png`) +} + +test('international text is correct on its first visible paint', async ({ page }) => { + await openEditor(page) + + const result = await page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') + const fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager } - - fontManager.markLoaded('Noto Sans SC', 'Regular', await notoResponse.arrayBuffer()) - fontManager.markLoaded('AppleGothic', 'Regular', await appleGothicResponse.arrayBuffer()) - fontManager.setCJKFallbackFamily('Noto Sans SC') - fontManager.setCJKFallbackFamily('AppleGothic') - - const lines = [ - { label: 'Simplified Chinese', text: '你好世界' }, - { label: 'Traditional Chinese', text: '繁體中文' }, - { label: 'Japanese', text: '日本語かなカナ' }, - { label: 'Korean', text: '안녕하세요' } + const pageId = store.state.currentPageId + const samples = [ + { + label: 'Simplified Chinese', + text: '你好世界', + language: 'zh-Hans', + family: 'Noto Sans CJK SC' + }, + { + label: 'Traditional Chinese', + text: '繁體中文', + language: 'zh-Hant', + family: 'Noto Sans CJK SC' + }, + { label: 'Japanese', text: '日本語かなカナ', language: 'ja', family: 'Noto Sans CJK SC' }, + { label: 'Korean', text: '안녕하세요', language: 'ko', family: 'Noto Sans CJK SC' }, + { label: 'Arabic', text: 'مرحبا بالعالم', language: 'ar', family: 'Noto Naskh Arabic' }, + { + label: 'Mixed scripts', + text: 'OpenPencil · 你好 · مرحبا', + language: 'en', + family: 'Noto Sans CJK SC' + } ] - store.graph.createNode('FRAME', pageId, { - name: 'CJK rendering visual backdrop', - x: 64, - y: 52, - width: 720, - height: 312, - cornerRadius: 20, - fills: [ - { type: 'SOLID', color: { r: 0.97, g: 0.98, b: 1, a: 1 }, visible: true, opacity: 1 } - ], - strokes: [ - { - color: { r: 0.78, g: 0.84, b: 0.95, a: 1 }, - weight: 1, - visible: true, - opacity: 1, - align: 'INSIDE' - } - ] - }) - - for (const [index, line] of lines.entries()) { - const y = 84 + index * 64 + const sampleIds = samples.map((sample, index) => { + const y = 70 + index * 62 store.graph.createNode('TEXT', pageId, { - name: `${line.label} label`, - x: 96, - y, - width: 210, + name: `${sample.label} label`, + x: 88, + y: y + 4, + width: 220, height: 28, - text: line.label, + text: sample.label, fontFamily: 'Inter', - fontSize: 17, + fontSize: 16, fontWeight: 600, fills: [ { type: 'SOLID', color: { r: 0.25, g: 0.32, b: 0.45, a: 1 }, visible: true, opacity: 1 } ] }) - store.graph.createNode('TEXT', pageId, { - name: `${line.label} sample`, - x: 328, - y: y - 4, - width: 360, - height: 42, - text: line.text, - fontFamily: 'Inter', - fontSize: 32, - textAutoResize: 'HEIGHT', + return store.graph.createNode('TEXT', pageId, { + name: `${sample.label} sample`, + x: 330, + y, + width: 430, + height: 44, + text: sample.text, + textLanguage: sample.language, + fontFamily: sample.family, + fontSize: 30, + textAutoResize: 'NONE', + styleRuns: + sample.label === 'Mixed scripts' + ? [ + { + start: sample.text.indexOf('مرحبا'), + length: 'مرحبا'.length, + style: { fontFamily: 'Noto Naskh Arabic', textLanguage: 'ar' } + } + ] + : [], fills: [ { type: 'SOLID', color: { r: 0.04, g: 0.06, b: 0.12, a: 1 }, visible: true, opacity: 1 } ] + }).id + }) + + fontManager.blockNodesUntilFontsResolve(sampleIds) + const blockedBeforeFonts = sampleIds.every((id) => fontManager.isNodeBlocked(id)) + const [cjk, arabic] = await Promise.all([ + fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf').then((response) => + response.arrayBuffer() + ), + fetch('/tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf').then((response) => + response.arrayBuffer() + ) + ]) + fontManager.markLoaded('Noto Sans CJK SC', 'Regular', cjk) + fontManager.markLoaded('Noto Naskh Arabic', 'Regular', arabic) + fontManager.setCJKFallbackFamily('Noto Sans CJK SC') + fontManager.setArabicFallbackFamily('Noto Naskh Arabic') + + await store.loadFontsForNodes(sampleIds) + fontManager.unblockNodes(sampleIds) + for (const id of sampleIds) { + const node = store.graph.getNode(id) + if (node?.type === 'TEXT') store.graph.updateNode(id, { text: node.text }) + } + store.clearSelection() + store.renderer.invalidateAllPictures() + store.requestRender() + store.renderer.renderFromEditorState( + store.state, + store.graph, + store.textEditor, + window.innerWidth, + window.innerHeight, + false, + 'full' + ) + const pageNode = store.graph.getNode(pageId) + const image = await store.renderExportImage(pageNode?.childIds ?? [], 2, 'PNG') + if (!image) throw new Error('First-paint export failed') + return { + blockedBeforeFonts, + image: Array.from(image), + readiness: sampleIds.map((id) => { + const node = store.graph.getNode(id) + return { + name: node?.name, + state: node ? store.renderer?.nodeFontReadiness(node) : 'missing' + } + }), + unblockedAfterFonts: sampleIds.every((id) => !fontManager.isNodeBlocked(id)) + } + }) + + const { image, ...state } = result + expect(state).toEqual({ + blockedBeforeFonts: true, + readiness: [ + { name: 'Simplified Chinese sample', state: 'ready' }, + { name: 'Traditional Chinese sample', state: 'ready' }, + { name: 'Japanese sample', state: 'ready' }, + { name: 'Korean sample', state: 'ready' }, + { name: 'Arabic sample', state: 'ready' }, + { name: 'Mixed scripts sample', state: 'ready' } + ], + unblockedAfterFonts: true + }) + expect(Buffer.from(image)).toMatchSnapshot('international-text-first-paint.png') +}) + +test('typed and tool-created text repaint with the same resolved fallbacks', async ({ page }) => { + const canvas = await openEditor(page) + + const ids = await page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const pageId = store.state.currentPageId + + const labels = ['Tool-created', 'Interactively typed'] + for (const [index, label] of labels.entries()) { + store.graph.createNode('TEXT', pageId, { + name: `${label} label`, + x: 88, + y: 112 + index * 92, + width: 190, + height: 28, + text: label, + fontFamily: 'Inter', + fontSize: 16, + fontWeight: 600, + fills: [ + { type: 'SOLID', color: { r: 0.25, g: 0.32, b: 0.45, a: 1 }, visible: true, opacity: 1 } + ] }) } - await store.loadFontsForNodes(store.graph.getPages().flatMap((page) => page.childIds)) - store.clearSelection() - store.requestRender() - - return { - loaded: { - noto: fontManager.isStyleLoaded('Noto Sans SC', 'Regular'), - appleGothic: fontManager.isStyleLoaded('AppleGothic', 'Regular') - }, - fallbacks: fontManager.getCJKFallbackFamilies() + const common = { + width: 450, + height: 52, + textLanguage: 'zh-Hans', + fontFamily: 'Inter', + fontSize: 30, + textAutoResize: 'NONE' as const, + fills: [ + { + type: 'SOLID' as const, + color: { r: 0.04, g: 0.06, b: 0.12, a: 1 }, + visible: true, + opacity: 1 + } + ] } + const tool = store.graph.createNode('TEXT', pageId, { + ...common, + name: 'Tool-created mixed script', + x: 300, + y: 104, + text: 'OpenPencil · 你好 · مرحبا' + }) + const typed = store.graph.createNode('TEXT', pageId, { + ...common, + name: 'Interactively typed mixed script', + x: 300, + y: 196, + text: '' + }) + const nodeIds = [tool.id, typed.id] + const fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager + } + fontManager.blockNodesUntilFontsResolve(nodeIds) + store.select([typed.id]) + store.startTextEditing(typed.id) + store.requestRender() + return { nodeIds, toolId: tool.id, typedId: typed.id } }) - expect(smoke.loaded).toEqual({ noto: true, appleGothic: true }) - expect(smoke.fallbacks).toEqual(expect.arrayContaining(['Noto Sans SC', 'AppleGothic'])) - await editor.canvas.waitForRender() - await expectCanvas('cjk-fallback-text-rendering') + await page.locator('textarea[aria-hidden="true"]').fill('OpenPencil · 你好 · مرحبا') + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') + store.renderer.invalidateAllPictures() + store.renderer.renderFromEditorState( + store.state, + store.graph, + store.textEditor, + window.innerWidth, + window.innerHeight, + false, + 'full' + ) + }) + await canvas.waitForRender() + const pending = await page.evaluate(async (nodeIds) => { + const fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager + } + return nodeIds.every((id) => fontManager.isNodeBlocked(id)) + }, ids.nodeIds) + expect(pending).toBe(true) + await expectCanvas(canvas, 'interactive-font-fallback-pending') + + const resolved = await page.evaluate(async ({ nodeIds, toolId, typedId }) => { + const store = window.openPencil?.getStore?.() + if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') + const fontModuleUrl = performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .find((url) => url.includes('/packages/core/src/text/fonts.ts')) + if (!fontModuleUrl) throw new Error('Active font manager module not found') + const { fontManager } = (await import(/* @vite-ignore */ fontModuleUrl)) as { + fontManager: FontManager + } + const [cjk, arabic] = await Promise.all([ + fetch('/tests/fixtures/fonts/NotoSansCJK-Test.otf').then((response) => + response.arrayBuffer() + ), + fetch('/tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf').then((response) => + response.arrayBuffer() + ) + ]) + fontManager.markLoaded('Noto Sans CJK SC', 'Regular', cjk) + fontManager.markLoaded('Noto Naskh Arabic', 'Regular', arabic) + fontManager.setCJKFallbackFamily('Noto Sans CJK SC') + fontManager.setArabicFallbackFamily('Noto Naskh Arabic') + store.commitTextEdit() + await store.loadFontsForNodes(nodeIds) + fontManager.unblockNodes(nodeIds) + for (const id of nodeIds) { + const node = store.graph.getNode(id) + if (node?.type === 'TEXT') store.graph.updateNode(id, { text: node.text }) + } + store.clearSelection() + store.renderer.invalidateAllPictures() + store.requestRender() + store.renderer.renderFromEditorState( + store.state, + store.graph, + store.textEditor, + window.innerWidth, + window.innerHeight, + false, + 'full' + ) + + const tool = store.graph.getNode(toolId) + const typed = store.graph.getNode(typedId) + const pageNode = store.graph.getNode(store.state.currentPageId) + const image = await store.renderExportImage(pageNode?.childIds ?? [], 2, 'PNG') + if (!image) throw new Error('Interactive export failed') + return { + image: Array.from(image), + textsMatch: tool?.type === 'TEXT' && typed?.type === 'TEXT' && tool.text === typed.text, + toolReady: tool ? store.renderer.isNodeFontLoaded(tool) : false, + typedReady: typed ? store.renderer.isNodeFontLoaded(typed) : false, + unblocked: nodeIds.every((id) => !fontManager.isNodeBlocked(id)) + } + }, ids) + + const { image, ...state } = resolved + expect(state).toEqual({ textsMatch: true, toolReady: true, typedReady: true, unblocked: true }) + expect(Buffer.from(image)).toMatchSnapshot('interactive-font-fallback-resolved.png') }) diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/cjk-fallback-text-rendering-openpencil-darwin.png b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/cjk-fallback-text-rendering-openpencil-darwin.png deleted file mode 100644 index 71844438e..000000000 Binary files a/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/cjk-fallback-text-rendering-openpencil-darwin.png and /dev/null differ diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-pending-openpencil-darwin.png b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-pending-openpencil-darwin.png new file mode 100644 index 000000000..d6d95e8f1 Binary files /dev/null and b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-pending-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-resolved-openpencil-darwin.png b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-resolved-openpencil-darwin.png new file mode 100644 index 000000000..e044e0ee8 Binary files /dev/null and b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/interactive-font-fallback-resolved-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/international-text-first-paint-openpencil-darwin.png b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/international-text-first-paint-openpencil-darwin.png new file mode 100644 index 000000000..5f47cff42 Binary files /dev/null and b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/international-text-first-paint-openpencil-darwin.png differ diff --git a/tests/engine/text/fonts/lifecycle.test.ts b/tests/engine/text/fonts/lifecycle.test.ts index 0f53816d8..7e1434a0a 100644 --- a/tests/engine/text/fonts/lifecycle.test.ts +++ b/tests/engine/text/fonts/lifecycle.test.ts @@ -25,10 +25,10 @@ describe('font lifecycle', () => { expect(providerGeneration).toBeGreaterThan(0) expect(registrationGeneration).toBeGreaterThan(providerGeneration) expect(manager.generation()).toBe(registrationGeneration) - expect(registrations).toEqual(['Generation Test', '__op_font__Generation_Test__Regular']) + expect(registrations).toEqual(['Generation Test']) }) - test('moves replaced subset fonts to a fresh render family', () => { + test('keeps cumulative subset registrations under the source family', () => { const manager = new FontManager() const registrations: string[] = [] const provider = { @@ -39,13 +39,12 @@ describe('font lifecycle', () => { manager.attachProvider({} as CanvasKit, provider) manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(8)) - const firstRenderFamily = manager.renderFamily('Subset Font', 'Regular') + const firstGeneration = manager.generation() manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(12)) - const secondRenderFamily = manager.renderFamily('Subset Font', 'Regular') - expect(firstRenderFamily).toBe('__op_font__Subset_Font__Regular') - expect(secondRenderFamily).toBe('__op_font__Subset_Font__Regular__2') - expect(registrations).toContain(secondRenderFamily) + expect(manager.renderFamily('Subset Font', 'Regular')).toBe('Subset Font') + expect(manager.generation()).toBeGreaterThan(firstGeneration) + expect(registrations).toEqual(['Subset Font', 'Subset Font']) }) test('tracks nodes gated by pre-render font resolution', () => { diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index fb7b28d0a..327b785f5 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -132,16 +132,10 @@ describe('FontManager loaded font cache', () => { manager.attachProvider(canvasKit, first.provider) manager.markLoaded('ProviderLifecycle', 'Regular', new ArrayBuffer(12)) - expect(first.registrations).toEqual([ - { family: 'ProviderLifecycle', byteLength: 12 }, - { family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 } - ]) + expect(first.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }]) manager.attachProvider(canvasKit, second.provider) - expect(second.registrations).toEqual([ - { family: 'ProviderLifecycle', byteLength: 12 }, - { family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 } - ]) + expect(second.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }]) manager.detachProvider(first.provider) expect(manager.provider()).toBe(second.provider) @@ -149,7 +143,7 @@ describe('FontManager loaded font cache', () => { expect(manager.provider()).toBeNull() }) - test('registers loaded faces under exact render families', () => { + test('renders loaded faces under their source families', () => { const manager = new FontManager() const recording = createRecordingProvider() @@ -158,13 +152,10 @@ describe('FontManager loaded font cache', () => { const renderFamily = manager.renderFamily('Inter', 'SemiBold') - expect(renderFamily).toBe('__op_font__Inter__SemiBold') - expect(recording.registrations).toEqual([ - { family: 'Inter', byteLength: 12 }, - { family: '__op_font__Inter__SemiBold', byteLength: 12 } - ]) + expect(renderFamily).toBe('Inter') + expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: 12 }]) expect(manager.renderFamily('Inter', 'SemiBold')).toBe(renderFamily) - expect(recording.registrations).toHaveLength(2) + expect(recording.registrations).toHaveLength(1) }) test('prefers local font data before downloaded cache', async () => { @@ -206,10 +197,7 @@ describe('FontManager loaded font cache', () => { }) await expect(manager.loadFont('DownloadedCache', 'Regular')).resolves.toBe(data) - expect(recording.registrations).toEqual([ - { family: 'DownloadedCache', byteLength: 16 }, - { family: '__op_font__DownloadedCache__Regular', byteLength: 16 } - ]) + expect(recording.registrations).toEqual([{ family: 'DownloadedCache', byteLength: 16 }]) expect(writes).toBe(0) }) @@ -226,10 +214,7 @@ describe('FontManager loaded font cache', () => { const data = await manager.loadFont('Inter', 'ExtraBold') expect(data?.byteLength).toBeGreaterThan(0) - expect(recording.registrations).toEqual([ - { family: 'Inter', byteLength: data?.byteLength }, - { family: '__op_font__Inter__ExtraBold', byteLength: data?.byteLength } - ]) + expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: data?.byteLength }]) } finally { globalThis.fetch = originalFetch } diff --git a/tests/fixtures/fonts/NotoSansCJK-Test.otf b/tests/fixtures/fonts/NotoSansCJK-Test.otf new file mode 100644 index 000000000..7eccd1cc7 Binary files /dev/null and b/tests/fixtures/fonts/NotoSansCJK-Test.otf differ diff --git a/tests/fixtures/fonts/OFL.txt b/tests/fixtures/fonts/OFL.txt new file mode 100644 index 000000000..1c9f43281 --- /dev/null +++ b/tests/fixtures/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.