/** * 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 ` ${body} `; } // --------------------------------------------------------------------------- // 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; }