From a6940bcab8f6f19811c4223be35661c607f98492 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 27 Jul 2026 16:22:05 +0300 Subject: [PATCH] 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> --- AGENTS.md | 2 + CHANGELOG.md | 1 + README.md | 1 + desktop/src/http.rs | 41 ++- packages/core/src/editor/create.ts | 5 + packages/core/src/editor/vectorize.ts | 124 ++++++++ .../core/src/vector/vectorize/placement.ts | 8 +- packages/vue/src/i18n/locales/de/dialogs.json | 3 + packages/vue/src/i18n/locales/de/menu.json | 1 + packages/vue/src/i18n/locales/es/dialogs.json | 3 + packages/vue/src/i18n/locales/es/menu.json | 1 + packages/vue/src/i18n/locales/fr/dialogs.json | 3 + packages/vue/src/i18n/locales/fr/menu.json | 1 + packages/vue/src/i18n/locales/it/dialogs.json | 3 + packages/vue/src/i18n/locales/it/menu.json | 1 + packages/vue/src/i18n/locales/ja/dialogs.json | 3 + packages/vue/src/i18n/locales/ja/menu.json | 1 + packages/vue/src/i18n/locales/pl/dialogs.json | 3 + packages/vue/src/i18n/locales/pl/menu.json | 1 + packages/vue/src/i18n/locales/ru/dialogs.json | 3 + packages/vue/src/i18n/locales/ru/menu.json | 1 + .../vue/src/i18n/locales/zh-cn/dialogs.json | 3 + packages/vue/src/i18n/locales/zh-cn/menu.json | 1 + packages/vue/src/i18n/messages/dialogs.ts | 4 + packages/vue/src/i18n/messages/menu.ts | 1 + src/app/editor/canvas/menu/actions.ts | 11 +- src/app/editor/canvas/menu/context.ts | 12 + src/app/editor/canvas/menu/registry.ts | 1 + src/app/editor/vectorize/action.ts | 89 ++++++ src/app/editor/vectorize/credentials.ts | 36 +++ src/app/editor/vectorize/index.ts | 9 + src/app/editor/vectorize/preferences.ts | 17 ++ src/app/editor/vectorize/providers.ts | 276 ++++++++++++++++++ src/app/editor/vectorize/types.ts | 28 ++ src/app/settings/credentials/persistence.ts | 2 + src/app/tauri/http.ts | 20 +- src/components/canvas/CanvasMenu.vue | 2 +- src/components/settings/SettingsDialog.vue | 2 + .../vectorize/VectorizeSettingsSection.vue | 81 +++++ tests/e2e/vectorize/basic.spec.ts | 159 ++++++++++ .../store-path.test.ts} | 0 .../app/editor/vectorize/providers.test.ts | 66 +++++ tests/engine/editor/vectorize.test.ts | 96 ++++++ tests/engine/vector/vectorize.test.ts | 11 +- 44 files changed, 1123 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/editor/vectorize.ts create mode 100644 src/app/editor/vectorize/action.ts create mode 100644 src/app/editor/vectorize/credentials.ts create mode 100644 src/app/editor/vectorize/index.ts create mode 100644 src/app/editor/vectorize/preferences.ts create mode 100644 src/app/editor/vectorize/providers.ts create mode 100644 src/app/editor/vectorize/types.ts create mode 100644 src/components/settings/vectorize/VectorizeSettingsSection.vue create mode 100644 tests/e2e/vectorize/basic.spec.ts rename tests/engine/app/{editor-store-path.test.ts => editor/store-path.test.ts} (100%) create mode 100644 tests/engine/app/editor/vectorize/providers.test.ts create mode 100644 tests/engine/editor/vectorize.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6672cf20c..b078991f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2634ac3..142564984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 8fcc19daf..704fc559c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/desktop/src/http.rs b/desktop/src/http.rs index b0462cb7c..8d5cb2127 100644 --- a/desktop/src/http.rs +++ b/desktop/src/http.rs @@ -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, headers: Option>, body: Option>, + max_response_bytes: Option, + follow_redirects: Option, + timeout_ms: Option, } #[derive(Serialize)] @@ -23,6 +27,7 @@ pub struct ProxyHttpResponse { status: u16, headers: Vec, body: Vec, + url: String, } fn request_headers(headers: Option>) -> HeaderMap { @@ -51,10 +56,16 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result Result Result 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, }) } diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 8575cf8b4..7def31343 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -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, diff --git a/packages/core/src/editor/vectorize.ts b/packages/core/src/editor/vectorize.ts new file mode 100644 index 000000000..8f9d23a3a --- /dev/null +++ b/packages/core/src/editor/vectorize.ts @@ -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 } +} diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 289261116..145584f10 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -14,8 +14,12 @@ export interface VectorFramePlacement { offsetY: number } -function shouldTightenToContent(node: Pick, content: Rect): boolean { +function shouldTightenToContent( + node: Pick, + content: Rect +): boolean { return ( + node.rotation === 0 && content.width > 0 && content.height > 0 && (content.x > 0 || @@ -26,7 +30,7 @@ function shouldTightenToContent(node: Pick, conte } export function resolveVectorFramePlacement( - node: Pick, + node: Pick, content: Rect ): VectorFramePlacement { const tighten = shouldTightenToContent(node, content) diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 564378440..9728460d1 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/de/menu.json b/packages/vue/src/i18n/locales/de/menu.json index b84d79382..2735d91bb 100644 --- a/packages/vue/src/i18n/locales/de/menu.json +++ b/packages/vue/src/i18n/locales/de/menu.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 25c9e5f69..4cdeec3cd 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/menu.json b/packages/vue/src/i18n/locales/es/menu.json index 338b84ac0..f0d0c5f74 100644 --- a/packages/vue/src/i18n/locales/es/menu.json +++ b/packages/vue/src/i18n/locales/es/menu.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 2091bb83e..5a9b8de0b 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -98,6 +98,9 @@ "noModel": "Aucun", "back": "Retour", "settingsMedia": "Médias", + "vectorization": "Vectorisation d’image", + "vectorizationDescription": "Envoie les calques d’image à Recraft ou fal.ai et renvoie des vecteurs modifiables. Des frais peuvent s’appliquer.", + "vectorizeProvider": "Service de vectorisation", "settingsStorage": "Stockage cloud", "storageWorkspace": "Espace de stockage", "openStorageWorkspace": "Ouvrir l’espace", diff --git a/packages/vue/src/i18n/locales/fr/menu.json b/packages/vue/src/i18n/locales/fr/menu.json index 6d2b31849..0665f33b5 100644 --- a/packages/vue/src/i18n/locales/fr/menu.json +++ b/packages/vue/src/i18n/locales/fr/menu.json @@ -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é", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index b9a7218eb..207f8c1dc 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/it/menu.json b/packages/vue/src/i18n/locales/it/menu.json index 72d292047..49391f4e9 100644 --- a/packages/vue/src/i18n/locales/it/menu.json +++ b/packages/vue/src/i18n/locales/it/menu.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index ff2922486..c61c4c2c3 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -98,6 +98,9 @@ "noModel": "なし", "back": "戻る", "settingsMedia": "メディア", + "vectorization": "画像のベクター化", + "vectorizationDescription": "画像レイヤーを Recraft または fal.ai に送信し、編集可能なベクターを返します。プロバイダー料金が発生する場合があります。", + "vectorizeProvider": "ベクター化サービス", "settingsStorage": "クラウドストレージ", "storageWorkspace": "ストレージワークスペース", "openStorageWorkspace": "ワークスペースを開く", diff --git a/packages/vue/src/i18n/locales/ja/menu.json b/packages/vue/src/i18n/locales/ja/menu.json index 167ca4cbe..4d163b5fe 100644 --- a/packages/vue/src/i18n/locales/ja/menu.json +++ b/packages/vue/src/i18n/locales/ja/menu.json @@ -42,6 +42,7 @@ "copyAsJSX": "JSXとしてコピー", "copyNodeId": "ノードIDをコピー", "copyXPath": "XPathをコピー", + "convertToVector": "ベクターに変換", "checkUpdates": "アップデートを確認…", "booleanOperations": "パスの型抜き (ブーリアン演算)", "closeTab": "タブを閉じる", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index b386afd57..a2714daf9 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/pl/menu.json b/packages/vue/src/i18n/locales/pl/menu.json index 1b123a4aa..953ed6a85 100644 --- a/packages/vue/src/i18n/locales/pl/menu.json +++ b/packages/vue/src/i18n/locales/pl/menu.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index f264c58be..15bd29a98 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -98,6 +98,9 @@ "noModel": "Нет", "back": "Назад", "settingsMedia": "Медиа", + "vectorization": "Векторизация изображений", + "vectorizationDescription": "Отправляет слои изображений в Recraft или fal.ai и возвращает редактируемые векторы. Может взиматься плата провайдера.", + "vectorizeProvider": "Сервис векторизации", "settingsStorage": "Облачное хранилище", "storageWorkspace": "Рабочая область хранилища", "openStorageWorkspace": "Открыть область", diff --git a/packages/vue/src/i18n/locales/ru/menu.json b/packages/vue/src/i18n/locales/ru/menu.json index 124a73f0f..59518e260 100644 --- a/packages/vue/src/i18n/locales/ru/menu.json +++ b/packages/vue/src/i18n/locales/ru/menu.json @@ -43,6 +43,7 @@ "copyAsJSX": "Копировать как JSX", "copyNodeId": "Копировать ID узла", "copyXPath": "Копировать XPath", + "convertToVector": "Преобразовать в вектор", "checkUpdates": "Проверить обновления…", "booleanOperations": "Логические операции", "strikethrough": "Зачёркивание", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index a9f6e9e60..b4f714a62 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -98,6 +98,9 @@ "noModel": "无", "back": "返回", "settingsMedia": "媒体", + "vectorization": "图像矢量化", + "vectorizationDescription": "将图像图层发送到 Recraft 或 fal.ai,并返回可编辑的矢量。服务商可能收费。", + "vectorizeProvider": "矢量化服务", "settingsStorage": "云存储", "storageWorkspace": "存储工作区", "openStorageWorkspace": "打开工作区", diff --git a/packages/vue/src/i18n/locales/zh-cn/menu.json b/packages/vue/src/i18n/locales/zh-cn/menu.json index 6e049e5f9..c8854d878 100644 --- a/packages/vue/src/i18n/locales/zh-cn/menu.json +++ b/packages/vue/src/i18n/locales/zh-cn/menu.json @@ -43,6 +43,7 @@ "copyAsJSX": "复制为 JSX", "copyNodeId": "复制节点 ID", "copyXPath": "复制 XPath", + "convertToVector": "转换为矢量", "checkUpdates": "检查更新…", "booleanOperations": "布尔运算", "strikethrough": "删除线", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 5c22f6564..ae9afd123 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -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', diff --git a/packages/vue/src/i18n/messages/menu.ts b/packages/vue/src/i18n/messages/menu.ts index 63913b8a3..3f3999d39 100644 --- a/packages/vue/src/i18n/messages/menu.ts +++ b/packages/vue/src/i18n/messages/menu.ts @@ -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', diff --git a/src/app/editor/canvas/menu/actions.ts b/src/app/editor/canvas/menu/actions.ts index 7710d7331..a29290fa7 100644 --- a/src/app/editor/canvas/menu/actions.ts +++ b/src/app/editor/canvas/menu/actions.ts @@ -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 { + void selectedIds.value + return canVectorizeImageNode(store) + }, + vectorizeImage: async () => { + const nodeId = ids()[0] + if (nodeId) await vectorizeImageNode(store, nodeId) + } } } diff --git a/src/app/editor/canvas/menu/context.ts b/src/app/editor/canvas/menu/context.ts index 1333e68ac..7f465b5e3 100644 --- a/src/app/editor/canvas/menu/context.ts +++ b/src/app/editor/canvas/menu/context.ts @@ -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(() => { 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)] }) } diff --git a/src/app/editor/canvas/menu/registry.ts b/src/app/editor/canvas/menu/registry.ts index 9c0424f38..d4c9fe7b0 100644 --- a/src/app/editor/canvas/menu/registry.ts +++ b/src/app/editor/canvas/menu/registry.ts @@ -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 = [ diff --git a/src/app/editor/vectorize/action.ts b/src/app/editor/vectorize/action.ts new file mode 100644 index 000000000..83ff24952 --- /dev/null +++ b/src/app/editor/vectorize/action.ts @@ -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() +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 { + 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) + } +} diff --git a/src/app/editor/vectorize/credentials.ts b/src/app/editor/vectorize/credentials.ts new file mode 100644 index 000000000..b9f1ce56e --- /dev/null +++ b/src/app/editor/vectorize/credentials.ts @@ -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 { + return appCredentialServices.manager.status(vectorizeCredentialRef(providerID)) +} + +export async function setVectorizeCredential( + providerID: VectorizeProviderID, + value: string +): Promise { + 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 { + return appCredentialServices.resolver.resolve(vectorizeCredentialRef(providerID)) +} diff --git a/src/app/editor/vectorize/index.ts b/src/app/editor/vectorize/index.ts new file mode 100644 index 000000000..3f3653ff8 --- /dev/null +++ b/src/app/editor/vectorize/index.ts @@ -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' diff --git a/src/app/editor/vectorize/preferences.ts b/src/app/editor/vectorize/preferences.ts new file mode 100644 index 000000000..554996ff4 --- /dev/null +++ b/src/app/editor/vectorize/preferences.ts @@ -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('open-pencil:vectorize:provider', 'recraft') + +function isVectorizeProviderID(value: string): value is VectorizeProviderID { + return VECTORIZE_PROVIDER_IDS.some((providerID) => providerID === value) +} + +export const vectorizeProviderID = computed({ + get: () => (isVectorizeProviderID(storedProvider.value) ? storedProvider.value : 'recraft'), + set: (providerID) => { + storedProvider.value = providerID + } +}) diff --git a/src/app/editor/vectorize/providers.ts b/src/app/editor/vectorize/providers.ts new file mode 100644 index 000000000..5552e0788 --- /dev/null +++ b/src/app/editor/vectorize/providers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = { recraft, fal } + +export function getVectorizeProvider(providerID: VectorizeProviderID): VectorizeProvider { + return providers[providerID] +} diff --git a/src/app/editor/vectorize/types.ts b/src/app/editor/vectorize/types.ts new file mode 100644 index 000000000..410681137 --- /dev/null +++ b/src/app/editor/vectorize/types.ts @@ -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 +} + +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' + } +} diff --git a/src/app/settings/credentials/persistence.ts b/src/app/settings/credentials/persistence.ts index ddcc55414..6b60deb74 100644 --- a/src/app/settings/credentials/persistence.ts +++ b/src/app/settings/credentials/persistence.ts @@ -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 ]) } diff --git a/src/app/tauri/http.ts b/src/app/tauri/http.ts index f3b15df11..276f0605b 100644 --- a/src/app/tauri/http.ts +++ b/src/app/tauri/http.ts @@ -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(promise: Promise, signal: AbortSignal): Pr }) } -export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise { +export async function tauriFetch( + input: RequestInfo | URL, + init?: RequestInit, + maxResponseBytes?: number, + timeoutMs?: number +): Promise { 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('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 } diff --git a/src/components/canvas/CanvasMenu.vue b/src/components/canvas/CanvasMenu.vue index cce1cb71b..16d601ce3 100644 --- a/src/components/canvas/CanvasMenu.vue +++ b/src/components/canvas/CanvasMenu.vue @@ -167,7 +167,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi

{{ dialogs.settingsMedia }}

+ diff --git a/src/components/settings/vectorize/VectorizeSettingsSection.vue b/src/components/settings/vectorize/VectorizeSettingsSection.vue new file mode 100644 index 000000000..cda01dde0 --- /dev/null +++ b/src/components/settings/vectorize/VectorizeSettingsSection.vue @@ -0,0 +1,81 @@ + + + diff --git a/tests/e2e/vectorize/basic.spec.ts b/tests/e2e/vectorize/basic.spec.ts new file mode 100644 index 000000000..ecdbea1ea --- /dev/null +++ b/tests/e2e/vectorize/basic.spec.ts @@ -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 { + 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 { + 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((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() +}) diff --git a/tests/engine/app/editor-store-path.test.ts b/tests/engine/app/editor/store-path.test.ts similarity index 100% rename from tests/engine/app/editor-store-path.test.ts rename to tests/engine/app/editor/store-path.test.ts diff --git a/tests/engine/app/editor/vectorize/providers.test.ts b/tests/engine/app/editor/vectorize/providers.test.ts new file mode 100644 index 000000000..ad6051601 --- /dev/null +++ b/tests/engine/app/editor/vectorize/providers.test.ts @@ -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('', { + 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') + }) +}) diff --git a/tests/engine/editor/vectorize.test.ts b/tests/engine/editor/vectorize.test.ts new file mode 100644 index 000000000..6a0d5f23a --- /dev/null +++ b/tests/engine/editor/vectorize.test.ts @@ -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( + ``, + { 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 + }) + }) +}) diff --git a/tests/engine/vector/vectorize.test.ts b/tests/engine/vector/vectorize.test.ts index e639db5bc..f837d7e84 100644 --- a/tests/engine/vector/vectorize.test.ts +++ b/tests/engine/vector/vectorize.test.ts @@ -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', () => {