import { describe, test, expect, mock } from 'bun:test' import { detectTextDirection, resolveTextDirection, SceneGraph, SkiaRenderer as SkiaRendererClass } from '@open-pencil/core' import type { SceneNode } from '@open-pencil/scene-graph' import { initCanvasKit } from '#cli/headless' import type { SkiaRenderer } from '#core/canvas/renderer' import { renderText } from '#core/canvas/scene' import { buildParagraph, isNodeFontLoaded } from '#core/canvas/text' import { fontManager } from '#core/text/fonts' import { expectDefined } from '#tests/helpers/assert' import { repoPath } from '#tests/helpers/paths' function createMockCanvas() { return { drawParagraph: mock(() => undefined), drawPicture: mock(() => undefined), drawText: mock(() => undefined), drawPath: mock(() => undefined), drawRect: mock(() => undefined), save: mock(() => undefined), saveLayer: mock(() => undefined), restore: mock(() => undefined), clipRect: mock(() => undefined) } } function createMockParagraph() { return { delete: mock(() => undefined) } } function createMockPicture() { return { delete: mock(() => undefined) } } function createMockRenderer(overrides: Partial> = {}) { const paragraph = createMockParagraph() return { fontsLoaded: true, fontProvider: {}, textFont: {}, fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) }, effectLayerPaint: { setBlendMode: mock(() => undefined), setColorFilter: mock(() => undefined), setImageFilter: mock(() => undefined) }, ck: { MakePicture: mock(() => createMockPicture()), Path: class { moveTo = mock(() => undefined) lineTo = mock(() => undefined) cubicTo = mock(() => undefined) quadTo = mock(() => undefined) close = mock(() => undefined) delete = mock(() => undefined) }, LTRBRect: mock((...args: number[]) => args), Color4f: mock((...args: number[]) => new Float32Array(args)), BlendMode: { SrcOver: 0, SrcIn: 1 }, ClipOp: { Intersect: 0 } }, DEFAULT_FONT_SIZE: 14, isNodeFontLoaded: mock(() => true), buildParagraph: mock(() => paragraph), _paragraph: paragraph, ...overrides } as SkiaRenderer & { _paragraph: ReturnType } } function textNode(overrides: Partial = {}): SceneNode { return { type: 'TEXT', text: 'Hello 你好', fontSize: 16, fontFamily: 'Arial', fontWeight: 400, italic: false, letterSpacing: 0, lineHeight: null, textAlignHorizontal: 'LEFT', textAlignVertical: 'TOP', textAutoResize: 'NONE', textDecoration: 'NONE', textDirection: 'AUTO', styleRuns: [], ...overrides } as SceneNode } async function createTextRenderer() { const ck = await initCanvasKit() const surface = expectDefined(ck.MakeSurface(400, 120), 'surface') const renderer = new SkiaRendererClass(ck, surface) return { renderer, surface } } 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('skips text while the node font is not available', () => { const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) }) const canvas = createMockCanvas() renderText(r, canvas as never, textNode()) expect(r.buildParagraph).not.toHaveBeenCalled() expect(canvas.drawParagraph).not.toHaveBeenCalled() expect(canvas.drawText).not.toHaveBeenCalled() }) test('renders gradient text through a paragraph mask without outline font data', () => { const r = createMockRenderer() const canvas = createMockCanvas() renderText(r, canvas as never, textNode(), { type: 'GRADIENT_LINEAR', visible: true, opacity: 1, gradientStops: [], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }) expect(r.buildParagraph).toHaveBeenCalledTimes(1) expect(canvas.saveLayer).toHaveBeenCalledTimes(2) expect(canvas.drawParagraph).toHaveBeenCalledTimes(1) expect(canvas.drawRect).toHaveBeenCalledTimes(1) expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcIn) expect(r._paragraph.delete).toHaveBeenCalledTimes(1) }) test('renders non-solid text fills as vector outlines when outline font data is available', async () => { const interData = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer() fontManager.markLoaded('Inter', 'Regular', interData) const r = createMockRenderer() const canvas = createMockCanvas() renderText( r, canvas as never, textNode({ text: 'OPEN', fontFamily: 'Inter', width: 120, height: 40 }), { type: 'GRADIENT_LINEAR', visible: true, opacity: 1, gradientStops: [], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } } ) expect(canvas.drawPath).toHaveBeenCalledTimes(1) expect(r.buildParagraph).not.toHaveBeenCalled() expect(canvas.saveLayer).not.toHaveBeenCalled() }) test('prefers textPicture over paragraph', () => { const r = createMockRenderer() 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() }) 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('paragraph font weights', () => { test('bold Inter paragraph is wider than regular Inter', async () => { const { renderer, surface } = await createTextRenderer() await renderer.loadFonts() const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer() const bold = await Bun.file(repoPath('public/Inter-Bold.ttf')).arrayBuffer() fontManager.markLoaded('Inter', 'Regular', regular) fontManager.markLoaded('Inter', 'Bold', bold) const base = textNode({ text: 'World largest design', fontFamily: 'Inter', fontSize: 64, width: 1000, height: 100, fontWeight: 400, italic: false }) const regularParagraph = buildParagraph(renderer, base) const boldParagraph = buildParagraph(renderer, { ...base, fontWeight: 700 }) expect(boldParagraph.getLongestLine()).toBeGreaterThan(regularParagraph.getLongestLine()) regularParagraph.delete() boldParagraph.delete() surface.delete() }) }) describe('renderText headless visual', () => { test('detects base direction for Arabic and mixed text', () => { expect(detectTextDirection('مرحبا')).toBe('RTL') expect(resolveTextDirection('AUTO', 'مرحبا world')).toBe('RTL') expect(resolveTextDirection('AUTO', 'Hello مرحبا')).toBe('LTR') expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL') }) test('does not require fallback families when the primary font covers CJK glyphs', async () => { const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf') const notoData = await Bun.file(notoPath).arrayBuffer() fontManager.markLoaded('Noto Sans SC', 'Regular', notoData) const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] } const originalFallbacks = [...manager.cjkFallbackFamilies] manager.cjkFallbackFamilies = [] try { const loaded = isNodeFontLoaded( { fontProvider: {}, fontsLoaded: true } as never, textNode({ text: '你好世界', fontFamily: 'Noto Sans SC', fontWeight: 400 }) ) expect(loaded).toBe(true) } finally { manager.cjkFallbackFamilies = originalFallbacks } }) test('renders CJK text via fallback font through paragraph shaper', async () => { const ck = await initCanvasKit() const fontProvider = ck.TypefaceFontProvider.Make() fontManager.attachProvider(ck, fontProvider) const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() fontProvider.registerFont(interData, 'Inter') fontManager.markLoaded('Inter', 'Regular', interData) const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf') const notoData = await Bun.file(notoPath).arrayBuffer() fontProvider.registerFont(notoData, 'Noto Sans SC') fontManager.setCJKFallbackFamily('Noto Sans SC') const graph = new SceneGraph() const page = graph.getPages()[0] const node = graph.createNode('TEXT', page.id, { text: '你好世界', fontFamily: 'Inter', 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 = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface') const renderer = new SkiaRendererClass(ck, surface) renderer.viewportWidth = 200 renderer.viewportHeight = 50 renderer.dpr = 1 renderer.fontsLoaded = true renderer.fontProvider = fontProvider const canvas = surface.getCanvas() canvas.clear(ck.WHITE) renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node')) surface.flush() const image = surface.makeImageSnapshot() const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG') image.delete() surface.delete() expect(encoded.length).toBeGreaterThan(200) const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image') 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) }) test('renders linear gradient text through the canvas scene fill path', async () => { const ck = await initCanvasKit() const fontProvider = ck.TypefaceFontProvider.Make() fontManager.attachProvider(ck, fontProvider) const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() fontProvider.registerFont(interData, 'Inter') fontManager.markLoaded('Inter', 'Regular', interData) const graph = new SceneGraph() const page = graph.getPages()[0] const node = graph.createNode('TEXT', page.id, { text: 'OPEN', fontFamily: 'Inter', fontSize: 64, fontWeight: 400, width: 220, height: 80, fills: [ { type: 'GRADIENT_LINEAR', opacity: 1, visible: true, gradientStops: [ { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } } ], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } } ] }) const surface = expectDefined(ck.MakeSurface(220, 80), 'CanvasKit surface') const renderer = new SkiaRendererClass(ck, surface) renderer.viewportWidth = 220 renderer.viewportHeight = 80 renderer.dpr = 1 renderer.fontsLoaded = true renderer.fontProvider = fontProvider const canvas = surface.getCanvas() canvas.clear(ck.WHITE) renderer.renderShape(canvas, expectDefined(graph.getNode(node.id), 'text node'), graph) surface.flush() const image = surface.makeImageSnapshot() const pixels = image.readPixels(0, 0, { width: 220, height: 80, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB }) image.delete() surface.delete() let redTextPixels = 0 let blueTextPixels = 0 for (let y = 0; y < 80; y++) { for (let x = 0; x < 220; x++) { const i = (y * 220 + x) * 4 const r = pixels[i] const g = pixels[i + 1] const b = pixels[i + 2] if (g > 220) continue if (x < 110 && b > r + 40) blueTextPixels++ if (x >= 110 && r > b + 40) redTextPixels++ } } expect(redTextPixels).toBeGreaterThan(40) expect(blueTextPixels).toBeGreaterThan(40) }) test('renders Arabic text via fallback font through paragraph shaper', async () => { const ck = await initCanvasKit() const fontProvider = ck.TypefaceFontProvider.Make() fontManager.attachProvider(ck, fontProvider) const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() fontProvider.registerFont(interData, 'Inter') fontManager.markLoaded('Inter', 'Regular', interData) const arabicPath = repoPath('tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf') const arabicData = await Bun.file(arabicPath).arrayBuffer() fontProvider.registerFont(arabicData, 'Noto Naskh Arabic') fontManager.setArabicFallbackFamily('Noto Naskh Arabic') const graph = new SceneGraph() const page = graph.getPages()[0] const node = graph.createNode('TEXT', page.id, { text: 'مرحبا بالعالم', textDirection: 'AUTO', fontFamily: 'Inter', fontSize: 32, fontWeight: 400, width: 220, height: 60, fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] }) const surface = expectDefined(ck.MakeSurface(220, 60), 'CanvasKit surface') const renderer = new SkiaRendererClass(ck, surface) renderer.viewportWidth = 220 renderer.viewportHeight = 60 renderer.dpr = 1 renderer.fontsLoaded = true renderer.fontProvider = fontProvider const canvas = surface.getCanvas() canvas.clear(ck.WHITE) renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node')) surface.flush() const image = surface.makeImageSnapshot() const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG') image.delete() surface.delete() expect(encoded.length).toBeGreaterThan(200) const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image') const pixels = decodedImage.readPixels(0, 0, { width: 220, height: 60, 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++ } } expect(darkPixels).toBeGreaterThan(450) }) })