import { beforeAll, describe, expect, test } from 'bun:test' import { preprocessForVectorize, resolveVectorFramePlacement, svgToVectorPaths } from '@open-pencil/core/vector' import { initCanvasKit } from '#cli/headless' import { expectDefined } from '#tests/helpers/assert' import { testPath } from '#tests/helpers/paths' let ck: Awaited> const VECTORIZE_FIXTURES = testPath('fixtures/vectorize') async function loadFixturePng(name: string): Promise { const buf = await Bun.file(`${VECTORIZE_FIXTURES}/${name}`).arrayBuffer() return new Uint8Array(buf) } function countTransparentPixels( image: NonNullable> ): number { const pixels = image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }) if (!pixels) return 0 let transparent = 0 for (let i = 3; i < pixels.length; i += 4) { if (pixels[i] === 0) transparent++ } return transparent } function createPng(width: number, height: number, alpha = 255): Uint8Array { const pixels = ck.Malloc(Uint8Array, width * height * 4) for (let i = 0; i < width * height; i++) { const offset = i * 4 pixels[offset] = 80 pixels[offset + 1] = 120 pixels[offset + 2] = 200 pixels[offset + 3] = alpha } const surface = ck.MakeRasterDirectSurface( { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width, height }, pixels, width * 4 ) if (!surface) throw new Error('Failed to create surface') const image = surface.makeImageSnapshot() const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100) image.delete() surface.delete() ck.Free(pixels) if (!encoded) throw new Error('Failed to encode PNG') return new Uint8Array(encoded) } beforeAll(async () => { ck = await initCanvasKit() }) describe('preprocessForVectorize', () => { test('leaves images at or above 256px short side unchanged', () => { const bytes = createPng(400, 300) const result = preprocessForVectorize(bytes, () => ck) expect(result).not.toBeNull() expect(result?.width).toBe(400) expect(result?.height).toBe(300) expect(result?.originalWidth).toBe(400) expect(result?.originalHeight).toBe(300) }) test('upscales images below 256px short side', () => { const bytes = createPng(100, 200) const result = preprocessForVectorize(bytes, () => ck) expect(result).not.toBeNull() expect(Math.min(result?.width ?? 0, result?.height ?? 0)).toBeGreaterThanOrEqual(256) }) test('preserves alpha channel', () => { const bytes = createPng(128, 128, 0) const result = preprocessForVectorize(bytes, () => ck) const processed = expectDefined(result, 'preprocess result') const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png') expect(countTransparentPixels(decoded)).toBeGreaterThan(0) decoded.delete() }) }) describe('preprocessForVectorize fixtures', () => { test('python_logo.png upscales short side below 256px', async () => { const bytes = await loadFixturePng('python_logo.png') const source = expectDefined(ck.MakeImageFromEncoded(bytes), 'source png') expect(source.width()).toBe(580) expect(source.height()).toBe(164) source.delete() const result = preprocessForVectorize(bytes, () => ck) const processed = expectDefined(result, 'preprocess result') expect(processed.originalWidth).toBe(580) expect(processed.originalHeight).toBe(164) expect(Math.min(processed.width, processed.height)).toBeGreaterThanOrEqual(256) expect(processed.width).toBeGreaterThan(580) expect(processed.height).toBe(256) }) test('euro_shield.png keeps dimensions and alpha', async () => { const bytes = await loadFixturePng('euro_shield.png') const result = preprocessForVectorize(bytes, () => ck) const processed = expectDefined(result, 'preprocess result') expect(processed.width).toBe(577) expect(processed.height).toBe(721) const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png') expect(countTransparentPixels(decoded)).toBeGreaterThan(0) decoded.delete() }) test('pilot_avatar.png keeps dimensions without upscale', async () => { const bytes = await loadFixturePng('pilot_avatar.png') const result = preprocessForVectorize(bytes, () => ck) const processed = expectDefined(result, 'preprocess result') expect(processed.width).toBe(412) expect(processed.height).toBe(364) expect(Math.min(processed.width, processed.height)).toBeGreaterThanOrEqual(256) const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png') expect(countTransparentPixels(decoded)).toBeGreaterThan(0) decoded.delete() }) test('sander_test_01.png preprocesses opaque illustration', async () => { const bytes = await loadFixturePng('sander_test_01.png') const source = expectDefined(ck.MakeImageFromEncoded(bytes), 'source png') expect(source.width()).toBe(417) expect(source.height()).toBe(391) const sourceTransparent = countTransparentPixels(source) source.delete() const result = preprocessForVectorize(bytes, () => ck) const processed = expectDefined(result, 'preprocess result') expect(processed.width).toBe(417) expect(processed.height).toBe(391) expect(processed.pngBytes.length).toBeGreaterThan(0) expect(processed.pngBytes.length).toBeLessThanOrEqual(5 * 1024 * 1024) expect(sourceTransparent).toBe(0) }) }) describe('resolveVectorFramePlacement', () => { test('tightens the replacement frame around inset vector content', () => { expect( resolveVectorFramePlacement( { x: 10, y: 20, width: 200, height: 100, rotation: 0 }, { x: 25, y: 10, width: 150, height: 80 } ) ).toEqual({ x: 35, y: 30, width: 150, height: 80, offsetX: 25, offsetY: 10 }) }) test('keeps rotated replacements in the original coordinate box', () => { expect( resolveVectorFramePlacement( { x: 10, y: 20, width: 200, height: 100, rotation: 30 }, { x: 25, y: 10, width: 150, height: 80 } ) ).toEqual({ x: 10, y: 20, width: 200, height: 100, offsetX: 0, offsetY: 0 }) }) }) describe('svgToVectorPaths', () => { test('accepts single-quoted SVG attributes', () => { const result = svgToVectorPaths( ``, { width: 20, height: 20 } ) expect(result?.paths).toHaveLength(1) expect(result?.paths[0]?.fills[0]?.type).toBe('SOLID') }) test('maps multi-path vendor SVG with viewBox larger than width/height attrs', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 200, height: 160 }) expect(result?.paths.length).toBe(3) let maxX = 0 let maxY = 0 for (const path of result?.paths ?? []) { for (const vertex of path.vectorNetwork.vertices) { maxX = Math.max(maxX, vertex.x) maxY = Math.max(maxY, vertex.y) } } expect(maxX).toBeCloseTo(200, 0) expect(maxY).toBeCloseTo(160, 0) }) test('maps viewBox paths onto target bounds', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 200, height: 100 }) expect(result?.paths.length).toBe(1) const path = expectDefined(result?.paths[0], 'vector path') expect(path.fills[0]?.type).toBe('SOLID') const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) expect(maxX).toBeCloseTo(200, 0) }) test('uses viewBox user units when width/height attributes differ', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 240, height: 300 }) const path = expectDefined(result?.paths[0], 'vector path') const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) expect(maxX).toBeCloseTo(240, 0) expect(maxY).toBeCloseTo(300, 0) }) test('maps viewBox with non-zero origin into target bounds', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 200, height: 200 }) const path = expectDefined(result?.paths[0], 'vector path') const minX = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const minY = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) expect(minX).toBeCloseTo(0, 0) expect(minY).toBeCloseTo(0, 0) expect(maxX).toBeCloseTo(200, 0) expect(maxY).toBeCloseTo(200, 0) }) test('scales cubic control points under non-uniform viewBox mapping', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 300, height: 100 }) const segment = expectDefined(result?.paths[0]?.vectorNetwork.segments[0], 'cubic segment') expect(segment.tangentStart.x).toBeCloseTo(120, 0) expect(segment.tangentStart.y).toBeCloseTo(-50, 0) expect(segment.tangentEnd.x).toBeCloseTo(-120, 0) expect(segment.tangentEnd.y).toBeCloseTo(50, 0) }) test('applies path transform before viewBox mapping', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 200, height: 200 }) const path = expectDefined(result?.paths[0], 'vector path') const minX = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const minY = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) expect(minX).toBeCloseTo(20, 0) expect(minY).toBeCloseTo(40, 0) expect(maxX).toBeCloseTo(120, 0) expect(maxY).toBeCloseTo(140, 0) }) test('applies non-translate transforms (scale) via svgpath', () => { // scale(2) was silently ignored by the old translate/matrix-only parser. const svg = ` ` const result = svgToVectorPaths(svg, { width: 100, height: 100 }) const path = expectDefined(result?.paths[0], 'vector path') // 25→50 in user space after scale(2), then viewBox 100→bounds 100 (1:1). const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x)) const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y)) expect(maxX).toBeCloseTo(50, 0) expect(maxY).toBeCloseTo(50, 0) }) test('reports tight content bounds inside target bounds', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 200, height: 200 }) const bounds = expectDefined(result?.contentBounds, 'content bounds') expect(bounds.x).toBeCloseTo(50, 0) expect(bounds.y).toBeCloseTo(50, 0) expect(bounds.width).toBeCloseTo(100, 0) expect(bounds.height).toBeCloseTo(100, 0) }) test('resolves objectBoundingBox linearGradient reference to a GRADIENT_LINEAR fill', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 10, height: 10 }) const fill = expectDefined(result?.paths[0]?.fills[0], 'gradient fill') expect(fill.type).toBe('GRADIENT_LINEAR') expect(fill.gradientStops?.length).toBe(2) expect(fill.gradientStops?.[0]?.color.r).toBeCloseTo(0, 2) expect(fill.gradientStops?.[1]?.color.r).toBeCloseTo(1, 2) expect(fill.gradientTransform).toBeDefined() }) test('resolves Recraft-style userSpaceOnUse gradient with two stops', () => { // Mirrors Recraft vectorize output: userSpaceOnUse coords across the node box. const svg = ` ` const result = svgToVectorPaths(svg, { width: 100, height: 100 }) const fill = expectDefined(result?.paths[0]?.fills[0], 'gradient fill') expect(fill.type).toBe('GRADIENT_LINEAR') expect(fill.gradientStops?.[0]?.color.b).toBeCloseTo(176 / 255, 2) expect(fill.gradientStops?.[1]?.color.r).toBeCloseTo(1, 2) // horizontal axis across the node box: start x≈0, end x≈1 const t = expectDefined(fill.gradientTransform, 'gradient transform') expect(t.m02).toBeCloseTo(0, 1) expect(t.m00 + t.m02).toBeCloseTo(1, 1) }) test('preserves rotated radial gradient axes', () => { const svg = ` ` const fill = expectDefined( svgToVectorPaths(svg, { width: 100, height: 100 })?.paths[0]?.fills[0], 'radial fill' ) const transform = expectDefined(fill.gradientTransform, 'gradient transform') expect(fill.type).toBe('GRADIENT_RADIAL') expect(transform.m00).toBeCloseTo(0, 3) expect(transform.m10).toBeCloseTo(0.5, 3) expect(transform.m01).toBeCloseTo(-0.5, 3) expect(transform.m11).toBeCloseTo(0, 3) }) test('still resolves a solid color fill alongside gradients', () => { const svg = ` ` const result = svgToVectorPaths(svg, { width: 10, height: 10 }) const fill = expectDefined(result?.paths[0]?.fills[0], 'solid fill') expect(fill.type).toBe('SOLID') expect(fill.color.r).toBeCloseTo(225 / 255, 2) }) })