ppt-tool/frontend/app/api/generate-pptx/route.ts
Vadym Samoilenko 58e738e79b Replace PPTX export pipeline: Puppeteer/python-pptx → PptxGenJS
- New POST /api/generate-pptx route (Next.js) uses PptxGenJS to build
  PPTX directly from the Phase 8 JSON element model — no headless Chrome
- export_utils.py queries DB for slides + layout codes, POSTs payload to
  Next.js, saves binary response to disk (removes python-pptx/Puppeteer)
- Coordinate conversion: px / 96 → inches (1280×720 = 13.333×7.5 in)
- CSS color/font-size parsing (hex, rgb/rgba, px→pt at 0.75pt/px)
- Fallback renderer for slides without a JSON layout schema
- PDF export (Puppeteer) unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:04:31 +00:00

301 lines
7.9 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 pptxgen from "pptxgenjs";
import { NextRequest, NextResponse } from "next/server";
import {
LayoutSchema,
SlideElement,
mergeElementsWithContent,
isJsonLayoutCode,
} from "@/app/hooks/parseLayoutSchema";
// Slide dimensions: 1280×720 px at 96 DPI = 13.333×7.5 inches
const PX_TO_IN = 1 / 96;
interface IncomingSlide {
id: string;
layout: string;
layout_group: string;
index: number;
content: Record<string, unknown>;
speaker_note?: string;
}
interface IncomingLayout {
layout_id: string;
layout_name: string;
layout_code: string;
}
interface GeneratePptxPayload {
title?: string;
slides: IncomingSlide[];
layouts: IncomingLayout[];
}
export async function POST(request: NextRequest) {
try {
const body: GeneratePptxPayload = await request.json();
const { title = "presentation", slides, layouts } = body;
if (!slides || slides.length === 0) {
return NextResponse.json({ error: "No slides provided" }, { status: 400 });
}
const pres = new pptxgen();
pres.defineLayout({ name: "WIDE", width: 13.333, height: 7.5 });
pres.layout = "WIDE";
// Build layout lookup by layout_id
const layoutMap = new Map<string, IncomingLayout>();
for (const layout of layouts || []) {
layoutMap.set(layout.layout_id, layout);
}
for (const slideData of slides) {
const slide = pres.addSlide();
const layoutData = layoutMap.get(slideData.layout);
if (layoutData && isJsonLayoutCode(layoutData.layout_code)) {
renderJsonLayout(pres, slide, layoutData.layout_code, slideData.content);
} else {
// Fallback: render content as text blocks
renderFallback(slide, slideData.content);
}
if (slideData.speaker_note) {
slide.addNotes(slideData.speaker_note);
}
}
const buffer = await pres.write({ outputType: "arraybuffer" });
const safeTitle = title.replace(/[^a-zA-Z0-9_\- ]/g, "").trim() || "presentation";
return new NextResponse(buffer as ArrayBuffer, {
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"Content-Disposition": `attachment; filename="${safeTitle}.pptx"`,
},
});
} catch (error: any) {
console.error("[generate-pptx] Error:", error);
return NextResponse.json(
{ error: `Failed to generate PPTX: ${error?.message || error}` },
{ status: 500 }
);
}
}
function renderJsonLayout(
pres: pptxgen,
slide: pptxgen.Slide,
layoutCode: string,
content: Record<string, unknown>
) {
const schema: LayoutSchema = JSON.parse(layoutCode);
// Background color
const bgColor = parseCssColor(schema.background);
if (bgColor) {
slide.background = { color: bgColor };
}
const elements = mergeElementsWithContent(schema, content);
for (const elem of elements) {
if (!elem.w || !elem.h) continue;
const pos = {
x: elem.x * PX_TO_IN,
y: elem.y * PX_TO_IN,
w: elem.w * PX_TO_IN,
h: elem.h * PX_TO_IN,
};
if (elem.type === "text") {
addTextElement(slide, elem, pos);
} else if (elem.type === "image") {
addImageElement(slide, elem, pos);
} else if (elem.type === "shape") {
addShapeElement(pres, slide, elem, pos);
}
}
}
function addTextElement(
slide: pptxgen.Slide,
elem: SlideElement,
pos: { x: number; y: number; w: number; h: number }
) {
const text = (elem.content || elem.defaultContent || "").trim();
if (!text) return;
const fontSize = parseFontSize(elem.style?.fontSize);
const colorHex = parseCssColor(String(elem.style?.color || ""));
const align = parseTextAlign(String(elem.style?.textAlign || "left"));
const fontFace = parseFontFamily(String(elem.style?.fontFamily || "Calibri"));
const bold =
elem.style?.fontWeight === "bold" ||
String(elem.style?.fontWeight) === "700";
const italic = elem.style?.fontStyle === "italic";
try {
slide.addText(text, {
...pos,
fontSize,
color: colorHex || "000000",
bold,
italic,
fontFace,
align,
valign: "top",
wrap: true,
});
} catch {
// Skip element if PptxGenJS rejects it
}
}
function addImageElement(
slide: pptxgen.Slide,
elem: SlideElement,
pos: { x: number; y: number; w: number; h: number }
) {
const src = elem.imageUrl;
if (!src) return;
try {
slide.addImage({ path: src, ...pos });
} catch {
// Skip if image path/URL is invalid
}
}
function addShapeElement(
pres: pptxgen,
slide: pptxgen.Slide,
elem: SlideElement,
pos: { x: number; y: number; w: number; h: number }
) {
const fillColor =
parseCssColor(String(elem.style?.backgroundColor || "")) ||
parseCssColor(String(elem.style?.background || "")) ||
"EEEEEE";
try {
slide.addShape(pres.ShapeType.rect, {
...pos,
fill: { color: fillColor },
line: { color: fillColor, width: 0 },
});
} catch {
// Skip if shape fails
}
}
function renderFallback(
slide: pptxgen.Slide,
content: Record<string, unknown>
) {
// Render content keys as simple text list
const lines: string[] = [];
for (const [key, val] of Object.entries(content)) {
if (key.startsWith("__") || !val) continue;
lines.push(String(val));
}
if (lines.length === 0) return;
try {
slide.addText(lines.join("\n"), {
x: 0.5,
y: 0.5,
w: 12.333,
h: 6.5,
fontSize: 18,
color: "333333",
wrap: true,
valign: "top",
});
} catch {
// skip
}
}
// ─── helpers ────────────────────────────────────────────────────────────────
/**
* Parse a CSS color value to a 6-char uppercase hex string.
* Returns undefined if the color can't be parsed.
*/
function parseCssColor(color: string | undefined): string | undefined {
if (!color) return undefined;
const s = color.trim();
// Already 6-char hex (with or without #)
const hex6 = s.replace(/^#/, "");
if (/^[0-9A-Fa-f]{6}$/.test(hex6)) return hex6.toUpperCase();
// 3-char hex
if (/^[0-9A-Fa-f]{3}$/.test(hex6)) {
return `${hex6[0]}${hex6[0]}${hex6[1]}${hex6[1]}${hex6[2]}${hex6[2]}`.toUpperCase();
}
// rgb(r, g, b) or rgba(r, g, b, a)
const rgb = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (rgb) {
const r = parseInt(rgb[1]).toString(16).padStart(2, "0");
const g = parseInt(rgb[2]).toString(16).padStart(2, "0");
const b = parseInt(rgb[3]).toString(16).padStart(2, "0");
return `${r}${g}${b}`.toUpperCase();
}
// Named colors (minimal set for dark/light backgrounds)
const named: Record<string, string> = {
white: "FFFFFF",
black: "000000",
red: "FF0000",
green: "008000",
blue: "0000FF",
gray: "808080",
grey: "808080",
transparent: "FFFFFF",
};
if (named[s.toLowerCase()]) return named[s.toLowerCase()];
return undefined;
}
/**
* Parse CSS fontSize (e.g. "24px", "18pt", 24) → points for PptxGenJS.
* PptxGenJS uses pt. 1px = 0.75pt at 96 DPI.
*/
function parseFontSize(
val: string | number | undefined,
fallback = 16
): number {
if (!val) return fallback;
const s = String(val).trim();
if (s.endsWith("pt")) {
const n = parseFloat(s);
return isNaN(n) ? fallback : Math.round(n);
}
if (s.endsWith("px")) {
const n = parseFloat(s);
return isNaN(n) ? fallback : Math.round(n * 0.75);
}
const n = parseFloat(s);
// Heuristic: if > 100, probably px; otherwise pt
if (!isNaN(n)) return n > 100 ? Math.round(n * 0.75) : Math.round(n);
return fallback;
}
function parseTextAlign(
val: string
): "left" | "center" | "right" | "justify" {
if (val === "center" || val === "right" || val === "justify") return val;
return "left";
}
function parseFontFamily(val: string): string {
if (!val) return "Calibri";
// Take first font in CSS font-family stack
return val.split(",")[0].trim().replace(/['"]/g, "") || "Calibri";
}