/**
* exportSvg.ts
* Exports each slide as a standalone SVG file for Figma import.
*
* Figma imports SVG files via File > Import (or drag-and-drop) and treats
* every text element as editable, every shape as a vector layer.
* Each SVG lands as a top-level frame in Figma at 1080x1080.
*
* No external dependencies — SVG is generated directly from SlideContent.
*/
import fs from 'fs';
import path from 'path';
import type { SlideContent } from '../schema/carouselSchema';
import type { Theme } from '../render/themes';
const W = 1080;
const H = 1080;
// ---------------------------------------------------------------------------
// SVG primitives
// ---------------------------------------------------------------------------
function rect(
x: number, y: number, w: number, h: number,
fill: string, rx = 0, opacity = 1
): string {
return ``;
}
function circle(cx: number, cy: number, r: number, fill: string, opacity = 1): string {
return ``;
}
function text(
content: string,
x: number, y: number,
opts: {
fontSize?: number;
fontWeight?: number | string;
fill?: string;
textAnchor?: string;
fontFamily?: string;
letterSpacing?: number;
opacity?: number;
} = {}
): string {
const {
fontSize = 24,
fontWeight = 400,
fill = '#1a1a2e',
textAnchor = 'start',
fontFamily = 'Inter, Arial, sans-serif',
letterSpacing = 0,
opacity = 1,
} = opts;
const escaped = content
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
return `${escaped}`;
}
/**
* Wrap long text into multiple SVG lines.
* Returns an array of lines split at maxChars.
*/
function wrapText(str: string, maxChars: number): string[] {
if (!str) return [];
const words = str.split(' ');
const lines: string[] = [];
let current = '';
for (const word of words) {
if ((current + ' ' + word).trim().length > maxChars) {
if (current) lines.push(current.trim());
current = word;
} else {
current = current ? current + ' ' + word : word;
}
}
if (current) lines.push(current.trim());
return lines;
}
function multilineText(
content: string,
x: number, y: number,
lineHeight: number,
maxChars: number,
opts: Parameters[3] = {}
): string {
const lines = wrapText(content, maxChars);
return lines
.map((line, i) => text(line, x, y + i * lineHeight, opts))
.join('\n ');
}
function eyebrow(label: string, x: number, y: number, fill: string): string {
return text(label, x, y, {
fontSize: 18, fontWeight: 600, fill, letterSpacing: 3,
});
}
function slideNumber(num: string, fill: string): string {
return text(num, W - 80, 52, { fontSize: 22, fontWeight: 500, fill, textAnchor: 'end' });
}
// ---------------------------------------------------------------------------
// Per-template SVG builders
// ---------------------------------------------------------------------------
function buildHookSvg(content: SlideContent, theme: Theme): string {
const { bgColor, accentColor, textColor, subtextColor } = theme;
const titleLines = wrapText(content.title ?? '', 30);
const subtitleLines = wrapText(content.subtitle ?? '', 48);
const titleY = 210;
const titleLineH = 78;
const subtitleY = titleY + titleLines.length * titleLineH + 30;
return `
${rect(0, 0, W, H, bgColor)}
${rect(0, 0, 10, H, accentColor)}
${slideNumber('01', subtextColor)}
${eyebrow('CAREER CAROUSEL', 88, 140, accentColor)}
${titleLines.map((line, i) =>
text(line, 88, titleY + i * titleLineH, { fontSize: 70, fontWeight: 800, fill: textColor })
).join('\n ')}
${subtitleLines.map((line, i) =>
text(line, 88, subtitleY + i * 40, { fontSize: 28, fill: subtextColor })
).join('\n ')}
${content.body ? rect(88, subtitleY + subtitleLines.length * 40 + 24, 360, 56, accentColor, 8) : ''}
${content.body ? text(content.body.slice(0, 50), 268, subtitleY + subtitleLines.length * 40 + 58, {
fontSize: 20, fontWeight: 600, fill: '#FFFFFF', textAnchor: 'middle',
}) : ''}
${text('Swipe to see why →', 88, H - 52, { fontSize: 20, fill: subtextColor })}
`.trim();
}
function buildFitSvg(content: SlideContent, theme: Theme): string {
const { bgColor, accentColor, textColor, subtextColor, cardBg } = theme;
const bullets = content.bullets ?? [];
const titleLines = wrapText(content.title ?? '', 32);
const cardYs = [430, 610, 790];
return `
${rect(0, 0, W, H, bgColor)}
${rect(0, 0, 10, H, accentColor)}
${slideNumber('02', subtextColor)}
${eyebrow('ROLE FIT', 88, 110, accentColor)}
${titleLines.map((line, i) =>
text(line, 88, 170 + i * 72, { fontSize: 56, fontWeight: 800, fill: textColor })
).join('\n ')}
${content.subtitle ? text(content.subtitle, 88, 170 + titleLines.length * 72 + 16, {
fontSize: 24, fill: subtextColor,
}) : ''}
${bullets.slice(0, 3).map((bullet, i) => {
const colonIdx = bullet.indexOf(':');
const area = colonIdx > -1 ? bullet.substring(0, colonIdx).trim() : `Strength ${i + 1}`;
const evidence = colonIdx > -1 ? bullet.substring(colonIdx + 1).trim() : bullet;
const y = cardYs[i] ?? 430 + i * 180;
const badgeCy = y + 88;
return `
${rect(88, y, 904, 140, cardBg, 8)}
${rect(88, y, 4, 140, accentColor)}
${circle(152, badgeCy, 30, accentColor)}
${text(String(i + 1), 152, badgeCy + 10, { fontSize: 22, fontWeight: 700, fill: '#FFFFFF', textAnchor: 'middle' })}
${text(area, 204, y + 52, { fontSize: 26, fontWeight: 700, fill: textColor })}
${text(evidence.slice(0, 70) + (evidence.length > 70 ? '…' : ''), 204, y + 90, { fontSize: 20, fill: subtextColor })}
`.trim();
}).join('\n ')}
`.trim();
}
function buildProofSvg(content: SlideContent, theme: Theme, slideNum: number): string {
const { bgColor, accentColor, textColor, subtextColor } = theme;
const label = slideNum === 3 ? 'SIGNATURE ACHIEVEMENT' : 'SUPPORTING ACHIEVEMENT';
const numStr = String(slideNum).padStart(2, '0');
const metricY = 240;
const titleY = content.metric ? metricY + 140 : 200;
const titleLines = wrapText(content.title ?? '', 36);
const bodyLines = wrapText(content.body ?? '', 52);
return `
${rect(0, 0, W, H, bgColor)}
${slideNumber(numStr, subtextColor)}
${eyebrow(label, 88, 140, accentColor)}
${content.metric ? text(content.metric, 88, metricY, { fontSize: 120, fontWeight: 800, fill: textColor }) : ''}
${titleLines.map((line, i) =>
text(line, 88, titleY + i * 60, { fontSize: 44, fontWeight: 700, fill: textColor })
).join('\n ')}
${bodyLines.map((line, i) =>
text(line, 88, titleY + titleLines.length * 60 + 40 + i * 40, { fontSize: 26, fill: subtextColor })
).join('\n ')}
${content.subtitle ? `
${rect(88, H - 160, Math.min(content.subtitle.length * 11, 600), 48, bgColor, 24)}
${rect(88, H - 160, Math.min(content.subtitle.length * 11, 600), 48, accentColor + '18', 24)}
${text(content.subtitle.slice(0, 70), 110, H - 128, { fontSize: 18, fill: subtextColor })}
`.trim() : ''}
`.trim();
}
function buildWorkStyleSvg(content: SlideContent, theme: Theme): string {
const { bgColor, accentColor, textColor, subtextColor } = theme;
const bullets = content.bullets ?? [];
const bulletStartY = 570;
const bulletLineH = 180;
return `
${rect(0, 0, W, H, bgColor)}
${circle(W + 80, -80, 300, '#F0F2F5')}
${slideNumber('05', subtextColor)}
${eyebrow('WORKING STYLE', 88, 340, accentColor)}
${text(content.title ?? '', 88, 420, { fontSize: 56, fontWeight: 800, fill: textColor })}
${content.subtitle ? text(content.subtitle, 88, 490, { fontSize: 24, fill: subtextColor }) : ''}
${bullets.slice(0, 3).map((bullet, i) => {
const y = bulletStartY + i * bulletLineH;
return `
${rect(88, y, 904, 1, '#E5E7EB')}
${circle(106, y + 36, 10, textColor)}
${text(bullet.slice(0, 80), 136, y + 48, { fontSize: 26, fill: textColor })}
`.trim();
}).join('\n ')}
`.trim();
}
function buildValuePropSvg(content: SlideContent, theme: Theme): string {
const { accentColor, textColor, subtextColor } = theme;
const titleLines = wrapText(content.title ?? '', 28);
const bodyLines = wrapText(content.body ?? '', 50);
return `
${rect(0, 0, W, H, '#EEF2FF')}
${circle(W - 80, H + 80, 380, '#DDE3F5')}
${slideNumber('06', subtextColor)}
${eyebrow('VALUE PROPOSITION', 88, 200, accentColor)}
${titleLines.map((line, i) =>
text(line, 88, 270 + i * 80, { fontSize: 58, fontWeight: 800, fill: textColor })
).join('\n ')}
${rect(88, 270 + titleLines.length * 80 + 24, 100, 8, accentColor, 4)}
${bodyLines.map((line, i) =>
text(line, 88, 270 + titleLines.length * 80 + 72 + i * 48, { fontSize: 28, fill: subtextColor })
).join('\n ')}
${content.subtitle ? `
${rect(88, 720, 6, 100, accentColor)}
${multilineText(
`"${content.subtitle.slice(0, 120)}"`,
114, 748, 40, 55,
{ fontSize: 22, fill: subtextColor, fontWeight: 400 }
)}
`.trim() : ''}
`.trim();
}
function buildCtaSvg(content: SlideContent, theme: Theme): string {
const { subtextColor } = theme;
const titleLines = wrapText(content.title ?? '', 30);
const bodyLines = wrapText(content.body ?? '', 48);
return `
${rect(0, 0, W, H, '#152244')}
${circle(-100, -100, 420, '#FFFFFF', 0.04)}
${circle(-200, -200, 580, '#FFFFFF', 0.03)}
${slideNumber('07', '#AAAAAA')}
${eyebrow("LET'S CONNECT", W / 2, 310, '#FFFFFF')}
${titleLines.map((line, i) =>
text(line, W / 2, 390 + i * 80, { fontSize: 56, fontWeight: 800, fill: '#FFFFFF', textAnchor: 'middle' })
).join('\n ')}
${bodyLines.map((line, i) =>
text(line, W / 2, 390 + titleLines.length * 80 + 40 + i * 40, {
fontSize: 24, fill: '#CCCCCC', textAnchor: 'middle',
})
).join('\n ')}
${content.cta ? `
${rect(140, 780, W - 280, 72, '#FFFFFF', 36)}
${text(content.cta.replace(/📩\s?/, '').slice(0, 70), W / 2, 826, {
fontSize: 20, fontWeight: 700, fill: '#152244', textAnchor: 'middle',
})}
`.trim() : ''}
${content.subtitle ? text(content.subtitle.slice(0, 60), W / 2, H - 52, {
fontSize: 18, fill: '#888888', textAnchor: 'middle',
}) : ''}
`.trim();
}
function buildTimelineSvg(content: SlideContent, theme: Theme): string {
const { bgColor, accentColor, textColor, subtextColor, borderColor } = theme;
const EDU_COLOR = '#6366F1';
const entries = content.timelineEntries ?? [];
const TIMELINE_Y = 580;
const X_START = 100;
const X_END = 980;
const X_SPAN = X_END - X_START;
const numStr = String(content.slideNumber).padStart(2, '0');
const years = entries.map((e) => parseInt(e.year, 10)).filter((y) => !isNaN(y));
const minYear = years.length ? Math.min(...years) : 2015;
const maxYear = years.length ? Math.max(...years) : 2024;
const yearSpan = Math.max(maxYear - minYear, 1);
const getX = (year: string): number => {
const y = parseInt(year, 10);
return isNaN(y) ? X_START : X_START + ((y - minYear) / yearSpan) * X_SPAN;
};
const titleLines = wrapText(content.title ?? '', 28);
const CONNECTOR_H = 60;
const entryElements = entries.map((entry, i) => {
const x = getX(entry.year);
const isAbove = i % 2 === 0;
const nodeColor = entry.isEducation ? EDU_COLOR : entry.isHighlighted ? accentColor : '#9CA3AF';
const nodeR = entry.isHighlighted ? 18 : 11;
const lineY1 = isAbove ? TIMELINE_Y - CONNECTOR_H : TIMELINE_Y;
const lineY2 = lineY1 + CONNECTOR_H;
// Label positions
const labelY = isAbove ? TIMELINE_Y - CONNECTOR_H - 48 : TIMELINE_Y + CONNECTOR_H + 24;
const sublabelY = isAbove ? TIMELINE_Y - CONNECTOR_H - 16 : TIMELINE_Y + CONNECTOR_H + 56;
const yearY = isAbove ? TIMELINE_Y + 30 : TIMELINE_Y - 14;
const labelLines = wrapText(entry.label, 18);
return `
${entry.year}
${labelLines.map((line, li) =>
`${line}`
).join('\n ')}
${entry.sublabel ? `${entry.sublabel.slice(0, 24)}` : ''}
`.trim();
}).join('\n ');
const hasEducation = entries.some((e) => e.isEducation);
const legendItems = [
{ color: accentColor, label: 'Relevant to target role', shape: 'circle' },
{ color: '#9CA3AF', label: 'Other experience', shape: 'circle' },
...(hasEducation ? [{ color: EDU_COLOR, label: 'Education', shape: 'diamond' }] : []),
];
const legendSvg = legendItems.map((item, i) => {
const lx = 88 + i * 280;
const ly = H - 60;
const shape = item.shape === 'diamond'
? ``
: ``;
return `${shape}${item.label}`;
}).join('\n ');
return `
${rect(0, 0, W, H, bgColor)}
${rect(0, 0, W, 8, accentColor)}
${slideNumber(numStr, subtextColor)}
${eyebrow('CAREER TRAJECTORY', 88, 90, accentColor)}
${titleLines.map((line, i) =>
text(line, 88, 150 + i * 70, { fontSize: 56, fontWeight: 800, fill: textColor })
).join('\n ')}
${content.subtitle ? text(content.subtitle, 88, 150 + titleLines.length * 70 + 28, {
fontSize: 22, fill: subtextColor,
}) : ''}
${entryElements}
${legendSvg}
`.trim();
}
// ---------------------------------------------------------------------------
// Slide router
// ---------------------------------------------------------------------------
function buildSlideSvgBody(content: SlideContent, theme: Theme): string {
switch (content.templateType) {
case 'hook': return buildHookSvg(content, theme);
case 'timeline': return buildTimelineSvg(content, theme);
case 'fit': return buildFitSvg(content, theme);
case 'proof': return buildProofSvg(content, theme, content.slideNumber);
case 'workstyle': return buildWorkStyleSvg(content, theme);
case 'valueprop': return buildValuePropSvg(content, theme);
case 'cta': return buildCtaSvg(content, theme);
default: return buildHookSvg(content, theme);
}
}
function wrapInSvgDocument(body: string): string {
return `
`;
}
// ---------------------------------------------------------------------------
// Public exporter
// ---------------------------------------------------------------------------
/**
* Export each slide as an SVG file into outputDir/figma_export/.
* Each file is named slide_01.svg, slide_02.svg, etc.
*
* Import into Figma: File > Import (or drag SVG files onto the canvas).
* Each slide becomes a fully editable frame with vector shapes and live text.
*
* Returns an array of absolute file paths.
*/
export function exportToSvgs(
slides: SlideContent[],
theme: Theme,
outputDir: string
): string[] {
const svgDir = path.join(outputDir, 'figma_export');
fs.mkdirSync(svgDir, { recursive: true });
const svgPaths: string[] = [];
for (let i = 0; i < slides.length; i++) {
const content = slides[i];
const body = buildSlideSvgBody(content, theme);
const svgDoc = wrapInSvgDocument(body);
const filename = `slide_${String(i + 1).padStart(2, '0')}.svg`;
const filepath = path.join(svgDir, filename);
fs.writeFileSync(filepath, svgDoc, 'utf-8');
svgPaths.push(filepath);
console.log(` ✓ SVG exported: figma_export/${filename}`);
}
// Write an import guide alongside the SVGs
const guide = `# Figma Import Guide
## How to import these SVGs into Figma
1. Open Figma (web or desktop)
2. Create a new file or open an existing one
3. Go to: File > Import (or simply drag all SVG files onto the Figma canvas)
4. Select all slide_*.svg files from this folder
5. Each slide will appear as a 1080×1080 frame, fully editable:
- Text elements are live and editable
- Shapes and backgrounds are vector layers
- Colors can be updated via the color picker
## Tips
- Group all 7 slides into a "Carousel" frame for easy layout
- Use Figma's "Auto Layout" to space slides for the LinkedIn preview
- Export as PNG at 2x for LinkedIn upload (2160×2160)
- The carousel.json in the parent folder contains all slide content
if you need to re-import or regenerate
## Slide files
${slides.map((s, i) => `- slide_${String(i + 1).padStart(2, '0')}.svg — ${s.templateType} (${s.title?.slice(0, 40) ?? ''})`).join('\n')}
`;
fs.writeFileSync(path.join(svgDir, 'FIGMA_IMPORT_GUIDE.md'), guide, 'utf-8');
return svgPaths;
}