Fix CJK text garbled when font unavailable (#89)
* Fix CJK text garbled when font unavailable (#69) Always use buildParagraph for text rendering when fonts are loaded, even when the node's specific font isn't available. The paragraph shaper falls back to the CJK font in fontFamilies, instead of the previous drawText fallback which used Inter (no CJK glyphs). * Add renderText unit tests for CJK font fallback behavior * Add CJK visual regression test with Noto Sans SC fixture - Download NotoSansSC-Regular.ttf as LFS-tracked test fixture - Add setCJKFallbackFamily() export for headless/test use - Visual test verifies CJK text renders through buildParagraph fallback when node font is unavailable (the exact PR #89 scenario) - Assert dark pixel count > 500 to distinguish real glyphs from tofu
This commit is contained in:
parent
f25e238e53
commit
0d1f42f0b1
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1,2 +1,3 @@
|
|||
tests/fixtures/*.fig filter=lfs diff=lfs merge=lfs -text
|
||||
tests/fixtures/fonts/*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
packages/core/vendor/canvaskit-webgpu/*.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
|
|
@ -331,6 +331,10 @@ export function getCJKFallbackFamily(): string | null {
|
|||
return cjkFallbackFamily
|
||||
}
|
||||
|
||||
export function setCJKFallbackFamily(family: string): void {
|
||||
cjkFallbackFamily = family
|
||||
}
|
||||
|
||||
export function weightToStyle(weight: number, italic = false): string {
|
||||
let label = 'Regular'
|
||||
if (weight <= 100) label = 'Thin'
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export {
|
|||
ensureNodeFont,
|
||||
ensureCJKFallback,
|
||||
getCJKFallbackFamily,
|
||||
setCJKFallbackFamily,
|
||||
styleToWeight,
|
||||
weightToStyle
|
||||
} from './fonts'
|
||||
|
|
|
|||
195
tests/engine/render-text.test.ts
Normal file
195
tests/engine/render-text.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, test, expect, mock } from 'bun:test'
|
||||
import { renderText } from '../../packages/core/src/renderer/scene'
|
||||
import type { SceneNode } from '../../packages/core/src/scene-graph'
|
||||
import type { SkiaRenderer } from '../../packages/core/src/renderer/renderer'
|
||||
import { initCanvasKit } from '../../packages/cli/src/headless'
|
||||
import { SceneGraph, SkiaRenderer as SkiaRendererClass } from '@open-pencil/core'
|
||||
import { initFontService, setCJKFallbackFamily } from '../../packages/core/src/fonts'
|
||||
|
||||
function createMockCanvas() {
|
||||
return {
|
||||
drawParagraph: mock(() => {}),
|
||||
drawPicture: mock(() => {}),
|
||||
drawText: mock(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function createMockParagraph() {
|
||||
return { delete: mock(() => {}) }
|
||||
}
|
||||
|
||||
function createMockPicture() {
|
||||
return { delete: mock(() => {}) }
|
||||
}
|
||||
|
||||
function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const paragraph = createMockParagraph()
|
||||
return {
|
||||
fontsLoaded: true,
|
||||
fontProvider: {},
|
||||
textFont: {},
|
||||
fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
|
||||
ck: { MakePicture: mock(() => createMockPicture()) },
|
||||
DEFAULT_FONT_SIZE: 14,
|
||||
isNodeFontLoaded: mock(() => true),
|
||||
buildParagraph: mock(() => paragraph),
|
||||
_paragraph: paragraph,
|
||||
...overrides
|
||||
} as unknown as SkiaRenderer & { _paragraph: ReturnType<typeof createMockParagraph> }
|
||||
}
|
||||
|
||||
function textNode(overrides: Partial<SceneNode> = {}): SceneNode {
|
||||
return {
|
||||
text: 'Hello 你好',
|
||||
fontSize: 16,
|
||||
fontFamily: 'Arial',
|
||||
...overrides
|
||||
} as SceneNode
|
||||
}
|
||||
|
||||
describe('renderText', () => {
|
||||
test('uses buildParagraph when fonts are loaded and node font is available', () => {
|
||||
const r = createMockRenderer()
|
||||
const canvas = createMockCanvas()
|
||||
|
||||
renderText(r, canvas as never, textNode())
|
||||
|
||||
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||||
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('uses buildParagraph when fonts loaded but node font NOT available', () => {
|
||||
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
|
||||
const canvas = createMockCanvas()
|
||||
|
||||
renderText(r, canvas as never, textNode())
|
||||
|
||||
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('prefers textPicture when node font is NOT available and textPicture exists', () => {
|
||||
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
|
||||
const canvas = createMockCanvas()
|
||||
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
|
||||
|
||||
renderText(r, canvas as never, node)
|
||||
|
||||
expect(canvas.drawPicture).toHaveBeenCalledTimes(1)
|
||||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('uses buildParagraph even with textPicture when node font IS loaded', () => {
|
||||
const r = createMockRenderer({ isNodeFontLoaded: mock(() => true) })
|
||||
const canvas = createMockCanvas()
|
||||
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
|
||||
|
||||
renderText(r, canvas as never, node)
|
||||
|
||||
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.drawPicture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('never uses drawText when fonts are loaded (CJK safety)', () => {
|
||||
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
|
||||
const canvas = createMockCanvas()
|
||||
|
||||
renderText(r, canvas as never, textNode())
|
||||
|
||||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('falls back to drawText only when fonts are NOT loaded', () => {
|
||||
const r = createMockRenderer({ fontsLoaded: false, fontProvider: null })
|
||||
const canvas = createMockCanvas()
|
||||
|
||||
renderText(r, canvas as never, textNode())
|
||||
|
||||
expect(canvas.drawText).toHaveBeenCalledTimes(1)
|
||||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does nothing for empty text', () => {
|
||||
const r = createMockRenderer()
|
||||
const canvas = createMockCanvas()
|
||||
|
||||
renderText(r, canvas as never, textNode({ text: '' }))
|
||||
|
||||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||||
expect(canvas.drawPicture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderText headless visual', () => {
|
||||
test('renders CJK text via fallback font when node font is unavailable', async () => {
|
||||
const ck = await initCanvasKit()
|
||||
const fontProvider = ck.TypefaceFontProvider.Make()
|
||||
initFontService(ck, fontProvider)
|
||||
|
||||
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||||
fontProvider.registerFont(interData, 'Inter')
|
||||
|
||||
const notoPath = new URL('../../tests/fixtures/fonts/NotoSansSC-Regular.ttf', import.meta.url).pathname
|
||||
const notoData = await Bun.file(notoPath).arrayBuffer()
|
||||
fontProvider.registerFont(notoData, 'Noto Sans SC')
|
||||
setCJKFallbackFamily('Noto Sans SC')
|
||||
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const node = graph.createNode('TEXT', page.id, {
|
||||
text: '你好世界',
|
||||
fontFamily: 'UnavailableFont',
|
||||
fontSize: 32,
|
||||
fontWeight: 400,
|
||||
width: 200,
|
||||
height: 50,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
|
||||
const surface = ck.MakeSurface(200, 50)!
|
||||
const renderer = new SkiaRendererClass(ck, surface)
|
||||
renderer.viewportWidth = 200
|
||||
renderer.viewportHeight = 50
|
||||
renderer.dpr = 1
|
||||
renderer.fontsLoaded = true
|
||||
;(renderer as unknown as Record<string, unknown>).fontProvider = fontProvider
|
||||
|
||||
const canvas = surface.getCanvas()
|
||||
canvas.clear(ck.WHITE)
|
||||
renderText(renderer, canvas, graph.getNode(node.id)!)
|
||||
surface.flush()
|
||||
|
||||
const image = surface.makeImageSnapshot()
|
||||
const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100)!
|
||||
image.delete()
|
||||
surface.delete()
|
||||
|
||||
expect(encoded.length).toBeGreaterThan(200)
|
||||
|
||||
const decodedImage = ck.MakeImageFromEncoded(encoded)!
|
||||
const pixels = decodedImage.readPixels(0, 0, {
|
||||
width: 200,
|
||||
height: 50,
|
||||
colorType: ck.ColorType.RGBA_8888,
|
||||
alphaType: ck.AlphaType.Unpremul,
|
||||
colorSpace: ck.ColorSpace.SRGB
|
||||
})!
|
||||
decodedImage.delete()
|
||||
|
||||
let darkPixels = 0
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
|
||||
darkPixels++
|
||||
}
|
||||
}
|
||||
// CJK characters are dense — should have many dark pixels if rendering correctly
|
||||
// Tofu boxes would have far fewer (just outlines)
|
||||
expect(darkPixels).toBeGreaterThan(500)
|
||||
})
|
||||
})
|
||||
3
tests/fixtures/fonts/NotoSansSC-Regular.ttf
vendored
Normal file
3
tests/fixtures/fonts/NotoSansSC-Regular.ttf
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:450625c8d46ab3df97b7904ded955ec2746d17ec76740cb1e91d1ba63a0f89af
|
||||
size 10540644
|
||||
Loading…
Reference in a new issue