- canvas-board.tsx: préfère data.html (iframe) sur data.spec (ancien renderer) → corrige slide 3 noire en mode HTML viewer - pptx/route.ts: ajoute watermark 'memento-note.com' sur chaque slide via buildPptx (PPTX téléchargé depuis le canvas) - run-for-note/route.ts: checkEntitlementOrThrow avant création agent - slides.tool.ts: incrementUsageAsync après canvas créé avec succès Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
537 lines
21 KiB
TypeScript
537 lines
21 KiB
TypeScript
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<string, PptxPalette> = {
|
||
'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<Buffer> {
|
||
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': {
|
||
console.log('[PPTX] Rendering bullets slide:', slide.title, 'items:', slide.items?.length)
|
||
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 ?? []
|
||
console.log('[PPTX] Bullet items:', items.length, items[0])
|
||
// 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<Buffer>
|
||
}
|
||
|
||
// ─── 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 })
|
||
}
|
||
}
|