/** * exportPptx.ts * Exports carousel slides to a PPTX file for Canva import. * * Canva supports PPTX import and makes all text/shapes editable. * Each slide maps to a pptxgenjs slide with positioned text boxes and shapes. * * Install: npm install pptxgenjs @types/pptxgenjs */ import fs from 'fs'; import path from 'path'; import PptxGenJS from 'pptxgenjs'; import type { SlideContent } from '../schema/carouselSchema'; import type { Theme } from '../render/themes'; // pptxgenjs does not export its internal Slide type publicly — use any // eslint-disable-next-line @typescript-eslint/no-explicit-any type PptxSlide = any; // Slide dimensions — 10" x 10" square (matches 1080x1080 at 108 DPI) const SLIDE_W = 10; const SLIDE_H = 10; // Helper: convert hex color to pptxgenjs-compatible format (strip #) function hex(color: string): string { return color.replace('#', ''); } // Helper: truncate text to a max character length for slide safety function cap(text: string | undefined, maxChars: number): string { if (!text) return ''; return text.length > maxChars ? text.slice(0, maxChars - 1) + '…' : text; } // --------------------------------------------------------------------------- // Per-template slide builders // --------------------------------------------------------------------------- async function buildHookSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); // Background slide.background = { color: bg }; // Top accent bar slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.12, h: SLIDE_H, fill: { color: accent }, line: { color: accent, width: 0 }, }); // Slide number badge slide.addText('01', { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', bold: false, }); // Eyebrow label slide.addText('CAREER CAROUSEL', { x: 0.9, y: 1.1, w: 8, h: 0.4, fontSize: 11, color: accent, bold: true, charSpacing: 3, align: 'left', }); // Headline (title) slide.addText(cap(content.title, 120), { x: 0.9, y: 1.7, w: 8.0, h: 3.2, fontSize: 44, color: text, bold: true, align: 'left', valign: 'top', wrap: true, }); // Subtitle if (content.subtitle) { slide.addText(cap(content.subtitle, 160), { x: 0.9, y: 5.2, w: 7.5, h: 1.2, fontSize: 20, color: subtext, align: 'left', wrap: true, }); } // Body pill / CTA box if (content.body) { slide.addText(cap(content.body, 100), { x: 0.9, y: 6.6, w: 4.5, h: 0.6, fontSize: 16, color: '#FFFFFF', fill: { color: accent }, align: 'center', bold: true, }); } // Swipe hint slide.addText('Swipe to see why →', { x: 0.9, y: SLIDE_H - 0.7, w: 5, h: 0.35, fontSize: 13, color: subtext, }); } async function buildFitSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); const cardBg = hex(theme.cardBg); slide.background = { color: bg }; // Left accent bar slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.12, h: SLIDE_H, fill: { color: accent }, line: { color: accent, width: 0 }, }); // Slide number slide.addText('02', { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', }); // Eyebrow slide.addText('ROLE FIT', { x: 0.9, y: 0.9, w: 4, h: 0.35, fontSize: 11, color: accent, bold: true, charSpacing: 3, }); // Title slide.addText(cap(content.title, 80), { x: 0.9, y: 1.4, w: 8.0, h: 1.6, fontSize: 38, color: text, bold: true, align: 'left', wrap: true, }); // Subtitle if (content.subtitle) { slide.addText(cap(content.subtitle, 100), { x: 0.9, y: 3.1, w: 7, h: 0.5, fontSize: 18, color: subtext, }); } // Bullet cards const bullets = content.bullets ?? []; const cardY = [3.9, 5.5, 7.1]; bullets.slice(0, 3).forEach((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 = cardY[i] ?? 3.9 + i * 1.6; // Card background slide.addShape(pptx.ShapeType.rect, { x: 0.9, y, w: 8.2, h: 1.3, fill: { color: cardBg }, line: { color: accent, width: 3, dashType: 'solid' }, }); // Number badge slide.addText(String(i + 1), { x: 1.1, y: y + 0.3, w: 0.55, h: 0.55, fontSize: 17, color: '#FFFFFF', bold: true, fill: { color: accent }, align: 'center', }); // Area label slide.addText(area, { x: 1.85, y: y + 0.1, w: 6.8, h: 0.45, fontSize: 19, color: text, bold: true, }); // Evidence slide.addText(cap(evidence, 90), { x: 1.85, y: y + 0.58, w: 6.8, h: 0.55, fontSize: 15, color: subtext, }); }); } async function buildProofSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme, slideNumber: number ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); const label = slideNumber === 3 ? 'SIGNATURE ACHIEVEMENT' : 'SUPPORTING ACHIEVEMENT'; const numStr = String(slideNumber).padStart(2, '0'); slide.background = { color: bg }; // Slide number slide.addText(numStr, { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', }); // Eyebrow slide.addText(label, { x: 0.9, y: 1.1, w: 7, h: 0.4, fontSize: 11, color: accent, bold: true, charSpacing: 3, }); // Metric hero if (content.metric) { slide.addText(content.metric, { x: 0.9, y: 1.65, w: 5, h: 1.4, fontSize: 80, color: text, bold: true, }); } // Title const titleY = content.metric ? 3.15 : 1.65; slide.addText(cap(content.title, 80), { x: 0.9, y: titleY, w: 8.0, h: 1.4, fontSize: 30, color: text, bold: true, align: 'left', wrap: true, }); // Body if (content.body) { slide.addText(cap(content.body, 200), { x: 0.9, y: titleY + 1.55, w: 8.0, h: 1.8, fontSize: 18, color: subtext, align: 'left', wrap: true, }); } // Source tag if (content.subtitle) { slide.addText(cap(content.subtitle, 80), { x: 0.9, y: titleY + 3.6, w: 6, h: 0.45, fontSize: 13, color: subtext, line: { color: hex(theme.subtextColor), width: 1 }, }); } } async function buildWorkStyleSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); slide.background = { color: bg }; // Decorative circle slide.addShape(pptx.ShapeType.ellipse, { x: 6.5, y: -1.2, w: 4.5, h: 4.5, fill: { color: 'F0F2F5' }, line: { color: 'F0F2F5', width: 0 }, }); // Slide number slide.addText('05', { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', }); // Eyebrow slide.addText('WORKING STYLE', { x: 0.9, y: 2.8, w: 5, h: 0.4, fontSize: 11, color: accent, bold: true, charSpacing: 3, }); // Title slide.addText(cap(content.title, 60), { x: 0.9, y: 3.3, w: 8, h: 1.2, fontSize: 40, color: text, bold: true, wrap: true, }); // Subtitle if (content.subtitle) { slide.addText(cap(content.subtitle, 80), { x: 0.9, y: 4.65, w: 7, h: 0.45, fontSize: 18, color: subtext, }); } // Bullets with dividers const bullets = content.bullets ?? []; const bulletY = [5.4, 6.6, 7.8]; bullets.slice(0, 3).forEach((bullet, i) => { const y = bulletY[i] ?? 5.4 + i * 1.2; // Divider line above slide.addShape(pptx.ShapeType.line, { x: 0.9, y, w: 8.2, h: 0, line: { color: 'E5E7EB', width: 1 }, }); // Bullet dot slide.addShape(pptx.ShapeType.ellipse, { x: 0.9, y: y + 0.2, w: 0.18, h: 0.18, fill: { color: text }, line: { color: text, width: 0 }, }); // Bullet text slide.addText(cap(bullet, 100), { x: 1.25, y: y + 0.07, w: 7.8, h: 0.8, fontSize: 18, color: text, }); }); } async function buildValuePropSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); // Slightly tinted background slide.background = { color: 'EEF2FF' }; // Decorative circle slide.addShape(pptx.ShapeType.ellipse, { x: 5.5, y: 5.5, w: 5.5, h: 5.5, fill: { color: 'DDE3F5' }, line: { color: 'DDE3F5', width: 0 }, }); // Slide number slide.addText('06', { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', }); // Eyebrow slide.addText('VALUE PROPOSITION', { x: 0.9, y: 1.8, w: 6, h: 0.4, fontSize: 11, color: accent, bold: true, charSpacing: 3, }); // Title slide.addText(cap(content.title, 80), { x: 0.9, y: 2.4, w: 8, h: 1.8, fontSize: 38, color: text, bold: true, wrap: true, }); // Accent divider slide.addShape(pptx.ShapeType.rect, { x: 0.9, y: 4.4, w: 0.9, h: 0.08, fill: { color: accent }, line: { color: accent, width: 0 }, }); // Body if (content.body) { slide.addText(cap(content.body, 200), { x: 0.9, y: 4.7, w: 7.5, h: 1.6, fontSize: 20, color: subtext, wrap: true, }); } // Quote / subheading if (content.subtitle) { slide.addText(`"${cap(content.subtitle, 120)}"`, { x: 1.2, y: 6.6, w: 7, h: 1.4, fontSize: 16, color: subtext, italic: true, wrap: true, line: { color: accent, width: 3, dashType: 'solid' }, }); } } async function buildTimelineSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const bg = hex(theme.bgColor); const accent = hex(theme.accentColor); const text = hex(theme.textColor); const subtext = hex(theme.subtextColor); const border = hex(theme.borderColor); const EDU_COLOR = '6366F1'; slide.background = { color: bg }; // Top accent bar slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: SLIDE_W, h: 0.07, fill: { color: accent }, line: { color: accent, width: 0 }, }); // Slide number slide.addText(String(content.slideNumber).padStart(2, '0'), { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: subtext, align: 'right', }); // Eyebrow slide.addText('CAREER TRAJECTORY', { x: 0.9, y: 0.65, w: 6, h: 0.35, fontSize: 11, color: accent, bold: true, charSpacing: 3, }); // Title slide.addText(cap(content.title, 80), { x: 0.9, y: 1.1, w: 8.2, h: 1.4, fontSize: 38, color: text, bold: true, wrap: true, }); // Subtitle if (content.subtitle) { slide.addText(cap(content.subtitle, 100), { x: 0.9, y: 2.6, w: 8.2, h: 0.5, fontSize: 16, color: subtext, }); } // --- Timeline --- const entries = content.timelineEntries ?? []; if (entries.length === 0) return; const TIMELINE_Y = 5.8; // inches from top const X_START = 0.9; const X_END = 9.1; const X_SPAN = X_END - X_START; // Timeline line slide.addShape(pptx.ShapeType.line, { x: X_START, y: TIMELINE_Y, w: X_SPAN, h: 0, line: { color: border, width: 2 }, }); const years = entries.map((e) => parseInt(e.year, 10)).filter((y) => !isNaN(y)); const minYear = Math.min(...years); const maxYear = Math.max(...years); 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; }; entries.forEach((entry, i) => { const x = getX(entry.year); const isAbove = i % 2 === 0; const nodeColor = entry.isEducation ? EDU_COLOR : entry.isHighlighted ? accent : 'D1D5DB'; const nodeR = entry.isHighlighted ? 0.18 : 0.12; const CONNECTOR_H = 0.6; // Connector line slide.addShape(pptx.ShapeType.line, { x, y: isAbove ? TIMELINE_Y - CONNECTOR_H : TIMELINE_Y, w: 0, h: CONNECTOR_H, line: { color: nodeColor, width: 2 }, }); // Node slide.addShape(pptx.ShapeType.ellipse, { x: x - nodeR, y: TIMELINE_Y - nodeR, w: nodeR * 2, h: nodeR * 2, fill: { color: nodeColor }, line: { color: nodeColor, width: 0 }, }); // Year const yearY = isAbove ? TIMELINE_Y + 0.15 : TIMELINE_Y - 0.35; slide.addText(entry.year, { x: x - 0.35, y: yearY, w: 0.7, h: 0.25, fontSize: 10, color: subtext, align: 'center', }); // Title label const BOX_W = 1.6; const labelX = Math.min(Math.max(x - BOX_W / 2, X_START), X_END - BOX_W); const labelY = isAbove ? TIMELINE_Y - CONNECTOR_H - 0.45 : TIMELINE_Y + CONNECTOR_H + 0.05; slide.addText(cap(entry.label, 30), { x: labelX, y: labelY, w: BOX_W, h: 0.35, fontSize: entry.isHighlighted ? 13 : 11, bold: entry.isHighlighted, color: entry.isEducation ? EDU_COLOR : entry.isHighlighted ? text : subtext, align: 'center', wrap: true, }); // Sublabel if (entry.sublabel) { slide.addText(cap(entry.sublabel, 30), { x: labelX, y: labelY + 0.38, w: BOX_W, h: 0.28, fontSize: 10, color: subtext, align: 'center', wrap: true, }); } }); // Legend const hasEducation = entries.some((e) => e.isEducation); const legendY = 9.0; const legendItems: Array<{ color: string; label: string }> = [ { color: accent, label: 'Relevant to target role' }, { color: 'D1D5DB', label: 'Other experience' }, ...(hasEducation ? [{ color: EDU_COLOR, label: 'Education' }] : []), ]; legendItems.forEach((item, i) => { const lx = 0.9 + i * 2.8; slide.addShape(pptx.ShapeType.ellipse, { x: lx, y: legendY, w: 0.14, h: 0.14, fill: { color: item.color }, line: { color: item.color, width: 0 }, }); slide.addText(item.label, { x: lx + 0.22, y: legendY - 0.02, w: 2.4, h: 0.22, fontSize: 11, color: subtext, }); }); } async function buildCtaSlide( pptx: PptxGenJS, slide: PptxSlide, content: SlideContent, theme: Theme ): Promise { const accent = hex(theme.accentColor); const text = hex(theme.textColor); // Dark navy background slide.background = { color: '152244' }; // Decorative rings slide.addShape(pptx.ShapeType.ellipse, { x: -1.5, y: -1.5, w: 6, h: 6, fill: { type: 'none' }, line: { color: 'FFFFFF', width: 1, transparency: 85 }, }); slide.addShape(pptx.ShapeType.ellipse, { x: -2.5, y: -2.5, w: 8, h: 8, fill: { type: 'none' }, line: { color: 'FFFFFF', width: 1, transparency: 85 }, }); // Slide number slide.addText('07', { x: SLIDE_W - 1.2, y: 0.3, w: 0.9, h: 0.4, fontSize: 14, color: 'FFFFFF', align: 'right', }); // Eyebrow slide.addText("LET'S CONNECT", { x: 2.0, y: 2.8, w: 6, h: 0.4, fontSize: 11, color: 'FFFFFF', bold: true, charSpacing: 4, align: 'center', }); // Title slide.addText(cap(content.title, 80), { x: 0.9, y: 3.5, w: 8.2, h: 2.0, fontSize: 38, color: 'FFFFFF', bold: true, align: 'center', wrap: true, }); // Body if (content.body) { slide.addText(cap(content.body, 150), { x: 1.0, y: 5.7, w: 8.0, h: 0.8, fontSize: 18, color: 'CCCCCC', align: 'center', wrap: true, }); } // CTA button if (content.cta) { slide.addText(cap(content.cta.replace(/📩\s?/, ''), 80), { x: 1.5, y: 6.8, w: 7.0, h: 0.75, fontSize: 16, color: '152244', bold: true, fill: { color: 'FFFFFF' }, align: 'center', }); } // Subtitle if (content.subtitle) { slide.addText(cap(content.subtitle, 80), { x: 2.0, y: 7.85, w: 6, h: 0.45, fontSize: 14, color: 'AAAAAA', align: 'center', }); } } // --------------------------------------------------------------------------- // Main exporter // --------------------------------------------------------------------------- /** * Export all slides to a PPTX file suitable for Canva import. * Returns the path to the generated .pptx file. */ export async function exportToPptx( slides: SlideContent[], theme: Theme, outputDir: string ): Promise { const pptx = new PptxGenJS(); // Register a custom square layout matching 1080x1080 at 108 DPI (10" x 10") pptx.defineLayout({ name: 'SQUARE_1080', width: SLIDE_W, height: SLIDE_H }); pptx.layout = 'SQUARE_1080'; for (const content of slides) { const slide = pptx.addSlide(); switch (content.templateType) { case 'hook': await buildHookSlide(pptx, slide, content, theme); break; case 'timeline': await buildTimelineSlide(pptx, slide, content, theme); break; case 'fit': await buildFitSlide(pptx, slide, content, theme); break; case 'proof': await buildProofSlide(pptx, slide, content, theme, content.slideNumber); break; case 'workstyle': await buildWorkStyleSlide(pptx, slide, content, theme); break; case 'valueprop': await buildValuePropSlide(pptx, slide, content, theme); break; case 'cta': await buildCtaSlide(pptx, slide, content, theme); break; default: slide.addText(content.title ?? '', { x: 0.5, y: 0.5, w: 9, h: 1.5, fontSize: 32, color: hex(theme.textColor), bold: true, }); } } fs.mkdirSync(outputDir, { recursive: true }); const pptxPath = path.join(outputDir, 'linkedin_carousel.pptx'); await pptx.writeFile({ fileName: pptxPath }); console.log(` ✓ PPTX exported: linkedin_carousel.pptx (${slides.length} slides)`); return pptxPath; }