Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
1171 lines
45 KiB
TypeScript
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}` }
|
|
}
|
|
},
|
|
}),
|
|
})
|