Merge pull request #236 from presenton/fix/custom_ai_layout

Fix/custom ai layout
This commit is contained in:
Suraj Jha 2025-08-19 23:47:11 +05:45 committed by GitHub
commit afb32c4c1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 306 additions and 113 deletions

View file

@ -8,7 +8,7 @@ events {
}
http {
client_max_body_size 20M;
client_max_body_size 100M;
server {
listen 80;

View file

@ -20,7 +20,7 @@ async def upload_files(files: Optional[List[UploadFile]]):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir(get_random_uuid())
validate_files(files, True, True, 50, UPLOAD_ACCEPTED_FILE_TYPES)
validate_files(files, True, True, 100, UPLOAD_ACCEPTED_FILE_TYPES)
temp_files: List[str] = []
if files:

View file

@ -46,6 +46,12 @@ async def process_pdf_slides(
status_code=400,
detail=f"Invalid file type. Expected PDF file, got {pdf_file.content_type}"
)
# Enforce 100MB size limit
if hasattr(pdf_file, "size") and pdf_file.size and pdf_file.size > (100 * 1024 * 1024):
raise HTTPException(
status_code=400,
detail="PDF file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:

View file

@ -275,6 +275,12 @@ async def process_pptx_slides(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}"
)
# Enforce 100MB size limit
if hasattr(pptx_file, "size") and pptx_file.size and pptx_file.size > (100 * 1024 * 1024):
raise HTTPException(
status_code=400,
detail="PPTX file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:

View file

@ -0,0 +1,56 @@
"use client";
import React from "react";
interface SlideErrorBoundaryProps {
children: React.ReactNode;
label?: string;
}
interface SlideErrorBoundaryState {
hasError: boolean;
errorMessage: string;
}
export class SlideErrorBoundary extends React.Component<
SlideErrorBoundaryProps,
SlideErrorBoundaryState
> {
constructor(props: SlideErrorBoundaryProps) {
super(props);
this.state = { hasError: false, errorMessage: "" };
}
static getDerivedStateFromError(error: unknown): SlideErrorBoundaryState {
return {
hasError: true,
errorMessage: error instanceof Error ? error.message : String(error),
};
}
componentDidCatch(error: unknown) {
// Optionally log to an error reporting service
// eslint-disable-next-line no-console
console.error("Slide render error:", error);
}
render() {
if (this.state.hasError) {
return (
<div className="aspect-video w-full h-full bg-red-50 text-red-700 flex flex-col items-start justify-start p-4 space-y-2 rounded-md border border-red-200">
<div className="text-sm font-semibold">
{this.props.label ? `${this.props.label} render error` : "Slide render error"}
</div>
<pre className="text-xs whitespace-pre-wrap break-words max-h-full overflow-auto bg-red-100 rounded-md p-2 border border-red-200">
{this.state.errorMessage}
</pre>
</div>
);
}
return this.props.children;
}
}
export default SlideErrorBoundary;

View file

@ -378,80 +378,167 @@ export const LayoutProvider: React.FC<{
const groupLayouts: LayoutInfo[] = [];
const groupFullData: FullDataInfo[] = [];
for (const i of allLayout) {
/* ---------- 1. compile JSX to plain script ------------------ */
const module = compileCustomLayout(i.layout_code, React, z);
if (!module.default) {
toast.error(`Custom Layout has no default export`, {
description:
"Please ensure the layout file exports a default component",
});
console.warn(`❌ Custom Layout has no default export`);
continue;
}
if (!module.Schema) {
toast.error(`Custom Layout has no Schema export`, {
description: "Please ensure the layout file exports a Schema",
});
console.warn(`❌ Custom Layout has no Schema export`);
continue;
}
const cacheKey = createCacheKey(
`custom-${presentationId}`,
i.layout_name
// Helper to create an inline error component for this specific slide
const createErrorComponent = (title: string, message: string): React.ComponentType<{ data: any }> => {
const ErrorSlide: React.FC<{ data: any }> = () => (
<div className="aspect-video w-full h-full bg-red-50 text-red-700 flex flex-col items-start justify-start p-4 space-y-2">
<div className="text-sm font-semibold">{title}</div>
<pre className="text-xs whitespace-pre-wrap break-words max-h-full overflow-auto bg-red-100 rounded-md p-2 border border-red-200">{message}</pre>
</div>
);
if (!layoutCache.has(cacheKey)) {
layoutCache.set(cacheKey, module.default);
ErrorSlide.displayName = "CustomTemplateErrorSlide";
return ErrorSlide;
};
for (const i of allLayout) {
try {
/* ---------- 1. compile JSX to plain script ------------------ */
const module = compileCustomLayout(i.layout_code, React, z);
// Determine identifiers even if subsequent steps fail
const originalLayoutId =
(module && (module as any).layoutId) ||
i.layout_name.toLowerCase().replace(/layout$/, "");
const uniqueKey = `${`custom-${presentationId}`}:${originalLayoutId}`;
const layoutName =
(module && (module as any).layoutName) ||
i.layout_name.replace(/([A-Z])/g, " $1").trim();
const layoutDescription =
(module && (module as any).layoutDescription) ||
`${layoutName} layout for presentations`;
let fullData: FullDataInfo | null = null;
let jsonSchema: any = null;
let componentToUse: React.ComponentType<{ data: any } | any> | null = null;
let sampleData: any = {};
// Validate exports
if (!module || !(module as any).default) {
const errorComp = createErrorComponent(
`Invalid export in ${i.layout_name}`,
"Default export not found. Please export a default React component."
);
componentToUse = errorComp;
jsonSchema = {};
} else if (!(module as any).Schema) {
const errorComp = createErrorComponent(
`Schema missing in ${i.layout_name}`,
"Schema export not found. Please export a Zod Schema as 'Schema'."
);
componentToUse = errorComp;
jsonSchema = {};
} else {
// Cache valid component
const cacheKey = createCacheKey(
`custom-${presentationId}`,
i.layout_name
);
if (!layoutCache.has(cacheKey)) {
layoutCache.set(cacheKey, (module as any).default);
}
componentToUse = (module as any).default;
// Build schema and sample data with guards
try {
jsonSchema = z.toJSONSchema((module as any).Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
} catch (schemaErr: any) {
const errorComp = createErrorComponent(
`Schema generation failed for ${i.layout_name}`,
schemaErr?.message || String(schemaErr)
);
componentToUse = errorComp;
jsonSchema = {};
}
if (componentToUse !== null && componentToUse !== (module as any).default) {
// componentToUse already replaced with error component
sampleData = {};
} else {
try {
sampleData = (module as any).Schema.parse({});
} catch (parseErr: any) {
const errorComp = createErrorComponent(
`Schema.parse failed for ${i.layout_name}`,
parseErr?.message || String(parseErr)
);
componentToUse = errorComp;
sampleData = {};
jsonSchema = jsonSchema || {};
}
}
}
customFonts.set(presentationId, i.fonts);
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupName,
};
fullData = {
name: layoutName,
component: componentToUse as React.ComponentType<any>,
schema: jsonSchema,
sampleData: sampleData,
fileName: i.layout_name,
groupName: groupName,
layoutId: uniqueKey,
};
groupFullData.push(fullData);
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, {
fileName: i.layout_name,
groupName: groupName,
});
groupLayouts.push(layout);
layouts.push(layout);
} catch (e: any) {
// Handle compilation/runtime errors during transformation
const uniqueKey = `${`custom-${presentationId}`}:${i.layout_name.toLowerCase().replace(/layout$/, "")}`;
const layoutName = i.layout_name.replace(/([A-Z])/g, " $1").trim();
const errorComp = createErrorComponent(
`Compilation error in ${i.layout_name}`,
e?.message || String(e)
);
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: `Failed to compile ${i.layout_name}`,
json_schema: {},
groupName: groupName,
};
const fullData: FullDataInfo = {
name: layoutName,
component: errorComp,
schema: {},
sampleData: {},
fileName: i.layout_name,
groupName: groupName,
layoutId: uniqueKey,
};
groupFullData.push(fullData);
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, {
fileName: i.layout_name,
groupName: groupName,
});
groupLayouts.push(layout);
layouts.push(layout);
}
customFonts.set(presentationId, i.fonts);
const originalLayoutId =
module.layoutId ||
i.layout_name.toLowerCase().replace(/layout$/, "");
const uniqueKey = `${`custom-${presentationId}`}:${originalLayoutId}`;
const layoutName =
module.layoutName ||
i.layout_name.replace(/([A-Z])/g, " $1").trim();
const layoutDescription =
module.layoutDescription ||
`${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout: LayoutInfo = {
id: uniqueKey,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
groupName: groupName,
};
const sampleData = module.Schema.parse({});
const fullData: FullDataInfo = {
name: layoutName,
component: module.default,
schema: jsonSchema,
sampleData: sampleData,
fileName: i.layout_name,
groupName: groupName,
layoutId: uniqueKey,
};
groupFullData.push(fullData);
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupName)!.add(uniqueKey);
fileMap.set(uniqueKey, {
fileName: i.layout_name,
groupName: groupName,
});
groupLayouts.push(layout);
layouts.push(layout);
}
setCustomTemplateFonts(customFonts);
// Cache grouped layouts

View file

@ -36,10 +36,10 @@ export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload PPTX File
Upload PDF or PPTX File
</CardTitle>
<CardDescription>
Select a PowerPoint file (.pptx) to process. Maximum file size: 50MB
Select a PDF or PowerPoint file (.pdf or .pptx) to process. Maximum file size: 100MB
</CardDescription>
{slides.length > 0 && (
<div className="flex items-center justify-end gap-2">
@ -56,12 +56,12 @@ export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<Label htmlFor="file-upload" className="cursor-pointer">
<span className="text-lg font-medium text-gray-700">
Click to upload a PPTX file
Click to upload a PDF or PPTX file
</span>
<input
id="file-upload"
type="file"
accept=".pptx"
accept=".pdf,.pptx"
onChange={handleFileSelect}
className="opacity-0 w-full h-full cursor-pointer absolute top-0 left-0 z-10"
/>
@ -106,7 +106,7 @@ export const FileUploadSection: React.FC<FileUploadSectionProps> = ({
{isProcessingPptx
? "Extracting Slides..."
: !selectedFile
? "Select a PPTX file"
? "Select a PDF or PPTX file"
: "Process File"}
</Button>
{isProcessingPptx && <Timer duration={90} />}

View file

@ -10,15 +10,18 @@ export const useFileUpload = () => {
if (!file) return;
// Validate file type
if (!file.name.toLowerCase().endsWith(".pptx")) {
toast.error("Please select a valid PPTX file");
const lowerName = file.name.toLowerCase();
const isPptx = lowerName.endsWith(".pptx");
const isPdf = lowerName.endsWith(".pdf");
if (!isPptx && !isPdf) {
toast.error("Please select a valid PDF or PPTX file");
return;
}
// Validate file size (50MB limit)
const maxSize = 50 * 1024 * 1024; // 50MB
// Validate file size (100MB limit)
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
toast.error("File size must be less than 50MB");
toast.error("File size must be less than 100MB");
return;
}

View file

@ -115,10 +115,10 @@ export const useSlideProcessing = (
[]
);
// Process PPTX file to extract slides
// Process PDF or PPTX file to extract slides
const processFile = useCallback(async () => {
if (!selectedFile) {
toast.error("Please select a PPTX file first");
toast.error("Please select a PDF or PPTX file first");
return;
}
@ -126,30 +126,51 @@ export const useSlideProcessing = (
setIsProcessingPptx(true);
const formData = new FormData();
formData.append("pptx_file", selectedFile);
const fileName = selectedFile.name.toLowerCase();
const isPdf = fileName.endsWith(".pdf");
const isPptx = fileName.endsWith(".pptx");
const pptxResponse = await fetch("/api/v1/ppt/pptx-slides/process", {
method: "POST",
body: formData,
});
const pptxData = await ApiResponseHandler.handleResponse(
pptxResponse,
"Failed to process PPTX file"
);
if (!pptxData.success || !pptxData.slides?.length) {
throw new Error("No slides found in the PPTX file");
let slidesResponseData: any = null;
if (isPdf) {
formData.append("pdf_file", selectedFile);
const pdfResponse = await fetch("/api/v1/ppt/pdf-slides/process", {
method: "POST",
body: formData,
});
slidesResponseData = await ApiResponseHandler.handleResponse(
pdfResponse,
"Failed to process PDF file"
);
} else if (isPptx) {
formData.append("pptx_file", selectedFile);
const pptxResponse = await fetch("/api/v1/ppt/pptx-slides/process", {
method: "POST",
body: formData,
});
slidesResponseData = await ApiResponseHandler.handleResponse(
pptxResponse,
"Failed to process PPTX file"
);
} else {
throw new Error("Unsupported file type. Please upload a PDF or PPTX file.");
}
// Extract fonts data from the response
if (pptxData.fonts) {
setFontsData(pptxData.fonts);
if (!slidesResponseData.success || !slidesResponseData.slides?.length) {
throw new Error("No slides found in the uploaded file");
}
// Initialize slides with skeleton state
const initialSlides: ProcessedSlide[] = pptxData.slides.map(
// Extract fonts data only for PPTX where available
if (slidesResponseData.fonts) {
setFontsData(slidesResponseData.fonts);
}
// Initialize slides with skeleton state; for PDF, xml/fonts won't exist
const initialSlides: ProcessedSlide[] = slidesResponseData.slides.map(
(slide: any) => ({
...slide,
slide_number: slide.slide_number,
screenshot_url: slide.screenshot_url,
xml_content: slide.xml_content ?? "",
normalized_fonts: slide.normalized_fonts ?? [],
processing: false,
processed: false,
})
@ -157,7 +178,7 @@ export const useSlideProcessing = (
setSlides(initialSlides);
const hasUnsupported = Array.isArray(pptxData.fonts?.not_supported_fonts) && pptxData.fonts.not_supported_fonts.length > 0;
const hasUnsupported = Array.isArray(slidesResponseData.fonts?.not_supported_fonts) && slidesResponseData.fonts.not_supported_fonts.length > 0;
toast.success(
`Template Processing Finished`,

View file

@ -103,8 +103,8 @@ const CustomTemplatePage = () => {
Custom Template Processor
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Upload your PPTX file to extract slides and convert them to
template which then can be used to generate AI presentations.
Upload your PDF or PPTX file to extract slides and convert them to
a template which you can use to generate AI presentations.
</p>
<div className="max-w-2xl mx-auto mt-2">
<div className="inline-block rounded border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">

View file

@ -3,7 +3,7 @@ import type React from "react";
export interface SlideData {
slide_number: number;
screenshot_url: string;
xml_content: string;
xml_content?: string;
normalized_fonts?: string[];
}

View file

@ -3,6 +3,7 @@ import React, { useMemo } from "react";
import { useDispatch } from "react-redux";
import { useLayout } from "../context/LayoutContext";
import EditableLayoutWrapper from "../components/EditableLayoutWrapper";
import SlideErrorBoundary from "../components/SlideErrorBoundary";
import TiptapTextReplacer from "../components/TiptapTextReplacer";
import { updateSlideContent } from "../../../store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
@ -66,7 +67,6 @@ export const useGroupLayouts = () => {
dataPath: string,
slideIndex?: number
) => {
// Dispatch Redux action to update slide content
if (dataPath && slideIndex !== undefined) {
dispatch(
updateSlideContent({
@ -78,12 +78,18 @@ export const useGroupLayouts = () => {
}
}}
>
<Layout data={slide.content} />
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
<Layout data={slide.content} />
</SlideErrorBoundary>
</TiptapTextReplacer>
</EditableLayoutWrapper>
);
}
return <Layout data={slide.content} />;
return (
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
<Layout data={slide.content} />
</SlideErrorBoundary>
);
};
}, [getGroupLayout, dispatch]);

View file

@ -17,6 +17,7 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
getLayoutsByGroup,
getGroupSetting,
getAllGroups,
getFullDataByGroup,
loading
} = useLayout();
@ -47,7 +48,14 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
const groups = getAllGroups();
if (groups.length === 0) return [];
const Groups: LayoutGroup[] = groups.map(groupName => {
const Groups: LayoutGroup[] = groups
.filter(groupName => {
// Filter out groups that contain any errored layouts (from custom templates compile/parse errors)
const fullData = getFullDataByGroup(groupName);
const hasErroredLayouts = fullData.some(fd => (fd as any)?.component?.displayName === "CustomTemplateErrorSlide");
return !hasErroredLayouts;
})
.map(groupName => {
const settings = getGroupSetting(groupName);
const customMeta = summaryMap[groupName];
const isCustom = groupName.toLowerCase().startsWith("custom-");
@ -66,7 +74,7 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
if (!a.default && b.default) return 1;
return a.name.localeCompare(b.name);
});
}, [getAllGroups, getLayoutsByGroup, getGroupSetting, summaryMap]);
}, [getAllGroups, getLayoutsByGroup, getGroupSetting, getFullDataByGroup, summaryMap]);
const inBuiltGroups = React.useMemo(
() => layoutGroups.filter(g => !g.id.toLowerCase().startsWith("custom-")),