Created
January 24, 2026 13:29
-
-
Save claudioluciano/25e12310b416682370234d8d58c45ef0 to your computer and use it in GitHub Desktop.
Pencil node to SVG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| /** | |
| * Pencil .pen to SVG Converter | |
| * Converts Pencil design files directly to scalable SVG format. | |
| * | |
| * Usage: | |
| * bun pen-to-svg.ts <node_id> -f <file.pen> [-o output.svg] | |
| * bun pen-to-svg.ts --list -f <file.pen> | |
| */ | |
| import { parseArgs } from "util"; | |
| const { values, positionals } = parseArgs({ | |
| args: Bun.argv.slice(2), | |
| options: { | |
| output: { type: "string", short: "o", default: "export.svg" }, | |
| file: { type: "string", short: "f" }, | |
| list: { type: "boolean", short: "l", default: false }, | |
| help: { type: "boolean", short: "h", default: false }, | |
| }, | |
| allowPositionals: true, | |
| }); | |
| // Types for .pen file format | |
| interface PenNode { | |
| type: string; | |
| id: string; | |
| name?: string; | |
| x?: number; | |
| y?: number; | |
| width?: number; | |
| height?: number; | |
| fill?: string | PenGradient; | |
| stroke?: string; | |
| strokeWidth?: number; | |
| cornerRadius?: number | number[]; | |
| children?: PenNode[]; | |
| effect?: PenEffect; | |
| opacity?: number; | |
| content?: string; | |
| fontSize?: number; | |
| fontFamily?: string; | |
| fontWeight?: string | number; | |
| textAlign?: string; | |
| letterSpacing?: number; | |
| lineHeight?: number; | |
| rotation?: number; | |
| // Layout properties | |
| layout?: "none" | "horizontal" | "vertical"; | |
| gap?: number; | |
| padding?: number | number[]; | |
| alignItems?: "start" | "center" | "end" | "stretch"; | |
| justifyContent?: "start" | "center" | "end" | "space-between"; | |
| } | |
| interface PenGradient { | |
| type: "gradient"; | |
| gradientType: "linear" | "radial"; | |
| enabled?: boolean; | |
| rotation?: number; | |
| colors: Array<{ color: string; position: number }>; | |
| size?: { width?: number; height?: number }; | |
| } | |
| interface PenEffect { | |
| type: string; | |
| shadowType?: string; | |
| color?: string; | |
| offset?: { x: number; y: number }; | |
| blur?: number; | |
| spread?: number; | |
| } | |
| interface PenDocument { | |
| version: string; | |
| children: PenNode[]; | |
| } | |
| // SVG Builder | |
| class SVGBuilder { | |
| private defs: string[] = []; | |
| private defIds = new Set<string>(); | |
| private gradientCounter = 0; | |
| private filterCounter = 0; | |
| private escapeXml(str: string): string { | |
| return str | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| private addGradient(gradient: PenGradient): string { | |
| const id = `gradient-${++this.gradientCounter}`; | |
| if (this.defIds.has(id)) return id; | |
| this.defIds.add(id); | |
| const rotation = gradient.rotation || 0; | |
| const rad = (rotation * Math.PI) / 180; | |
| const x1 = 50 - Math.cos(rad) * 50; | |
| const y1 = 50 - Math.sin(rad) * 50; | |
| const x2 = 50 + Math.cos(rad) * 50; | |
| const y2 = 50 + Math.sin(rad) * 50; | |
| const stops = gradient.colors | |
| .map( | |
| (c) => | |
| `<stop offset="${c.position * 100}%" stop-color="${c.color.slice(0, 7)}"${ | |
| c.color.length > 7 ? ` stop-opacity="${parseInt(c.color.slice(7), 16) / 255}"` : "" | |
| }/>` | |
| ) | |
| .join("\n "); | |
| if (gradient.gradientType === "radial") { | |
| this.defs.push(` | |
| <radialGradient id="${id}" cx="50%" cy="50%" r="50%"> | |
| ${stops} | |
| </radialGradient>`); | |
| } else { | |
| this.defs.push(` | |
| <linearGradient id="${id}" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%"> | |
| ${stops} | |
| </linearGradient>`); | |
| } | |
| return id; | |
| } | |
| private addShadowFilter(effect: PenEffect): string { | |
| const id = `shadow-${++this.filterCounter}`; | |
| if (this.defIds.has(id)) return id; | |
| this.defIds.add(id); | |
| const dx = effect.offset?.x || 0; | |
| const dy = effect.offset?.y || 0; | |
| const blur = effect.blur || 0; | |
| const color = effect.color || "#00000040"; | |
| // Parse color with alpha | |
| const hex = color.slice(0, 7); | |
| const alpha = color.length > 7 ? parseInt(color.slice(7), 16) / 255 : 0.25; | |
| this.defs.push(` | |
| <filter id="${id}" x="-50%" y="-50%" width="200%" height="200%"> | |
| <feDropShadow dx="${dx}" dy="${dy}" stdDeviation="${blur / 2}" flood-color="${hex}" flood-opacity="${alpha}"/> | |
| </filter>`); | |
| return id; | |
| } | |
| private nodeToSvg(node: PenNode, offsetX = 0, offsetY = 0): string { | |
| const x = (node.x || 0) - offsetX; | |
| const y = (node.y || 0) - offsetY; | |
| const w = node.width || 0; | |
| const h = node.height || 0; | |
| let fill = "none"; | |
| if (node.fill) { | |
| if (typeof node.fill === "string") { | |
| fill = node.fill; | |
| } else if (node.fill.type === "gradient" && node.fill.enabled !== false) { | |
| fill = `url(#${this.addGradient(node.fill)})`; | |
| } | |
| } | |
| let filter = ""; | |
| if (node.effect?.type === "shadow") { | |
| filter = ` filter="url(#${this.addShadowFilter(node.effect)})"`; | |
| } | |
| const opacity = node.opacity !== undefined ? ` opacity="${node.opacity}"` : ""; | |
| const transform = node.rotation ? ` transform="rotate(${node.rotation} ${x + w / 2} ${y + h / 2})"` : ""; | |
| let stroke = ""; | |
| if (node.stroke) { | |
| stroke = ` stroke="${node.stroke}" stroke-width="${node.strokeWidth || 1}"`; | |
| } | |
| switch (node.type) { | |
| case "frame": | |
| case "group": { | |
| // Handle auto-layout for frames | |
| const children = node.children || []; | |
| const isAutoLayout = node.layout !== "none" && (node.gap !== undefined || node.alignItems); | |
| const isHorizontal = node.layout !== "vertical"; | |
| const gap = node.gap || 0; | |
| const padding = Array.isArray(node.padding) ? node.padding : [node.padding || 0]; | |
| const padTop = padding[0] || 0; | |
| const padRight = padding[1] ?? padTop; | |
| const padBottom = padding[2] ?? padTop; | |
| const padLeft = padding[3] ?? padRight; | |
| let childSvgs: string[] = []; | |
| if (isAutoLayout && children.length > 0) { | |
| // Calculate positions for auto-layout | |
| let currentPos = isHorizontal ? padLeft : padTop; | |
| for (const child of children) { | |
| const childW = child.width || 0; | |
| const childH = child.height || (child.fontSize || 16); // Text height fallback | |
| let childX: number; | |
| let childY: number; | |
| if (isHorizontal) { | |
| childX = currentPos; | |
| // Vertical alignment | |
| if (node.alignItems === "center") { | |
| childY = (h - childH) / 2; | |
| } else if (node.alignItems === "end") { | |
| childY = h - padBottom - childH; | |
| } else { | |
| childY = padTop; | |
| } | |
| currentPos += childW + gap; | |
| } else { | |
| childY = currentPos; | |
| // Horizontal alignment | |
| if (node.alignItems === "center") { | |
| childX = (w - childW) / 2; | |
| } else if (node.alignItems === "end") { | |
| childX = w - padRight - childW; | |
| } else { | |
| childX = padLeft; | |
| } | |
| currentPos += childH + gap; | |
| } | |
| // Create a modified child with computed position | |
| const positionedChild = { ...child, x: childX, y: childY }; | |
| childSvgs.push(this.nodeToSvg(positionedChild, 0, 0)); | |
| } | |
| } else { | |
| // No auto-layout, use explicit positions | |
| childSvgs = children.map((child) => this.nodeToSvg(child, 0, 0)); | |
| } | |
| return ` <g id="${node.id}"${opacity}${transform}> | |
| ${childSvgs.join("\n")} | |
| </g>`; | |
| } | |
| case "rectangle": { | |
| let rx = ""; | |
| if (node.cornerRadius) { | |
| const r = Array.isArray(node.cornerRadius) ? node.cornerRadius[0] : node.cornerRadius; | |
| rx = ` rx="${r}" ry="${r}"`; | |
| } | |
| return ` <rect id="${node.id}" x="${x}" y="${y}" width="${w}" height="${h}"${rx} fill="${fill}"${stroke}${filter}${opacity}${transform}/>`; | |
| } | |
| case "ellipse": { | |
| const cx = x + w / 2; | |
| const cy = y + h / 2; | |
| return ` <ellipse id="${node.id}" cx="${cx}" cy="${cy}" rx="${w / 2}" ry="${h / 2}" fill="${fill}"${stroke}${filter}${opacity}${transform}/>`; | |
| } | |
| case "text": { | |
| const fontSize = node.fontSize || 16; | |
| const fontFamily = node.fontFamily || "system-ui"; | |
| const fontWeight = node.fontWeight || "normal"; | |
| const letterSpacing = node.letterSpacing !== undefined ? ` letter-spacing="${node.letterSpacing}px"` : ""; | |
| const textAnchor = | |
| node.textAlign === "center" ? "middle" : node.textAlign === "right" ? "end" : "start"; | |
| const textX = node.textAlign === "center" ? x + w / 2 : node.textAlign === "right" ? x + w : x; | |
| // Use dominant-baseline for better vertical alignment | |
| const textY = Math.round((y + fontSize * 0.8) * 100) / 100; // Approximate baseline position | |
| const content = this.escapeXml(node.content || ""); | |
| // Use the font directly - it's installed if it's in the .pen file | |
| const fontStack = `'${fontFamily}', system-ui, sans-serif`; | |
| return ` <text id="${node.id}" x="${textX}" y="${textY}" font-size="${fontSize}" font-family="${fontStack}" font-weight="${fontWeight}" text-anchor="${textAnchor}"${letterSpacing} fill="${fill}"${opacity}${transform}>${content}</text>`; | |
| } | |
| case "line": { | |
| return ` <line id="${node.id}" x1="${x}" y1="${y}" x2="${x + w}" y2="${y + h}" stroke="${node.stroke || fill}" stroke-width="${node.strokeWidth || 1}"${opacity}${transform}/>`; | |
| } | |
| case "path": { | |
| // Path data would need special handling | |
| return ` <!-- Path node ${node.id} - needs path data -->`; | |
| } | |
| default: | |
| // For unknown types, try to render children | |
| if (node.children) { | |
| const children = node.children | |
| .map((child) => this.nodeToSvg(child, offsetX, offsetY)) | |
| .join("\n"); | |
| return ` <g id="${node.id}"${opacity}${transform}> | |
| ${children} | |
| </g>`; | |
| } | |
| return ` <!-- Unknown type: ${node.type} (${node.id}) -->`; | |
| } | |
| } | |
| convert(node: PenNode): string { | |
| // Use node's position as offset to normalize coordinates | |
| const offsetX = node.x || 0; | |
| const offsetY = node.y || 0; | |
| const width = node.width || 100; | |
| const height = node.height || 100; | |
| // Reset state | |
| this.defs = []; | |
| this.defIds.clear(); | |
| this.gradientCounter = 0; | |
| this.filterCounter = 0; | |
| // Convert node tree | |
| const content = this.nodeToSvg(node, offsetX, offsetY); | |
| // Build SVG | |
| const defsSection = this.defs.length > 0 ? ` <defs>${this.defs.join("")}\n </defs>\n` : ""; | |
| return `<?xml version="1.0" encoding="UTF-8"?> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"> | |
| ${defsSection}${content} | |
| </svg>`; | |
| } | |
| } | |
| function findNode(nodes: PenNode[], id: string): PenNode | null { | |
| for (const node of nodes) { | |
| if (node.id === id) return node; | |
| if (node.children) { | |
| const found = findNode(node.children, id); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| } | |
| function listNodes(nodes: PenNode[], indent = ""): void { | |
| for (const node of nodes) { | |
| const name = node.name ? ` "${node.name}"` : ""; | |
| const size = node.width && node.height ? ` (${node.width}×${node.height})` : ""; | |
| console.log(`${indent}${node.id}: ${node.type}${name}${size}`); | |
| if (node.children) { | |
| listNodes(node.children, indent + " "); | |
| } | |
| } | |
| } | |
| async function main() { | |
| if (values.help) { | |
| console.log(` | |
| Pencil .pen to SVG Converter | |
| Converts Pencil design files directly to scalable SVG format. | |
| Usage: | |
| bun pen-to-svg.ts <node_id> -f <file.pen> [-o output.svg] | |
| bun pen-to-svg.ts --list -f <file.pen> | |
| Options: | |
| -o, --output <path> Output SVG path (default: export.svg) | |
| -f, --file <path> Path to .pen file (required) | |
| -l, --list List all nodes in the file | |
| -h, --help Show this help | |
| Examples: | |
| bun pen-to-svg.ts 4FQG9 -f design.pen -o logo.svg | |
| bun pen-to-svg.ts --list -f design.pen | |
| `); | |
| process.exit(0); | |
| } | |
| if (!values.file) { | |
| console.error("Error: --file (-f) is required."); | |
| process.exit(1); | |
| } | |
| // Read and parse .pen file | |
| const penFile = await Bun.file(values.file).text(); | |
| const doc = JSON.parse(penFile) as PenDocument; | |
| if (values.list) { | |
| console.log("Nodes in", values.file); | |
| console.log("─".repeat(40)); | |
| listNodes(doc.children); | |
| return; | |
| } | |
| if (positionals.length === 0) { | |
| console.error("Error: Please specify a node ID to export."); | |
| process.exit(1); | |
| } | |
| const nodeId = positionals[0]; | |
| const node = findNode(doc.children, nodeId); | |
| if (!node) { | |
| console.error(`Error: Node "${nodeId}" not found.`); | |
| process.exit(1); | |
| } | |
| const builder = new SVGBuilder(); | |
| const svg = builder.convert(node); | |
| await Bun.write(values.output!, svg); | |
| console.log(`✓ Exported ${node.name || nodeId} to ${values.output}`); | |
| console.log(` Size: ${node.width}×${node.height}`); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment