fix(text): verify visible international font rendering
- Register CanvasKit faces under their source family names so CJK and Arabic glyphs paint instead of only shaping metrics - Add deterministic CJK, Arabic, mixed-script, first-paint, and interactive fallback image oracles - Cover typed and tool-created text parity with a repository-owned Noto CJK subset Fixes #324 Fixes #291
This commit is contained in:
parent
47fe43c3ec
commit
9919d8710a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
"ignoreWorkspaces": ["packages/acp", "packages/demos"],
|
||||
"ignoreUnresolved": [
|
||||
"~icons/*",
|
||||
"/packages/core/src/text/fonts.ts",
|
||||
"/src/app/editor/fonts/index.ts",
|
||||
"#core/*"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
private renderFamilyAliases = new Map<string, string>()
|
||||
private renderFamilyRevisions = new Map<string, number>()
|
||||
private cjkFallbackFamilies: string[] = []
|
||||
private cjkFallbackPromise: Promise<string[]> | 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,23 +324,12 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
return renderFamily
|
||||
}
|
||||
|
||||
collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> {
|
||||
return collectGraphFontKeys(graph, nodeIds)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<typeof renderer.renderFromEditorState>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<CanvasHelper> {
|
||||
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 [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}`)
|
||||
}
|
||||
|
||||
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: '안녕하세요' }
|
||||
]
|
||||
|
||||
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'
|
||||
}
|
||||
]
|
||||
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
|
||||
}
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const y = 84 + index * 64
|
||||
async function expectCanvas(canvas: CanvasHelper, name: string): Promise<void> {
|
||||
await canvas.page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((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
|
||||
}
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
||||
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')
|
||||
})
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
BIN
tests/fixtures/fonts/NotoSansCJK-Test.otf
vendored
Normal file
BIN
tests/fixtures/fonts/NotoSansCJK-Test.otf
vendored
Normal file
Binary file not shown.
93
tests/fixtures/fonts/OFL.txt
vendored
Normal file
93
tests/fixtures/fonts/OFL.txt
vendored
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue