'use server' // import type is erased at build time — Turbopack won't try to resolve the module import type PptxGenJSModule from 'pptxgenjs' // Lazy singleton — actual module loaded at runtime only let _PptxGenJS: (new () => PptxGenJSModule) | null = null async function getPptxGenClass(): Promise PptxGenJSModule> { if (!_PptxGenJS) { const mod = await import('pptxgenjs') _PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule } return _PptxGenJS } import { tool } from 'ai' import { z } from 'zod' import { toolRegistry } from './registry' import { prisma } from '@/lib/prisma' interface SlideSpec { title: string subtitle?: string content: string[] notes?: string imageUrl?: string layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'timeline' | 'process' | 'comparison' | 'metrics' | 'image-content' | 'image-full' } interface PresentationSpec { title: string slides: SlideSpec[] theme?: string style?: 'sharp' | 'soft' | 'rounded' | 'pill' } interface Theme { primary: string secondary: string accent: string light: string bg: string } const PALETTES: Record = { // bg always clean, primary always dark enough for headers, accent/secondary distinct modern_wellness: { primary: '006d77', secondary: '83c5be', accent: 'e29578', light: 'edf6f9', bg: 'ffffff' }, business_authority: { primary: '2b2d42', secondary: '3d405b', accent: 'ef233c', light: 'f0f2f5', bg: 'ffffff' }, nature_outdoors: { primary: '606c38', secondary: '283618', accent: 'dda15e', light: 'f5f4e8', bg: 'fefefe' }, vintage_academic: { primary: '780000', secondary: '4a5568', accent: 'c1121f', light: 'fdf0d5', bg: 'fffdf7' }, soft_creative: { primary: '7209b7', secondary: 'c77dff', accent: 'f72585', light: 'f8edff', bg: 'ffffff' }, bohemian: { primary: '8b5e3c', secondary: 'd4a373', accent: 'a2836e', light: 'fefae0', bg: 'fdfaf4' }, vibrant_tech: { primary: '023047', secondary: '219ebc', accent: 'ffb703', light: 'e8f4f8', bg: 'ffffff' }, craft_artisan: { primary: '5c3d2e', secondary: 'a68a64', accent: 'd4a574', light: 'f5ede4', bg: 'faf6f2' }, tech_night: { primary: 'e2e8f0', secondary: '60a5fa', accent: 'fbbf24', light: '1e293b', bg: '0f172a' }, education_charts: { primary: '264653', secondary: '2a9d8f', accent: 'e76f51', light: 'f0f7f7', bg: 'ffffff' }, forest_eco: { primary: '344e41', secondary: '588157', accent: 'd4a373', light: 'f0f4ef', bg: 'fafefa' }, elegant_fashion: { primary: '2d3748', secondary: '718096', accent: 'e91e8c', light: 'f7f0f7', bg: 'fefefe' }, art_food: { primary: '1a3a4a', secondary: 'e09f3e', accent: '9e2a2b', light: 'fffdf0', bg: 'ffffff' }, luxury_mystery: { primary: 'f8f9fa', secondary: '9a8c98', accent: 'c9ada7', light: '4a4e69', bg: '22223b' }, pure_tech_blue: { primary: '03045e', secondary: '0077b6', accent: '00b4d8', light: 'e8f8fd', bg: 'ffffff' }, coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' }, vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' }, platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' }, architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' }, minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' }, } const PALETTE_ALIASES: Record = { modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion', dark: 'tech_night', midnight: 'luxury_mystery', forest: 'forest_eco', coral: 'coastal_coral', ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts', berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness', warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech', architectural: 'architectural_mono', silk: 'minimal_silk', } function resolveTheme(spec: PresentationSpec): { theme: Theme; key: string } { const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_') const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'vibrant_tech') return { theme: PALETTES[key]!, key } } function textOnBg(bgHex: string): string { const r = parseInt(bgHex.substring(0, 2), 16) const g = parseInt(bgHex.substring(2, 4), 16) const b = parseInt(bgHex.substring(4, 6), 16) return (r * 299 + g * 587 + b * 114) / 1000 > 140 ? '1a1a1a' : 'ffffff' } interface StyleCfg { cardRadius: number badgeRadius: number margin: number padding: number gap: number } const STYLES: Record = { sharp: { cardRadius: 0.03, badgeRadius: 0, margin: 0.4, padding: 0.15, gap: 0.2 }, soft: { cardRadius: 0.1, badgeRadius: 0.05, margin: 0.5, padding: 0.2, gap: 0.25 }, rounded: { cardRadius: 0.2, badgeRadius: 0.1, margin: 0.5, padding: 0.25, gap: 0.35 }, pill: { cardRadius: 0.3, badgeRadius: 0.15, margin: 0.6, padding: 0.3, gap: 0.4 }, } const SHAPE_RECT = 'rect' as const const SHAPE_ROUND_RECT = 'roundRect' as const const SHAPE_OVAL = 'ellipse' as const function addBadge(s: any, num: number, accent: string) { s.addShape(SHAPE_OVAL, { x: 9.3, y: 5.1, w: 0.4, h: 0.4, fill: { color: accent }, }) s.addText(String(num), { x: 9.3, y: 5.1, w: 0.4, h: 0.4, fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle', }) } /** Modern split-layout cover: white left panel with big title, colored right panel */ function addCoverSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg) { const s = pres.addSlide() s.background = { color: t.bg } // Right accent panel (bold color block) s.addShape(SHAPE_RECT, { x: 5.8, y: 0, w: 4.2, h: 5.63, fill: { color: t.primary }, }) // Thin accent stripe between panels s.addShape(SHAPE_RECT, { x: 5.7, y: 0, w: 0.12, h: 5.63, fill: { color: t.accent }, }) // Left panel: title area s.addText(slide.title, { x: style.margin, y: 1.1, w: 5.0, h: 2.5, fontSize: 42, fontFace: 'Arial', color: t.primary, bold: true, valign: 'middle', fit: 'shrink', wrap: true, }) // Decorative dot cluster (3 dots) ;[0, 0.45, 0.9].forEach((offset) => { s.addShape(SHAPE_OVAL, { x: style.margin + offset, y: 3.8, w: 0.2, h: 0.2, fill: { color: t.accent }, }) }) if (slide.subtitle) { s.addText(slide.subtitle, { x: style.margin, y: 4.1, w: 5.0, h: 0.7, fontSize: 17, fontFace: 'Arial', color: t.secondary, valign: 'top', fit: 'shrink', }) } const today = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) s.addText(today, { x: style.margin, y: 5.1, w: 3.5, h: 0.35, fontSize: 11, fontFace: 'Arial', color: t.secondary, }) // Right panel: large decorative initial letter (translucent) const initial = (slide.title || 'P').charAt(0).toUpperCase() s.addText(initial, { x: 5.8, y: -0.5, w: 4.2, h: 4.0, fontSize: 200, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, align: 'center', valign: 'middle', transparency: 82, }) // Right panel: subtitle / tagline if (slide.subtitle) { s.addText(slide.subtitle, { x: 6.0, y: 3.8, w: 3.8, h: 1.2, fontSize: 15, fontFace: 'Arial', color: textOnBg(t.primary), valign: 'top', fit: 'shrink', transparency: 10, }) } return s } function addTocSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Left accent bar s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 0.12, h: 5.63, fill: { color: t.accent } }) s.addText(slide.title || 'Sommaire', { x: style.margin, y: 0.3, w: 10 - style.margin * 2, h: 0.75, fontSize: 30, fontFace: 'Arial', color: t.primary, bold: true, fit: 'shrink', }) s.addShape(SHAPE_RECT, { x: style.margin, y: 1.1, w: 2.5, h: 0.04, fill: { color: t.accent }, }) const items = slide.content.slice(0, 8) const startY = 1.35 const lineH = 0.5 const mid = Math.ceil(items.length / 2) items.forEach((item, i) => { const isRight = i >= mid const row = isRight ? i - mid : i const xBase = isRight ? 5.2 : style.margin const num = String(i + 1).padStart(2, '0') const cy = startY + row * lineH // Pill background for number s.addShape(SHAPE_ROUND_RECT, { x: xBase, y: cy + 0.05, w: 0.5, h: 0.38, fill: { color: i % 2 === 0 ? t.primary : t.accent }, rectRadius: 0.06, }) s.addText(num, { x: xBase, y: cy + 0.05, w: 0.5, h: 0.38, fontSize: 13, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle', }) s.addText(item, { x: xBase + 0.6, y: cy, w: isRight ? 4.3 : 4.3, h: lineH, fontSize: 15, fontFace: 'Arial', color: textColor, valign: 'middle', fit: 'shrink', }) }) addBadge(s, idx, t.accent) return s } /** Section divider: full-bleed colored left panel + large decorative number on right */ function addSectionSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const rawNum = (slide.content[0] || '').trim() const isShortCode = rawNum.length <= 4 && /^[0-9A-Za-z]+$/.test(rawNum) const sectionNum = isShortCode ? rawNum : String(idx - 1).padStart(2, '0') // Full-height colored left panel s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 5.5, h: 5.63, fill: { color: t.primary } }) // Thin accent stripe on right edge of panel s.addShape(SHAPE_RECT, { x: 5.38, y: 0, w: 0.14, h: 5.63, fill: { color: t.accent } }) const leftTextColor = textOnBg(t.primary) s.addText(sectionNum, { x: 0.4, y: 0.5, w: 4.6, h: 1.4, fontSize: 80, fontFace: 'Arial', color: t.accent, bold: true, valign: 'top', transparency: 15, }) s.addText(slide.title, { x: 0.4, y: 2.1, w: 4.8, h: 1.5, fontSize: 36, fontFace: 'Arial', color: leftTextColor, bold: true, valign: 'middle', fit: 'shrink', }) if (slide.subtitle) { s.addText(slide.subtitle, { x: 0.4, y: 3.7, w: 4.8, h: 0.65, fontSize: 15, fontFace: 'Arial', color: leftTextColor, transparency: 25, fit: 'shrink', }) } // Right side: big translucent decorative number s.addText(sectionNum, { x: 5.5, y: -0.3, w: 4.5, h: 5.0, fontSize: 220, fontFace: 'Arial', color: t.primary, bold: true, align: 'center', valign: 'middle', transparency: 88, }) addBadge(s, idx, t.accent) return s } function addContentSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Top header band s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 10, h: 1.1, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 1.1, w: 10, h: 0.07, fill: { color: t.accent } }) s.addText(slide.title, { x: style.margin, y: 0, w: 10 - style.margin * 2, h: 1.1, fontSize: 26, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, valign: 'middle', fit: 'shrink', }) const bullets = slide.content.map((line, i) => ({ text: line, options: { fontSize: 15, fontFace: 'Arial', color: textColor, bullet: { type: 'bullet' as const, indent: 10 }, breakLine: true, paraSpaceAfter: 10, paraSpaceBefore: i === 0 ? 0 : 2 }, })) s.addText(bullets, { x: style.margin, y: 1.35, w: 10 - style.margin * 2, h: 3.9, valign: 'top', lineSpacing: 24, fit: 'shrink', }) addBadge(s, idx, t.accent) return s } function addTwoColumnSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Top header band s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 10, h: 1.1, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 1.1, w: 10, h: 0.07, fill: { color: t.accent } }) s.addText(slide.title, { x: style.margin, y: 0, w: 10 - style.margin * 2, h: 1.1, fontSize: 26, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, valign: 'middle', fit: 'shrink', }) const mid = Math.ceil(slide.content.length / 2) const colW = (10 - style.margin * 2 - 0.5) / 2 const leftBullets = slide.content.slice(0, mid).map(line => ({ text: line, options: { fontSize: 14, fontFace: 'Arial', color: textColor, bullet: true, breakLine: true, paraSpaceAfter: 8 }, })) const rightBullets = slide.content.slice(mid).map(line => ({ text: line, options: { fontSize: 14, fontFace: 'Arial', color: textColor, bullet: true, breakLine: true, paraSpaceAfter: 8 }, })) s.addText(leftBullets, { x: style.margin, y: 1.35, w: colW, h: 3.9, valign: 'top', lineSpacing: 22, fit: 'shrink' }) s.addText(rightBullets, { x: style.margin + colW + 0.5, y: 1.35, w: colW, h: 3.9, valign: 'top', lineSpacing: 22, fit: 'shrink' }) // Column divider s.addShape(SHAPE_RECT, { x: style.margin + colW + 0.2, y: 1.5, w: 0.08, h: 3.5, fill: { color: t.accent }, }) addBadge(s, idx, t.accent) return s } function addCardsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } s.addText(slide.title, { x: style.margin, y: 0.2, w: 10 - style.margin * 2, h: 0.75, fontSize: 26, fontFace: 'Arial', color: t.primary, bold: true, valign: 'middle', fit: 'shrink', }) s.addShape(SHAPE_RECT, { x: style.margin, y: 0.98, w: 1.6, h: 0.05, fill: { color: t.accent } }) const items = slide.content.slice(0, 6) const cols = items.length <= 3 ? items.length : 3 const rows = Math.ceil(items.length / cols) const totalW = 10 - style.margin * 2 const totalH = 4.2 const cardW = (totalW - style.gap * (cols - 1)) / cols const cardH = (totalH - style.gap * (rows - 1)) / rows // Alternate between primary, light+border, accent to avoid mono-color const cardBgs = [t.primary, t.light, t.accent] const cardBorders = ['none', t.primary, 'none'] items.forEach((item, i) => { const col = i % cols const row = Math.floor(i / cols) const cx = style.margin + col * (cardW + style.gap) const cy = 1.2 + row * (cardH + style.gap) const cardColor = cardBgs[i % cardBgs.length]! const cardText = textOnBg(cardColor) const hasBorder = cardBorders[i % cardBorders.length] !== 'none' // Card background s.addShape(SHAPE_ROUND_RECT, { x: cx, y: cy, w: cardW, h: cardH, fill: { color: cardColor }, ...(hasBorder ? { line: { color: t.primary, width: 1.5 } } : {}), rectRadius: style.cardRadius, }) // Top accent strip on card s.addShape(SHAPE_ROUND_RECT, { x: cx, y: cy, w: cardW, h: 0.1, fill: { color: t.accent }, rectRadius: style.cardRadius, }) // Number badge (top-left) s.addShape(SHAPE_OVAL, { x: cx + style.padding, y: cy + 0.18, w: 0.32, h: 0.32, fill: { color: cardText === 'FFFFFF' ? 'FFFFFF' : t.primary }, }) s.addText(String(i + 1), { x: cx + style.padding, y: cy + 0.18, w: 0.32, h: 0.32, fontSize: 10, fontFace: 'Arial', color: cardText === 'FFFFFF' ? t.primary : 'FFFFFF', bold: true, align: 'center', valign: 'middle', }) // Parse "Title: description" format const parts = item.split(':') const cardTitle = parts[0]?.trim() ?? item const cardDesc = parts.slice(1).join(':').trim() s.addText(cardTitle, { x: cx + style.padding, y: cy + 0.62, w: cardW - style.padding * 2, h: 0.45, fontSize: 13, fontFace: 'Arial', color: cardText, bold: true, valign: 'top', fit: 'shrink', }) if (cardDesc) { s.addText(cardDesc, { x: cx + style.padding, y: cy + 1.1, w: cardW - style.padding * 2, h: cardH - 1.2, fontSize: 12, fontFace: 'Arial', color: cardText, valign: 'top', lineSpacing: 17, fit: 'shrink', wrap: true, transparency: 10, }) } }) addBadge(s, idx, t.accent) return s } function addStatsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } s.addText(slide.title, { x: style.margin, y: 0.3, w: 10 - style.margin * 2, h: 0.8, fontSize: 28, fontFace: 'Arial', color: t.primary, bold: true, valign: 'middle', fit: 'shrink', }) const items = slide.content.slice(0, 4) const colW = (10 - style.margin * 2) / items.length items.forEach((item, i) => { const cx = style.margin + i * colW const parts = item.split(/[-\u2013\u2014:]/) const stat = parts[0]?.trim() || item const label = parts.slice(1).join(':').trim() // Adaptive font: shorter values get a bigger font const statLen = stat.replace(/\s/g, '').length const statFontSize = statLen <= 4 ? 56 : statLen <= 7 ? 44 : 34 s.addText(stat, { x: cx, y: 1.6, w: colW - 0.2, h: 1.6, fontSize: statFontSize, fontFace: 'Arial', color: t.primary, bold: true, align: 'center', valign: 'bottom', fit: 'shrink', }) if (label) { s.addText(label, { x: cx, y: 3.35, w: colW - 0.2, h: 0.9, fontSize: 13, fontFace: 'Arial', color: t.secondary, align: 'center', valign: 'top', lineSpacing: 18, fit: 'shrink', }) } }) s.addShape(SHAPE_RECT, { x: style.margin, y: 3.25, w: 10 - style.margin * 2, h: 0.03, fill: { color: t.light }, }) addBadge(s, idx, t.accent) return s } function addQuoteSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.primary } const textColor = textOnBg(t.primary) s.addShape(SHAPE_RECT, { x: style.margin, y: 1.5, w: 0.1, h: 2.5, fill: { color: t.accent }, }) s.addText('\u201C', { x: style.margin + 0.3, y: 0.8, w: 2, h: 1.2, fontSize: 72, fontFace: 'Georgia', color: t.accent, bold: true, }) s.addText(slide.title, { x: style.margin + 0.4, y: 1.5, w: 10 - style.margin * 2 - 0.4, h: 2.2, fontSize: 24, fontFace: 'Georgia', color: textColor, italic: true, valign: 'middle', }) if (slide.subtitle) { s.addText(slide.subtitle, { x: style.margin + 0.4, y: 3.9, w: 10 - style.margin * 2 - 0.4, h: 0.5, fontSize: 14, fontFace: 'Arial', color: t.secondary, align: 'right', }) } addBadge(s, idx, t.accent) return s } function addSummarySlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.light } const textColor = textOnBg(t.light) s.addText(slide.title || 'En resume', { x: style.margin, y: 0.4, w: 10 - style.margin * 2, h: 0.8, fontSize: 32, fontFace: 'Arial', color: t.primary, bold: true, }) s.addShape(SHAPE_RECT, { x: style.margin, y: 1.3, w: 1.2, h: 0.04, fill: { color: t.accent }, }) const items = slide.content.slice(0, 5) items.forEach((item, i) => { const yPos = 1.6 + i * 0.65 s.addShape(SHAPE_OVAL, { x: style.margin, y: yPos + 0.1, w: 0.25, h: 0.25, fill: { color: t.accent }, }) s.addText(item, { x: style.margin + 0.45, y: yPos, w: 9 - style.margin * 2, h: 0.5, fontSize: 16, fontFace: 'Arial', color: textColor, valign: 'middle', fit: 'shrink', }) }) addBadge(s, idx, t.accent) return s } /** Parse an image URL/data-URI into pptxgenjs addImage-compatible props */ function resolveImageProps(imageUrl: string): Record { if (imageUrl.startsWith('data:')) { const commaIdx = imageUrl.indexOf(',') const header = imageUrl.substring(0, commaIdx) const data = imageUrl.substring(commaIdx + 1) const mimeMatch = header.match(/data:([^;]+)/) const mime = mimeMatch?.[1] || 'image/png' const ext = (mime.split('/')[1] || 'png').replace('jpeg', 'jpg') return { data, extn: ext } } return { path: imageUrl } } /** Image on the right (40%), bullets on the left (55%) */ function addImageContentSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Top header band s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 10, h: 1.0, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 1.0, w: 10, h: 0.07, fill: { color: t.accent } }) s.addText(slide.title, { x: style.margin, y: 0, w: 10 - style.margin * 2, h: 1.0, fontSize: 24, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, valign: 'middle', fit: 'shrink', }) // Left text zone if (slide.content.length > 0) { const bullets = slide.content.map(line => ({ text: line, options: { fontSize: 14, fontFace: 'Arial', color: textColor, bullet: true, breakLine: true, paraSpaceAfter: 10 }, })) s.addText(bullets, { x: style.margin, y: 1.25, w: 5.4, h: 4.0, valign: 'top', lineSpacing: 22, fit: 'shrink', }) } else if (slide.subtitle) { s.addText(slide.subtitle, { x: style.margin, y: 1.4, w: 5.4, h: 3.8, fontSize: 16, fontFace: 'Arial', color: textColor, valign: 'top', fit: 'shrink', wrap: true, lineSpacing: 24, }) } // Right image zone with rounded frame const imgX = 5.8 const imgY = 1.2 const imgW = 3.8 const imgH = 3.8 s.addShape(SHAPE_ROUND_RECT, { x: imgX - 0.05, y: imgY - 0.05, w: imgW + 0.1, h: imgH + 0.1, fill: { color: t.light }, rectRadius: style.cardRadius, }) if (slide.imageUrl) { try { s.addImage({ x: imgX, y: imgY, w: imgW, h: imgH, ...resolveImageProps(slide.imageUrl) }) } catch { s.addText('[ Image ]', { x: imgX, y: imgY, w: imgW, h: imgH, fontSize: 14, fontFace: 'Arial', color: t.secondary, align: 'center', valign: 'middle', }) } } // Caption below image if (slide.subtitle && slide.content.length > 0) { s.addText(slide.subtitle, { x: imgX, y: imgY + imgH + 0.1, w: imgW, h: 0.35, fontSize: 10, fontFace: 'Arial', color: t.secondary, align: 'center', fit: 'shrink', }) } addBadge(s, idx, t.accent) return s } /** Full-bleed image slide with title + subtitle overlay */ function addImageFullSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.primary } if (slide.imageUrl) { try { s.addImage({ x: 0, y: 0, w: 10, h: 5.63, ...resolveImageProps(slide.imageUrl) }) } catch { /* fallback: colored bg */ } } // Dark overlay at bottom for text legibility s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 10, h: 2.33, fill: { color: '000000', transparency: 40 } }) s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 0.12, h: 2.33, fill: { color: t.accent } }) s.addText(slide.title, { x: 0.3, y: 3.45, w: 9.4, h: 1.0, fontSize: 28, fontFace: 'Arial', color: 'FFFFFF', bold: true, valign: 'middle', fit: 'shrink', }) if (slide.subtitle) { s.addText(slide.subtitle, { x: 0.3, y: 4.5, w: 9.4, h: 0.8, fontSize: 15, fontFace: 'Arial', color: 'FFFFFF', valign: 'middle', fit: 'shrink', transparency: 15, }) } addBadge(s, idx, t.accent) return s } /** Horizontal timeline — content items: "Step title: description" */ function addTimelineSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Top header band s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 10, h: 1.0, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 1.0, w: 10, h: 0.07, fill: { color: t.accent } }) s.addText(slide.title, { x: style.margin, y: 0, w: 10 - style.margin * 2, h: 1.0, fontSize: 24, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, valign: 'middle', fit: 'shrink', }) const items = slide.content.slice(0, 5) const colW = (10 - style.margin * 2) / items.length const nodeColors = [t.accent, t.primary, t.secondary, t.accent, t.primary] // Layout zones (no overlap guaranteed): // Above zone: title 1.25-1.8, desc 1.85-2.35 // Circle zone: 2.5-3.1 (centered at 2.8) // Below zone: title 3.2-3.7, desc 3.75-4.25 const lineY = 2.8 // horizontal line Y (circle center) const nodeR = 0.3 // circle radius // Horizontal connector line s.addShape(SHAPE_RECT, { x: style.margin, y: lineY, w: 10 - style.margin * 2, h: 0.06, fill: { color: t.secondary }, }) items.forEach((item, i) => { const cx = style.margin + i * colW + colW / 2 const parts = item.split(':') const stepTitle = (parts[0] ?? item).trim() const stepDesc = parts.slice(1).join(':').trim() const nodeColor = nodeColors[i % nodeColors.length]! const isAbove = i % 2 === 0 // even steps: content above line // Double-ring circle: outer + white gap + inner filled s.addShape(SHAPE_OVAL, { x: cx - nodeR - 0.08, y: lineY - nodeR - 0.08, w: (nodeR + 0.08) * 2, h: (nodeR + 0.08) * 2, fill: { color: nodeColor }, }) s.addShape(SHAPE_OVAL, { x: cx - nodeR, y: lineY - nodeR, w: nodeR * 2, h: nodeR * 2, fill: { color: t.bg }, }) s.addShape(SHAPE_OVAL, { x: cx - nodeR + 0.1, y: lineY - nodeR + 0.1, w: (nodeR - 0.1) * 2, h: (nodeR - 0.1) * 2, fill: { color: nodeColor }, }) // Step number inside inner circle s.addText(String(i + 1), { x: cx - nodeR + 0.1, y: lineY - nodeR + 0.1, w: (nodeR - 0.1) * 2, h: (nodeR - 0.1) * 2, fontSize: 12, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle', }) // Vertical connector from circle to text block const connY = isAbove ? lineY - nodeR - 0.08 : lineY + nodeR + 0.08 const connH = isAbove ? 0.2 : 0.2 s.addShape(SHAPE_RECT, { x: cx - 0.03, y: connY - (isAbove ? connH : 0), w: 0.06, h: connH, fill: { color: nodeColor }, }) // Text: strictly above or below circle, no overlap with circle bounds if (isAbove) { // Title: 1.25 → 1.8 (above the line zone) s.addText(stepTitle, { x: cx - colW * 0.45, y: 1.25, w: colW * 0.9, h: 0.55, fontSize: 12, fontFace: 'Arial', color: t.primary, bold: true, align: 'center', valign: 'bottom', fit: 'shrink', wrap: true, }) if (stepDesc) { s.addText(stepDesc, { x: cx - colW * 0.45, y: 1.83, w: colW * 0.9, h: 0.55, fontSize: 10, fontFace: 'Arial', color: textColor, align: 'center', valign: 'top', fit: 'shrink', wrap: true, transparency: 15, }) } } else { // Title: 3.2 → 3.7 (below the line zone) s.addText(stepTitle, { x: cx - colW * 0.45, y: 3.2, w: colW * 0.9, h: 0.55, fontSize: 12, fontFace: 'Arial', color: t.primary, bold: true, align: 'center', valign: 'top', fit: 'shrink', wrap: true, }) if (stepDesc) { s.addText(stepDesc, { x: cx - colW * 0.45, y: 3.8, w: colW * 0.9, h: 0.55, fontSize: 10, fontFace: 'Arial', color: textColor, align: 'center', valign: 'top', fit: 'shrink', wrap: true, transparency: 15, }) } } }) addBadge(s, idx, t.accent) return s } /** Vertical numbered steps with connecting line — content items: "Step title: description" */ function addProcessSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } const textColor = textOnBg(t.bg) // Top header band s.addShape(SHAPE_RECT, { x: 0, y: 0, w: 10, h: 1.0, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 1.0, w: 10, h: 0.07, fill: { color: t.accent } }) s.addText(slide.title, { x: style.margin, y: 0, w: 10 - style.margin * 2, h: 1.0, fontSize: 24, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, valign: 'middle', fit: 'shrink', }) const items = slide.content.slice(0, 5) const stepH = 4.1 / Math.max(items.length, 1) const circleX = style.margin const circleR = 0.48 const stepColors = [t.accent, t.primary, t.secondary, t.accent, t.primary] items.forEach((item, i) => { const cy = 1.2 + i * stepH const parts = item.split(':') const stepTitle = (parts[0] ?? item).trim() const stepDesc = parts.slice(1).join(':').trim() const circleColor = stepColors[i % stepColors.length]! // Connector line to next step if (i < items.length - 1) { s.addShape(SHAPE_RECT, { x: circleX + circleR / 2 - 0.04, y: cy + circleR, w: 0.08, h: stepH - circleR + 0.05, fill: { color: t.light }, }) } // Numbered circle with ring effect s.addShape(SHAPE_OVAL, { x: circleX - 0.06, y: cy - 0.06, w: circleR + 0.12, h: circleR + 0.12, fill: { color: circleColor } }) s.addShape(SHAPE_OVAL, { x: circleX + 0.06, y: cy + 0.06, w: circleR - 0.12, h: circleR - 0.12, fill: { color: t.bg } }) s.addText(String(i + 1), { x: circleX, y: cy, w: circleR, h: circleR, fontSize: 13, fontFace: 'Arial', color: circleColor, bold: true, align: 'center', valign: 'middle', }) // Row background band (alternating) if (i % 2 === 0) { s.addShape(SHAPE_ROUND_RECT, { x: circleX + 0.65, y: cy - 0.08, w: 9 - style.margin - 0.65, h: stepH - 0.1, fill: { color: t.light }, rectRadius: style.cardRadius, }) } // Title and description on the right s.addText(stepTitle, { x: circleX + 0.85, y: cy, w: 9 - style.margin - 0.85, h: 0.48, fontSize: 15, fontFace: 'Arial', color: t.primary, bold: true, valign: 'middle', fit: 'shrink', }) if (stepDesc) { s.addText(stepDesc, { x: circleX + 0.85, y: cy + 0.48, w: 9 - style.margin - 0.85, h: stepH - 0.55, fontSize: 12, fontFace: 'Arial', color: textColor, valign: 'top', fit: 'shrink', wrap: true, transparency: 5, }) } }) addBadge(s, idx, t.accent) return s } /** Two-panel comparison — subtitle: "Left Label | Right Label", content split 50/50 */ function addComparisonSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } s.addText(slide.title, { x: style.margin, y: 0.2, w: 10 - style.margin * 2, h: 0.65, fontSize: 26, fontFace: 'Arial', color: t.primary, bold: true, fit: 'shrink', }) const subtitleParts = (slide.subtitle || '').split('|') const leftLabel = (subtitleParts[0] ?? '').trim() || 'Option A' const rightLabel = (subtitleParts[1] ?? '').trim() || 'Option B' const panelW = (10 - style.margin * 2 - 0.3) / 2 const panelY = 1.0 const panelH = 4.3 // Left panel s.addShape(SHAPE_ROUND_RECT, { x: style.margin, y: panelY, w: panelW, h: panelH, fill: { color: t.primary }, rectRadius: style.cardRadius, }) s.addText(leftLabel, { x: style.margin + 0.2, y: panelY + 0.15, w: panelW - 0.4, h: 0.5, fontSize: 17, fontFace: 'Arial', color: textOnBg(t.primary), bold: true, fit: 'shrink', }) s.addShape(SHAPE_RECT, { x: style.margin + 0.2, y: panelY + 0.7, w: panelW - 0.4, h: 0.03, fill: { color: t.accent }, }) // Right panel s.addShape(SHAPE_ROUND_RECT, { x: style.margin + panelW + 0.3, y: panelY, w: panelW, h: panelH, fill: { color: t.light }, rectRadius: style.cardRadius, }) s.addText(rightLabel, { x: style.margin + panelW + 0.5, y: panelY + 0.15, w: panelW - 0.4, h: 0.5, fontSize: 17, fontFace: 'Arial', color: t.primary, bold: true, fit: 'shrink', }) s.addShape(SHAPE_RECT, { x: style.margin + panelW + 0.5, y: panelY + 0.7, w: panelW - 0.4, h: 0.03, fill: { color: t.accent }, }) const mid = Math.ceil(slide.content.length / 2) const leftItems = slide.content.slice(0, mid) const rightItems = slide.content.slice(mid) const leftTextColor = textOnBg(t.primary) const rightTextColor = textOnBg(t.light) leftItems.forEach((item, i) => { s.addText(`• ${item}`, { x: style.margin + 0.2, y: panelY + 0.85 + i * 0.62, w: panelW - 0.4, h: 0.58, fontSize: 13, fontFace: 'Arial', color: leftTextColor, valign: 'middle', fit: 'shrink', wrap: true, }) }) rightItems.forEach((item, i) => { s.addText(`• ${item}`, { x: style.margin + panelW + 0.5, y: panelY + 0.85 + i * 0.62, w: panelW - 0.4, h: 0.58, fontSize: 13, fontFace: 'Arial', color: rightTextColor, valign: 'middle', fit: 'shrink', wrap: true, }) }) addBadge(s, idx, t.accent) return s } /** Visual KPI blocks — content items: "VALUE: label" or "VALUE - label" */ function addMetricsSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) { const s = pres.addSlide() s.background = { color: t.bg } s.addText(slide.title, { x: style.margin, y: 0.2, w: 10 - style.margin * 2, h: 0.7, fontSize: 26, fontFace: 'Arial', color: t.primary, bold: true, fit: 'shrink', }) s.addShape(SHAPE_RECT, { x: style.margin, y: 0.95, w: 1.4, h: 0.05, fill: { color: t.accent }, }) const items = slide.content.slice(0, 4) const blockColors = [t.primary, t.accent, t.secondary, t.light] const blockW = (10 - style.margin * 2 - style.gap * (items.length - 1)) / items.length const blockH = 3.7 items.forEach((item, i) => { const cx = style.margin + i * (blockW + style.gap) const cy = 1.15 const bgColor = blockColors[i % blockColors.length]! const btColor = textOnBg(bgColor) const parts = item.split(/[-:—]/) const value = (parts[0] ?? item).trim().replace(/ /g, '\u00A0') const label = parts.slice(1).join('').trim() s.addShape(SHAPE_ROUND_RECT, { x: cx, y: cy, w: blockW, h: blockH, fill: { color: bgColor }, rectRadius: style.cardRadius * 2, }) // Top accent bar s.addShape(SHAPE_ROUND_RECT, { x: cx, y: cy, w: blockW, h: 0.12, fill: { color: t.accent }, rectRadius: style.cardRadius * 2, }) const valLen = value.replace(/\u00A0/g, '').length const valFontSize = valLen <= 5 ? 52 : valLen <= 8 ? 38 : 28 s.addText(value, { x: cx + 0.1, y: cy + 0.7, w: blockW - 0.2, h: 1.8, fontSize: valFontSize, fontFace: 'Arial', color: btColor, bold: true, align: 'center', valign: 'middle', fit: 'shrink', }) if (label) { s.addText(label, { x: cx + 0.15, y: cy + 2.7, w: blockW - 0.3, h: 0.8, fontSize: 13, fontFace: 'Arial', color: btColor, align: 'center', valign: 'middle', fit: 'shrink', transparency: 15, }) } }) addBadge(s, idx, t.accent) return s } function addClosingSlide(pres: PptxGenJSModule, spec: PresentationSpec, t: Theme, style: StyleCfg) { const s = pres.addSlide() s.background = { color: t.bg } // Bottom half colored block s.addShape(SHAPE_RECT, { x: 0, y: 2.8, w: 10, h: 2.83, fill: { color: t.primary } }) s.addShape(SHAPE_RECT, { x: 0, y: 2.8, w: 10, h: 0.1, fill: { color: t.accent } }) // Large decorative circle s.addShape(SHAPE_OVAL, { x: 3.5, y: 1.2, w: 3.0, h: 3.0, fill: { color: t.accent }, }) s.addShape(SHAPE_OVAL, { x: 3.65, y: 1.35, w: 2.7, h: 2.7, fill: { color: t.primary }, }) const textColor = textOnBg(t.primary) s.addText('Merci', { x: 3.5, y: 1.6, w: 3.0, h: 2.2, fontSize: 36, fontFace: 'Arial', color: textColor, bold: true, align: 'center', valign: 'middle', }) s.addText(spec.title, { x: 1.5, y: 3.2, w: 7, h: 0.7, fontSize: 15, fontFace: 'Arial', color: textOnBg(t.primary), align: 'center', fit: 'shrink', }) // Accent dots ;[-0.6, 0, 0.6].forEach((offset) => { s.addShape(SHAPE_OVAL, { x: 4.75 + offset, y: 4.1, w: 0.16, h: 0.16, fill: { color: t.accent }, }) }) return s } async function buildPresentation(spec: PresentationSpec): Promise { const { theme } = resolveTheme(spec) const style = STYLES[spec.style || 'soft'] || STYLES.soft! const PptxGenJS = await getPptxGenClass() const pres = new PptxGenJS() pres.title = spec.title pres.author = 'Momento' pres.subject = spec.title pres.layout = 'LAYOUT_16x9' const totalSlides = spec.slides.length + 2 for (let si = 0; si < spec.slides.length; si++) { const slide = spec.slides[si] const layout = slide.layout || 'content' const idx = si + 2 switch (layout) { case 'title': addCoverSlide(pres, slide, theme, style); break case 'toc': addTocSlide(pres, slide, theme, style, idx); break case 'section': addSectionSlide(pres, slide, theme, style, idx); break case 'two-column': addTwoColumnSlide(pres, slide, theme, style, idx); break case 'cards': addCardsSlide(pres, slide, theme, style, idx); break case 'stats': addStatsSlide(pres, slide, theme, style, idx); break case 'quote': addQuoteSlide(pres, slide, theme, style, idx); break case 'summary': addSummarySlide(pres, slide, theme, style, idx); break case 'timeline': addTimelineSlide(pres, slide, theme, style, idx); break case 'process': addProcessSlide(pres, slide, theme, style, idx); break case 'comparison': addComparisonSlide(pres, slide, theme, style, idx); break case 'metrics': addMetricsSlide(pres, slide, theme, style, idx); break case 'image-content': addImageContentSlide(pres, slide, theme, style, idx); break case 'image-full': addImageFullSlide(pres, slide, theme, style, idx); break default: if (si === 0) addCoverSlide(pres, slide, theme, style) else addContentSlide(pres, slide, theme, style, idx) } } addClosingSlide(pres, spec, theme, style) return pres } function parseSlidesFromText(text: string): PresentationSpec { const lines = text.split('\n').filter(l => l.trim().length > 0) const title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Presentation' const slides: SlideSpec[] = [] let current: SlideSpec | null = null for (const line of lines) { const t = line.trim() if (t.match(/^#{1,2}\s+/) || t.match(/^slide\s+\d+/i)) { if (current) slides.push(current) current = { title: t.replace(/^#{1,2}\s+/, '').replace(/^slide\s+\d+\s*[:-]?\s*/i, ''), content: [] } } else if (current && (t.match(/^[-*]\s+/) || t.match(/^\d+\.\s+/))) { current.content.push(t.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) } } if (current) slides.push(current) if (slides.length === 0) slides.push({ title, content: lines.slice(1, 8).map(l => l.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) }) return { title, slides } } toolRegistry.register({ name: 'generate_pptx', description: 'Generate a professional PowerPoint presentation (.pptx) and save it for download.', isInternal: true, buildTool: (ctx) => tool({ description: `Generate a professional, modern PowerPoint presentation (.pptx) and save it for download. Provide a JSON presentation specification: { "title": "Presentation Title", "theme": "vibrant_tech", "style": "soft", "slides": [ { "title": "App Name", "subtitle": "The tagline", "content": [], "layout": "title" }, { "title": "Agenda", "content": ["Section 1", "Section 2", "Section 3"], "layout": "toc" }, { "title": "Key Points", "content": ["Point 1", "Point 2", "Point 3"], "layout": "content" }, { "title": "Features", "content": ["Feature A: desc", "Feature B: desc"], "layout": "cards" }, { "title": "KPIs", "content": ["99%: Uptime SLA", "50K+: Active users", "3x: Speed gain"], "layout": "metrics" }, { "title": "Section title", "subtitle": "description", "content": [], "layout": "section" }, { "title": "Roadmap", "content": ["Discovery: research & interviews", "Design: wireframes", "Build: MVP sprint", "Launch: beta"], "layout": "timeline" }, { "title": "How it works", "content": ["Collect: gather user data", "Analyse: AI processing", "Deliver: instant insights"], "layout": "process" }, { "title": "Old vs New", "subtitle": "Before | After", "content": ["Slow manual work", "Siloed teams", "Automated workflows", "Unified platform"], "layout": "comparison" }, { "title": "A great quote.", "subtitle": "- Author", "content": [], "layout": "quote" }, { "title": "Summary", "content": ["Key takeaway 1", "Key takeaway 2"], "layout": "summary" } ] } THEMES: modern_wellness, business_authority, nature_outdoors, vintage_academic, soft_creative, bohemian, vibrant_tech, craft_artisan, tech_night, education_charts, forest_eco, elegant_fashion, art_food, luxury_mystery, pure_tech_blue, coastal_coral, vibrant_orange_mint, platinum_white_gold STYLES: sharp (dense reports), soft (corporate), rounded (marketing), pill (premium) LAYOUTS — choose the most visual for each slide: - title: cover slide with large title + subtitle - toc: numbered table of contents - section: full-bleed section divider with big decorative number - content: bulleted list (max 7 bullets) - two-column: two bullet columns side by side - cards: 3-6 feature cards in a grid (content items: "Title: description") - metrics: 2-4 large KPI blocks with colored backgrounds (content: "VALUE: label") - stats: 2-4 numbers in a row (content: "NUMBER - label") — use metrics instead when possible - timeline: horizontal timeline for chronological steps (content: "Step title: detail") - process: numbered vertical steps with connector (content: "Step title: detail") - comparison: two contrasting panels (subtitle: "Left label | Right label", content split 50/50) - image-content: image on right + bullet points on left (add "imageUrl": "https://..." to slide object) - image-full: full-bleed image with title/subtitle overlay (add "imageUrl": "https://..." to slide object) - quote: full-slide quote (title=quote text, subtitle=author) - summary: closing key takeaways RULES: - Use the THEME and STYLE provided in the prompt context. - First slide MUST be "title" - Second slide: "toc" - Use "section" as dividers between major topics - Prefer DIAGRAM layouts (timeline, process, metrics, comparison) over plain content - Use at least 2 diagram layouts per presentation - 8-12 slides, never repeat same layout consecutively - All text content: max 100 chars per item, concise and impactful`, inputSchema: z.object({ title: z.string().describe('Title for the presentation'), slides: z.string().describe('JSON presentation specification'), }), execute: async ({ title, slides }) => { try { let spec: PresentationSpec try { const parsed = JSON.parse(slides) if (parsed.slides && Array.isArray(parsed.slides) && parsed.slides.length > 0) { spec = { title: parsed.title || title || 'Presentation', theme: parsed.theme || 'vibrant_tech', style: parsed.style || 'soft', slides: parsed.slides.map((s: any) => ({ title: String(s.title || '').substring(0, 120), subtitle: s.subtitle ? String(s.subtitle).substring(0, 200) : undefined, content: Array.isArray(s.content) ? s.content.map((c: any) => String(c).substring(0, 300)).slice(0, 12) : [], notes: s.notes ? String(s.notes).substring(0, 500) : undefined, layout: ['title', 'content', 'section', 'two-column', 'cards', 'stats', 'quote', 'toc', 'summary', 'timeline', 'process', 'comparison', 'metrics', 'image-content', 'image-full'].includes(s.layout) ? s.layout : undefined, imageUrl: s.imageUrl ? String(s.imageUrl).substring(0, 2000) : undefined, })), } } else { spec = parseSlidesFromText(slides) } } catch { spec = parseSlidesFromText(slides) } if (spec.slides.length === 0) return { success: false, error: 'No slides provided' } // Pre-fetch all image URLs server-side and convert to base64 data URIs. // pptxgenjs on Node.js cannot reliably fetch authenticated or relative URLs, // so we do it here where we have full server context. for (const slide of spec.slides) { if (!slide.imageUrl || slide.imageUrl.startsWith('data:')) continue try { const res = await fetch(slide.imageUrl, { signal: AbortSignal.timeout(8000) }) if (res.ok) { const buf = await res.arrayBuffer() const mime = res.headers.get('content-type') || 'image/png' const b64 = Buffer.from(buf).toString('base64') slide.imageUrl = `data:${mime};base64,${b64}` } else { slide.imageUrl = undefined // failed: show placeholder text } } catch { slide.imageUrl = undefined } } const pptx = await buildPresentation(spec) const base64 = await pptx.write({ outputType: 'base64' }) as string const canvas = await prisma.canvas.create({ data: { name: title || spec.title || 'Presentation', data: JSON.stringify({ type: 'pptx', title: spec.title, theme: spec.theme, slideCount: spec.slides.length, filename: `${spec.title.replace(/[^a-zA-Z0-9]/g, '_')}.pptx`, base64, }), userId: ctx.userId, }, }) return { success: true, canvasId: canvas.id, canvasName: canvas.name, slideCount: spec.slides.length, theme: spec.theme, message: `Presentation created with ${spec.slides.length} slides (${spec.theme || 'vibrant_tech'} theme).`, } } catch (e: any) { return { success: false, error: `Failed: ${e.message}` } } }, }), })