Files
Momento/memento-note/lib/ai/tools/pptx.tool.ts
Antigravity 7326cfc98f
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
fix: import type + lazy singleton pour dagre et pptxgenjs (Turbopack build)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:48:54 +00:00

1171 lines
45 KiB
TypeScript

'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<new () => 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<string, Theme> = {
// 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' },
}
const PALETTE_ALIASES: Record<string, string> = {
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',
}
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<string, StyleCfg> = {
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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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', 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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<string, any> {
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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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: PptxGenJS, 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<PptxGenJSModule> {
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:
- 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
- For "section" layout: title = section heading, content = [] (the slide number is auto-generated)
- 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}` }
}
},
}),
})