fix(canvas): render CJK fallback text as outlines

This commit is contained in:
Danila Poyarkov 2026-07-01 23:22:14 +03:00
parent 2c171139c1
commit 3efc7de149
3 changed files with 131 additions and 1 deletions

View file

@ -606,6 +606,12 @@ function drawOutlinedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boo
return true 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( function drawGradientText(
r: SkiaRenderer, r: SkiaRenderer,
canvas: Canvas, canvas: Canvas,
@ -666,7 +672,10 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil
canvas.restore() canvas.restore()
return return
} }
if (shouldRenderTextAsOutline(fill) && drawOutlinedText(r, canvas, node)) { if (
(shouldRenderTextAsOutline(fill) || shouldRenderCJKAsOutline(node)) &&
drawOutlinedText(r, canvas, node)
) {
canvas.restore() canvas.restore()
return return
} }

View file

@ -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')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB