* 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
63 lines
1.8 KiB
TypeScript
63 lines
1.8 KiB
TypeScript
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, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
}
|
|
|
|
function escapeText(value: string): string {
|
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
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')
|
|
}
|