openpencil/tests/engine/vector/vectorize.test.ts
Danila Poyarkov a6940bcab8 feat(app): convert image layers to vectors
- Add secure Recraft and fal.ai clients with centralized credentials and Media settings

- Replace image rectangles with editable vector frames in one undoable operation

- Bound desktop proxy responses, timeouts, and redirects for provider downloads

- Cover provider parsing, placement, settings, context-menu visibility, and undo

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
2026-07-27 16:22:05 +03:00

377 lines
15 KiB
TypeScript

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<ReturnType<typeof initCanvasKit>>
const VECTORIZE_FIXTURES = testPath('fixtures/vectorize')
async function loadFixturePng(name: string): Promise<Uint8Array> {
const buf = await Bun.file(`${VECTORIZE_FIXTURES}/${name}`).arrayBuffer()
return new Uint8Array(buf)
}
function countTransparentPixels(
image: NonNullable<ReturnType<typeof ck.MakeImageFromEncoded>>
): 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(
`<svg viewBox='0 0 10 10'><path d='M0 0 H10 V10 H0 Z' fill='#336699'/></svg>`,
{ 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 = `<svg viewBox="0 0 1000 800" width="200" height="160" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0 H1000 V800 H0 Z" fill="#336699"/>
<path d="M100 100 H400 V400 H100 Z" fill="#ffcc00"/>
<path d="M600 200 H900 V500 H600 Z" fill="#ffcc00"/>
</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 = `<svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0 H100 V50 H0 Z" fill="#336699"/>
</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 = `<svg width="240" height="300" viewBox="0 0 100 125" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0 H100 V125 H0 Z" fill="#003399"/>
</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 = `<svg viewBox="50 50 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M50 50 H150 V150 H50 Z" fill="#336699"/>
</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 = `<svg viewBox="0 0 100 200" xmlns="http://www.w3.org/2000/svg">
<path d="M0 100 C40 0, 60 200, 100 100" fill="#336699"/>
</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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(10, 20)" d="M0 0 H50 V50 H0 Z" fill="#336699"/>
</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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path transform="scale(2)" d="M0 0 H25 V25 H0 Z" fill="#336699"/>
</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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M25 25 H75 V75 H25 Z" fill="#ffcc00"/>
</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 = `<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient>
</defs>
<path d="M0 0 H10 V10 H0 Z" fill="url(#g)"/>
</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 = `<svg viewBox="0 0 100 100" width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="Gradient1" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
<stop offset="0" stop-color="#3572b0"/>
<stop offset="1" stop-color="#ffd43b"/>
</linearGradient>
</defs>
<path d="M0 0 H100 V100 H0 Z" fill="url(#Gradient1)"/>
</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 = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="g" gradientUnits="userSpaceOnUse" cx="50" cy="50" r="50" gradientTransform="rotate(90 50 50)">
<stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/>
</radialGradient>
</defs>
<path d="M0 0 H100 V100 H0 Z" fill="url(#g)"/>
</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 = `<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0 H10 V10 H0 Z" fill="#e1e1e1"/>
</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)
})
})