diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index efb9041cb..823bc4d59 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -606,6 +606,12 @@ function drawOutlinedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boo return true } +const CJK_TEXT_PATTERN = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u + +function shouldRenderCJKAsOutline(node: SceneNode): boolean { + return CJK_TEXT_PATTERN.test(node.text) +} + function drawGradientText( r: SkiaRenderer, canvas: Canvas, @@ -666,7 +672,10 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil canvas.restore() return } - if (shouldRenderTextAsOutline(fill) && drawOutlinedText(r, canvas, node)) { + if ( + (shouldRenderTextAsOutline(fill) || shouldRenderCJKAsOutline(node)) && + drawOutlinedText(r, canvas, node) + ) { canvas.restore() return } diff --git a/tests/e2e/fonts/cjk-rendering-visual.spec.ts b/tests/e2e/fonts/cjk-rendering-visual.spec.ts new file mode 100644 index 000000000..410953303 --- /dev/null +++ b/tests/e2e/fonts/cjk-rendering-visual.spec.ts @@ -0,0 +1,121 @@ +import { existsSync, readFileSync } from 'node:fs' + +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' + +const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') +const appleGothic = '/System/Library/Fonts/Supplemental/AppleGothic.ttf' + +test.skip( + process.platform !== 'darwin' || !existsSync(appleGothic), + 'CJK visual snapshot uses macOS AppleGothic for Hangul coverage' +) + +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 () => { + 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' + } + ] + }) + + for (const [index, line] of lines.entries()) { + const y = 84 + index * 64 + store.graph.createNode('TEXT', pageId, { + name: `${line.label} label`, + x: 96, + y, + width: 210, + height: 28, + text: line.label, + fontFamily: 'Inter', + fontSize: 17, + 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', + fills: [ + { type: 'SOLID', color: { r: 0.04, g: 0.06, b: 0.12, 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() + } + }) + + 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') +}) 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 new file mode 100644 index 000000000..71844438e Binary files /dev/null and b/tests/e2e/fonts/cjk-rendering-visual.spec.ts-snapshots/cjk-fallback-text-rendering-openpencil-darwin.png differ