openpencil/tests/engine/render-text.test.ts
Danila Poyarkov 0d1f42f0b1
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
2026-03-10 16:58:57 +03:00

196 lines
6.6 KiB
TypeScript

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