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>
This commit is contained in:
Danila Poyarkov 2026-07-27 16:22:05 +03:00
parent 7d8d33e1e5
commit a6940bcab8
44 changed files with 1123 additions and 14 deletions

View file

@ -58,6 +58,8 @@ Tauri stores secrets in the native system credential store through `desktop/src/
Storage-provider schemas and runtime adapters live under `src/app/integrations/storage/`; non-secret preferences and credential references stay separate, and adapters resolve secrets at operation time. Local-first document caching and outbox synchronization live under `src/app/storage/`. A remote storage binding augments document source state and must not replace local file identity.
Bitmap-to-vector conversion lives in `packages/core/src/vector/vectorize/`; app provider clients, preferences, and lazy credential resolution live under `src/app/editor/vectorize/`. Keep provider credentials in the centralized credential manager, bound request and response sizes, and validate provider-owned download URLs before importing returned SVG.
App dialogs compose the Reka-backed components under `src/components/ui/dialog/` and the typed theme in `src/theme/dialog.ts`. Do not repeat portal, overlay, content, header, or footer infrastructure in feature dialogs.
## Commands

View file

@ -22,6 +22,7 @@
- Manage AI, agent, and media credentials from unified Settings, using the system credential store on desktop and optional encrypted storage in the browser.
- Assign separate Design, Review, Fast, and Vision models, providers, endpoints, and credentials from AI settings.
- Connect an S3-compatible storage workspace with local-first saves, background synchronization, and centrally managed credentials.
- Convert image layers into editable vector layers with Recraft or fal.ai from the canvas context menu. (#322)
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive property grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338)
- Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names.

View file

@ -25,6 +25,7 @@ Or download from the [releases page](https://github.com/open-pencil/open-pencil/
- **Fully programmable** — headless CLI, XPath queries, Figma Plugin API via `eval`, MCP server for AI agents, and desktop agent integrations for Claude Code, Codex, and Gemini CLI
- **Lint, convert, and extract tokens** — inspect documents, lint naming/layout/accessibility, convert between supported formats, analyze colors/typography/spacing/clusters, and extract design tokens
- **Components and variants** — create reusable components, group variants into component sets, insert local assets as instances, and switch variants from the inspector
- **Image vectorization** — convert image layers into editable vector layers with Recraft or fal.ai
- **Design-to-code export** — export selections as JSX/Tailwind, generate token outputs, and map designs into component-oriented code workflows
- **Vue SDK for custom editors** — headless components and composables for embedding OpenPencil into other apps or building workflow-specific editing surfaces. [Read the SDK docs →](https://openpencil.dev/programmable/sdk/)
- **Real-time collaboration** — P2P via WebRTC, no server, no account. Cursors, presence, follow mode

View file

@ -3,6 +3,7 @@ use reqwest::{
Method,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Clone, Deserialize, Serialize)]
pub struct ProxyHttpHeader {
@ -16,6 +17,9 @@ pub struct ProxyHttpRequest {
method: Option<String>,
headers: Option<Vec<ProxyHttpHeader>>,
body: Option<Vec<u8>>,
max_response_bytes: Option<usize>,
follow_redirects: Option<bool>,
timeout_ms: Option<u64>,
}
#[derive(Serialize)]
@ -23,6 +27,7 @@ pub struct ProxyHttpResponse {
status: u16,
headers: Vec<ProxyHttpHeader>,
body: Vec<u8>,
url: String,
}
fn request_headers(headers: Option<Vec<ProxyHttpHeader>>) -> HeaderMap {
@ -51,10 +56,16 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
return Err("Only HTTP(S) requests are supported".into());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| e.to_string())?;
let redirect_policy = if request.follow_redirects.unwrap_or(true) {
reqwest::redirect::Policy::limited(5)
} else {
reqwest::redirect::Policy::none()
};
let mut client_builder = reqwest::Client::builder().redirect(redirect_policy);
if let Some(timeout_ms) = request.timeout_ms {
client_builder = client_builder.timeout(Duration::from_millis(timeout_ms));
}
let client = client_builder.build().map_err(|e| e.to_string())?;
let mut builder = client
.request(request_method(request.method)?, parsed)
.headers(request_headers(request.headers));
@ -62,8 +73,9 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
builder = builder.body(body);
}
let response = builder.send().await.map_err(|e| e.to_string())?;
let mut response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let url = response.url().to_string();
let headers = response
.headers()
.iter()
@ -74,11 +86,28 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
})
})
.collect();
let body = response.bytes().await.map_err(|e| e.to_string())?.to_vec();
if let (Some(limit), Some(content_length)) =
(request.max_response_bytes, response.content_length())
{
if content_length > limit as u64 {
return Err("HTTP response exceeds the configured size limit".into());
}
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
if request
.max_response_bytes
.is_some_and(|limit| body.len() + chunk.len() > limit)
{
return Err("HTTP response exceeds the configured size limit".into());
}
body.extend_from_slice(&chunk);
}
Ok(ProxyHttpResponse {
status,
headers,
body,
url,
})
}

View file

@ -40,6 +40,7 @@ import type {
} from './types'
import { createUndoActions } from './undo'
import { createVariableActions } from './variables'
import { createVectorizeActions } from './vectorize'
import { createViewportActions } from './viewport'
export { createDefaultEditorState } from './state'
@ -165,6 +166,7 @@ export function createEditor(options?: EditorOptions) {
const text = createTextActions(ctx)
const nodes = createNodeActions(ctx)
const variables = createVariableActions(ctx)
const vectorize = createVectorizeActions(ctx)
const alignment = createAlignmentActions(ctx)
const clipboardBridge = createClipboardBridge(clipboard, selection)
const componentBridge = createComponentBridge(components, selection, structure, pages)
@ -251,6 +253,9 @@ export function createEditor(options?: EditorOptions) {
// Alignment (align, flip, rotate)
...alignment,
// Bitmap-to-vector replacement
...vectorize,
// Variables
...variables,

View file

@ -0,0 +1,124 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { copyEffects, copyStrokes } from '@open-pencil/scene-graph/copy'
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
import type { EditorContext } from '#core/editor/types'
import {
createVectorFrameChildren,
resolveVectorFramePlacement,
type SVGVectorizeResult
} from '#core/vector/vectorize'
function hasRoundedCorners(node: SceneNode): boolean {
return node.independentCorners
? [node.topLeftRadius, node.topRightRadius, node.bottomRightRadius, node.bottomLeftRadius].some(
(radius) => radius > 0
)
: node.cornerRadius > 0
}
function replacementFrameProps(node: SceneNode) {
return {
name: node.name,
rotation: node.rotation,
flipX: node.flipX,
flipY: node.flipY,
opacity: node.opacity,
visible: node.visible,
locked: node.locked,
blendMode: node.blendMode,
effects: copyEffects(node.effects),
strokes: copyStrokes(node.strokes),
strokeStyleId: node.strokeStyleId,
strokeCap: node.strokeCap,
strokeJoin: node.strokeJoin,
strokeMiterLimit: node.strokeMiterLimit,
dashPattern: [...node.dashPattern],
cornerRadius: node.cornerRadius,
topLeftRadius: node.topLeftRadius,
topRightRadius: node.topRightRadius,
bottomRightRadius: node.bottomRightRadius,
bottomLeftRadius: node.bottomLeftRadius,
independentCorners: node.independentCorners,
cornerSmoothing: node.cornerSmoothing,
clipsContent: node.clipsContent || hasRoundedCorners(node),
horizontalConstraint: node.horizontalConstraint,
verticalConstraint: node.verticalConstraint,
layoutPositioning: node.layoutPositioning,
layoutGrow: node.layoutGrow,
layoutAlignSelf: node.layoutAlignSelf,
minWidth: node.minWidth,
maxWidth: node.maxWidth,
minHeight: node.minHeight,
maxHeight: node.maxHeight,
isMask: node.isMask,
maskType: node.maskType,
maskIsOutline: node.maskIsOutline
}
}
export function createVectorizeActions(ctx: EditorContext) {
function replaceNodeWithVectorFrame(
nodeId: string,
vectorized: SVGVectorizeResult
): string | null {
const node = ctx.graph.getNode(nodeId)
const parentId = node?.parentId
const parent = parentId ? ctx.graph.getNode(parentId) : null
if (!node || !parentId || !parent) return null
const insertIndex = parent.childIds.indexOf(node.id)
if (insertIndex === -1) return null
const placement = resolveVectorFramePlacement(node, vectorized.contentBounds)
const originalSubtree = snapshotSubtree(ctx.graph, node.id)
const previousSelection = new Set(ctx.state.selectedIds)
const frame = ctx.graph.createNode('FRAME', parentId, {
...replacementFrameProps(node),
x: placement.x,
y: placement.y,
width: placement.width,
height: placement.height,
fills: []
})
ctx.graph.insertChildAt(frame.id, parentId, insertIndex)
createVectorFrameChildren(ctx.graph, frame.id, vectorized, placement)
if (frame.childIds.length === 0) {
ctx.graph.deleteNode(frame.id)
return null
}
const frameSubtree = snapshotSubtree(ctx.graph, frame.id)
ctx.graph.deleteNode(node.id)
ctx.setSelectedIds(new Set([frame.id]))
ctx.undo.push({
label: 'Vectorize image',
forward: () => {
if (ctx.graph.getNode(node.id)) ctx.graph.deleteNode(node.id)
const frameRoot = frameSubtree.get(frame.id)
if (frameRoot && !ctx.graph.getNode(frame.id)) {
restoreSubtree(ctx.graph, frameRoot, parentId, frameSubtree)
ctx.graph.insertChildAt(frame.id, parentId, insertIndex)
}
ctx.setSelectedIds(new Set([frame.id]))
ctx.requestRender()
},
inverse: () => {
if (ctx.graph.getNode(frame.id)) ctx.graph.deleteNode(frame.id)
const originalRoot = originalSubtree.get(node.id)
if (originalRoot && !ctx.graph.getNode(node.id)) {
restoreSubtree(ctx.graph, originalRoot, parentId, originalSubtree)
ctx.graph.insertChildAt(node.id, parentId, insertIndex)
}
ctx.setSelectedIds(previousSelection)
ctx.requestRender()
}
})
ctx.requestRender()
return frame.id
}
return { replaceNodeWithVectorFrame }
}

View file

@ -14,8 +14,12 @@ export interface VectorFramePlacement {
offsetY: number
}
function shouldTightenToContent(node: Pick<SceneNode, 'width' | 'height'>, content: Rect): boolean {
function shouldTightenToContent(
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
content: Rect
): boolean {
return (
node.rotation === 0 &&
content.width > 0 &&
content.height > 0 &&
(content.x > 0 ||
@ -26,7 +30,7 @@ function shouldTightenToContent(node: Pick<SceneNode, 'width' | 'height'>, conte
}
export function resolveVectorFramePlacement(
node: Pick<SceneNode, 'x' | 'y' | 'width' | 'height'>,
node: Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'rotation'>,
content: Rect
): VectorFramePlacement {
const tighten = shouldTightenToContent(node, content)

View file

@ -98,6 +98,9 @@
"noModel": "Keines",
"back": "Zurück",
"settingsMedia": "Medien",
"vectorization": "Bildvektorisierung",
"vectorizationDescription": "Sendet Bildebenen an Recraft oder fal.ai und gibt bearbeitbare Vektoren zurück. Es können Anbietergebühren anfallen.",
"vectorizeProvider": "Vektorisierungsdienst",
"settingsStorage": "Cloud-Speicher",
"storageWorkspace": "Speicher-Arbeitsbereich",
"openStorageWorkspace": "Arbeitsbereich öffnen",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "Als JSX kopieren",
"copyNodeId": "Knoten-ID kopieren",
"copyXPath": "XPath kopieren",
"convertToVector": "In Vektor umwandeln",
"checkUpdates": "Nach Updates suchen…",
"booleanOperations": "Boolesche Operationen",
"strikethrough": "Durchstreichen",

View file

@ -98,6 +98,9 @@
"noModel": "Ninguno",
"back": "Atrás",
"settingsMedia": "Multimedia",
"vectorization": "Vectorización de imágenes",
"vectorizationDescription": "Envía capas de imagen a Recraft o fal.ai y devuelve vectores editables. Pueden aplicarse cargos del proveedor.",
"vectorizeProvider": "Servicio de vectorización",
"settingsStorage": "Almacenamiento en la nube",
"storageWorkspace": "Espacio de almacenamiento",
"openStorageWorkspace": "Abrir espacio",

View file

@ -42,6 +42,7 @@
"copyAsJSX": "Copiar como JSX",
"copyNodeId": "Copiar ID del nodo",
"copyXPath": "Copiar XPath",
"convertToVector": "Convertir a vector",
"checkUpdates": "Buscar actualizaciones…",
"booleanOperations": "Operaciones booleanas",
"closeTab": "Cerrar pestaña",

View file

@ -98,6 +98,9 @@
"noModel": "Aucun",
"back": "Retour",
"settingsMedia": "Médias",
"vectorization": "Vectorisation dimage",
"vectorizationDescription": "Envoie les calques dimage à Recraft ou fal.ai et renvoie des vecteurs modifiables. Des frais peuvent sappliquer.",
"vectorizeProvider": "Service de vectorisation",
"settingsStorage": "Stockage cloud",
"storageWorkspace": "Espace de stockage",
"openStorageWorkspace": "Ouvrir lespace",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "Copier en JSX",
"copyNodeId": "Copier l'ID du nœud",
"copyXPath": "Copier le XPath",
"convertToVector": "Convertir en vecteur",
"checkUpdates": "Rechercher des mises à jour…",
"booleanOperations": "Opérations booléennes",
"strikethrough": "Barré",

View file

@ -98,6 +98,9 @@
"noModel": "Nessuno",
"back": "Indietro",
"settingsMedia": "Media",
"vectorization": "Vettorializzazione immagini",
"vectorizationDescription": "Invia i livelli immagine a Recraft o fal.ai e restituisce vettori modificabili. Potrebbero essere applicati costi.",
"vectorizeProvider": "Servizio di vettorializzazione",
"settingsStorage": "Archiviazione cloud",
"storageWorkspace": "Area di archiviazione",
"openStorageWorkspace": "Apri area",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "Copia come JSX",
"copyNodeId": "Copia ID nodo",
"copyXPath": "Copia XPath",
"convertToVector": "Converti in vettore",
"checkUpdates": "Controlla aggiornamenti…",
"booleanOperations": "Operazioni booleane",
"strikethrough": "Barrato",

View file

@ -98,6 +98,9 @@
"noModel": "なし",
"back": "戻る",
"settingsMedia": "メディア",
"vectorization": "画像のベクター化",
"vectorizationDescription": "画像レイヤーを Recraft または fal.ai に送信し、編集可能なベクターを返します。プロバイダー料金が発生する場合があります。",
"vectorizeProvider": "ベクター化サービス",
"settingsStorage": "クラウドストレージ",
"storageWorkspace": "ストレージワークスペース",
"openStorageWorkspace": "ワークスペースを開く",

View file

@ -42,6 +42,7 @@
"copyAsJSX": "JSXとしてコピー",
"copyNodeId": "ードIDをコピー",
"copyXPath": "XPathをコピー",
"convertToVector": "ベクターに変換",
"checkUpdates": "アップデートを確認…",
"booleanOperations": "パスの型抜き (ブーリアン演算)",
"closeTab": "タブを閉じる",

View file

@ -98,6 +98,9 @@
"noModel": "Brak",
"back": "Wstecz",
"settingsMedia": "Media",
"vectorization": "Wektoryzacja obrazów",
"vectorizationDescription": "Wysyła warstwy obrazów do Recraft lub fal.ai i zwraca edytowalne wektory. Mogą obowiązywać opłaty dostawcy.",
"vectorizeProvider": "Usługa wektoryzacji",
"settingsStorage": "Przechowywanie w chmurze",
"storageWorkspace": "Obszar przechowywania",
"openStorageWorkspace": "Otwórz obszar",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "Kopiuj jako JSX",
"copyNodeId": "Kopiuj ID węzła",
"copyXPath": "Kopiuj XPath",
"convertToVector": "Konwertuj na wektor",
"checkUpdates": "Sprawdź aktualizacje…",
"booleanOperations": "Operacje logiczne",
"strikethrough": "Przekreślenie",

View file

@ -98,6 +98,9 @@
"noModel": "Нет",
"back": "Назад",
"settingsMedia": "Медиа",
"vectorization": "Векторизация изображений",
"vectorizationDescription": "Отправляет слои изображений в Recraft или fal.ai и возвращает редактируемые векторы. Может взиматься плата провайдера.",
"vectorizeProvider": "Сервис векторизации",
"settingsStorage": "Облачное хранилище",
"storageWorkspace": "Рабочая область хранилища",
"openStorageWorkspace": "Открыть область",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "Копировать как JSX",
"copyNodeId": "Копировать ID узла",
"copyXPath": "Копировать XPath",
"convertToVector": "Преобразовать в вектор",
"checkUpdates": "Проверить обновления…",
"booleanOperations": "Логические операции",
"strikethrough": "Зачёркивание",

View file

@ -98,6 +98,9 @@
"noModel": "无",
"back": "返回",
"settingsMedia": "媒体",
"vectorization": "图像矢量化",
"vectorizationDescription": "将图像图层发送到 Recraft 或 fal.ai并返回可编辑的矢量。服务商可能收费。",
"vectorizeProvider": "矢量化服务",
"settingsStorage": "云存储",
"storageWorkspace": "存储工作区",
"openStorageWorkspace": "打开工作区",

View file

@ -43,6 +43,7 @@
"copyAsJSX": "复制为 JSX",
"copyNodeId": "复制节点 ID",
"copyXPath": "复制 XPath",
"convertToVector": "转换为矢量",
"checkUpdates": "检查更新…",
"booleanOperations": "布尔运算",
"strikethrough": "删除线",

View file

@ -147,6 +147,10 @@ export const dialogMessageDefaults = {
noModel: 'None',
back: 'Back',
settingsMedia: 'Media',
vectorization: 'Image vectorization',
vectorizationDescription:
'Send image layers to Recraft or fal.ai and return editable vectors. Provider charges may apply.',
vectorizeProvider: 'Vectorization service',
settingsStorage: 'Cloud storage',
storageWorkspace: 'Storage workspace',
openStorageWorkspace: 'Open workspace',

View file

@ -52,6 +52,7 @@ export const menuMessageDefaults = {
copyAsJSX: 'Copy as JSX',
copyNodeId: 'Copy node ID',
copyXPath: 'Copy XPath',
convertToVector: 'Convert to vector',
booleanOperations: 'Boolean operations',
arrangeAlignLeft: 'Align left',
arrangeAlignCenter: 'Align center',

View file

@ -6,6 +6,7 @@ import { nodeToXPath } from '@open-pencil/core/xpath'
import type { EditorStore } from '@/app/editor/active-store'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import { executeClipboardCommand } from '@/app/editor/clipboard/system'
import { canVectorizeImageNode, vectorizeImageNode } from '@/app/editor/vectorize'
import { toast } from '@/app/shell/ui'
import { writeTauriClipboardText } from '@/app/tauri/clipboard'
import { isTauri } from '@/app/tauri/env'
@ -75,6 +76,14 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
clipboardWrite,
copyNodeId,
copyXPath,
copyAsPNG
copyAsPNG,
canVectorizeImage: () => {
void selectedIds.value
return canVectorizeImageNode(store)
},
vectorizeImage: async () => {
const nodeId = ids()[0]
if (nodeId) await vectorizeImageNode(store, nodeId)
}
}
}

View file

@ -6,6 +6,7 @@ import type { createCanvasMenuActions } from '@/app/editor/canvas/menu/actions'
import {
CANVAS_COPY_AS_ACTIONS,
CANVAS_COPY_AS_GROUP_TEST_ID,
CANVAS_VECTORIZE_TEST_ID,
type CanvasContextActionId
} from '@/app/editor/canvas/menu/registry'
@ -21,6 +22,7 @@ type CanvasCopyLabels = {
copyAsJSX: string
copyNodeId: string
copyXPath: string
convertToVector: string
}
function withoutStaticSelectionCommands(entries: readonly MenuEntry[]): MenuEntry[] {
@ -90,6 +92,16 @@ export function useCanvasContextMenu(
return computed<MenuEntry[]>(() => {
const entries = withoutStaticSelectionCommands(baseEntries.value)
if (!hasSelection.value) return entries
if (actions.canVectorizeImage()) {
entries.push(
{ separator: true },
{
label: labels.value.convertToVector,
testId: CANVAS_VECTORIZE_TEST_ID,
action: runAsync(actions.vectorizeImage)
}
)
}
return [...entries, { separator: true }, copyPasteAsEntry(editor, actions, labels.value)]
})
}

View file

@ -24,6 +24,7 @@ export type CanvasContextActionMeta = {
}
export const CANVAS_COPY_AS_GROUP_TEST_ID = 'context-copy-paste-as' satisfies TestId
export const CANVAS_VECTORIZE_TEST_ID = 'context-vectorize' satisfies TestId
export const COPY_AS_PNG_SHORTCUT = 'MOD+SHIFT+C'
export const CANVAS_COPY_AS_ACTIONS = [

View file

@ -0,0 +1,89 @@
import { preprocessForVectorize, svgToVectorPaths } from '@open-pencil/core/vector'
import type { Fill, SceneNode } from '@open-pencil/scene-graph'
import type { EditorStore } from '@/app/editor/active-store'
import { openSettingsDialog } from '@/app/settings/dialog'
import { toast } from '@/app/shell/ui'
import { resolveVectorizeCredential } from './credentials'
import { vectorizeProviderID } from './preferences'
import { getVectorizeProvider } from './providers'
import { VectorizeAuthError } from './types'
const activeStores = new WeakSet<EditorStore>()
const ERROR_MAX_LENGTH = 240
function imageFill(node: SceneNode): Fill | null {
if (node.type !== 'RECTANGLE' || node.childIds.length > 0 || node.fills.length !== 1) return null
const fill = node.fills[0]
return fill.type === 'IMAGE' && fill.imageHash ? fill : null
}
export function canVectorizeImageNode(store: EditorStore): boolean {
if (store.state.selectedIds.size !== 1 || activeStores.has(store)) return false
const nodeId = store.state.selectedIds.values().next().value
if (!nodeId) return false
const node = store.graph.getNode(nodeId)
return Boolean(node && imageFill(node))
}
function vectorizeErrorMessage(error: unknown, providerName: string): string {
if (error instanceof TypeError) {
return `${providerName} could not be reached. Check your connection and try again.`
}
const message = error instanceof Error ? error.message : 'Vectorization failed'
return message.length <= ERROR_MAX_LENGTH ? message : `${message.slice(0, ERROR_MAX_LENGTH)}`
}
export async function vectorizeImageNode(store: EditorStore, nodeId: string): Promise<void> {
if (activeStores.has(store)) return
const node = store.graph.getNode(nodeId)
const fill = node ? imageFill(node) : null
if (!node || fill?.type !== 'IMAGE' || !fill.imageHash) return
const provider = getVectorizeProvider(vectorizeProviderID.value)
activeStores.add(store)
try {
const apiKey = await resolveVectorizeCredential(provider.id)
if (!apiKey) {
toast.error(`Add a ${provider.name} API key in Settings → Media.`)
openSettingsDialog('media')
return
}
const imageBytes = store.graph.images.get(fill.imageHash)
if (!imageBytes) {
toast.error('Image data is missing for this layer')
return
}
toast.info('Vectorizing image…')
const preprocessed = preprocessForVectorize(imageBytes, () => store.renderer?.ck ?? null)
if (!preprocessed) throw new Error('Could not prepare this image for vectorization')
const svg = await provider.vectorize(preprocessed.pngBytes, apiKey)
const currentNode = store.graph.getNode(node.id)
const currentFill = currentNode ? imageFill(currentNode) : null
if (!currentNode || currentFill?.type !== 'IMAGE' || currentFill.imageHash !== fill.imageHash) {
throw new Error('The image changed before vectorization finished')
}
const vectorized = svgToVectorPaths(svg, {
width: currentNode.width,
height: currentNode.height
})
if (!vectorized?.paths.length) throw new Error('The vectorizer returned no editable paths')
const frameId = store.replaceNodeWithVectorFrame(currentNode.id, vectorized)
if (!frameId) throw new Error('Could not replace this image with vector layers')
toast.info('Image converted to vectors')
} catch (error) {
if (error instanceof VectorizeAuthError) {
toast.error(`${error.message}. Update it in Settings → Media.`)
openSettingsDialog('media')
} else {
toast.error(vectorizeErrorMessage(error, provider.name))
}
} finally {
activeStores.delete(store)
}
}

View file

@ -0,0 +1,36 @@
import { appCredentialServices } from '@/app/settings/credentials/app'
import { credentialRef } from '@/app/settings/credentials/reference'
import type { CredentialRef, CredentialStatus } from '@/app/settings/credentials/types'
import type { VectorizeProviderID } from './types'
export function vectorizeCredentialRef(providerID: VectorizeProviderID): CredentialRef {
return credentialRef(`vectorize-${providerID}`, 'api-key')
}
export const VECTORIZE_CREDENTIAL_REFS = [
vectorizeCredentialRef('recraft'),
vectorizeCredentialRef('fal')
] as const
export function vectorizeCredentialStatus(
providerID: VectorizeProviderID
): Promise<CredentialStatus> {
return appCredentialServices.manager.status(vectorizeCredentialRef(providerID))
}
export async function setVectorizeCredential(
providerID: VectorizeProviderID,
value: string
): Promise<void> {
const reference = vectorizeCredentialRef(providerID)
const credential = value.trim()
if (credential) await appCredentialServices.manager.set(reference, credential)
else await appCredentialServices.manager.clear(reference)
}
export function resolveVectorizeCredential(
providerID: VectorizeProviderID
): Promise<string | null> {
return appCredentialServices.resolver.resolve(vectorizeCredentialRef(providerID))
}

View file

@ -0,0 +1,9 @@
export { canVectorizeImageNode, vectorizeImageNode } from './action'
export {
setVectorizeCredential,
vectorizeCredentialRef,
vectorizeCredentialStatus
} from './credentials'
export { vectorizeProviderID } from './preferences'
export { getVectorizeProvider, VECTORIZE_PROVIDER_DEFINITIONS } from './providers'
export type { VectorizeProviderID } from './types'

View file

@ -0,0 +1,17 @@
import { useLocalStorage } from '@vueuse/core'
import { computed } from 'vue'
import { VECTORIZE_PROVIDER_IDS, type VectorizeProviderID } from '@/app/editor/vectorize/types'
const storedProvider = useLocalStorage<string>('open-pencil:vectorize:provider', 'recraft')
function isVectorizeProviderID(value: string): value is VectorizeProviderID {
return VECTORIZE_PROVIDER_IDS.some((providerID) => providerID === value)
}
export const vectorizeProviderID = computed<VectorizeProviderID>({
get: () => (isVectorizeProviderID(storedProvider.value) ? storedProvider.value : 'recraft'),
set: (providerID) => {
storedProvider.value = providerID
}
})

View file

@ -0,0 +1,276 @@
import { IS_TAURI } from '@open-pencil/core/constants'
import { tauriFetch } from '@/app/tauri/http'
import {
VectorizeAuthError,
type VectorizeProvider,
type VectorizeProviderDefinition,
type VectorizeProviderID,
VectorizeTimeoutError
} from './types'
const REQUEST_TIMEOUT_MS = 30_000
const NATIVE_TIMEOUT_GRACE_MS = 1000
const MAX_SVG_BYTES = 20 * 1024 * 1024
const MAX_API_RESPONSE_BYTES = 1024 * 1024
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const BASE64_CHUNK_SIZE = 32_768
const RECRAFT_DEFINITION = {
id: 'recraft',
name: 'Recraft',
keyURL: 'https://www.recraft.ai/profile/api',
keyPlaceholder: 'Recraft API key'
} satisfies VectorizeProviderDefinition
const FAL_DEFINITION = {
id: 'fal',
name: 'fal.ai',
keyURL: 'https://fal.ai/dashboard/keys',
keyPlaceholder: 'fal API key'
} satisfies VectorizeProviderDefinition
export const VECTORIZE_PROVIDER_DEFINITIONS: readonly VectorizeProviderDefinition[] = [
RECRAFT_DEFINITION,
FAL_DEFINITION
]
async function vectorizeFetch(
input: string,
init?: RequestInit,
maxResponseBytes = MAX_API_RESPONSE_BYTES
): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
const requestInit = { ...init, signal: controller.signal }
return await (IS_TAURI
? tauriFetch(
input,
requestInit,
maxResponseBytes,
REQUEST_TIMEOUT_MS + NATIVE_TIMEOUT_GRACE_MS
)
: fetch(input, requestInit))
} catch (error) {
if (controller.signal.aborted) throw new VectorizeTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE))
}
return btoa(binary)
}
type VectorizeResponseRecord = {
image?: unknown
data?: unknown
images?: unknown
url?: unknown
}
function isVectorizeResponseRecord(value: unknown): value is VectorizeResponseRecord {
return typeof value === 'object' && value !== null
}
function readURLField(value: unknown): string | null {
if (!value || typeof value !== 'object' || !('url' in value)) return null
return typeof value.url === 'string' && value.url ? value.url : null
}
export function extractVectorizedSVGURL(payload: unknown): string | null {
if (!isVectorizeResponseRecord(payload)) return null
const record = payload
if ('image' in record) {
const imageURL = readURLField(record.image)
if (imageURL) return imageURL
}
for (const field of ['data', 'images'] as const) {
if (!(field in record)) continue
const value = record[field]
if (Array.isArray(value) && value.length > 0) {
const itemURL = readURLField(value[0])
if (itemURL) return itemURL
}
const nestedURL = extractVectorizedSVGURL(value)
if (nestedURL) return nestedURL
}
return readURLField(record)
}
function isAllowedAssetHost(providerID: VectorizeProviderID, hostname: string): boolean {
const suffixes = providerID === 'recraft' ? ['recraft.ai'] : ['fal.media', 'fal.run', 'fal.ai']
return suffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
}
export function validateVectorizedAssetURL(providerID: VectorizeProviderID, rawURL: string): URL {
const url = new URL(rawURL)
if (url.protocol !== 'https:' || !isAllowedAssetHost(providerID, url.hostname.toLowerCase())) {
throw new Error('Vectorization provider returned an untrusted download URL')
}
return url
}
async function readBoundedResponse(
response: Response,
maxBytes: number,
sizeError: string
): Promise<string> {
const contentLength = Number(response.headers.get('content-length'))
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new Error(sizeError)
}
if (!response.body) {
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) throw new Error(sizeError)
return new TextDecoder().decode(bytes)
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
let result = await reader.read()
while (!result.done) {
totalBytes += result.value.byteLength
if (totalBytes > maxBytes) throw new Error(sizeError)
chunks.push(result.value)
result = await reader.read()
}
} catch (error) {
await reader.cancel().catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
const bytes = new Uint8Array(totalBytes)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(bytes)
}
async function fetchSVGFromURL(providerID: VectorizeProviderID, rawURL: string): Promise<string> {
const url = validateVectorizedAssetURL(providerID, rawURL)
let response: Response
try {
response = await vectorizeFetch(
url.href,
{ credentials: 'omit', redirect: 'error' },
MAX_SVG_BYTES
)
} catch (error) {
if (error instanceof VectorizeTimeoutError) throw error
throw new Error('Vectorized SVG could not be downloaded. Try again or use the desktop app.')
}
if (!response.ok) throw new Error(`Failed to download vectorized SVG (${response.status})`)
if (response.url) validateVectorizedAssetURL(providerID, response.url)
return readBoundedResponse(response, MAX_SVG_BYTES, 'Vectorized SVG is too large')
}
async function responseErrorDetail(response: Response): Promise<string> {
const detail = await readBoundedResponse(
response,
4096,
'Provider error response is too large'
).catch(() => '')
return detail.trim().slice(0, 500)
}
const recraft: VectorizeProvider = {
...RECRAFT_DEFINITION,
async vectorize(pngBytes, apiKey) {
if (pngBytes.byteLength > MAX_IMAGE_BYTES) throw new Error('Image is too large to vectorize')
const form = new FormData()
const payload = pngBytes.buffer.slice(
pngBytes.byteOffset,
pngBytes.byteOffset + pngBytes.byteLength
) as ArrayBuffer
form.append('file', new Blob([payload], { type: 'image/png' }), 'image.png')
const response = await vectorizeFetch('https://external.api.recraft.ai/v1/images/vectorize', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
credentials: 'omit',
redirect: 'error'
})
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new VectorizeAuthError(recraft.name)
}
const detail = await responseErrorDetail(response)
throw new Error(
detail
? `${recraft.name} vectorization failed (${response.status}): ${detail}`
: `${recraft.name} vectorization failed (${response.status})`
)
}
const url = extractVectorizedSVGURL(
JSON.parse(
await readBoundedResponse(
response,
MAX_API_RESPONSE_BYTES,
`${recraft.name} response is too large`
)
)
)
if (!url) throw new Error(`${recraft.name} returned no SVG URL`)
return fetchSVGFromURL(recraft.id, url)
}
}
const fal: VectorizeProvider = {
...FAL_DEFINITION,
async vectorize(pngBytes, apiKey) {
if (pngBytes.byteLength > MAX_IMAGE_BYTES) throw new Error('Image is too large to vectorize')
const response = await vectorizeFetch('https://fal.run/fal-ai/recraft/vectorize', {
method: 'POST',
headers: {
Authorization: `Key ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ image_url: `data:image/png;base64,${bytesToBase64(pngBytes)}` }),
credentials: 'omit',
redirect: 'error'
})
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new VectorizeAuthError(fal.name)
}
const detail = await responseErrorDetail(response)
throw new Error(
detail
? `${fal.name} vectorization failed (${response.status}): ${detail}`
: `${fal.name} vectorization failed (${response.status})`
)
}
const url = extractVectorizedSVGURL(
JSON.parse(
await readBoundedResponse(
response,
MAX_API_RESPONSE_BYTES,
`${fal.name} response is too large`
)
)
)
if (!url) throw new Error(`${fal.name} returned no SVG URL`)
return fetchSVGFromURL(fal.id, url)
}
}
const providers: Record<VectorizeProviderID, VectorizeProvider> = { recraft, fal }
export function getVectorizeProvider(providerID: VectorizeProviderID): VectorizeProvider {
return providers[providerID]
}

View file

@ -0,0 +1,28 @@
export const VECTORIZE_PROVIDER_IDS = ['recraft', 'fal'] as const
export type VectorizeProviderID = (typeof VECTORIZE_PROVIDER_IDS)[number]
export type VectorizeProviderDefinition = {
id: VectorizeProviderID
name: string
keyURL: string
keyPlaceholder: string
}
export type VectorizeProvider = VectorizeProviderDefinition & {
vectorize(pngBytes: Uint8Array, apiKey: string): Promise<string>
}
export class VectorizeAuthError extends Error {
constructor(providerName: string) {
super(`${providerName} API key was rejected`)
this.name = 'VectorizeAuthError'
}
}
export class VectorizeTimeoutError extends Error {
constructor() {
super('Vectorization request timed out')
this.name = 'VectorizeTimeoutError'
}
}

View file

@ -1,6 +1,7 @@
import { AI_PROVIDERS } from '@open-pencil/core/constants'
import { aiModelSettings, modelConnectionCredentialRef } from '@/app/ai/models'
import { VECTORIZE_CREDENTIAL_REFS } from '@/app/editor/vectorize/credentials'
import { storageCredentialRefs, storageProviderRegistry } from '@/app/integrations/storage'
import {
PEXELS_CREDENTIAL,
@ -31,6 +32,7 @@ export function appCredentialRefs(): CredentialRef[] {
...modelConnectionRefs,
PEXELS_CREDENTIAL,
UNSPLASH_CREDENTIAL,
...VECTORIZE_CREDENTIAL_REFS,
...storageCredentials
])
}

View file

@ -8,12 +8,16 @@ interface ProxyHttpRequest {
method?: string
headers?: ProxyHttpHeader[]
body?: number[]
max_response_bytes?: number
follow_redirects?: boolean
timeout_ms?: number
}
interface ProxyHttpResponse {
status: number
headers: ProxyHttpHeader[]
body: number[]
url: string
}
function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
@ -56,7 +60,12 @@ export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Pr
})
}
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
export async function tauriFetch(
input: RequestInfo | URL,
init?: RequestInit,
maxResponseBytes?: number,
timeoutMs?: number
): Promise<Response> {
const request = new Request(input, init)
request.signal.throwIfAborted()
const { invoke } = await import('@tauri-apps/api/core')
@ -64,15 +73,20 @@ export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit):
url: request.url,
method: request.method,
headers: headersToProxyHeaders(request.headers),
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())]
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())],
max_response_bytes: maxResponseBytes,
follow_redirects: request.redirect === 'follow',
timeout_ms: timeoutMs
}
request.signal.throwIfAborted()
const response = await withAbortSignal(
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
request.signal
)
return new Response(new Uint8Array(response.body), {
const proxiedResponse = new Response(new Uint8Array(response.body), {
status: response.status,
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
})
Object.defineProperty(proxiedResponse, 'url', { value: response.url })
return proxiedResponse
}

View file

@ -167,7 +167,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
</ContextMenuSub>
<ContextMenuItem
v-else
v-test-id="contextCommandTestId(item.id)"
v-test-id="item.testId ?? contextCommandTestId(item.id)"
:class="canvasMenuItemClass(item.label, cls)"
:disabled="item.disabled"
@select="item.action?.()"

View file

@ -10,6 +10,7 @@ import { settingsDialogOpen, settingsDialogSection } from '@/app/settings/dialog
import ModelsPanel from '@/components/settings/models/ModelsPanel.vue'
import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeysSection.vue'
import StorageSettingsPanel from '@/components/settings/storage/StorageSettingsPanel.vue'
import VectorizeSettingsSection from '@/components/settings/vectorize/VectorizeSettingsSection.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue'
import { AppDialogFooter, AppDialogHeader, AppDialogRoot } from '@/components/ui/dialog'
@ -104,6 +105,7 @@ const navigationClass =
>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsMedia }}</h3>
<StockPhotoKeysSection />
<VectorizeSettingsSection />
</section>
<StorageSettingsPanel v-else />

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from '@open-pencil/vue'
import {
setVectorizeCredential,
vectorizeCredentialStatus,
vectorizeProviderID,
VECTORIZE_PROVIDER_DEFINITIONS
} from '@/app/editor/vectorize'
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
const { dialogs } = useI18n()
const keyDraft = ref('')
const keyStatus = ref<'configured' | 'missing' | 'unavailable' | 'locked'>('missing')
const provider = computed(() =>
VECTORIZE_PROVIDER_DEFINITIONS.find((definition) => definition.id === vectorizeProviderID.value)
)
const providerOptions = VECTORIZE_PROVIDER_DEFINITIONS.map((definition) => ({
value: definition.id,
label: definition.name
}))
async function refreshStatus(): Promise<void> {
const providerID = vectorizeProviderID.value
const status = await vectorizeCredentialStatus(providerID)
if (vectorizeProviderID.value === providerID) keyStatus.value = status
}
async function saveCredential(): Promise<void> {
if (!keyDraft.value.trim()) return
await setVectorizeCredential(vectorizeProviderID.value, keyDraft.value)
keyDraft.value = ''
await refreshStatus()
}
async function clearCredential(): Promise<void> {
await setVectorizeCredential(vectorizeProviderID.value, '')
keyDraft.value = ''
await refreshStatus()
}
watch(vectorizeProviderID, () => {
keyDraft.value = ''
void refreshStatus()
})
onMounted(() => void refreshStatus())
</script>
<template>
<section class="flex flex-col gap-2.5 border-t border-border pt-3" data-vectorize-settings>
<div>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.vectorization }}</h3>
<p class="mt-0.5 text-[10px] text-muted">{{ dialogs.vectorizationDescription }}</p>
</div>
<label class="flex flex-col gap-1 text-[10px] text-muted">
{{ dialogs.vectorizeProvider }}
<AppSelect
v-model="vectorizeProviderID"
:label="dialogs.vectorizeProvider"
:options="providerOptions"
/>
</label>
<ProviderSettingsKeyField
v-if="provider"
v-model="keyDraft"
:label="dialogs.apiKey"
:saved="keyStatus === 'configured'"
kind="api"
:placeholder="keyStatus === 'configured' ? dialogs.keySavedReplace : provider.keyPlaceholder"
:key-url="provider.keyURL"
:key-url-label="dialogs.getAPIKeyGeneric"
@change="saveCredential"
@clear="clearCredential"
/>
</section>
</template>

View file

@ -0,0 +1,159 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
const editor = useEditorSetupWithClear('/?test')
const MOCK_SVG = readFileSync(
join(process.cwd(), 'tests/fixtures/vectorize/euro_shield.recraft.svg'),
'utf8'
)
async function rightClickSelected(): Promise<void> {
const box = expectDefined(await editor.canvas.canvas.boundingBox(), 'canvas bounds')
const point = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store || store.state.selectedIds.size !== 1) return null
const node = store.graph.getNode([...store.state.selectedIds][0])
if (!node) return null
return {
x: (node.x + node.width / 2) * store.state.zoom + store.state.panX,
y: (node.y + node.height / 2) * store.state.zoom + store.state.panY
}
})
const selectedPoint = expectDefined(point, 'selected node screen point')
await editor.page.mouse.click(box.x + selectedPoint.x, box.y + selectedPoint.y, {
button: 'right'
})
}
async function createImageNode(): Promise<string> {
return editor.page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const imageCanvas = document.createElement('canvas')
imageCanvas.width = 120
imageCanvas.height = 80
const context = imageCanvas.getContext('2d')
if (!context) throw new Error('Cannot create image fixture canvas')
context.fillStyle = '#4488cc'
context.fillRect(0, 0, 120, 80)
const blob = await new Promise<Blob>((resolve, reject) => {
imageCanvas.toBlob((result) => {
if (result) resolve(result)
else reject(new Error('toBlob failed'))
}, 'image/png')
})
const hash = store.storeImage(new Uint8Array(await blob.arrayBuffer()))
const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
name: 'Vectorize Target',
x: 180,
y: 180,
width: 120,
height: 80,
fills: [
{
type: 'IMAGE',
color: { r: 0, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1,
imageHash: hash,
imageScaleMode: 'FILL'
}
]
})
store.select([node.id])
store.zoomToSelection()
store.requestRender()
return node.id
})
}
test.beforeAll(async () => {
await editor.page.route('**/external.api.recraft.ai/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ image: { url: 'https://cdn.recraft.ai/mock.svg' } })
})
})
await editor.page.route('https://cdn.recraft.ai/mock.svg', async (route) => {
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: MOCK_SVG })
})
})
test('Convert to vector appears only for a single image node', async () => {
await createImageNode()
await editor.canvas.waitForRender()
await rightClickSelected()
await expect(editor.page.getByTestId('context-vectorize')).toBeVisible()
await editor.page.keyboard.press('Escape')
await editor.canvas.drawRect(400, 400, 60, 60)
await rightClickSelected()
await expect(editor.page.getByTestId('context-vectorize')).toHaveCount(0)
editor.canvas.assertNoErrors()
})
test('missing key opens Media settings and saves through the credential manager', async () => {
const nodeId = await createImageNode()
await editor.canvas.waitForRender()
await rightClickSelected()
await editor.page.getByTestId('context-vectorize').click()
const section = editor.page.locator('[data-vectorize-settings]')
await expect(section).toBeVisible()
await section.getByTestId('provider-settings-api-key').fill('test-recraft-key')
await editor.page.getByTestId('app-settings-done').click()
await expect(editor.page.getByTestId('app-settings-dialog')).toHaveCount(0)
expect(
await editor.page.evaluate(
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type,
nodeId
)
).toBe('RECTANGLE')
})
test('vectorize replaces an image with editable vectors and undo restores it', async () => {
const nodeId = await createImageNode()
await editor.canvas.waitForRender()
await rightClickSelected()
await editor.page.getByTestId('context-vectorize').click()
await expect
.poll(() =>
editor.page.evaluate(
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type ?? null,
nodeId
)
)
.toBeNull()
const replacement = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store || store.state.selectedIds.size !== 1) return null
const frameId = [...store.state.selectedIds][0]
const frame = store.graph.getNode(frameId)
if (!frame) return null
return {
frameId,
type: frame.type,
childTypes: frame.childIds.map((id) => store.graph.getNode(id)?.type)
}
})
expect(replacement?.type).toBe('FRAME')
expect(replacement?.childTypes.filter((type) => type === 'VECTOR').length).toBeGreaterThan(1)
await editor.canvas.undo()
await editor.canvas.waitForRender()
expect(
await editor.page.evaluate(
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type,
nodeId
)
).toBe('RECTANGLE')
editor.canvas.assertNoErrors()
})

View file

@ -0,0 +1,66 @@
import { describe, expect, spyOn, test } from 'bun:test'
import {
extractVectorizedSVGURL,
getVectorizeProvider,
validateVectorizedAssetURL
} from '@/app/editor/vectorize/providers'
import { appCredentialRefs } from '@/app/settings/credentials/persistence'
import { credentialKey } from '@/app/settings/credentials/reference'
describe('vectorization providers', () => {
test('extracts supported nested SVG response URLs', () => {
expect(extractVectorizedSVGURL({ image: { url: 'https://cdn.recraft.ai/a.svg' } })).toBe(
'https://cdn.recraft.ai/a.svg'
)
expect(extractVectorizedSVGURL({ data: [{ url: 'https://v3.fal.media/a.svg' }] })).toBe(
'https://v3.fal.media/a.svg'
)
expect(extractVectorizedSVGURL({ data: { image: { url: 'https://fal.media/a.svg' } } })).toBe(
'https://fal.media/a.svg'
)
})
test('allows only provider-owned HTTPS asset hosts', () => {
expect(validateVectorizedAssetURL('recraft', 'https://cdn.recraft.ai/a.svg').hostname).toBe(
'cdn.recraft.ai'
)
expect(validateVectorizedAssetURL('fal', 'https://v3.fal.media/a.svg').hostname).toBe(
'v3.fal.media'
)
expect(() => validateVectorizedAssetURL('recraft', 'https://example.com/a.svg')).toThrow(
'untrusted'
)
expect(() => validateVectorizedAssetURL('fal', 'http://fal.media/a.svg')).toThrow('untrusted')
})
test('rejects redirects from trusted asset hosts to untrusted hosts', async () => {
let requestCount = 0
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(async () => {
requestCount += 1
if (requestCount === 1) {
return Response.json({ image: { url: 'https://cdn.recraft.ai/result.svg' } })
}
const response = new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
headers: { 'content-type': 'image/svg+xml' }
})
Object.defineProperty(response, 'url', { value: 'https://example.com/result.svg' })
return response
})
try {
await expect(
getVectorizeProvider('recraft').vectorize(new Uint8Array([1, 2, 3]), 'secret')
).rejects.toThrow('untrusted')
expect(requestCount).toBe(2)
} finally {
fetchSpy.mockRestore()
}
})
test('includes vectorization keys in browser persistence changes', () => {
const keys = appCredentialRefs().map(credentialKey)
expect(keys).toContain('v1:vectorize-recraft:default:api-key')
expect(keys).toContain('v1:vectorize-fal:default:api-key')
})
})

View file

@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { svgToVectorPaths } from '@open-pencil/core/vector'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
function vectorizedInset() {
return expectDefined(
svgToVectorPaths(
`<svg viewBox="0 0 100 100"><path d="M25 10 H75 V90 H25 Z" fill="#336699"/></svg>`,
{ width: 200, height: 100 }
),
'vectorized SVG'
)
}
describe('replaceNodeWithVectorFrame', () => {
test('replaces an image node in place and restores it with undo', () => {
const editor = createEditor()
const page = expectDefined(editor.graph.getPages()[0], 'page')
const source = editor.graph.createNode('RECTANGLE', page.id, {
name: 'Photo',
x: 40,
y: 60,
width: 200,
height: 100,
opacity: 0.75,
cornerRadius: 12,
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.3 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true,
blendMode: 'NORMAL'
}
]
})
editor.select([source.id])
const frameId = expectDefined(
editor.replaceNodeWithVectorFrame(source.id, vectorizedInset()),
'replacement frame'
)
const frame = getNodeOrThrow(editor.graph, frameId)
expect(frame.type).toBe('FRAME')
expect(frame).toMatchObject({
x: 90,
y: 70,
width: 100,
height: 80,
opacity: 0.75,
cornerRadius: 12,
clipsContent: true
})
expect(frame.effects).toHaveLength(1)
expect(frame.childIds).toHaveLength(1)
expect(editor.graph.getNode(source.id)).toBeUndefined()
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, source.id).type).toBe('RECTANGLE')
expect(editor.graph.getNode(frameId)).toBeUndefined()
expect([...editor.state.selectedIds]).toEqual([source.id])
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, frameId).type).toBe('FRAME')
expect(editor.graph.getNode(source.id)).toBeUndefined()
})
test('preserves the coordinate box and rotation of rotated images', () => {
const editor = createEditor()
const page = expectDefined(editor.graph.getPages()[0], 'page')
const source = editor.graph.createNode('RECTANGLE', page.id, {
x: 40,
y: 60,
width: 200,
height: 100,
rotation: 30
})
const frameId = expectDefined(
editor.replaceNodeWithVectorFrame(source.id, vectorizedInset()),
'replacement frame'
)
expect(getNodeOrThrow(editor.graph, frameId)).toMatchObject({
x: 40,
y: 60,
width: 200,
height: 100,
rotation: 30
})
})
})

View file

@ -164,11 +164,20 @@ describe('resolveVectorFramePlacement', () => {
test('tightens the replacement frame around inset vector content', () => {
expect(
resolveVectorFramePlacement(
{ x: 10, y: 20, width: 200, height: 100 },
{ 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', () => {