From 0d1f42f0b1b2112b283fae8f85b6e1c7c4e81adf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 10 Mar 2026 16:58:57 +0300 Subject: [PATCH] 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 --- .gitattributes | 1 + packages/core/src/fonts.ts | 4 + packages/core/src/index.ts | 1 + tests/engine/render-text.test.ts | 195 ++++++++++++++++++++ tests/fixtures/fonts/NotoSansSC-Regular.ttf | 3 + 5 files changed, 204 insertions(+) create mode 100644 tests/engine/render-text.test.ts create mode 100644 tests/fixtures/fonts/NotoSansSC-Regular.ttf diff --git a/.gitattributes b/.gitattributes index 015331b06..b90471d70 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index ab1e06136..5304f1f0f 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -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' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ce618d6b..fa6138d08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { ensureNodeFont, ensureCJKFallback, getCJKFallbackFamily, + setCJKFallbackFamily, styleToWeight, weightToStyle } from './fonts' diff --git a/tests/engine/render-text.test.ts b/tests/engine/render-text.test.ts new file mode 100644 index 000000000..608336210 --- /dev/null +++ b/tests/engine/render-text.test.ts @@ -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> = {}) { + 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 } +} + +function textNode(overrides: Partial = {}): 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).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) + }) +}) diff --git a/tests/fixtures/fonts/NotoSansSC-Regular.ttf b/tests/fixtures/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 000000000..85576bfae --- /dev/null +++ b/tests/fixtures/fonts/NotoSansSC-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:450625c8d46ab3df97b7904ded955ec2746d17ec76740cb1e91d1ba63a0f89af +size 10540644