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