From ed61445e2dd4359e14500d6ce859b7e0ddea171e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 5 Mar 2026 10:31:02 +0300 Subject: [PATCH] SVG export (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SVG export Scene graph → SVG serializer with support for rectangles, ellipses, lines, stars, polygons, vectors (fill/stroke geometry and vector networks), text with style runs, gradients (linear, radial, angular), image fills, effects (drop shadow, inner shadow, blur), opacity, rotation, flips, blend modes, clip paths, and nested groups. - packages/core: svg-node.ts (minimal XML builder), svg-export.ts (serializer), ExportFormat now includes 'SVG', computeContentBounds extracted for reuse - packages/cli: export command accepts --format svg - UI: SVG added to ExportSection format picker (scale hidden for SVG), context menu 'Export as SVG' option - 47 new tests covering SVGNode builder, geometry blob → path, vector network → path, and full node export for all shape types * Update changelog * Add export_svg tool to ToolDefs Available via MCP, AI chat, and CLI eval command. * Replace standalone export items with Copy/Paste as submenu Figma-style submenu: Copy as text, Copy as SVG, Copy as PNG (⇧⌘C), Copy as JSX. Keeps Export as PNG (⇧⌘E) as a separate item. * Remove standalone Export as PNG from context menu * Update changelog --- CHANGELOG.md | 3 + packages/cli/src/commands/export.ts | 24 +- packages/core/src/index.ts | 9 +- packages/core/src/render-image.ts | 4 +- packages/core/src/svg-export.ts | 870 ++++++++++++++++++++ packages/core/src/svg-node.ts | 62 ++ packages/core/src/tools/schema.ts | 28 +- src/components/NodeContextMenuContent.vue | 85 +- src/components/properties/ExportSection.vue | 3 +- src/engine/svg-export.ts | 1 + src/stores/editor.ts | 31 +- tests/engine/svg-export.test.ts | 762 +++++++++++++++++ 12 files changed, 1862 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/svg-export.ts create mode 100644 packages/core/src/svg-node.ts create mode 100644 src/engine/svg-export.ts create mode 100644 tests/engine/svg-export.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8df8cd4..9be8a0b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ### Features +- SVG export — export selections as SVG from the export panel, context menu, CLI (`bun open-pencil export --format svg`), or MCP/AI tools (`export_svg`). Supports rectangles, ellipses, lines, stars, polygons, vectors, text with style runs, gradients, image fills, effects, blend modes, clip paths, and nested groups (#46) +- Copy/Paste as submenu in context menu — Copy as text, Copy as SVG, Copy as PNG (⇧⌘C), Copy as JSX + - Stroke align (Inside/Center/Outside) with clip-based rendering matching Figma behavior - Individual stroke weights per side (Top/Right/Bottom/Left) with side selector dropdown - Google Fonts fallback — automatically loads fonts from Google Fonts API when not available locally diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 0c3117279..1344a4cc9 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -1,16 +1,20 @@ import { defineCommand } from 'citty' import { basename, extname, resolve } from 'node:path' +import { renderNodesToSVG } from '@open-pencil/core' + import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless' import { ok, printError } from '../format' import type { ExportFormat } from '@open-pencil/core' +const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP'] + export default defineCommand({ - meta: { description: 'Export a .fig file to PNG, JPG, or WEBP' }, + meta: { description: 'Export a .fig file to PNG, JPG, WEBP, or SVG' }, args: { file: { type: 'positional', description: '.fig file path', required: true }, output: { type: 'string', alias: 'o', description: 'Output file path (default: .)' }, - format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp (default: png)', default: 'png' }, + format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg (default: png)', default: 'png' }, scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' }, quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' }, page: { type: 'string', description: 'Page name (default: first page)' }, @@ -21,8 +25,8 @@ export default defineCommand({ }, async run({ args }) { const format = args.format.toUpperCase() as ExportFormat - if (!['PNG', 'JPG', 'WEBP'].includes(format)) { - printError(`Invalid format "${args.format}". Use png, jpg, or webp.`) + if (![...RASTER_FORMATS, 'SVG'].includes(format)) { + printError(`Invalid format "${args.format}". Use png, jpg, webp, or svg.`) process.exit(1) } @@ -43,6 +47,18 @@ export default defineCommand({ const defaultName = basename(args.file, extname(args.file)) const output = resolve(args.output ?? `${defaultName}.${ext}`) + if (format === 'SVG') { + const nodeIds = args.node ? [args.node] : page.childIds + const svgStr = renderNodesToSVG(graph, page.id, nodeIds) + if (!svgStr) { + printError('Nothing to export (empty page or no visible nodes).') + process.exit(1) + } + await Bun.write(output, svgStr) + console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`)) + return + } + let data: Uint8Array | null if (args.thumbnail) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9b5c2984..5e3c7d8b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,7 +86,14 @@ export { adjustRunsForInsert, adjustRunsForDelete } from './style-runs' -export { renderNodesToImage, renderThumbnail, type ExportFormat } from './render-image' +export { renderNodesToImage, renderThumbnail, computeContentBounds, type ExportFormat } from './render-image' +export { + renderNodesToSVG, + geometryBlobToSVGPath, + vectorNetworkToSVGPaths, + type SVGExportOptions +} from './svg-export' +export { svg, renderSVGNode, type SVGNode } from './svg-node' export { exportFigFile } from './fig-export' export { FIG_KIWI_VERSION, diff --git a/packages/core/src/render-image.ts b/packages/core/src/render-image.ts index 77f68f03b..961bb0afb 100644 --- a/packages/core/src/render-image.ts +++ b/packages/core/src/render-image.ts @@ -2,7 +2,7 @@ import type { SkiaRenderer } from './renderer' import type { SceneGraph } from './scene-graph' import type { CanvasKit } from 'canvaskit-wasm' -export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' +export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' | 'SVG' interface RenderOptions { scale: number @@ -10,7 +10,7 @@ interface RenderOptions { quality?: number } -function computeContentBounds( +export function computeContentBounds( graph: SceneGraph, nodeIds: string[] ): { minX: number; minY: number; maxX: number; maxY: number } | null { diff --git a/packages/core/src/svg-export.ts b/packages/core/src/svg-export.ts new file mode 100644 index 000000000..ca301fae7 --- /dev/null +++ b/packages/core/src/svg-export.ts @@ -0,0 +1,870 @@ +import { colorToHex } from './color' +import { computeContentBounds } from './render-image' + +import { svg, renderSVGNode } from './svg-node' + +import type { SVGNode } from './svg-node' +import type { + SceneGraph, + SceneNode, + Fill, + Effect, + VectorNetwork, + VectorSegment, + VectorVertex +} from './scene-graph' +import type { Color } from './types' + +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +interface SVGExportContext { + defs: SVGNode[] + defIdCounter: number + graph: SceneGraph +} + +function nextDefId(ctx: SVGExportContext, prefix: string): string { + return `${prefix}${ctx.defIdCounter++}` +} + +function round(n: number, decimals = 2): number { + const factor = 10 ** decimals + return Math.round(n * factor) / factor +} + +function formatColor(color: Color, opacity = 1): string { + if (opacity < 1) { + const hex = colorToHex(color) + const alpha = Math.round(opacity * 255) + .toString(16) + .padStart(2, '0') + return hex + alpha + } + return colorToHex(color) +} + +// --- Path data --- + +export function geometryBlobToSVGPath(blob: Uint8Array): string { + if (!blob || blob.length === 0) return '' + const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength) + let o = 0 + const parts: string[] = [] + + while (o < blob.length) { + const cmd = blob[o++] + switch (cmd) { + case CMD_CLOSE: + parts.push('Z') + break + case CMD_MOVE_TO: { + const x = round(dv.getFloat32(o, true)) + const y = round(dv.getFloat32(o + 4, true)) + o += 8 + parts.push(`M${x} ${y}`) + break + } + case CMD_LINE_TO: { + const x = round(dv.getFloat32(o, true)) + const y = round(dv.getFloat32(o + 4, true)) + o += 8 + parts.push(`L${x} ${y}`) + break + } + case CMD_CUBIC_TO: { + const x1 = round(dv.getFloat32(o, true)) + const y1 = round(dv.getFloat32(o + 4, true)) + const x2 = round(dv.getFloat32(o + 8, true)) + const y2 = round(dv.getFloat32(o + 12, true)) + const x = round(dv.getFloat32(o + 16, true)) + const y = round(dv.getFloat32(o + 20, true)) + o += 24 + parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`) + break + } + default: + return parts.join('') + } + } + + return parts.join('') +} + +function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string { + const start = forward ? vertices[seg.start] : vertices[seg.end] + const end = forward ? vertices[seg.end] : vertices[seg.start] + const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y } + const te = forward ? seg.tangentEnd : { x: -seg.tangentStart.x, y: -seg.tangentStart.y } + + const isStraight = + Math.abs(ts.x) < 0.001 && + Math.abs(ts.y) < 0.001 && + Math.abs(te.x) < 0.001 && + Math.abs(te.y) < 0.001 + + if (isStraight) { + return `L${round(end.x)} ${round(end.y)}` + } + + const cp1x = round(start.x + ts.x) + const cp1y = round(start.y + ts.y) + const cp2x = round(end.x + te.x) + const cp2y = round(end.y + te.y) + return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}` +} + +export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] { + const { vertices, segments, regions } = network + + if (regions.length > 0) { + return regions.map((region) => { + const parts: string[] = [] + for (const loop of region.loops) { + if (loop.length === 0) continue + const firstSeg = segments[loop[0]] + parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`) + for (const segIdx of loop) { + parts.push(segmentToSVG(segments[segIdx], vertices, true)) + } + parts.push('Z') + } + return parts.join('') + }) + } + + const parts: string[] = [] + for (const seg of segments) { + parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`) + parts.push(segmentToSVG(seg, vertices, true)) + } + + return parts.length > 0 ? [parts.join('')] : [] +} + +// --- Gradients --- + +function createGradientDef( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): { id: string; node: SVGNode } | null { + const stops = fill.gradientStops + const t = fill.gradientTransform + if (!stops || !t) return null + + const stopNodes = stops.map((s) => + svg('stop', { + offset: `${round(s.position * 100)}%`, + 'stop-color': colorToHex(s.color), + 'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined + }) + ) + + const id = nextDefId(ctx, 'grad') + + if (fill.type === 'GRADIENT_LINEAR') { + const startX = round(t.m02 * 100) + const startY = round(t.m12 * 100) + const endX = round((t.m00 + t.m02) * 100) + const endY = round((t.m10 + t.m12) * 100) + return { + id, + node: svg( + 'linearGradient', + { + id, + x1: `${startX}%`, + y1: `${startY}%`, + x2: `${endX}%`, + y2: `${endY}%`, + gradientUnits: 'objectBoundingBox' + }, + ...stopNodes + ) + } + } + + if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') { + const cx = round(t.m02 * 100) + const cy = round(t.m12 * 100) + const r = round(Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * 100) + return { + id, + node: svg( + 'radialGradient', + { id, cx: `${cx}%`, cy: `${cy}%`, r: `${r}%`, gradientUnits: 'objectBoundingBox' }, + ...stopNodes + ) + } + } + + if (fill.type === 'GRADIENT_ANGULAR') { + const cx = round(t.m02 * node.width) + const cy = round(t.m12 * node.height) + const r = Math.max(node.width, node.height) + return { + id, + node: svg( + 'radialGradient', + { id, cx, cy, r, gradientUnits: 'userSpaceOnUse' }, + ...stopNodes + ) + } + } + + return null +} + +// --- Image fills --- + +function createImagePattern( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): { id: string; node: SVGNode } | null { + if (!fill.imageHash) return null + const data = ctx.graph.images.get(fill.imageHash) + if (!data) return null + + const id = nextDefId(ctx, 'img') + const base64 = btoa(String.fromCharCode(...data)) + const mime = detectImageMime(data) + + return { + id, + node: svg( + 'pattern', + { + id, + patternUnits: 'objectBoundingBox', + width: 1, + height: 1 + }, + svg('image', { + href: `data:${mime};base64,${base64}`, + width: node.width, + height: node.height, + preserveAspectRatio: fill.imageScaleMode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice' + }) + ) + } +} + +function detectImageMime(data: Uint8Array): string { + if (data[0] === 0x89 && data[1] === 0x50) return 'image/png' + if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg' + if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp' + return 'image/png' +} + +// --- Effects → SVG filters --- + +function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string; node: SVGNode } | null { + const visible = effects.filter((e) => e.visible) + if (visible.length === 0) return null + + const id = nextDefId(ctx, 'fx') + const primitives: SVGNode[] = [] + + for (const effect of visible) { + if (effect.type === 'DROP_SHADOW') { + const stdDev = round(effect.radius / 2) + primitives.push( + svg('feDropShadow', { + dx: round(effect.offset.x), + dy: round(effect.offset.y), + stdDeviation: stdDev, + 'flood-color': colorToHex(effect.color), + 'flood-opacity': round(effect.color.a) + }) + ) + } else if (effect.type === 'INNER_SHADOW') { + const sid = `${id}_is` + const stdDev = round(effect.radius / 2) + primitives.push( + svg('feGaussianBlur', { in: 'SourceAlpha', stdDeviation: stdDev, result: `${sid}_blur` }), + svg('feOffset', { + dx: round(effect.offset.x), + dy: round(effect.offset.y), + result: `${sid}_off` + }), + svg('feComposite', { + in: 'SourceAlpha', + in2: `${sid}_off`, + operator: 'out', + result: `${sid}_inv` + }), + svg('feFlood', { + 'flood-color': colorToHex(effect.color), + 'flood-opacity': round(effect.color.a) + }), + svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }), + svg('feComposite', { + in: `${sid}_shadow`, + in2: 'SourceGraphic', + operator: 'over' + }) + ) + } else if ( + effect.type === 'LAYER_BLUR' || + effect.type === 'BACKGROUND_BLUR' || + effect.type === 'FOREGROUND_BLUR' + ) { + const stdDev = round(effect.radius / 2) + primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev })) + } + } + + if (primitives.length === 0) return null + + return { + id, + node: svg('filter', { id }, ...primitives) + } +} + +// --- Fill resolution --- + +function resolveFill( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): string | null { + if (!fill.visible) return null + + if (fill.type === 'SOLID') { + return formatColor(fill.color, fill.opacity) + } + + if (fill.type.startsWith('GRADIENT')) { + const grad = createGradientDef(fill, node, ctx) + if (grad) { + ctx.defs.push(grad.node) + return `url(#${grad.id})` + } + } + + if (fill.type === 'IMAGE') { + const pattern = createImagePattern(fill, node, ctx) + if (pattern) { + ctx.defs.push(pattern.node) + return `url(#${pattern.id})` + } + } + + return null +} + +// --- Stroke helpers --- + +const SVG_STROKE_CAP: Record = { + NONE: 'butt', + ROUND: 'round', + SQUARE: 'square' +} + +const SVG_STROKE_JOIN: Record = { + MITER: 'miter', + ROUND: 'round', + BEVEL: 'bevel' +} + +const SVG_BLEND_MODE: Record = { + NORMAL: 'normal', + DARKEN: 'darken', + MULTIPLY: 'multiply', + COLOR_BURN: 'color-burn', + LIGHTEN: 'lighten', + SCREEN: 'screen', + COLOR_DODGE: 'color-dodge', + OVERLAY: 'overlay', + SOFT_LIGHT: 'soft-light', + HARD_LIGHT: 'hard-light', + DIFFERENCE: 'difference', + EXCLUSION: 'exclusion', + HUE: 'hue', + SATURATION: 'saturation', + COLOR: 'color', + LUMINOSITY: 'luminosity' +} + +// --- Shape builders --- + +function makePolygonPoints(node: SceneNode): string { + const cx = node.width / 2 + const cy = node.height / 2 + const rx = node.width / 2 + const ry = node.height / 2 + const n = Math.max(3, node.pointCount) + const isStar = node.type === 'STAR' + const innerRatio = isStar ? node.starInnerRadius : 1 + const totalPoints = isStar ? n * 2 : n + const angleOffset = -Math.PI / 2 + + const points: string[] = [] + for (let i = 0; i < totalPoints; i++) { + const angle = angleOffset + (2 * Math.PI * i) / totalPoints + const isInner = isStar && i % 2 === 1 + const r = isInner ? innerRatio : 1 + points.push(`${round(cx + rx * r * Math.cos(angle))},${round(cy + ry * r * Math.sin(angle))}`) + } + return points.join(' ') +} + +function hasRadius(node: SceneNode): boolean { + return ( + node.cornerRadius > 0 || + (node.independentCorners && + (node.topLeftRadius > 0 || + node.topRightRadius > 0 || + node.bottomRightRadius > 0 || + node.bottomLeftRadius > 0)) + ) +} + +function roundedRectPath(node: SceneNode): string { + const w = node.width + const h = node.height + let tl: number, tr: number, br: number, bl: number + if (node.independentCorners) { + tl = node.topLeftRadius + tr = node.topRightRadius + br = node.bottomRightRadius + bl = node.bottomLeftRadius + } else { + tl = tr = br = bl = node.cornerRadius + } + + tl = Math.min(tl, w / 2, h / 2) + tr = Math.min(tr, w / 2, h / 2) + br = Math.min(br, w / 2, h / 2) + bl = Math.min(bl, w / 2, h / 2) + + return [ + `M${round(tl)} 0`, + `L${round(w - tr)} 0`, + tr > 0 ? `A${round(tr)} ${round(tr)} 0 0 1 ${round(w)} ${round(tr)}` : '', + `L${round(w)} ${round(h - br)}`, + br > 0 ? `A${round(br)} ${round(br)} 0 0 1 ${round(w - br)} ${round(h)}` : '', + `L${round(bl)} ${round(h)}`, + bl > 0 ? `A${round(bl)} ${round(bl)} 0 0 1 0 ${round(h - bl)}` : '', + `L0 ${round(tl)}`, + tl > 0 ? `A${round(tl)} ${round(tl)} 0 0 1 ${round(tl)} 0` : '', + 'Z' + ] + .filter(Boolean) + .join('') +} + +function arcPath(node: SceneNode): string { + if (!node.arcData) return '' + const { startingAngle, endingAngle, innerRadius } = node.arcData + const cx = node.width / 2 + const cy = node.height / 2 + const rx = node.width / 2 + const ry = node.height / 2 + + const fullCircle = Math.abs(endingAngle - startingAngle) >= Math.PI * 2 - 0.001 + if (fullCircle && innerRadius <= 0) { + return `M${round(cx - rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx + rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx - rx)} ${round(cy)}Z` + } + + const x1 = round(cx + rx * Math.cos(startingAngle)) + const y1 = round(cy + ry * Math.sin(startingAngle)) + const x2 = round(cx + rx * Math.cos(endingAngle)) + const y2 = round(cy + ry * Math.sin(endingAngle)) + const largeArc = Math.abs(endingAngle - startingAngle) > Math.PI ? 1 : 0 + const sweep = endingAngle > startingAngle ? 1 : 0 + + const parts = [`M${x1} ${y1}`, `A${round(rx)} ${round(ry)} 0 ${largeArc} ${sweep} ${x2} ${y2}`] + + if (innerRadius > 0) { + const irx = rx * innerRadius + const iry = ry * innerRadius + const ix1 = round(cx + irx * Math.cos(endingAngle)) + const iy1 = round(cy + iry * Math.sin(endingAngle)) + const ix2 = round(cx + irx * Math.cos(startingAngle)) + const iy2 = round(cy + iry * Math.sin(startingAngle)) + parts.push(`L${ix1} ${iy1}`) + parts.push(`A${round(irx)} ${round(iry)} 0 ${largeArc} ${sweep === 1 ? 0 : 1} ${ix2} ${iy2}`) + parts.push('Z') + } else { + parts.push(`L${round(cx)} ${round(cy)}Z`) + } + + return parts.join('') +} + +// --- Node rendering --- + +function nodeShapeElements( + node: SceneNode, + fillAttr: string | null, + strokeAttrs: Record +): SVGNode[] { + const common: Record = { + fill: fillAttr ?? 'none', + ...strokeAttrs + } + + switch (node.type) { + case 'ELLIPSE': { + if (node.arcData) { + return [svg('path', { d: arcPath(node), ...common })] + } + return [ + svg('ellipse', { + cx: round(node.width / 2), + cy: round(node.height / 2), + rx: round(node.width / 2), + ry: round(node.height / 2), + ...common + }) + ] + } + + case 'LINE': + return [ + svg('line', { + x1: 0, + y1: 0, + x2: round(node.width), + y2: round(node.height), + fill: 'none', + ...strokeAttrs + }) + ] + + case 'STAR': + case 'POLYGON': + return [svg('polygon', { points: makePolygonPoints(node), ...common })] + + case 'VECTOR': { + const elements: SVGNode[] = [] + if (node.fillGeometry.length > 0) { + for (const geo of node.fillGeometry) { + const d = geometryBlobToSVGPath(geo.commandsBlob) + if (d) { + elements.push( + svg('path', { + d, + 'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined, + ...common + }) + ) + } + } + } else if (node.vectorNetwork) { + const paths = vectorNetworkToSVGPaths(node.vectorNetwork) + for (const d of paths) { + elements.push(svg('path', { d, ...common })) + } + } + if (node.strokeGeometry.length > 0 && strokeAttrs.stroke && strokeAttrs.stroke !== 'none') { + for (const geo of node.strokeGeometry) { + const d = geometryBlobToSVGPath(geo.commandsBlob) + if (d) { + elements.push( + svg('path', { + d, + fill: strokeAttrs.stroke as string, + 'fill-opacity': strokeAttrs['stroke-opacity'], + stroke: 'none' + }) + ) + } + } + } + return elements.length > 0 + ? elements + : [svg('rect', { width: round(node.width), height: round(node.height), ...common })] + } + + default: { + if (hasRadius(node)) { + if (node.independentCorners) { + return [svg('path', { d: roundedRectPath(node), ...common })] + } + return [ + svg('rect', { + width: round(node.width), + height: round(node.height), + rx: round(node.cornerRadius), + ry: round(node.cornerRadius), + ...common + }) + ] + } + return [svg('rect', { width: round(node.width), height: round(node.height), ...common })] + } + } +} + +function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { + const attrs: Record = { + 'font-family': node.fontFamily || undefined, + 'font-size': node.fontSize || undefined, + 'font-weight': node.fontWeight !== 400 ? node.fontWeight : undefined, + 'font-style': node.italic ? 'italic' : undefined, + fill: fillAttr ?? undefined, + 'text-anchor': + node.textAlignHorizontal === 'CENTER' + ? 'middle' + : node.textAlignHorizontal === 'RIGHT' + ? 'end' + : undefined, + 'text-decoration': + node.textDecoration === 'UNDERLINE' + ? 'underline' + : node.textDecoration === 'STRIKETHROUGH' + ? 'line-through' + : undefined, + 'letter-spacing': node.letterSpacing ? round(node.letterSpacing) : undefined + } + + const x = + node.textAlignHorizontal === 'CENTER' + ? round(node.width / 2) + : node.textAlignHorizontal === 'RIGHT' + ? round(node.width) + : 0 + const y = node.fontSize || 14 + + if (node.styleRuns.length > 0) { + const spans: SVGNode[] = [] + let pos = 0 + for (const run of node.styleRuns) { + const text = node.text.slice(pos, pos + run.length) + pos += run.length + const spanAttrs: Record = {} + if (run.style.fontFamily) spanAttrs['font-family'] = run.style.fontFamily + if (run.style.fontSize) spanAttrs['font-size'] = run.style.fontSize + if (run.style.fontWeight) spanAttrs['font-weight'] = run.style.fontWeight + if (run.style.italic) spanAttrs['font-style'] = 'italic' + if (run.style.letterSpacing) spanAttrs['letter-spacing'] = round(run.style.letterSpacing) + if (run.style.textDecoration === 'UNDERLINE') spanAttrs['text-decoration'] = 'underline' + if (run.style.textDecoration === 'STRIKETHROUGH') + spanAttrs['text-decoration'] = 'line-through' + + if (Object.keys(spanAttrs).length > 0) { + spans.push(svg('tspan', spanAttrs, text)) + } else { + spans.push(svg('tspan', {}, text)) + } + } + + return svg('text', { x, y, ...attrs }, ...spans) + } + + return svg('text', { x, y, ...attrs }, node.text) +} + +// --- Main recursive renderer --- + +function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { + if (!node.visible) return null + + const children: (SVGNode | null)[] = [] + const groupAttrs: Record = {} + + // Transform + const transforms: string[] = [] + if (node.x !== 0 || node.y !== 0) transforms.push(`translate(${round(node.x)}, ${round(node.y)})`) + if (node.rotation !== 0) { + transforms.push( + `rotate(${round(node.rotation)}, ${round(node.width / 2)}, ${round(node.height / 2)})` + ) + } + if (node.flipX || node.flipY) { + const tx = node.flipX ? node.width : 0 + const ty = node.flipY ? node.height : 0 + const sx = node.flipX ? -1 : 1 + const sy = node.flipY ? -1 : 1 + transforms.push(`translate(${round(tx)}, ${round(ty)}) scale(${sx}, ${sy})`) + } + if (transforms.length > 0) groupAttrs.transform = transforms.join(' ') + + // Opacity + if (node.opacity < 1) groupAttrs.opacity = round(node.opacity) + + // Blend mode + const blend = SVG_BLEND_MODE[node.blendMode] + if (blend && blend !== 'normal' && node.blendMode !== 'PASS_THROUGH') { + groupAttrs.style = `mix-blend-mode: ${blend}` + } + + // Effects → filter + const filterDef = createFilterDef(node.effects, ctx) + if (filterDef) { + ctx.defs.push(filterDef.node) + groupAttrs.filter = `url(#${filterDef.id})` + } + + // Clip path for clipsContent + let clipId: string | undefined + if (node.clipsContent && node.childIds.length > 0) { + clipId = nextDefId(ctx, 'clip') + ctx.defs.push( + svg( + 'clipPath', + { id: clipId }, + svg('rect', { width: round(node.width), height: round(node.height) }) + ) + ) + } + + // Text node + if (node.type === 'TEXT') { + const firstFill = node.fills.find((f) => f.visible) + const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null + const textEl = renderTextNode(node, fillAttr) + return svg('g', groupAttrs, textEl) + } + + // Resolve fills and strokes + const visibleFills = node.fills.filter((f) => f.visible) + const visibleStrokes = node.strokes.filter((s) => s.visible) + + const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null + const strokeAttrs: Record = {} + + if (visibleStrokes.length > 0) { + const stroke = visibleStrokes[0] + strokeAttrs.stroke = formatColor(stroke.color, 1) + strokeAttrs['stroke-width'] = round(stroke.weight) + if (stroke.opacity < 1) strokeAttrs['stroke-opacity'] = round(stroke.opacity) + if (stroke.cap && stroke.cap !== 'NONE') { + strokeAttrs['stroke-linecap'] = SVG_STROKE_CAP[stroke.cap] ?? 'butt' + } + if (stroke.join && stroke.join !== 'MITER') { + strokeAttrs['stroke-linejoin'] = SVG_STROKE_JOIN[stroke.join] ?? 'miter' + } + if (stroke.dashPattern && stroke.dashPattern.length > 0) { + strokeAttrs['stroke-dasharray'] = stroke.dashPattern.map((n) => round(n)).join(' ') + } + } + + // Multiple fills need stacking + if (visibleFills.length > 1) { + for (const fill of visibleFills) { + const ref = resolveFill(fill, node, ctx) + if (ref) { + children.push( + ...nodeShapeElements( + node, + ref, + fill === visibleFills[visibleFills.length - 1] ? strokeAttrs : {} + ) + ) + } + } + } else { + const hasFillOrStroke = fillAttr || visibleStrokes.length > 0 + if (hasFillOrStroke && !isGroupLike(node)) { + children.push(...nodeShapeElements(node, fillAttr, strokeAttrs)) + } + } + + // Render children + const childNodes = ctx.graph.getChildren(node.id) + const childContent: SVGNode[] = [] + for (const child of childNodes) { + const rendered = renderNode(child, ctx) + if (rendered) childContent.push(rendered) + } + + if (clipId && childContent.length > 0) { + children.push(svg('g', { 'clip-path': `url(#${clipId})` }, ...childContent)) + } else { + children.push(...childContent) + } + + const validChildren = children.filter((c): c is SVGNode => c !== null) + + if (validChildren.length === 0 && Object.keys(groupAttrs).length === 0) { + return null + } + + if (validChildren.length === 1 && Object.keys(groupAttrs).length === 0) { + return validChildren[0] + } + + return svg('g', groupAttrs, ...validChildren) +} + +function isGroupLike(node: SceneNode): boolean { + return node.type === 'GROUP' +} + +// --- Public API --- + +export interface SVGExportOptions { + /** Include XML declaration (default: true) */ + xmlDeclaration?: boolean +} + +export function renderNodesToSVG( + graph: SceneGraph, + _pageId: string, + nodeIds: string[], + options: SVGExportOptions = {} +): string | null { + const bounds = computeContentBounds(graph, nodeIds) + if (!bounds) return null + + const { minX, minY, maxX, maxY } = bounds + const width = round(maxX - minX) + const height = round(maxY - minY) + + const ctx: SVGExportContext = { + defs: [], + defIdCounter: 0, + graph + } + + const contentNodes: SVGNode[] = [] + + for (const id of nodeIds) { + const node = graph.getNode(id) + if (!node || !node.visible) continue + + const abs = graph.getAbsolutePosition(id) + const offsetX = abs.x - minX + const offsetY = abs.y - minY + + const needsOffset = offsetX !== node.x || offsetY !== node.y + const clone = needsOffset + ? { ...node, x: round(offsetX), y: round(offsetY) } + : node + + const rendered = renderNode(clone as SceneNode, ctx) + if (rendered) contentNodes.push(rendered) + } + + if (contentNodes.length === 0) return null + + const rootChildren: (SVGNode | string)[] = [] + if (ctx.defs.length > 0) { + rootChildren.push(svg('defs', {}, ...ctx.defs)) + } + rootChildren.push(...contentNodes) + + const root = svg( + 'svg', + { + xmlns: 'http://www.w3.org/2000/svg', + 'xmlns:xlink': 'http://www.w3.org/1999/xlink', + width, + height, + viewBox: `0 0 ${width} ${height}` + }, + ...(rootChildren as SVGNode[]) + ) + + const svgStr = renderSVGNode(root) + const xmlDecl = options.xmlDeclaration !== false ? '\n' : '' + return xmlDecl + svgStr +} diff --git a/packages/core/src/svg-node.ts b/packages/core/src/svg-node.ts new file mode 100644 index 000000000..aeaf013b1 --- /dev/null +++ b/packages/core/src/svg-node.ts @@ -0,0 +1,62 @@ +export interface SVGNode { + tag: string + attrs: Record + children: (SVGNode | string)[] +} + +export function svg( + tag: string, + attrs: Record, + ...children: (SVGNode | string | null | undefined | false)[] +): SVGNode { + const cleaned: Record = {} + for (const [k, v] of Object.entries(attrs)) { + if (v != null) cleaned[k] = v + } + return { + tag, + attrs: cleaned, + children: children.filter((c): c is SVGNode | string => c != null && c !== false) + } +} + +function escapeAttr(value: string): string { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +function renderAttrs(attrs: Record): string { + const parts: string[] = [] + for (const [k, v] of Object.entries(attrs)) { + parts.push(`${k}="${escapeAttr(String(v))}"`) + } + return parts.length > 0 ? ' ' + parts.join(' ') : '' +} + +export function renderSVGNode(node: SVGNode, indent = 0): string { + const pad = ' '.repeat(indent) + const attrsStr = renderAttrs(node.attrs) + + if (node.children.length === 0) { + return `${pad}<${node.tag}${attrsStr}/>` + } + + const hasOnlyText = node.children.length === 1 && typeof node.children[0] === 'string' + if (hasOnlyText) { + return `${pad}<${node.tag}${attrsStr}>${escapeText(node.children[0] as string)}` + } + + const lines = [`${pad}<${node.tag}${attrsStr}>`] + for (const child of node.children) { + if (typeof child === 'string') { + lines.push(`${' '.repeat(indent + 1)}${escapeText(child)}`) + } else { + lines.push(renderSVGNode(child, indent + 1)) + } + } + lines.push(`${pad}`) + return lines.join('\n') +} diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index f2e8024ff..260db3d53 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -1711,6 +1711,30 @@ export const setLayoutChild = defineTool({ } }) +// ─── Export tools ───────────────────────────────────────────── + +export const exportSvg = defineTool({ + name: 'export_svg', + description: 'Export nodes as SVG markup. Returns the SVG string.', + params: { + ids: { + type: 'string[]', + description: 'Node IDs to export. Omit to export all top-level nodes on the current page.' + } + }, + execute: async (figma, args) => { + const { renderNodesToSVG } = await import('../svg-export.js') + const pageId = figma.currentPageId + const ids = + args.ids && args.ids.length > 0 + ? args.ids + : figma.currentPage.children.map((n) => n.id) + const svg = renderNodesToSVG(figma.graph, pageId, ids) + if (!svg) return { error: 'No visible nodes to export' } + return { svg } + } +}) + // ─── Registry ───────────────────────────────────────────────── export const ALL_TOOLS: ToolDef[] = [ @@ -1800,5 +1824,7 @@ export const ALL_TOOLS: ToolDef[] = [ // Text properties setTextProperties, // Layout child - setLayoutChild + setLayoutChild, + // Export + exportSvg ] diff --git a/src/components/NodeContextMenuContent.vue b/src/components/NodeContextMenuContent.vue index 1e6463351..4611aa663 100644 --- a/src/components/NodeContextMenuContent.vue +++ b/src/components/NodeContextMenuContent.vue @@ -9,8 +9,10 @@ import { ContextMenuSubContent, ContextMenuPortal } from 'reka-ui' +import { selectionToJsx, renderNodesToSVG } from '@open-pencil/core' import { useEditorStore } from '@/stores/editor' +import { toast } from '@/composables/use-toast' const store = useEditorStore() @@ -61,6 +63,42 @@ const isLocked = computed(() => { return singleNode.value.locked }) +function selectedIds(): string[] { + return [...store.state.selectedIds] +} + +async function copyAsText() { + const ids = selectedIds() + const names = ids.map((id) => store.graph.getNode(id)?.name ?? id).join('\n') + await navigator.clipboard.writeText(names) + toast.show('Copied as text') +} + +async function copyAsSVG() { + const ids = selectedIds() + const svg = renderNodesToSVG(store.graph, store.state.currentPageId, ids) + if (!svg) return + await navigator.clipboard.writeText(svg) + toast.show('Copied as SVG') +} + +async function copyAsPNG() { + const ids = selectedIds() + const data = await store.renderExportImage(ids, 2, 'PNG') + if (!data) return + const blob = new Blob([data], { type: 'image/png' }) + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + toast.show('Copied as PNG') +} + +function copyAsJSX() { + const ids = selectedIds() + const jsx = selectionToJsx(ids, store.graph) + if (!jsx) return + navigator.clipboard.writeText(jsx) + toast.show('Copied as JSX') +} + const itemClass = 'flex w-full cursor-pointer select-none items-center justify-between gap-6 rounded px-2 py-1.5 text-xs text-surface outline-none data-[highlighted]:bg-hover data-[disabled]:cursor-default data-[disabled]:text-muted' const componentItemClass = @@ -254,14 +292,45 @@ const menuClass = - - Export as PNG - ⇧⌘E - + + + Copy/Paste as + › + + + + + Copy as text + + + Copy as SVG + + + Copy as PNG + ⇧⌘C + + + Copy as JSX + + + + diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue index 1c4ec9145..63d3cb1bc 100644 --- a/src/components/properties/ExportSection.vue +++ b/src/components/properties/ExportSection.vue @@ -20,7 +20,7 @@ const exporting = ref(false) const SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const const SCALE_OPTIONS = SCALES.map((s) => ({ value: s, label: s % 1 === 0 ? `${s}x` : `${s}x` })) -const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP'] +const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG'] const FORMAT_OPTIONS = FORMATS.map((f) => ({ value: f, label: f })) const nodeName = computed(() => { @@ -115,6 +115,7 @@ onUnmounted(() => { class="flex items-center gap-1.5 py-0.5" > 0 ? ids : graph.getChildren(state.currentPageId).map((n) => n.id) + const svgStr = renderNodesToSVG(graph, state.currentPageId, nodeIds) + if (!svgStr) { + console.error('Export failed: renderNodesToSVG returned null') + return + } + const svgData = new TextEncoder().encode(svgStr) + const node = ids.length === 1 ? graph.getNode(ids[0]) : undefined + const fileName = `${node?.name ?? 'Export'}.svg` + await saveExportedFile(svgData, fileName, 'SVG', '.svg', 'image/svg+xml') + return + } + const data = await renderExportImage(ids, scale, format) if (!data) { console.error( @@ -821,7 +837,16 @@ export function createEditorStore() { const baseName = node?.name ?? 'Export' const ext = exportImageExtension(format) const fileName = `${baseName}@${scale}x${ext}` + await saveExportedFile(new Uint8Array(data), fileName, format, ext, exportImageMime(format)) + } + async function saveExportedFile( + data: Uint8Array, + fileName: string, + format: string, + ext: string, + mime: string + ) { if (IS_TAURI) { const { save } = await import('@tauri-apps/plugin-dialog') const path = await save({ @@ -840,8 +865,8 @@ export function createEditorStore() { suggestedName: fileName, types: [ { - description: `${format} image`, - accept: { [exportImageMime(format)]: [ext] } + description: `${format} file`, + accept: { [mime]: [ext] } } ] }) @@ -854,7 +879,7 @@ export function createEditorStore() { } } - downloadBlob(new Uint8Array(data), fileName, exportImageMime(format)) + downloadBlob(data, fileName, mime) } function runLayoutForNode(id: string) { diff --git a/tests/engine/svg-export.test.ts b/tests/engine/svg-export.test.ts new file mode 100644 index 000000000..4376a0a04 --- /dev/null +++ b/tests/engine/svg-export.test.ts @@ -0,0 +1,762 @@ +import { describe, expect, test } from 'bun:test' + +import { + SceneGraph, + renderNodesToSVG, + geometryBlobToSVGPath, + vectorNetworkToSVGPaths, + svg, + renderSVGNode +} from '@open-pencil/core' + +function makeGraph() { + const graph = new SceneGraph() + graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' }) + return graph +} + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +function exportSVG(graph: SceneGraph, nodeIds: string[], xmlDecl = false): string | null { + return renderNodesToSVG(graph, pageId(graph), nodeIds, { xmlDeclaration: xmlDecl }) +} + +// --- SVGNode builder tests --- + +describe('svg() and renderSVGNode()', () => { + test('self-closing element', () => { + const node = svg('rect', { width: 100, height: 50 }) + expect(renderSVGNode(node)).toBe('') + }) + + test('element with text child', () => { + const node = svg('text', { x: 10 }, 'Hello') + expect(renderSVGNode(node)).toBe('Hello') + }) + + test('nested elements', () => { + const node = svg('g', { id: 'group' }, svg('rect', { width: 10, height: 10 })) + const result = renderSVGNode(node) + expect(result).toContain('') + expect(result).toContain(' ') + expect(result).toContain('') + }) + + test('filters null/undefined attrs', () => { + const node = svg('rect', { width: 100, fill: undefined, stroke: null }) + expect(renderSVGNode(node)).toBe('') + }) + + test('filters null/undefined/false children', () => { + const node = svg('g', {}, null, false, svg('rect', {}), undefined) + const result = renderSVGNode(node) + expect(result).toContain('') + }) + + test('escapes attribute values', () => { + const node = svg('text', { 'data-info': 'a"b { + const node = svg('text', {}, '1 < 2 & 3 > 0') + expect(renderSVGNode(node)).toBe('1 < 2 & 3 > 0') + }) +}) + +// --- geometryBlobToSVGPath tests --- + +describe('geometryBlobToSVGPath()', () => { + function makeBlob(commands: number[]): Uint8Array { + const buf = new ArrayBuffer(commands.length) + const view = new DataView(buf) + let o = 0 + for (const cmd of commands) { + view.setUint8(o++, cmd) + } + return new Uint8Array(buf) + } + + function makeBlobWithFloats(ops: Array<{ cmd: number; floats?: number[] }>): Uint8Array { + let size = 0 + for (const op of ops) { + size += 1 + (op.floats?.length ?? 0) * 4 + } + const buf = new ArrayBuffer(size) + const view = new DataView(buf) + let o = 0 + for (const op of ops) { + view.setUint8(o, op.cmd) + o += 1 + if (op.floats) { + for (const f of op.floats) { + view.setFloat32(o, f, true) + o += 4 + } + } + } + return new Uint8Array(buf) + } + + test('empty blob', () => { + expect(geometryBlobToSVGPath(new Uint8Array(0))).toBe('') + }) + + test('move + line + close', () => { + const blob = makeBlobWithFloats([ + { cmd: 1, floats: [10, 20] }, + { cmd: 2, floats: [30, 40] }, + { cmd: 0 } + ]) + expect(geometryBlobToSVGPath(blob)).toBe('M10 20L30 40Z') + }) + + test('cubic bezier', () => { + const blob = makeBlobWithFloats([ + { cmd: 1, floats: [0, 0] }, + { cmd: 4, floats: [10, 0, 20, 10, 30, 30] }, + { cmd: 0 } + ]) + const result = geometryBlobToSVGPath(blob) + expect(result).toContain('M0 0') + expect(result).toContain('C10 0 20 10 30 30') + expect(result).toContain('Z') + }) +}) + +// --- vectorNetworkToSVGPaths tests --- + +describe('vectorNetworkToSVGPaths()', () => { + test('single straight segment', () => { + const paths = vectorNetworkToSVGPaths({ + vertices: [ + { x: 0, y: 0 }, + { x: 100, y: 100 } + ], + segments: [ + { + start: 0, + end: 1, + tangentStart: { x: 0, y: 0 }, + tangentEnd: { x: 0, y: 0 } + } + ], + regions: [] + }) + expect(paths).toHaveLength(1) + expect(paths[0]).toContain('M0 0') + expect(paths[0]).toContain('L100 100') + }) + + test('curved segment', () => { + const paths = vectorNetworkToSVGPaths({ + vertices: [ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ], + segments: [ + { + start: 0, + end: 1, + tangentStart: { x: 0, y: 50 }, + tangentEnd: { x: 0, y: 50 } + } + ], + regions: [] + }) + expect(paths).toHaveLength(1) + expect(paths[0]).toContain('C') + }) + + test('region with loop', () => { + const paths = vectorNetworkToSVGPaths({ + vertices: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 50, y: 100 } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }] + }) + expect(paths).toHaveLength(1) + expect(paths[0]).toContain('M0 0') + expect(paths[0]).toContain('Z') + }) + + test('empty network', () => { + const paths = vectorNetworkToSVGPaths({ vertices: [], segments: [], regions: [] }) + expect(paths).toHaveLength(0) + }) +}) + +// --- Full SVG export tests --- + +describe('renderNodesToSVG()', () => { + test('returns null for empty selection', () => { + const graph = makeGraph() + expect(exportSVG(graph, [])).toBeNull() + }) + + test('returns null for hidden nodes', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 50, + visible: false + }) + expect(exportSVG(graph, [node.id])).toBeNull() + }) + + test('basic rectangle', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 50, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 10, + height: 10, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const withDecl = renderNodesToSVG(graph, pageId(graph), [node.id], { xmlDeclaration: true })! + const withoutDecl = renderNodesToSVG(graph, pageId(graph), [node.id], { + xmlDeclaration: false + })! + expect(withDecl).toStartWith(' { + const graph = makeGraph() + const node = graph.createNode('ELLIPSE', pageId(graph), { + width: 80, + height: 60, + fills: [ + { type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 }, opacity: 1, visible: true } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('LINE', pageId(graph), { + width: 100, + height: 0, + strokes: [ + { + color: { r: 0, g: 0, b: 0, a: 1 }, + weight: 2, + opacity: 1, + visible: true, + align: 'CENTER' as const + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('STAR', pageId(graph), { + width: 100, + height: 100, + pointCount: 5, + starInnerRadius: 0.382, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 50, + cornerRadius: 10, + independentCorners: true, + topLeftRadius: 10, + topRightRadius: 0, + bottomRightRadius: 20, + bottomLeftRadius: 5, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('Hello World') + }) + + test('text with style runs', () => { + const graph = makeGraph() + const node = graph.createNode('TEXT', pageId(graph), { + width: 200, + height: 24, + text: 'Hello Bold', + fontSize: 14, + fontFamily: 'Inter', + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }], + styleRuns: [ + { start: 0, length: 6, style: {} }, + { start: 6, length: 4, style: { fontWeight: 700 } } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 50, + height: 50, + opacity: 0.5, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('opacity="0.5"') + }) + + test('rotation', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 50, + rotation: 45, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('rotate(45,') + }) + + test('stroke with dash pattern', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + strokes: [ + { + color: { r: 0, g: 0, b: 0, a: 1 }, + weight: 2, + opacity: 1, + visible: true, + align: 'CENTER' as const, + dashPattern: [5, 3] + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('stroke-dasharray="5 3"') + }) + + test('stroke cap and join', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + strokes: [ + { + color: { r: 0, g: 0, b: 0, a: 1 }, + weight: 3, + opacity: 1, + visible: true, + align: 'CENTER' as const, + cap: 'ROUND', + join: 'BEVEL' + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('stroke-linecap="round"') + expect(result).toContain('stroke-linejoin="bevel"') + }) + + test('frame with children', () => { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Card', + width: 200, + height: 150, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + graph.createNode('RECTANGLE', frame.id, { + width: 180, + height: 100, + x: 10, + y: 10, + fills: [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [frame.id])! + expect(result).toContain(' { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + width: 100, + height: 100, + clipsContent: true, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + graph.createNode('RECTANGLE', frame.id, { + width: 200, + height: 200, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [frame.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + fills: [ + { + type: 'GRADIENT_LINEAR', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + gradientStops: [ + { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } } + ], + gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + fills: [ + { + type: 'GRADIENT_RADIAL', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + gradientStops: [ + { position: 0, color: { r: 1, g: 1, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 1, b: 0, a: 1 } } + ], + gradientTransform: { m00: 1, m01: 0, m02: 0.5, m10: 0, m11: 1, m12: 0.5 } + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], + effects: [ + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.25 }, + offset: { x: 0, y: 4 }, + radius: 8, + spread: 0, + visible: true + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain(' { + const graph = makeGraph() + const a = graph.createNode('RECTANGLE', pageId(graph), { + x: 0, + y: 0, + width: 50, + height: 50, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const b = graph.createNode('ELLIPSE', pageId(graph), { + x: 60, + y: 0, + width: 40, + height: 40, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [a.id, b.id])! + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + blendMode: 'MULTIPLY', + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('mix-blend-mode: multiply') + }) + + test('fill with opacity < 1', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 50, + height: 50, + fills: [ + { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.5, visible: true } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('fill="#FF0000') + }) + + test('component and instance treated as containers', () => { + const graph = makeGraph() + const comp = graph.createNode('COMPONENT', pageId(graph), { + width: 100, + height: 100, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + graph.createNode('RECTANGLE', comp.id, { + width: 80, + height: 80, + x: 10, + y: 10, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [comp.id])! + expect(result).toContain(' { + const graph = makeGraph() + const section = graph.createNode('SECTION', pageId(graph), { + width: 500, + height: 300, + fills: [ + { type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.95, a: 1 }, opacity: 1, visible: true } + ] + }) + const result = exportSVG(graph, [section.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 50, + flipX: true, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('scale(-1, 1)') + }) + + test('stroke opacity', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + strokes: [ + { + color: { r: 1, g: 0, b: 0, a: 1 }, + weight: 1, + opacity: 0.5, + visible: true, + align: 'CENTER' as const + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('stroke-opacity="0.5"') + }) + + test('no fill no stroke produces no output for groups', () => { + const graph = makeGraph() + const group = graph.createNode('GROUP', pageId(graph), { + width: 100, + height: 100 + }) + const result = exportSVG(graph, [group.id]) + expect(result).toBeNull() + }) + + test('group with children', () => { + const graph = makeGraph() + const group = graph.createNode('GROUP', pageId(graph), { + width: 100, + height: 100 + }) + graph.createNode('RECTANGLE', group.id, { + width: 50, + height: 50, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const result = exportSVG(graph, [group.id])! + expect(result).toContain(' { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], + effects: [ + { + type: 'INNER_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.5 }, + offset: { x: 2, y: 2 }, + radius: 4, + spread: 0, + visible: true + } + ] + }) + const result = exportSVG(graph, [node.id])! + expect(result).toContain('