import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { auth } from '@/auth' import PptxGenJS from 'pptxgenjs' // ─── Color helpers ───────────────────────────────────────────────────────── const hex = (c: string) => c.replace('#', '').toUpperCase() interface PptxPalette { primary: string; secondary: string; accent: string bg: string; text: string; muted: string; isDark: boolean } function resolvePalette(theme?: string): PptxPalette { const palettes: Record = { 'midnight-cathedral': { primary: '0A0F1E', secondary: 'C9A84C', accent: 'E8D5B5', bg: '0A0F1E', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'aurora-borealis': { primary: '0F0A2A', secondary: '7C3AED', accent: '06B6D4', bg: '0F0A2A', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'tokyo-neon': { primary: '0A0A0F', secondary: 'FF006E', accent: '3A86FF', bg: '0A0A0F', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'venture-pitch': { primary: '18181B', secondary: 'F97316', accent: '14B8A6', bg: '18181B', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'forest-floor': { primary: '0D1B0E', secondary: '22C55E', accent: 'A3B18A', bg: '0D1B0E', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'steel-glass': { primary: '292524', secondary: 'D4C5A9', accent: '94A3B8', bg: '292524', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'cyberpunk-terminal': { primary: '0A0A0A', secondary: '00FF41', accent: 'FFA500', bg: '0A0A0A', text: 'F1F5F9', muted: '94A3B8', isDark: true }, 'sunlit-gallery': { primary: 'FAF7F0', secondary: 'D4A574', accent: '5B9BD5', bg: 'FAF7F0', text: '1C1C1C', muted: '64748B', isDark: false }, 'clinical-precision': { primary: 'F8FAFC', secondary: '0891B2', accent: '34D399', bg: 'F8FAFC', text: '0F172A', muted: '64748B', isDark: false }, 'editorial-ink': { primary: 'FFFCF5', secondary: '1A1A2E', accent: '800020', bg: 'FFFCF5', text: '1A1A2E', muted: '64748B', isDark: false }, 'coastal-morning': { primary: 'F0F7FF', secondary: '2563EB', accent: 'F97066', bg: 'F0F7FF', text: '0F172A', muted: '64748B', isDark: false }, 'paper-studio': { primary: 'FEFCF8', secondary: '1E293B', accent: 'C2410C', bg: 'FEFCF8', text: '1E293B', muted: '64748B', isDark: false }, 'architectural-saas': { primary: 'F2F0E9', secondary: 'A47148', accent: '4A4E69', bg: 'F2F0E9', text: '1C1C1C', muted: '64748B', isDark: false }, } const key = (theme || 'architectural-saas').toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-') return palettes[key] ?? palettes['architectural-saas'] } // ─── PPTX Builder (new JSON format) ──────────────────────────────────────── async function buildPptx(spec: any): Promise { const pptx = new PptxGenJS() pptx.layout = 'LAYOUT_WIDE' pptx.title = spec.title || 'Presentation' pptx.subject = spec.title || 'Presentation' const p = resolvePalette(spec.theme) const W = 13.33, H = 7.5 for (const slide of (spec.slides ?? [])) { const s = pptx.addSlide() // PLG watermark — bottom-right corner of LAYOUT_WIDE (13.33"×7.5") s.addText('memento-note.com', { x: 10.0, y: 7.1, w: 3.0, h: 0.25, fontSize: 7, fontFace: 'Arial', color: 'B8B0A8', align: 'right', italic: true, }) switch (slide.type) { case 'title': { s.background = { color: p.isDark ? p.primary : p.secondary } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: W, h: 0.08, fill: { color: p.accent }, line: { type: 'none' }, }) s.addText(slide.title || spec.title, { x: 1, y: 2, w: W - 2, h: 2, fontSize: 42, color: p.isDark ? 'FFFFFF' : 'FFFFFF', bold: true, align: 'center', }) if (slide.subtitle) { s.addText(slide.subtitle, { x: 1, y: 4.3, w: W - 2, h: 1, fontSize: 20, color: 'FFFFFF', align: 'center', transparency: 20, }) } s.addShape(pptx.ShapeType.rect, { x: 0, y: H - 0.08, w: W, h: 0.08, fill: { color: p.accent }, line: { type: 'none' }, }) break } case 'bullets': { s.background = { color: p.bg } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.06, h: H, fill: { color: p.secondary }, line: { type: 'none' }, }) s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) s.addShape(pptx.ShapeType.rect, { x: 0.5, y: 1.35, w: 0.5, h: 0.06, fill: { color: p.accent }, line: { type: 'none' }, }) const items = slide.items ?? [] // Use simpler bullet format - each item as separate text call with bullet option items.forEach((item: string, i: number) => { s.addText(item, { x: 0.5, y: 1.6 + i * 0.45, w: W - 1, h: 0.5, fontSize: 17, color: p.text, bullet: true, paraSpaceAfter: 6, }) }) break } case 'chart': { s.background = { color: p.bg } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.06, h: H, fill: { color: p.secondary }, line: { type: 'none' }, }) s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) if (slide.subtitle) { s.addText(slide.subtitle, { x: 0.5, y: 1.2, w: W - 1, h: 0.5, fontSize: 12, color: p.muted, }) } const data = slide.data ?? [] if (data.length > 0) { const chartType = slide.chartType === 'line' ? pptx.ChartType.line : slide.chartType === 'donut' ? pptx.ChartType.doughnut : slide.chartType === 'radar' ? pptx.ChartType.radar : pptx.ChartType.bar const chartData = [{ name: slide.title, labels: data.map((d: any) => d.label), values: data.map((d: any) => d.value), }] s.addChart(chartType, chartData, { x: 0.8, y: 1.8, w: W - 1.6, h: H - 2.5, showValue: true, showTitle: false, showLegend: false, chartColors: [p.secondary, p.accent, '10B981', 'F59E0B', 'EF4444', '6366F1'], valAxisHidden: slide.chartType === 'donut' || slide.chartType === 'radar', catAxisHidden: slide.chartType === 'donut', }) } break } case 'stats': { s.background = { color: p.bg } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.06, h: H, fill: { color: p.secondary }, line: { type: 'none' }, }) s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const stats = (slide.stats ?? []).slice(0, 4) const statW = (W - 1 - (stats.length - 1) * 0.3) / stats.length stats.forEach((st: any, i: number) => { const x = 0.5 + i * (statW + 0.3) s.addShape(pptx.ShapeType.rect, { x, y: 2, w: statW, h: 0.06, fill: { color: p.accent }, line: { type: 'none' }, }) s.addText(st.value, { x, y: 2.3, w: statW, h: 2, fontSize: 48, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, align: 'center', }) s.addText(st.label, { x, y: 4.4, w: statW, h: 0.7, fontSize: 13, color: p.muted, align: 'center', }) }) break } case 'table': { s.background = { color: p.bg } s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const headers = slide.headers ?? [] const rows = slide.rows ?? [] if (headers.length > 0) { const tableRows = [ headers.map((h: string) => ({ text: h, options: { bold: true, color: 'FFFFFF', fill: { color: p.secondary } } })), ...rows.map((row: string[], ri: number) => row.map((cell: string) => ({ text: cell, options: { fill: { color: ri % 2 === 0 ? 'F8F8F8' : 'FFFFFF' } } })) ), ] s.addTable(tableRows, { x: 0.5, y: 1.5, w: W - 1, h: H - 2, fontSize: 13, color: p.text, border: { type: 'solid', pt: 0.5, color: 'DDDDDD' }, colW: Array(headers.length).fill((W - 1) / headers.length), }) } break } case 'cards': { s.background = { color: p.bg } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.06, h: H, fill: { color: p.secondary }, line: { type: 'none' }, }) s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 26, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const cards = (slide.cards ?? []).slice(0, 6) const cols = cards.length <= 2 ? 2 : cards.length <= 3 ? 3 : cards.length === 4 ? 2 : 3 const cardW = (W - 1 - (cols - 1) * 0.2) / cols const cardH = cards.length > cols ? 2.3 : 3.5 cards.forEach((card: any, i: number) => { const col = i % cols const row = Math.floor(i / cols) const x = 0.5 + col * (cardW + 0.2) const y = 1.5 + row * (cardH + 0.15) s.addShape(pptx.ShapeType.roundRect, { x, y, w: cardW, h: cardH, fill: { color: p.isDark ? '1E1E2E' : 'FFFFFF' }, line: { color: p.isDark ? '333333' : 'E5E7EB', pt: 1 }, rectRadius: 0.08, }) s.addShape(pptx.ShapeType.rect, { x, y, w: cardW, h: 0.05, fill: { color: p.accent }, line: { type: 'none' }, }) s.addText(card.title, { x: x + 0.15, y: y + 0.25, w: cardW - 0.3, h: 0.6, fontSize: 14, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) s.addText(card.description, { x: x + 0.15, y: y + 0.8, w: cardW - 0.3, h: cardH - 1, fontSize: 12, color: p.text, }) }) break } case 'timeline': { s.background = { color: p.bg } s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const events = (slide.events ?? []).slice(0, 6) const evH = Math.min(0.9, (H - 2) / events.length) events.forEach((ev: any, i: number) => { const y = 1.6 + i * evH // Dot s.addShape(pptx.ShapeType.ellipse, { x: 0.7, y: y + 0.1, w: 0.2, h: 0.2, fill: { color: p.accent }, line: { type: 'none' }, }) // Line if (i < events.length - 1) { s.addShape(pptx.ShapeType.rect, { x: 0.78, y: y + 0.32, w: 0.04, h: evH - 0.2, fill: { color: p.accent }, line: { type: 'none' }, }) } s.addText(ev.date, { x: 1.1, y, w: 2, h: 0.4, fontSize: 10, color: p.accent, bold: true, }) s.addText(ev.title, { x: 1.1, y: y + 0.3, w: W - 2, h: 0.4, fontSize: 14, color: p.text, bold: true, }) if (ev.description) { s.addText(ev.description, { x: 1.1, y: y + 0.6, w: W - 2, h: 0.3, fontSize: 11, color: p.muted, }) } }) break } case 'quote': { const qBg = p.isDark ? '0D1117' : '1A1A2E' s.background = { color: qBg } s.addText('\u201C', { x: 0.5, y: 0.2, w: 2, h: 2, fontSize: 96, color: p.accent, bold: true, transparency: 60, }) s.addText(slide.quote, { x: 1, y: 1.5, w: W - 2, h: 3.5, fontSize: 24, color: 'FFFFFF', italic: true, align: 'left', }) if (slide.author) { s.addText(`\u2014 ${slide.author}`, { x: 1, y: 5.2, w: W - 2, h: 0.8, fontSize: 14, color: p.accent, align: 'left', }) } if (slide.context) { s.addText(slide.context, { x: 1, y: 6, w: W - 2, h: 0.8, fontSize: 12, color: 'AAAAAA', }) } break } case 'comparison': { s.background = { color: p.bg } s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const colW = (W - 1.5) / 2 const colY = 1.5, colH = H - 2 // Left if (slide.left) { s.addShape(pptx.ShapeType.roundRect, { x: 0.5, y: colY, w: colW, h: colH, fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' }, line: { color: p.secondary, pt: 1 }, rectRadius: 0.08, }) s.addText(slide.left.title, { x: 0.7, y: colY + 0.15, w: colW - 0.4, h: 0.5, fontSize: 14, color: p.secondary, bold: true, }) const lBullets = (slide.left.points ?? []).map((pt: string) => ({ text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 }, })) s.addText(lBullets, { x: 0.7, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4, fontSize: 13, color: p.text, }) if (slide.left.score) { s.addText(slide.left.score, { x: 0.7, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5, fontSize: 18, color: p.secondary, bold: true, align: 'center', }) } } // Right if (slide.right) { const rx = 0.5 + colW + 0.5 s.addShape(pptx.ShapeType.roundRect, { x: rx, y: colY, w: colW, h: colH, fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' }, line: { color: p.accent, pt: 1 }, rectRadius: 0.08, }) s.addText(slide.right.title, { x: rx + 0.2, y: colY + 0.15, w: colW - 0.4, h: 0.5, fontSize: 14, color: p.accent, bold: true, }) const rBullets = (slide.right.points ?? []).map((pt: string) => ({ text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 }, })) s.addText(rBullets, { x: rx + 0.2, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4, fontSize: 13, color: p.text, }) if (slide.right.score) { s.addText(slide.right.score, { x: rx + 0.2, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5, fontSize: 18, color: p.accent, bold: true, align: 'center', }) } } break } case 'equation': { s.background = { color: p.bg } s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const eqs = (slide.equations ?? []).slice(0, 4) const eqH = Math.min(1.5, (H - 2.5) / eqs.length) eqs.forEach((eq: any, i: number) => { const y = 1.8 + i * eqH s.addText(eq.latex, { x: 1, y, w: W - 2, h: eqH * 0.6, fontSize: 28, color: p.text, align: 'center', fontFace: 'Cambria Math', }) if (eq.label) { s.addText(eq.label, { x: 1, y: y + eqH * 0.6, w: W - 2, h: eqH * 0.3, fontSize: 11, color: p.muted, align: 'center', }) } }) if (slide.explanation) { s.addText(slide.explanation, { x: 1, y: H - 1.2, w: W - 2, h: 0.8, fontSize: 12, color: p.muted, align: 'center', }) } break } case 'image': { s.background = { color: p.bg } s.addText(slide.title, { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) if (slide.url) { try { s.addImage({ path: slide.url, x: 2, y: 1.5, w: W - 4, h: H - 2.5, sizing: { type: 'contain', w: W - 4, h: H - 2.5 }, }) } catch { /* image unavailable */ } } if (slide.caption) { s.addText(slide.caption, { x: 0.5, y: H - 0.9, w: W - 1, h: 0.7, fontSize: 12, color: p.muted, align: 'center', }) } break } case 'summary': { s.background = { color: p.bg } s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.06, h: H, fill: { color: p.secondary }, line: { type: 'none' }, }) s.addText(slide.title || 'En résumé', { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) s.addShape(pptx.ShapeType.rect, { x: 0.5, y: 1.35, w: 0.5, h: 0.06, fill: { color: p.accent }, line: { type: 'none' }, }) const items = (slide.items ?? []).slice(0, 8) items.forEach((item: string, i: number) => { const y = 1.6 + i * 0.7 s.addShape(pptx.ShapeType.rect, { x: 0.5, y, w: W - 1, h: 0.55, fill: { color: i % 2 === 0 ? (p.isDark ? '1E1E2E' : 'F5F5F5') : (p.isDark ? '151525' : 'EBEBEB') }, line: { type: 'none' }, }) s.addText('\u2713', { x: 0.7, y, w: 0.5, h: 0.55, fontSize: 14, color: p.accent, bold: true, valign: 'middle', }) s.addText(item, { x: 1.3, y, w: W - 2, h: 0.55, fontSize: 14, color: p.text, valign: 'middle', }) }) break } // Fallback: treat as bullets default: { s.background = { color: p.bg } s.addText(slide.title || '', { x: 0.5, y: 0.4, w: W - 1, h: 0.9, fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, }) const content = slide.items || slide.content || [] if (Array.isArray(content) && content.length) { const defBullets = content.map((item: string) => ({ text: String(item), options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 }, })) s.addText(defBullets, { x: 0.5, y: 1.5, w: W - 1, h: H - 2, fontSize: 17, color: p.text, }) } } } } return pptx.write({ outputType: 'nodebuffer' }) as Promise } // ─── API Route ───────────────────────────────────────────────────────────── export async function GET(req: NextRequest) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const canvasId = req.nextUrl.searchParams.get('id') if (!canvasId) { return NextResponse.json({ error: 'Missing id' }, { status: 400 }) } const canvas = await prisma.canvas.findUnique({ where: { id: canvasId, userId: session.user.id }, }) if (!canvas) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } let parsed: any try { parsed = JSON.parse(canvas.data) } catch { return NextResponse.json({ error: 'Invalid canvas data' }, { status: 500 }) } if (parsed.type !== 'slides') { return NextResponse.json({ error: 'Not a slides canvas' }, { status: 400 }) } // Support both new format (spec embedded) and legacy (spec at root) const spec = parsed.spec || parsed if (!spec || !Array.isArray(spec.slides)) { return NextResponse.json({ error: 'No slide data found — please regenerate the presentation' }, { status: 400 }) } const buffer = await buildPptx(spec) const filename = `${(canvas.name || 'presentation').replace(/[^a-zA-Z0-9-_ ]/g, '_').trim()}.pptx` return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': String(buffer.length), }, }) } catch (err: any) { console.error('[PPTX Export]', err) return NextResponse.json({ error: err.message || 'Export failed' }, { status: 500 }) } }