Skip to content

Instantly share code, notes, and snippets.

@claudioluciano
Created January 24, 2026 13:29
Show Gist options
  • Select an option

  • Save claudioluciano/25e12310b416682370234d8d58c45ef0 to your computer and use it in GitHub Desktop.

Select an option

Save claudioluciano/25e12310b416682370234d8d58c45ef0 to your computer and use it in GitHub Desktop.
Pencil node to SVG
#!/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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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