import { describe, expect, test } from 'bun:test' import { initCanvasKit } from '#cli/headless' import { SkiaRenderer } from '#core/canvas' import { BLACK, TRANSPARENT } from '#core/constants' import { createEditor } from '#core/editor' import { fontManager } from '#core/text/fonts' import { textNodeToOutlineLayout } from '#core/text/outlines' async function createEditorWithRenderer() { const ck = await initCanvasKit() const surface = ck.MakeSurface(200, 200) if (!surface) throw new Error('Could not create CanvasKit surface') const renderer = new SkiaRenderer(ck, surface) const editor = createEditor() editor.setCanvasKit(ck, renderer) return { editor, surface } } async function loadInterRegular() { const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() fontManager.markLoaded('Inter', 'Regular', data) } async function loadInterBold() { const data = await Bun.file('public/Inter-Bold.ttf').arrayBuffer() fontManager.markLoaded('Inter', 'Bold', data) } async function loadNotoSansSC() { const file = Bun.file('tests/fixtures/fonts/NotoSansSC-Regular.ttf') const text = await file.slice(0, 32).text() if (text.startsWith('version ')) return false const data = await file.arrayBuffer() fontManager.markLoaded('Noto Sans SC', 'Regular', data) fontManager.setCJKFallbackFamily('Noto Sans SC') return true } describe('flattenSelected', () => { test('converts selected shapes into a vector path', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const first = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 }) const second = editor.graph.createNode('ELLIPSE', pageId, { x: 40, y: 30, width: 50, height: 40 }) editor.select([first.id, second.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.x).toBe(10) expect(vector?.y).toBe(20) expect(vector?.width).toBe(80) expect(vector?.height).toBe(50) expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) expect(editor.graph.getNode(first.id)).toBeUndefined() expect(editor.graph.getNode(second.id)).toBeUndefined() surface.delete() }) test('outlines visible strokes into a vector path', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 20, y: 30, width: 40, height: 30, fills: [TRANSPARENT], strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }] }) editor.select([rect.id]) editor.outlineStrokeSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.name).toBe('Outline stroke') expect(vector?.x).toBe(16) expect(vector?.y).toBe(26) expect(vector?.width).toBe(48) expect(vector?.height).toBe(38) expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) expect(editor.graph.getNode(rect.id)).toBeUndefined() surface.delete() }) test('outlines stroked descendants inside groups', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 20, y: 30, width: 40, height: 30, fills: [TRANSPARENT], strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }] }) editor.select([rect.id]) editor.groupSelected() editor.outlineStrokeSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.name).toBe('Outline stroke') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) surface.delete() }) test('does not outline fill-only shapes as strokes', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 20, y: 30, width: 40, height: 30, strokes: [] }) editor.select([rect.id]) const result = editor.outlineStrokeSelected() expect(result).toBeNull() expect(editor.graph.getNode(rect.id)?.type).toBe('RECTANGLE') surface.delete() }) test('outlines loaded text through the shared text outline path', async () => { await loadInterRegular() const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Hi', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 80, height: 40 }) editor.select([text.id]) editor.outlineTextSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.name).toBe('Outline text') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) expect(editor.graph.getNode(text.id)).toBeUndefined() surface.delete() }) test('flattens loaded text as outlines', async () => { await loadInterRegular() const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Hi', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 80, height: 40 }) editor.select([text.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) expect(editor.graph.getNode(text.id)).toBeUndefined() surface.delete() }) test('wraps loaded text style runs as outlines', async () => { await loadInterRegular() await loadInterBold() const editor = createEditor() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Hello world', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 100, height: 100, styleRuns: [{ start: 6, length: 5, style: { fontWeight: 700 } }] }) const layout = textNodeToOutlineLayout(text) expect(layout).not.toBeNull() expect(layout?.height).toBeGreaterThan(40) }) test('flattens loaded text style runs as outlines', async () => { await loadInterRegular() await loadInterBold() const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Hi', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 80, height: 40, styleRuns: [{ start: 1, length: 1, style: { fontWeight: 700, fontSize: 40 } }] }) editor.select([text.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) surface.delete() }) test('flattens mixed Latin and CJK text with loaded fallback outlines', async () => { await loadInterRegular() const hasCjkFallback = await loadNotoSansSC() if (!hasCjkFallback) return const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Hi你', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 120, height: 40 }) editor.select([text.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) surface.delete() }) test('does not flatten complex script text without shaping support', async () => { await loadInterRegular() const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'مرحبا', fontFamily: 'Inter', fontWeight: 400, fontSize: 32, width: 120, height: 40 }) editor.select([text.id]) const result = editor.flattenSelected() expect(result).toBeNull() expect(editor.graph.getNode(text.id)?.type).toBe('TEXT') surface.delete() }) test('does not flatten unsupported text nodes', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const text = editor.graph.createNode('TEXT', pageId, { text: 'Nope', fontFamily: 'Definitely Missing Font' }) const rect = editor.graph.createNode('RECTANGLE', pageId) editor.select([text.id, rect.id]) const result = editor.flattenSelected() expect(result).toBeNull() expect(editor.graph.getNode(pageId)?.childIds).toEqual([text.id, rect.id]) expect(editor.state.selectedIds).toEqual(new Set([text.id, rect.id])) surface.delete() }) test('does not flatten containers with unsupported descendants', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId) const image = editor.graph.createNode('RECTANGLE', pageId, { fills: [ { type: 'IMAGE', imageHash: 'image', imageScaleMode: 'FILL', color: TRANSPARENT, opacity: 1, visible: true } ] }) editor.select([rect.id, image.id]) editor.groupSelected() const [groupId] = [...editor.state.selectedIds] const result = editor.flattenSelected() expect(result).toBeNull() expect(editor.graph.getNode(groupId)?.type).toBe('GROUP') surface.delete() }) test('flattens visual descendants from groups', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40 }) const ellipse = editor.graph.createNode('ELLIPSE', pageId, { x: 70, y: 20, width: 30, height: 30 }) editor.select([rect.id, ellipse.id]) editor.groupSelected() const [groupId] = [...editor.state.selectedIds] editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0) expect(editor.graph.getNode(groupId)).toBeUndefined() surface.delete() }) test('includes stroke-only rectangles as flattenable outlines', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const rect = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 50, height: 40, fills: [], strokes: [ { type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' } ] }) editor.select([rect.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] const vector = editor.graph.getNode(vectorId) expect(vector?.type).toBe('VECTOR') expect(vector?.width).toBeGreaterThan(50) expect(vector?.height).toBeGreaterThan(40) surface.delete() }) test('undo and redo restore flattened children and vector', async () => { const { editor, surface } = await createEditorWithRenderer() const pageId = editor.state.currentPageId const before = editor.graph.createNode('RECTANGLE', pageId, { name: 'Before' }) const first = editor.graph.createNode('RECTANGLE', pageId, { name: 'First' }) const second = editor.graph.createNode('ELLIPSE', pageId, { name: 'Second', x: 50 }) const after = editor.graph.createNode('RECTANGLE', pageId, { name: 'After' }) editor.select([first.id, second.id]) editor.flattenSelected() const [vectorId] = [...editor.state.selectedIds] expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id]) editor.undo.undo() expect(editor.graph.getNode(pageId)?.childIds).toEqual([ before.id, first.id, second.id, after.id ]) expect(editor.state.selectedIds).toEqual(new Set([first.id, second.id])) editor.undo.redo() expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id]) expect(editor.graph.getNode(vectorId)?.type).toBe('VECTOR') surface.delete() }) })