SVG export (#52)

* 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
This commit is contained in:
Danila Poyarkov 2026-03-05 10:31:02 +03:00 committed by GitHub
parent b7e5ea2d45
commit ed61445e2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1862 additions and 20 deletions

View file

@ -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

View file

@ -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: <name>.<format>)' },
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) {

View file

@ -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,

View file

@ -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 {

View file

@ -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<string, string> = {
NONE: 'butt',
ROUND: 'round',
SQUARE: 'square'
}
const SVG_STROKE_JOIN: Record<string, string> = {
MITER: 'miter',
ROUND: 'round',
BEVEL: 'bevel'
}
const SVG_BLEND_MODE: Record<string, string> = {
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<string, string | number | undefined>
): SVGNode[] {
const common: Record<string, string | number | undefined> = {
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<string, string | number | undefined> = {
'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<string, string | number | undefined> = {}
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<string, string | number | undefined> = {}
// 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<string, string | number | undefined> = {}
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 ? '<?xml version="1.0" encoding="UTF-8"?>\n' : ''
return xmlDecl + svgStr
}

View file

@ -0,0 +1,62 @@
export interface SVGNode {
tag: string
attrs: Record<string, string | number>
children: (SVGNode | string)[]
}
export function svg(
tag: string,
attrs: Record<string, string | number | undefined | null>,
...children: (SVGNode | string | null | undefined | false)[]
): SVGNode {
const cleaned: Record<string, string | number> = {}
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
function escapeText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderAttrs(attrs: Record<string, string | number>): 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)}</${node.tag}>`
}
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}</${node.tag}>`)
return lines.join('\n')
}

View file

@ -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
]

View file

@ -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 =
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem
data-test-id="context-export-png"
:class="itemClass"
@select="store.exportSelection(1, 'PNG')"
>
<span>Export as PNG</span>
<span class="text-[11px] text-muted">⇧⌘E</span>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger data-test-id="context-copy-paste-as" :class="itemClass">
<span>Copy/Paste as</span>
<span class="text-sm text-muted">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="menuClass">
<ContextMenuItem
data-test-id="context-copy-as-text"
:class="itemClass"
@select="copyAsText"
>
Copy as text
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-svg"
:class="itemClass"
@select="copyAsSVG"
>
Copy as SVG
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-png"
:class="itemClass"
@select="copyAsPNG"
>
<span>Copy as PNG</span>
<span class="text-[11px] text-muted">⇧⌘C</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-jsx"
:class="itemClass"
@select="copyAsJSX"
>
Copy as JSX
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
</template>
</ContextMenuContent>
</template>

View file

@ -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"
>
<AppSelect
v-if="setting.format !== 'SVG'"
:model-value="setting.scale"
:options="SCALE_OPTIONS"
@update:model-value="setting.scale = Number($event)"

1
src/engine/svg-export.ts Normal file
View file

@ -0,0 +1 @@
export { renderNodesToSVG, type SVGExportOptions } from '@open-pencil/core'

View file

@ -26,6 +26,7 @@ import { loadFont } from '@/engine/fonts'
import { computeLayout, computeAllLayouts, setTextMeasurer } from '@/engine/layout'
import { renderNodesToImage } from '@/engine/render-image'
import { SceneGraph } from '@/engine/scene-graph'
import { renderNodesToSVG } from '@/engine/svg-export'
import { TextEditor } from '@/engine/text-editor'
import { UndoManager } from '@/engine/undo'
import { computeVectorBounds } from '@/engine/vector'
@ -809,6 +810,21 @@ export function createEditorStore() {
async function exportSelection(scale: number, format: ExportFormat) {
const ids = [...state.selectedIds]
if (format === 'SVG') {
const nodeIds = ids.length > 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) {

View file

@ -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('<rect width="100" height="50"/>')
})
test('element with text child', () => {
const node = svg('text', { x: 10 }, 'Hello')
expect(renderSVGNode(node)).toBe('<text x="10">Hello</text>')
})
test('nested elements', () => {
const node = svg('g', { id: 'group' }, svg('rect', { width: 10, height: 10 }))
const result = renderSVGNode(node)
expect(result).toContain('<g id="group">')
expect(result).toContain(' <rect width="10" height="10"/>')
expect(result).toContain('</g>')
})
test('filters null/undefined attrs', () => {
const node = svg('rect', { width: 100, fill: undefined, stroke: null })
expect(renderSVGNode(node)).toBe('<rect width="100"/>')
})
test('filters null/undefined/false children', () => {
const node = svg('g', {}, null, false, svg('rect', {}), undefined)
const result = renderSVGNode(node)
expect(result).toContain('<rect/>')
})
test('escapes attribute values', () => {
const node = svg('text', { 'data-info': 'a"b<c' })
expect(renderSVGNode(node)).toContain('data-info="a&quot;b&lt;c"')
})
test('escapes text content', () => {
const node = svg('text', {}, '1 < 2 & 3 > 0')
expect(renderSVGNode(node)).toBe('<text>1 &lt; 2 &amp; 3 &gt; 0</text>')
})
})
// --- 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('<svg')
expect(result).toContain('width="100"')
expect(result).toContain('height="50"')
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('<rect')
expect(result).toContain('fill="#FF0000"')
})
test('xml declaration', () => {
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('<?xml')
expect(withoutDecl).toStartWith('<svg')
})
test('ellipse', () => {
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('<ellipse')
expect(result).toContain('cx="40"')
expect(result).toContain('cy="30"')
expect(result).toContain('rx="40"')
expect(result).toContain('ry="30"')
})
test('line', () => {
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('<line')
expect(result).toContain('x2="100"')
expect(result).toContain('stroke-width="2"')
})
test('star', () => {
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('<polygon')
expect(result).toContain('points="')
})
test('polygon', () => {
const graph = makeGraph()
const node = graph.createNode('POLYGON', pageId(graph), {
width: 100,
height: 100,
pointCount: 6,
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<polygon')
})
test('rounded rectangle', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 200,
height: 100,
cornerRadius: 16,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('rx="16"')
expect(result).toContain('ry="16"')
})
test('independent corners produce path', () => {
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('<path')
expect(result).toContain('d="M10 0')
})
test('text node', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
width: 200,
height: 24,
text: 'Hello World',
fontSize: 18,
fontWeight: 700,
fontFamily: 'Inter',
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('<text')
expect(result).toContain('font-size="18"')
expect(result).toContain('font-weight="700"')
expect(result).toContain('font-family="Inter"')
expect(result).toContain('>Hello World</text>')
})
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('<tspan')
expect(result).toContain('font-weight="700"')
})
test('opacity', () => {
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('<g')
expect(result).toContain('translate(10, 10)')
})
test('frame with clipsContent', () => {
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('<clipPath')
expect(result).toContain('clip-path="url(#clip')
})
test('hidden children excluded', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 100,
height: 100
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Visible',
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Hidden',
width: 50,
height: 50,
visible: false,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
expect(result).toContain('fill="#FF0000"')
expect(result).not.toContain('fill="#0000FF"')
})
test('linear gradient', () => {
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('<linearGradient')
expect(result).toContain('<stop')
expect(result).toContain('url(#grad')
})
test('radial gradient', () => {
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('<radialGradient')
expect(result).toContain('url(#grad')
})
test('drop shadow effect', () => {
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('<filter')
expect(result).toContain('<feDropShadow')
expect(result).toContain('dy="4"')
expect(result).toContain('filter="url(#fx')
})
test('layer blur effect', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 50,
height: 50,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }],
effects: [
{
type: 'LAYER_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 10,
spread: 0,
visible: true
}
]
})
const result = exportSVG(graph, [node.id])!
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('stdDeviation="5"')
})
test('multiple nodes', () => {
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('<rect')
expect(result).toContain('<ellipse')
})
test('blend mode', () => {
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('<rect')
expect(result).toContain('translate(10, 10)')
})
test('section node', () => {
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('<rect')
expect(result).toContain('width="500"')
})
test('flip transforms', () => {
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('<rect')
expect(result).toContain('fill="#FF0000"')
})
test('inner shadow effect', () => {
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('<filter')
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('<feComposite')
})
})