Files
Momento/memento-note/app/api/canvas/slides/pptx/route.ts
Antigravity 9e23c078e9
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Failing after 17s
fix: slide 3 noire, watermark PPTX, quota génération slides
- 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>
2026-05-29 11:58:31 +00:00

537 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
}
}