- 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>
301 lines
7.9 KiB
TypeScript
301 lines
7.9 KiB
TypeScript
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";
|
||
}
|