fix(canvas): import dropped SVG files

- Parse dropped SVGs through the shared XML-backed import pipeline
- Preserve transforms, gradients, viewport alignment, and reusable shapes
- Place mixed image drops in one ordered undoable transaction
- Cover file filtering, atomic placement, undo, and browser drops

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-29 15:20:06 +03:00
parent cb2c4aa3d3
commit 7e8feb998a
19 changed files with 754 additions and 296 deletions

View file

@ -22,6 +22,7 @@
- Find overlapping layers and overflowing children from the CLI, AI tools, and MCP.
- Use Figma-style number-key opacity shortcuts: `1``9` set 10%90%, `0` sets 100%, and two-digit sequences set exact values.
- Drag image files directly into the desktop app and paste Figma layers with their remote image fills.
- Drop SVG files onto the canvas to import them as editable vector layers alongside raster images. (#392)
- Drag with the Text tool to create a fixed-size text box, or click to create auto-width text.
- Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents.
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.

View file

@ -12,6 +12,7 @@ export function createClipboardBridge(clipboard: ClipboardActions, selection: Se
pasteFromHTML: clipboard.pasteFromHTML,
deleteSelected: clipboard.deleteSelected,
storeImage: clipboard.storeImage,
placeFiles: clipboard.placeFiles,
placeImageFiles: clipboard.placeImageFiles,
loadFontsForNodes: clipboard.loadFontsForNodes,
copySelectionAsText: clipboard.copySelectionAsText,

View file

@ -8,11 +8,11 @@ import {
} from '#core/clipboard'
import { computeAllLayouts } from '#core/layout'
import { createClipboardAssetActions } from './clipboard/assets'
import { createClipboardCopyActions } from './clipboard/copy'
import { createClipboardExportActions } from './clipboard/export'
import { createClipboardFontActions } from './clipboard/fonts'
import { deleteIds, recreateSnapshots, restoreDeletedEntries } from './clipboard/history'
import { createClipboardImageActions } from './clipboard/images'
import { replaceTargetsWithCreated, selectedReplacementTargets } from './clipboard/paste-replace'
import { resolvePasteTarget } from './clipboard/paste-target'
import { createClipboardPlacementActions } from './clipboard/placement'
@ -66,11 +66,11 @@ export function createClipboardActions(ctx: EditorContext) {
}
}
function pushPasteUndo(created: string[], prevSelection: Set<string>) {
function pushCreatedNodesUndo(created: string[], prevSelection: Set<string>, label = 'Paste') {
const allNodes = collectSubtrees(ctx.graph, created)
const pageId = ctx.state.currentPageId
ctx.undo.push({
label: 'Paste',
label,
forward: () => {
recreateSnapshots(ctx, allNodes, pageId)
computeAllLayouts(ctx.graph, pageId)
@ -115,7 +115,7 @@ export function createClipboardActions(ctx: EditorContext) {
placementActions.centerNodesAt(created, cx, cy)
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
pushPasteUndo(created, prevSelection)
pushCreatedNodesUndo(created, prevSelection)
}
await Promise.all([
@ -168,7 +168,7 @@ export function createClipboardActions(ctx: EditorContext) {
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
pushPasteUndo(created, prevSelection)
pushCreatedNodesUndo(created, prevSelection)
return created
}
@ -261,7 +261,7 @@ export function createClipboardActions(ctx: EditorContext) {
const copyActions = createClipboardCopyActions(ctx)
const exportActions = createClipboardExportActions(ctx)
const fontActions = createClipboardFontActions(ctx)
const imageActions = createClipboardImageActions(ctx)
const assetActions = createClipboardAssetActions(ctx, pushCreatedNodesUndo)
const placementActions = createClipboardPlacementActions(ctx)
return {
@ -273,7 +273,7 @@ export function createClipboardActions(ctx: EditorContext) {
pasteFromHTML,
warnMissingImages,
deleteSelected,
...imageActions,
...assetActions,
...exportActions
}
}

View file

@ -0,0 +1,184 @@
import type { Fill } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { TRANSPARENT } from '#core/constants'
import { resolvePasteTarget } from '#core/editor/clipboard/paste-target'
import type { EditorContext } from '#core/editor/types'
import { computeImageHash } from '#core/figma-api'
import {
createSVGNodesFromImport,
prepareSVGImport,
type SVGImportData
} from '#core/io/formats/svg'
import { computeAllLayouts } from '#core/layout'
const IMAGE_MAX_DIMENSION = 4096
const ASSET_GAP = 20
const RASTER_IMAGE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/avif'
])
interface PreparedRasterAsset {
kind: 'raster'
bytes: Uint8Array
name: string
width: number
height: number
}
interface PreparedSVGAsset {
kind: 'svg'
data: SVGImportData
name: string
width: number
height: number
}
type PreparedAsset = PreparedRasterAsset | PreparedSVGAsset
type PushCreatedNodesUndo = (
created: string[],
previousSelection: Set<string>,
label?: string
) => void
function isSVGFile(file: Pick<File, 'name' | 'type'>): boolean {
return (
file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg'))
)
}
export function createClipboardAssetActions(
ctx: EditorContext,
pushCreatedNodesUndo: PushCreatedNodesUndo
) {
function storeImage(bytes: Uint8Array): string {
const hash = computeImageHash(bytes)
ctx.graph.images.set(hash, bytes)
return hash
}
function decodeImageDimensions(bytes: Uint8Array): { width: number; height: number } | null {
const ck = ctx.getCk()
if (!ck) return null
const skImg = ck.MakeImageFromEncoded(bytes)
if (!skImg) return null
let width = skImg.width()
let height = skImg.height()
skImg.delete()
if (width > IMAGE_MAX_DIMENSION || height > IMAGE_MAX_DIMENSION) {
const ratio = Math.min(IMAGE_MAX_DIMENSION / width, IMAGE_MAX_DIMENSION / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
return { width, height }
}
async function prepareAsset(file: File): Promise<PreparedAsset | null> {
if (isSVGFile(file)) {
const data = prepareSVGImport(await file.text())
return data
? {
kind: 'svg',
data,
name: file.name.replace(/\.svg$/i, '') || 'SVG',
width: data.width,
height: data.height
}
: null
}
if (!RASTER_IMAGE_TYPES.has(file.type)) return null
const bytes = new Uint8Array(await file.arrayBuffer())
const dimensions = decodeImageDimensions(bytes)
return dimensions ? { kind: 'raster', bytes, name: file.name, ...dimensions } : null
}
function parentLocalPoint(parentId: string, x: number, y: number) {
const parent = ctx.graph.getNode(parentId)
if (!parent) return { x, y }
const inverse = Matrix.invert(getWorldMatrix(parent, ctx.graph))
return inverse ? Matrix.mapPoint(inverse, { x, y }) : { x, y }
}
function createRasterNode(
asset: PreparedRasterAsset,
parentId: string,
x: number,
y: number
): string {
const hash = storeImage(asset.bytes)
const fill: Fill = {
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
return ctx.graph.createNode('RECTANGLE', parentId, {
name: asset.name.replace(/\.[^.]+$/, ''),
x,
y,
width: asset.width,
height: asset.height,
fills: [fill]
}).id
}
async function placeFiles(files: File[], cx: number, cy: number) {
const prepared = (await Promise.all(files.map(prepareAsset))).filter(
(asset): asset is PreparedAsset => asset !== null
)
if (prepared.length === 0) return
const previousSelection = new Set(ctx.state.selectedIds)
const parentId = resolvePasteTarget(ctx)
const center = parentLocalPoint(parentId, cx, cy)
const totalWidth =
prepared.reduce((total, asset) => total + asset.width, 0) + ASSET_GAP * (prepared.length - 1)
const maxHeight = Math.max(...prepared.map((asset) => asset.height))
let x = center.x - totalWidth / 2
const y = center.y - maxHeight / 2
const created: string[] = []
try {
for (const asset of prepared) {
const id =
asset.kind === 'raster'
? createRasterNode(asset, parentId, x, y)
: createSVGNodesFromImport(ctx.graph, parentId, asset.data, {
name: asset.name,
x,
y
})?.id
if (id) created.push(id)
x += asset.width + ASSET_GAP
}
} catch (error) {
for (const id of created.reverse()) ctx.graph.deleteNode(id)
throw error
}
if (created.length === 0) return
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
pushCreatedNodesUndo(created, previousSelection, 'Place files')
ctx.requestRender()
}
function placeImageFiles(files: File[], cx: number, cy: number) {
return placeFiles(
files.filter((file) => RASTER_IMAGE_TYPES.has(file.type)),
cx,
cy
)
}
return { storeImage, placeFiles, placeImageFiles }
}

View file

@ -1,109 +0,0 @@
import type { Fill } from '@open-pencil/scene-graph'
import { TRANSPARENT } from '#core/constants'
import { resolvePasteTarget } from '#core/editor/clipboard/paste-target'
import type { EditorContext } from '#core/editor/types'
import { computeImageHash } from '#core/figma-api'
const IMAGE_MAX_DIMENSION = 4096
const IMAGE_GAP = 20
export function createClipboardImageActions(ctx: EditorContext) {
function storeImage(bytes: Uint8Array): string {
const hash = computeImageHash(bytes)
ctx.graph.images.set(hash, bytes)
return hash
}
function decodeImageDimensions(bytes: Uint8Array): { w: number; h: number } | null {
const ck = ctx.getCk()
if (!ck) return null
const skImg = ck.MakeImageFromEncoded(bytes)
if (!skImg) return null
let w = skImg.width()
let h = skImg.height()
skImg.delete()
if (w > IMAGE_MAX_DIMENSION || h > IMAGE_MAX_DIMENSION) {
const ratio = Math.min(IMAGE_MAX_DIMENSION / w, IMAGE_MAX_DIMENSION / h)
w = Math.round(w * ratio)
h = Math.round(h * ratio)
}
return { w, h }
}
function placeImageNode(
bytes: Uint8Array,
x: number,
y: number,
w: number,
h: number,
name = 'Image'
): string | null {
const hash = storeImage(bytes)
const displayName = name.replace(/\.[^.]+$/, '')
const pid = resolvePasteTarget(ctx)
const fill: Fill = {
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
const node = ctx.graph.createNode('RECTANGLE', pid, {
name: displayName,
x,
y,
width: w,
height: h,
fills: [fill]
})
const id = node.id
const snapshot = { ...node }
ctx.undo.push({
label: 'Place image',
forward: () => {
ctx.graph.images.set(hash, bytes)
ctx.graph.createNode(snapshot.type, pid, snapshot)
},
inverse: () => {
ctx.graph.deleteNode(id)
ctx.graph.images.delete(hash)
const next = new Set(ctx.state.selectedIds)
next.delete(id)
ctx.setSelectedIds(next)
}
})
return id
}
async function placeImageFiles(files: File[], cx: number, cy: number) {
const prepared: Array<{ bytes: Uint8Array; name: string; w: number; h: number }> = []
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer())
const dims = decodeImageDimensions(bytes)
if (dims) prepared.push({ bytes, name: file.name, ...dims })
}
if (!prepared.length) return
let totalW = 0
for (const p of prepared) totalW += p.w
totalW += IMAGE_GAP * (prepared.length - 1)
const maxH = Math.max(...prepared.map((p) => p.h))
let curX = cx - totalW / 2
const topY = cy - maxH / 2
const ids: string[] = []
for (const p of prepared) {
const id = placeImageNode(p.bytes, curX, topY, p.w, p.h, p.name)
if (id) ids.push(id)
curX += p.w + IMAGE_GAP
}
if (ids.length) {
ctx.setSelectedIds(new Set(ids))
ctx.requestRender()
}
}
return { storeImage, placeImageFiles }
}

View file

@ -33,21 +33,42 @@ function isElement(node: Node): node is Element {
return node.nodeType === node.ELEMENT_NODE
}
function inheritedAttribute(element: Element, name: string, inherited: string): string {
return element.hasAttribute(name) ? (element.getAttribute(name) ?? inherited) : inherited
function inlineStyles(element: Element): ReadonlyMap<string, string> {
const styles = new Map<string, string>()
for (const declaration of (element.getAttribute('style') ?? '').split(';')) {
const separator = declaration.indexOf(':')
if (separator <= 0) continue
const name = declaration.slice(0, separator).trim()
const value = declaration.slice(separator + 1).trim()
if (name && value) styles.set(name, value)
}
return styles
}
function inheritedAttribute(
element: Element,
styles: ReadonlyMap<string, string>,
name: string,
inherited: string
): string {
return (
styles.get(name) ??
(element.hasAttribute(name) ? (element.getAttribute(name) ?? inherited) : inherited)
)
}
function presentationFor(
element: Element,
inherited: PresentationAttributes
): PresentationAttributes {
const styles = inlineStyles(element)
return {
fill: inheritedAttribute(element, 'fill', inherited.fill),
stroke: inheritedAttribute(element, 'stroke', inherited.stroke),
strokeWidth: inheritedAttribute(element, 'stroke-width', inherited.strokeWidth),
strokeCap: inheritedAttribute(element, 'stroke-linecap', inherited.strokeCap),
strokeJoin: inheritedAttribute(element, 'stroke-linejoin', inherited.strokeJoin),
fillRule: inheritedAttribute(element, 'fill-rule', inherited.fillRule)
fill: inheritedAttribute(element, styles, 'fill', inherited.fill),
stroke: inheritedAttribute(element, styles, 'stroke', inherited.stroke),
strokeWidth: inheritedAttribute(element, styles, 'stroke-width', inherited.strokeWidth),
strokeCap: inheritedAttribute(element, styles, 'stroke-linecap', inherited.strokeCap),
strokeJoin: inheritedAttribute(element, styles, 'stroke-linejoin', inherited.strokeJoin),
fillRule: inheritedAttribute(element, styles, 'fill-rule', inherited.fillRule)
}
}
@ -133,20 +154,16 @@ function combinedTransform(parent: string | null, element: Element): string | nu
return current ?? parent
}
function collectPaths(
function appendShapePath(
tagName: string,
element: Element,
inherited: PresentationAttributes,
parentTransform: string | null,
presentation: PresentationAttributes,
transform: string | null,
result: IconPathInfo[]
): void {
const tagName = element.localName || element.tagName
if (NON_RENDERED_CONTAINERS.has(tagName)) return
const presentation = presentationFor(element, inherited)
const transform = combinedTransform(parentTransform, element)
if (SHAPE_NAMES.has(tagName)) {
if (!SHAPE_NAMES.has(tagName)) return
const pathData = tagName === 'path' ? element.getAttribute('d') : shapeToD(tagName, element)
if (pathData) {
if (!pathData) return
const strokeWidth = Number.parseFloat(presentation.strokeWidth)
result.push({
d: pathData,
@ -158,19 +175,72 @@ function collectPaths(
fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO',
transform
})
}
function collectUsePaths(
element: Element,
presentation: PresentationAttributes,
transform: string | null,
result: IconPathInfo[],
elementsById: ReadonlyMap<string, Element>,
useStack: ReadonlySet<Element>
): boolean {
const tagName = element.localName || element.tagName
if (tagName !== 'use') return false
const x = num(element, 'x')
const y = num(element, 'y')
const useTransform =
x !== 0 || y !== 0 ? `${transform ?? ''} translate(${x} ${y})`.trim() : transform
const href = element.getAttribute('href') ?? element.getAttribute('xlink:href')
const target = href?.startsWith('#') ? elementsById.get(href.slice(1)) : null
if (target && !useStack.has(target)) {
collectPaths(
target,
presentation,
useTransform,
result,
elementsById,
new Set([...useStack, target]),
true
)
}
}
return true
}
function collectPaths(
element: Element,
inherited: PresentationAttributes,
parentTransform: string | null,
result: IconPathInfo[],
elementsById: ReadonlyMap<string, Element>,
useStack: ReadonlySet<Element> = new Set(),
referenced = false
): void {
const tagName = element.localName || element.tagName
if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return
const presentation = presentationFor(element, inherited)
const transform = combinedTransform(parentTransform, element)
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return
appendShapePath(tagName, element, presentation, transform, result)
for (const child of Array.from(element.childNodes)) {
if (isElement(child)) collectPaths(child, presentation, transform, result)
if (isElement(child)) {
collectPaths(child, presentation, transform, result, elementsById, useStack, referenced)
}
}
}
export function extractPaths(svgBody: string): IconPathInfo[] {
const root = parseSVGFragment(svgBody)?.documentElement
if (!root) return []
const elementsById = new Map<string, Element>()
for (const element of Array.from(root.getElementsByTagName('*'))) {
const id = element.getAttribute('id')
if (id) elementsById.set(id, element)
}
const result: IconPathInfo[] = []
collectPaths(root, DEFAULT_PRESENTATION, null, result)
collectPaths(root, DEFAULT_PRESENTATION, null, result, elementsById)
return result
}

View file

@ -0,0 +1,75 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Size } from '@open-pencil/scene-graph/primitives'
import { createVectorFrameChildren } from '#core/vector/vectorize/placement'
import { svgToVectorPaths, type SVGVectorizeResult } from '#core/vector/vectorize/svg/to-vectors'
import { parseSVGSize } from './metadata'
export type SVGImportData = SVGVectorizeResult & Size
export interface SVGImportOptions {
name?: string
defaultColor?: string
x?: number
y?: number
}
export function prepareSVGImport(
source: string,
options: Pick<SVGImportOptions, 'defaultColor'> = {}
): SVGImportData | null {
const { width, height } = parseSVGSize(source)
const vectorized = svgToVectorPaths(
source,
{ width, height },
{
defaultColor: options.defaultColor,
preserveAspectRatio: true
}
)
return vectorized ? { width, height, ...vectorized } : null
}
export function createSVGNodesFromImport(
graph: SceneGraph,
parentId: string,
data: SVGImportData,
options: SVGImportOptions = {}
): SceneNode | null {
const frame = graph.createNode('FRAME', parentId, {
name: options.name ?? 'SVG',
x: options.x ?? 0,
y: options.y ?? 0,
width: data.width,
height: data.height,
fills: []
})
try {
createVectorFrameChildren(graph, frame.id, data, {
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
offsetX: 0,
offsetY: 0
})
if (graph.getChildren(frame.id).length > 0) return frame
graph.deleteNode(frame.id)
return null
} catch (error) {
graph.deleteNode(frame.id)
throw error
}
}
export function createSVGNodes(
graph: SceneGraph,
parentId: string,
source: string,
options: SVGImportOptions = {}
): SceneNode | null {
const data = prepareSVGImport(source, options)
return data ? createSVGNodesFromImport(graph, parentId, data, options) : null
}

View file

@ -1 +1,8 @@
export { renderNodesToSVG, geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './export'
export {
createSVGNodes,
createSVGNodesFromImport,
prepareSVGImport,
type SVGImportData,
type SVGImportOptions
} from './import'

View file

@ -23,7 +23,16 @@ export {
type RasterExportFormat,
type ExportFormat
} from './formats/raster'
export { renderNodesToSVG, geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './formats/svg'
export {
createSVGNodes,
createSVGNodesFromImport,
prepareSVGImport,
renderNodesToSVG,
geometryBlobToSVGPath,
vectorNetworkToSVGPaths,
type SVGImportData,
type SVGImportOptions
} from './formats/svg'
export {
renderNodesToPPTX,
type PPTXExportOptions,

View file

@ -1,61 +1,11 @@
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
import { parseColor } from '#core/color'
import { createPathStroke } from '#core/icons/path-style'
import { extractPaths } from '#core/icons/svg'
import type { IconPathInfo } from '#core/icons/types'
import { parseSVGSize } from '#core/io/formats/svg/metadata'
import { createSVGNodes } from '#core/io/formats/svg'
import { defineTool } from '#core/tools/schema'
function createVectorFromPath(
figma: Parameters<Parameters<typeof defineTool>[0]['execute']>[0],
path: IconPathInfo,
width: number,
height: number,
parentId: string,
defaultColor: string
) {
const vectorNetwork = parseSVGPath(path.d, path.fillRule)
const vector = figma.graph.createNode('VECTOR', parentId, {
name: 'path',
width,
height,
vectorNetwork
})
vector.x = 0
vector.y = 0
if (path.fill && path.fill !== 'none') {
const fillColor =
path.fill === 'currentColor' ? parseColor(defaultColor) : parseColor(path.fill)
figma.graph.updateNode(vector.id, {
fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }]
})
} else if (path.fill === null && !path.stroke) {
const fillColor = parseColor(defaultColor)
figma.graph.updateNode(vector.id, {
fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }]
})
} else {
figma.graph.updateNode(vector.id, { fills: [] })
}
if (path.stroke && path.stroke !== 'none') {
const strokeColor =
path.stroke === 'currentColor' ? parseColor(defaultColor) : parseColor(path.stroke)
figma.graph.updateNode(vector.id, {
strokes: [createPathStroke(strokeColor, path.strokeWidth, path.strokeCap, path.strokeJoin)]
})
}
return vector
}
export const importSvg = defineTool({
name: 'import_svg',
mutates: true,
description:
'Import raw SVG markup onto the canvas. Parses <path>, <circle>, <ellipse>, <rect>, <line>, <polygon>, <polyline> elements and creates vector nodes. Supports fill, stroke, stroke-width, viewBox sizing.',
'Import raw SVG markup onto the canvas as editable vector nodes. Supports common SVG shapes, inherited presentation attributes, transforms, gradients, and internal <use> references.',
params: {
svg: {
type: 'string',
@ -72,30 +22,15 @@ export const importSvg = defineTool({
y: { type: 'number', description: 'Y position' }
},
execute: async (figma, args) => {
const svg = args.svg
if (!svg || typeof svg !== 'string') return { error: 'svg parameter is required' }
if (!args.svg || typeof args.svg !== 'string') return { error: 'svg parameter is required' }
const paths = extractPaths(svg)
if (paths.length === 0) return { error: 'No supported SVG elements found in the markup' }
const { width, height } = parseSVGSize(svg)
const defaultColor = args.color ?? '#000000'
const parentId = args.parent_id ?? figma.currentPage.id
const frame = figma.graph.createNode('FRAME', parentId, {
name: args.name ?? 'SVG',
width,
height,
fills: []
const frame = createSVGNodes(figma.graph, args.parent_id ?? figma.currentPage.id, args.svg, {
name: args.name,
defaultColor: args.color,
x: args.x,
y: args.y
})
if (args.x !== undefined) frame.x = args.x
if (args.y !== undefined) frame.y = args.y
for (const path of paths) {
createVectorFromPath(figma, path, width, height, frame.id, defaultColor)
}
if (!frame) return { error: 'No supported SVG elements found in the markup' }
return { id: frame.id, name: frame.name, type: frame.type }
}
})

View file

@ -68,8 +68,8 @@ function normalizeVectorToNodeBounds(network: VectorNetwork): {
network: VectorNetwork
bounds: Rect
} | null {
if (network.vertices.length === 0) return null
const bounds = computeAccurateBounds(network)
if (bounds.width <= 0 || bounds.height <= 0) return null
return {
bounds,

View file

@ -9,14 +9,14 @@
* normalize into each node's bounding box (objectBoundingBox) space, matching the
* gradientTransform convention used by the SVG exporter (see io/formats/svg/defs).
*/
import svgpath from 'svgpath'
import type { Fill, GradientStop } from '@open-pencil/scene-graph'
import type { Color, Matrix, Rect, Size, Vector } from '@open-pencil/scene-graph/primitives'
import type { Color, Matrix, Rect, Vector } from '@open-pencil/scene-graph/primitives'
import { parseColor } from '#core/color'
import { parseSVGDocument } from '#core/io/formats/svg/document'
import { mapSVGPointToViewport, type SVGViewportMapping } from './transform'
interface RawStop {
offset: number
color: Color
@ -119,33 +119,6 @@ function gradientIdFromFill(fill: string | null): string | null {
return id && !id.includes(' ') ? id : null
}
/**
* Map a userSpaceOnUse point through the same element-transform + viewBoxbounds
* pipeline applied to the path data, yielding bounds-pixel-space coordinates.
*/
function mapUserPoint(
x: number,
y: number,
elementTransform: string | null,
gradientTransform: string | null,
space: Rect,
bounds: Size
): Vector {
const sx = bounds.width / space.width
const sy = bounds.height / space.height
let sp = svgpath(`M${x} ${y}`)
if (gradientTransform) sp = sp.transform(gradientTransform)
if (elementTransform) sp = sp.transform(elementTransform)
sp = sp.translate(-space.x, -space.y).scale(sx, sy)
const points: Vector[] = []
sp.abs().iterate((segment) => {
if (points.length === 0 && segment[0] === 'M') {
points.push({ x: segment[1], y: segment[2] })
}
})
return points[0] ?? { x, y }
}
function gradientStops(stops: RawStop[]): GradientStop[] {
return stops.map((s) => ({ color: s.color, position: s.offset }))
}
@ -159,8 +132,7 @@ export function resolveGradientFill(
fillRef: string | null,
gradients: Map<string, ParsedGradient>,
elementTransform: string | null,
space: Rect,
bounds: Size,
viewport: SVGViewportMapping,
nodeBounds: Rect
): Fill | null {
const id = gradientIdFromFill(fillRef)
@ -173,7 +145,7 @@ export function resolveGradientFill(
const mapped =
grad.units === 'objectBoundingBox'
? { x: nodeBounds.x + px * nodeBounds.width, y: nodeBounds.y + py * nodeBounds.height }
: mapUserPoint(px, py, elementTransform, grad.transform, space, bounds)
: mapSVGPointToViewport(px, py, elementTransform, grad.transform, viewport)
return {
x: (mapped.x - nodeBounds.x) / nodeBounds.width,
y: (mapped.y - nodeBounds.y) / nodeBounds.height

View file

@ -5,8 +5,6 @@
* reflect the input pixel size. Scale path data from the SVG coordinate space
* (viewBox, else width/height) into the target node bounds before parsing.
*/
import svgpath from 'svgpath'
import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph'
import { computeBounds } from '@open-pencil/scene-graph/geometry'
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
@ -20,14 +18,11 @@ import { parseSVGSize, parseSVGViewBox } from '#core/io/formats/svg/metadata'
import { computeAccurateBounds } from '#core/vector/curve-math'
import { parseSVGGradients, resolveGradientFill } from './gradients'
import { applySVGTransformToPath } from './transform'
/** Map path data from SVG user space (viewBox) into target pixel bounds. */
function mapPathDataToBounds(d: string, space: Rect, target: Size): string {
const sx = target.width / space.width
const sy = target.height / space.height
return svgpath(d).translate(-space.x, -space.y).scale(sx, sy).toString()
}
import {
applySVGTransformToPath,
mapSVGPathToViewport,
resolveSVGViewportMapping
} from './transform'
function parseSVGCoordinateSpace(svg: string): Rect {
const viewBox = parseSVGViewBox(svg)
@ -75,7 +70,7 @@ export interface SVGVectorizeResult {
export function svgToVectorPaths(
svgText: string,
bounds: Size,
options?: { defaultColor?: string }
options?: { defaultColor?: string; preserveAspectRatio?: boolean }
): SVGVectorizeResult | null {
const paths = extractPaths(svgText)
if (paths.length === 0) return null
@ -85,16 +80,20 @@ export function svgToVectorPaths(
const defaultColor = options?.defaultColor ?? '#000000'
const gradients = parseSVGGradients(svgText)
// Path coordinates are scaled from SVG space into the target bounds; scale stroke
// width by the same (uniform) factor so thickness matches the transformed geometry.
const strokeScale = Math.min(bounds.width / space.width, bounds.height / space.height)
const viewport = resolveSVGViewportMapping(
svgText,
space,
bounds,
options?.preserveAspectRatio ?? false
)
const strokeScale = Math.min(viewport.scaleX, viewport.scaleY)
const vectorized: VectorizedPath[] = []
for (const path of paths) {
const fillRule: WindingRule = path.fillRule
const transform = path.transform ?? null
const pathData = applySVGTransformToPath(path.d, transform)
const scaledD = mapPathDataToBounds(pathData, space, bounds)
const scaledD = mapSVGPathToViewport(pathData, viewport)
const network = parseSVGPath(scaledD, fillRule)
const gradientFill =
gradients.size > 0
@ -102,8 +101,7 @@ export function svgToVectorPaths(
path.fill,
gradients,
transform,
space,
bounds,
viewport,
computeAccurateBounds(network)
)
: null

View file

@ -1,17 +1,92 @@
import svgpath from 'svgpath'
/**
* Apply an SVG `transform` attribute to path data. Delegates the full transform
* grammar (translate/scale/rotate/skewX/skewY/matrix and lists) to svgpath rather
* than parsing a subset ourselves, so non-translate/matrix transforms import
* correctly. Returns the original path if the transform string can't be applied.
*/
import type { Rect, Size, Vector } from '@open-pencil/scene-graph/primitives'
import { parseSVGDocument } from '#core/io/formats/svg/document'
export interface SVGViewportMapping {
space: Rect
scaleX: number
scaleY: number
offsetX: number
offsetY: number
}
function alignmentOffset(align: string, remaining: number, axis: 'x' | 'y'): number {
const token = axis === 'x' ? align.slice(0, 4) : align.slice(4)
if (token.endsWith('Mid')) return remaining / 2
if (token.endsWith('Max')) return remaining
return 0
}
export function resolveSVGViewportMapping(
svg: string,
space: Rect,
bounds: Size,
preserveAspectRatio: boolean
): SVGViewportMapping {
const scaleX = bounds.width / space.width
const scaleY = bounds.height / space.height
if (!preserveAspectRatio) return { space, scaleX, scaleY, offsetX: 0, offsetY: 0 }
const value =
parseSVGDocument(svg)?.documentElement?.getAttribute('preserveAspectRatio')?.trim() ?? ''
const tokens = value.split(/\s+/).filter(Boolean)
if (tokens.includes('none')) return { space, scaleX, scaleY, offsetX: 0, offsetY: 0 }
const align = tokens.find((token) => token.startsWith('x')) ?? 'xMidYMid'
const uniformScale = tokens.includes('slice')
? Math.max(scaleX, scaleY)
: Math.min(scaleX, scaleY)
const remainingX = bounds.width - space.width * uniformScale
const remainingY = bounds.height - space.height * uniformScale
return {
space,
scaleX: uniformScale,
scaleY: uniformScale,
offsetX: alignmentOffset(align, remainingX, 'x'),
offsetY: alignmentOffset(align, remainingY, 'y')
}
}
export function mapSVGPathToViewport(d: string, mapping: SVGViewportMapping): string {
return svgpath(d)
.translate(-mapping.space.x, -mapping.space.y)
.scale(mapping.scaleX, mapping.scaleY)
.translate(mapping.offsetX, mapping.offsetY)
.toString()
}
export function mapSVGPointToViewport(
x: number,
y: number,
elementTransform: string | null,
gradientTransform: string | null,
mapping: SVGViewportMapping
): Vector {
let path = svgpath(`M${x} ${y}`)
if (gradientTransform) path = path.transform(gradientTransform)
if (elementTransform) path = path.transform(elementTransform)
path = path
.translate(-mapping.space.x, -mapping.space.y)
.scale(mapping.scaleX, mapping.scaleY)
.translate(mapping.offsetX, mapping.offsetY)
const points: Vector[] = []
path.abs().iterate((segment) => {
if (points.length === 0 && segment[0] === 'M') {
points.push({ x: segment[1], y: segment[2] })
}
})
return points[0] ?? { x, y }
}
/** Apply the complete SVG transform grammar through svgpath. */
export function applySVGTransformToPath(d: string, transform: string | null): string {
if (!transform || transform === 'none') return d
try {
return svgpath(d).transform(transform).toString()
} catch (err) {
console.warn('Ignoring unsupported SVG transform:', transform, err)
} catch (error) {
console.warn('Ignoring unsupported SVG transform:', transform, error)
return d
}
}

View file

@ -5,7 +5,13 @@ import type { Editor } from '@open-pencil/core/editor'
import { findMoveDropTarget } from '#vue/shared/input/drop-target'
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif'])
const RASTER_IMAGE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/avif'
])
const COMPONENT_MIME = 'application/x-openpencil-component'
function hasComponentData(e: DragEvent): boolean {
@ -38,14 +44,14 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
const isDraggingOver = ref(false)
useEventListener(canvasRef, 'dragover', (e: DragEvent) => {
if (!hasComponentData(e) && !hasImageFiles(e)) return
if (!hasComponentData(e) && !hasFileData(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
isDraggingOver.value = true
})
useEventListener(canvasRef, 'dragenter', (e: DragEvent) => {
if (!hasComponentData(e) && !hasImageFiles(e)) return
if (!hasComponentData(e) && !hasFileData(e)) return
e.preventDefault()
isDraggingOver.value = true
})
@ -71,31 +77,32 @@ export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, editor:
return
}
const files = filterImageFiles(e.dataTransfer?.files ?? null)
const files = filterCanvasFiles(e.dataTransfer?.files ?? null)
if (!files.length) return
void editor.placeImageFiles(files, point.x, point.y)
void editor.placeFiles(files, point.x, point.y).catch((error: unknown) => {
console.error('Failed to place dropped files', error)
})
})
return { isDraggingOver }
}
function hasImageFiles(e: DragEvent): boolean {
if (!e.dataTransfer?.types.includes('Files')) return false
for (const item of e.dataTransfer.items) {
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) return true
}
return false
function hasFileData(e: DragEvent): boolean {
return e.dataTransfer?.types.includes('Files') ?? false
}
function filterImageFiles(files: FileList | null): File[] {
function isSVGFile(file: File): boolean {
return (
file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg'))
)
}
export function filterCanvasFiles(files: ArrayLike<File> | Iterable<File> | null): File[] {
if (!files) return []
const result: File[] = []
for (const file of files) {
if (ACCEPTED_TYPES.has(file.type)) result.push(file)
}
return result
return Array.from(files).filter((file) => RASTER_IMAGE_TYPES.has(file.type) || isSVGFile(file))
}
export function extractImageFilesFromClipboard(e: ClipboardEvent): File[] {
return filterImageFiles(e.clipboardData?.files ?? null)
const files = e.clipboardData?.files
return files ? Array.from(files).filter((file) => RASTER_IMAGE_TYPES.has(file.type)) : []
}

View file

@ -0,0 +1,96 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
async function dispatchMixedFileDrop() {
return editor.page.getByTestId('canvas-element').evaluate(async (canvas) => {
const bounds = canvas.getBoundingClientRect()
const svg = new File(
[
'<svg width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#f00"/></svg>'
],
'mark.svg'
)
const rasterCanvas = document.createElement('canvas')
rasterCanvas.width = 20
rasterCanvas.height = 10
const context = rasterCanvas.getContext('2d')
if (!context) throw new Error('Canvas context unavailable')
context.fillStyle = '#00f'
context.fillRect(0, 0, 20, 10)
const blob = await new Promise<Blob>((resolve, reject) => {
rasterCanvas.toBlob((value) => {
if (value) resolve(value)
else reject(new Error('PNG failed'))
})
})
const image = new File([blob], 'photo.png', { type: 'image/png' })
const transfer = new DataTransfer()
transfer.items.add(svg)
transfer.items.add(image)
const eventOptions = {
bubbles: true,
cancelable: true,
clientX: bounds.left + 300,
clientY: bounds.top + 200,
dataTransfer: transfer
}
const dragoverAccepted = !canvas.dispatchEvent(new DragEvent('dragover', eventOptions))
canvas.dispatchEvent(new DragEvent('drop', eventOptions))
return dragoverAccepted
})
}
test('mixed SVG and raster drops share placement, selection, and undo', async () => {
expect(await dispatchMixedFileDrop()).toBe(true)
await expect
.poll(() =>
editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
return store?.graph.getChildren(store.state.currentPageId).length ?? 0
})
)
.toBe(2)
const placed = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return {
nodes: store.graph
.getChildren(store.state.currentPageId)
.map((node) => ({ name: node.name, type: node.type })),
selected: store.state.selectedIds.size,
undoLabel: store.undo.undoLabel
}
})
expect(placed).toEqual({
nodes: [
{ name: 'mark', type: 'FRAME' },
{ name: 'photo', type: 'RECTANGLE' }
],
selected: 2,
undoLabel: 'Place files'
})
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
await expect
.poll(() =>
editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
return store?.graph.getChildren(store.state.currentPageId).length ?? 0
})
)
.toBe(0)
await editor.page.evaluate(() => window.openPencil?.getStore?.().redoAction())
await expect
.poll(() =>
editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
return store?.graph.getChildren(store.state.currentPageId).length ?? 0
})
)
.toBe(2)
editor.canvas.assertNoErrors()
})

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
function svgFile(name = 'mark.svg', width = 40, height = 20) {
return new File(
[
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#f00"/></svg>`
],
name,
{ type: 'image/svg+xml' }
)
}
describe('dropped file placement', () => {
test('places files in order with one selection and undo entry', async () => {
const editor = createEditor()
await editor.placeFiles([svgFile(), svgFile('symbol.svg', 20, 10)], 200, 100)
const roots = editor.graph.getChildren(editor.state.currentPageId)
expect(roots.map((node) => node.name)).toEqual(['mark', 'symbol'])
expect(roots.map((node) => node.x)).toEqual([160, 220])
expect([...editor.state.selectedIds]).toEqual(roots.map((node) => node.id))
expect(editor.undo.undoLabel).toBe('Place files')
editor.undoAction()
expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(0)
expect(editor.state.selectedIds.size).toBe(0)
editor.redoAction()
expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(2)
expect(editor.state.selectedIds.size).toBe(2)
})
test('reads every file before mutating the graph', async () => {
const editor = createEditor()
const unreadable = {
name: 'broken.svg',
type: 'image/svg+xml',
text: () => Promise.reject(new Error('read failed'))
} as File
await expect(editor.placeFiles([svgFile(), unreadable], 200, 100)).rejects.toThrow(
'read failed'
)
expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(0)
expect(editor.undo.canUndo).toBe(false)
})
test('accepts an SVG filename when the drag source omits its MIME type', async () => {
const editor = createEditor()
const file = new File(
['<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>'],
'fallback.SVG'
)
await editor.placeFiles([file], 50, 50)
const frame = editor.graph.getChildren(editor.state.currentPageId)[0]
expect(frame.name).toBe('fallback')
expect(frame.type).toBe('FRAME')
})
})

View file

@ -148,4 +148,59 @@ describe('import_svg', () => {
const children = graph.getChildren(result.id)
expect(children.length).toBe(2)
})
test('applies nested transforms through the XML tree', async () => {
const result = (await importSvg.execute(figma, {
svg: `<svg viewBox="0 0 100 100"><g transform="translate(40 30)"><rect width="10" height="20"/></g></svg>`
})) as { id: string }
const path = getNodeOrThrow(graph, graph.getChildren(result.id)[0].id)
expect(path.x).toBeCloseTo(40)
expect(path.y).toBeCloseTo(30)
expect(path.width).toBeCloseTo(10)
expect(path.height).toBeCloseTo(20)
})
test('honors preserveAspectRatio when mapping the viewBox', async () => {
const result = (await importSvg.execute(figma, {
svg: `<svg width="200" height="200" viewBox="0 0 100 50"><rect width="100" height="50"/></svg>`
})) as { id: string }
const path = graph.getChildren(result.id)[0]
expect(path.x).toBeCloseTo(0)
expect(path.y).toBeCloseTo(50)
expect(path.width).toBeCloseTo(200)
expect(path.height).toBeCloseTo(100)
})
test('supports preserveAspectRatio none', async () => {
const result = (await importSvg.execute(figma, {
svg: `<svg width="200" height="200" viewBox="0 0 100 50" preserveAspectRatio="none"><rect width="100" height="50"/></svg>`
})) as { id: string }
const path = graph.getChildren(result.id)[0]
expect(path.x).toBeCloseTo(0)
expect(path.y).toBeCloseTo(0)
expect(path.width).toBeCloseTo(200)
expect(path.height).toBeCloseTo(200)
})
test('resolves internal use references and inline presentation styles', async () => {
const result = (await importSvg.execute(figma, {
svg: `<svg viewBox="0 0 100 100"><defs><path id="tile" d="M0 0H10V10H0Z"/></defs><use href="#tile" x="20" y="30" style="fill: #0000ff"/></svg>`
})) as { id: string }
const path = graph.getChildren(result.id)[0]
expect(path.x).toBeCloseTo(20)
expect(path.y).toBeCloseTo(30)
expect(path.fills[0].color.b).toBeCloseTo(1)
})
test('imports gradient fills through the shared SVG pipeline', async () => {
const result = (await importSvg.execute(figma, {
svg: `<svg viewBox="0 0 10 10"><defs><linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="10" height="10" fill="url(#g)"/></svg>`
})) as { id: string }
expect(graph.getChildren(result.id)[0].fills[0].type).toBe('GRADIENT_LINEAR')
})
})

View file

@ -0,0 +1,18 @@
import { describe, expect, test } from 'bun:test'
import { filterCanvasFiles } from '#vue/canvas/drop/use'
describe('canvas file drops', () => {
test('keeps supported raster and SVG files in source order', () => {
const svg = new File(['<svg/>'], 'mark.svg', { type: 'image/svg+xml' })
const image = new File(['png'], 'photo.png', { type: 'image/png' })
const text = new File(['text'], 'notes.txt', { type: 'text/plain' })
expect(filterCanvasFiles([svg, text, image])).toEqual([svg, image])
})
test('recognizes SVG filenames without MIME metadata', () => {
const svg = new File(['<svg/>'], 'mark.SVG')
expect(filterCanvasFiles([svg])).toEqual([svg])
})
})