Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3s
pptxgenjs sur Node.js ne peut pas charger les URLs qui nécessitent une auth ou des cookies. On fetch maintenant chaque imageUrl côté serveur et on la convertit en data URI base64 avant d'appeler buildPresentation. Si le fetch échoue (timeout 8s, 4xx/5xx), l'imageUrl est supprimée et le placeholder texte s'affiche à la place. Co-authored-by: Cursor <cursoragent@cursor.com>
1160 lines
44 KiB
TypeScript
1160 lines
44 KiB
TypeScript
'use server'
|
|
|
|
import PptxGenJS from '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
|
|
}
|
|
|
|
function buildPresentation(spec: PresentationSpec): PptxGenJS {
|
|
const { theme } = resolveTheme(spec)
|
|
const style = STYLES[spec.style || 'soft'] || STYLES.soft!
|
|
|
|
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 = 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}` }
|
|
}
|
|
},
|
|
}),
|
|
})
|