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('