From 587f5ef6e128a3d0d4e448bb12f29bde2ceb1458 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 19 Mar 2026 18:47:31 +0000 Subject: [PATCH] Add 3 sandbox features: diagrams, mermaid, and template code-gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 — PPTX from Template (Code-Gen): - backend/services/template_codegen_service.py: analyze PPTX, strip slides, Gemini code-gen + subprocess exec (60s timeout, auto-retry on error) - backend/api/v1/ppt/endpoints/template_codegen.py: POST /template-codegen/generate (multipart: presentation_id + template_file + custom_prompt, rate-limited 3/min) - frontend/components/TemplateCodegenExport.tsx: drag-drop modal - Header.tsx: "Export from Template" option in export dropdown Feature 2 — Diagrams in Slides: - backend/models/diagram_data.py: DiagramData / FlowStep / BarChartItem models - generate_slide_content.py: optional __diagram__ + __mermaid__ fields in LLM schema - DiagramRenderer.tsx: pure React flowchart / bar chart / pie chart (no deps) - SlideRenderer.tsx: chart elements render DiagramRenderer/MermaidRenderer; floating overlay fallback when no chart element exists in JSON layout - V1ContentRender.tsx: diagram/mermaid overlay on built-in template slides - generate-pptx/route.ts: addDiagramToSlide() — bar/pie via pptxgenjs addChart(), flowchart via addShape()+addText(), mermaid via /api/mermaid-to-image Feature 3 — Mermaid Diagrams: - MermaidRenderer.tsx: dynamic import mermaid@11, useEffect render, error fallback - frontend/app/api/mermaid-to-image/route.ts: Puppeteer renders Mermaid to PNG → base64 Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/ppt/endpoints/template_codegen.py | 70 ++++ backend/api/v1/ppt/router.py | 2 + backend/models/diagram_data.py | 19 + backend/services/template_codegen_service.py | 350 ++++++++++++++++++ .../utils/llm_calls/generate_slide_content.py | 51 +++ .../components/DiagramRenderer.tsx | 261 +++++++++++++ .../components/MermaidRenderer.tsx | 119 ++++++ .../components/SlideRenderer.tsx | 66 +++- .../components/TemplateCodegenExport.tsx | 175 +++++++++ .../components/V1ContentRender.tsx | 40 +- .../presentation/components/Header.tsx | 25 +- frontend/app/api/generate-pptx/route.ts | 142 +++++++ frontend/app/api/mermaid-to-image/route.ts | 58 +++ 13 files changed, 1366 insertions(+), 12 deletions(-) create mode 100644 backend/api/v1/ppt/endpoints/template_codegen.py create mode 100644 backend/models/diagram_data.py create mode 100644 backend/services/template_codegen_service.py create mode 100644 frontend/app/(presentation-generator)/components/DiagramRenderer.tsx create mode 100644 frontend/app/(presentation-generator)/components/MermaidRenderer.tsx create mode 100644 frontend/app/(presentation-generator)/components/TemplateCodegenExport.tsx create mode 100644 frontend/app/api/mermaid-to-image/route.ts diff --git a/backend/api/v1/ppt/endpoints/template_codegen.py b/backend/api/v1/ppt/endpoints/template_codegen.py new file mode 100644 index 0000000..0c33528 --- /dev/null +++ b/backend/api/v1/ppt/endpoints/template_codegen.py @@ -0,0 +1,70 @@ +import os +import tempfile +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from api.middlewares.rate_limit_middleware import limiter +from models.sql.user import UserModel +from services.template_codegen_service import generate_pptx_from_template +from utils.auth_dependencies import get_current_user +from fastapi import Request + +TEMPLATE_CODEGEN_ROUTER = APIRouter(prefix="/template-codegen", tags=["Template CodeGen"]) + + +@TEMPLATE_CODEGEN_ROUTER.post("/generate") +@limiter.limit("3/minute") +async def generate_from_template( + request: Request, + presentation_id: str = Form(...), + template_file: UploadFile = File(...), + custom_prompt: Optional[str] = Form(None), + _current_user: UserModel = Depends(get_current_user), +): + """ + Generate a populated PPTX from a branded template file and existing presentation content. + Accepts: multipart/form-data with presentation_id, template_file (.pptx), custom_prompt (optional) + Returns: PPTX file download + """ + # Validate file type + filename = template_file.filename or "" + if not filename.lower().endswith(".pptx"): + raise HTTPException(status_code=400, detail="Template file must be a .pptx file") + + # Save uploaded template to temp + tmp_dir = os.environ.get("TEMP_DIRECTORY", tempfile.gettempdir()) + template_path = os.path.join(tmp_dir, f"template_{uuid.uuid4().hex}.pptx") + output_path = os.path.join(tmp_dir, f"output_{uuid.uuid4().hex}.pptx") + + try: + content = await template_file.read() + with open(template_path, "wb") as f: + f.write(content) + + result = await generate_pptx_from_template( + template_path=template_path, + presentation_id=presentation_id, + custom_prompt=custom_prompt or "", + output_path=output_path, + ) + + return FileResponse( + path=result["output_path"], + media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + filename="presentation-from-template.pptx", + background=None, + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + # Clean up template file + try: + os.unlink(template_path) + except Exception: + pass diff --git a/backend/api/v1/ppt/router.py b/backend/api/v1/ppt/router.py index d3aff16..b9a466c 100644 --- a/backend/api/v1/ppt/router.py +++ b/backend/api/v1/ppt/router.py @@ -16,6 +16,7 @@ from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER from api.v1.ppt.endpoints.slide import SLIDE_ROUTER from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER from api.v1.ppt.endpoints.content import CONTENT_ROUTER +from api.v1.ppt.endpoints.template_codegen import TEMPLATE_CODEGEN_ROUTER API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt") @@ -39,3 +40,4 @@ API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER) API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER) API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER) API_V1_PPT_ROUTER.include_router(CONTENT_ROUTER) +API_V1_PPT_ROUTER.include_router(TEMPLATE_CODEGEN_ROUTER) diff --git a/backend/models/diagram_data.py b/backend/models/diagram_data.py new file mode 100644 index 0000000..f04bdbb --- /dev/null +++ b/backend/models/diagram_data.py @@ -0,0 +1,19 @@ +from typing import List, Optional +from pydantic import BaseModel + + +class BarChartItem(BaseModel): + label: str + value: float + + +class FlowStep(BaseModel): + label: str + description: Optional[str] = None + + +class DiagramData(BaseModel): + type: str # 'flowchart', 'bar_chart', 'pie_chart' + title: Optional[str] = None + flow_steps: Optional[List[FlowStep]] = None + bar_items: Optional[List[BarChartItem]] = None diff --git a/backend/services/template_codegen_service.py b/backend/services/template_codegen_service.py new file mode 100644 index 0000000..316ba0f --- /dev/null +++ b/backend/services/template_codegen_service.py @@ -0,0 +1,350 @@ +import asyncio +import json +import os +import subprocess +import sys +import tempfile +import uuid +import zipfile +from typing import Optional + +from google import genai +from google.genai.types import GenerateContentConfig + +from services.database import async_session_maker +from models.sql.slide import SlideModel +from sqlmodel import select +from utils.get_env import get_google_api_key_env, get_google_model_env + + +def _get_gemini_client(): + key = get_google_api_key_env() + if not key: + raise ValueError("GOOGLE_API_KEY is required for template codegen") + return genai.Client() + + +def _get_model() -> str: + model = get_google_model_env() or "gemini-2.0-flash" + # Strip "models/" prefix if present + if model.startswith("models/"): + model = model[len("models/"):] + return model + + +def analyze_pptx_template(pptx_path: str) -> dict: + """ + Extract layout names, placeholder types, fonts, and colors from a PPTX template. + """ + try: + from pptx import Presentation + from pptx.util import Pt + + prs = Presentation(pptx_path) + + result = { + "slide_width_emu": prs.slide_width, + "slide_height_emu": prs.slide_height, + "slide_layouts": [], + "theme_colors": [], + "theme_fonts": [], + } + + # Extract slide layouts + for layout in prs.slide_layouts: + layout_info = { + "name": layout.name, + "placeholders": [], + } + for ph in layout.placeholders: + ph_info = { + "idx": ph.placeholder_format.idx, + "type": str(ph.placeholder_format.type), + "name": ph.name, + } + try: + ph_info["left_emu"] = ph.left + ph_info["top_emu"] = ph.top + ph_info["width_emu"] = ph.width + ph_info["height_emu"] = ph.height + except Exception: + pass + layout_info["placeholders"].append(ph_info) + result["slide_layouts"].append(layout_info) + + # Extract theme colors + try: + theme_element = prs.core_properties + except Exception: + pass + + # Extract slides if any (template slides) + result["existing_slides"] = len(prs.slides) + + return result + + except ImportError: + raise RuntimeError("python-pptx is required for template analysis. Install with: pip install python-pptx") + except Exception as e: + raise RuntimeError(f"Failed to analyze PPTX template: {e}") + + +def clear_template_slides(pptx_path: str, output_path: str) -> None: + """ + Strip all slides from PPTX zip while preserving masters, layouts, and theme. + """ + import shutil + + shutil.copy2(pptx_path, output_path) + + with zipfile.ZipFile(output_path, 'r') as zin: + names = zin.namelist() + + # Identify slide files to remove + slide_files = [n for n in names if n.startswith("ppt/slides/slide") and n.endswith(".xml")] + slide_rels = [n for n in names if n.startswith("ppt/slides/_rels/slide") and n.endswith(".rels")] + to_remove = set(slide_files + slide_rels) + + # Rewrite zip without slide files + tmp_path = output_path + ".tmp" + with zipfile.ZipFile(output_path, 'r') as zin: + with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + if item.filename in to_remove: + continue + # Fix [Content_Types].xml to remove slide references + if item.filename == "[Content_Types].xml": + content = zin.read(item.filename).decode("utf-8") + for sf in slide_files: + # Remove Override for each slide + part_name = "/" + sf + import re + content = re.sub( + r'', + '', + content + ) + # Remove from ppt/_rels/presentation.xml.rels + zout.writestr(item, content.encode("utf-8")) + elif item.filename == "ppt/_rels/presentation.xml.rels": + content = zin.read(item.filename).decode("utf-8") + import re + # Remove Relationship entries pointing to slides/slide*.xml + content = re.sub( + r']*Target="slides/slide\d+\.xml"[^/]*/?>', + '', + content + ) + zout.writestr(item, content.encode("utf-8")) + elif item.filename == "ppt/presentation.xml": + content = zin.read(item.filename).decode("utf-8") + import re + # Remove entries + content = re.sub(r'', '', content) + zout.writestr(item, content.encode("utf-8")) + else: + zout.writestr(item, zin.read(item.filename)) + + import shutil + shutil.move(tmp_path, output_path) + + +def _build_slide_context(slides: list) -> str: + """Build a text summary of slide content for the LLM.""" + lines = [] + for i, slide in enumerate(slides): + lines.append(f"\n## Slide {i + 1}") + content = slide.content or {} + for key, val in content.items(): + if key.startswith("__") or not val: + continue + if isinstance(val, list): + lines.append(f" {key}:") + for item in val[:6]: + if isinstance(item, dict): + text = item.get("text") or item.get("title") or item.get("description") or str(item) + lines.append(f" - {text[:200]}") + else: + lines.append(f" - {str(item)[:200]}") + elif isinstance(val, dict): + text = val.get("text") or val.get("title") or str(val) + lines.append(f" {key}: {str(text)[:300]}") + else: + lines.append(f" {key}: {str(val)[:300]}") + return "\n".join(lines) + + +def _build_codegen_prompt(template_info: dict, slide_context: str, custom_prompt: str = "") -> str: + layout_summary = json.dumps(template_info.get("slide_layouts", [])[:8], indent=2) + + return f"""You are a Python expert specializing in python-pptx. + +Generate Python code that populates a PowerPoint template with the provided content. + +## Template Information +- Slide layouts available: {len(template_info.get('slide_layouts', []))} +- Layout details: +{layout_summary} + +## Presentation Content +{slide_context} + +## Requirements +1. Use python-pptx to open the template file at `template_path` variable (already defined) +2. Save the output to `output_path` variable (already defined) +3. Add one slide per content slide shown above +4. Match layout names from the template to content type (title slide, content slide, etc.) +5. Set text for all placeholders appropriately +6. Handle font sizes and colors from template — do NOT override them unless necessary +7. If a layout has no matching content, use the first available layout +8. Keep bullet points as separate text runs + +{f"## Additional Instructions{chr(10)}{custom_prompt}" if custom_prompt else ""} + +## Output Format +Output ONLY valid Python code. No explanations, no markdown code blocks. +The code should start with `from pptx import Presentation` and end with `prs.save(output_path)`. + +Variables already defined before your code runs: +- `template_path: str` — path to the cleared template PPTX +- `output_path: str` — path where the result should be saved +""" + + +async def generate_pptx_from_template( + template_path: str, + presentation_id: str, + custom_prompt: str = "", + output_path: Optional[str] = None, +) -> dict: + """ + Full pipeline: analyze template → load slides → LLM code-gen → execute → return path. + """ + if output_path is None: + tmp_dir = os.environ.get("TEMP_DIRECTORY", tempfile.gettempdir()) + output_path = os.path.join(tmp_dir, f"codegen_{uuid.uuid4().hex}.pptx") + + # 1. Analyze template + template_info = analyze_pptx_template(template_path) + + # 2. Clear slides from template copy + cleared_path = template_path + ".cleared.pptx" + try: + clear_template_slides(template_path, cleared_path) + except Exception as e: + # If clearing fails, use the original template + import shutil + shutil.copy2(template_path, cleared_path) + + # 3. Load slides from DB + try: + async with async_session_maker() as session: + result = await session.execute( + select(SlideModel) + .where(SlideModel.presentation == uuid.UUID(presentation_id)) + .where(SlideModel.deleted_at == None) + .order_by(SlideModel.index) + ) + slides = result.scalars().all() + except Exception as e: + raise RuntimeError(f"Failed to load slides from database: {e}") + + if not slides: + raise ValueError("Presentation has no slides") + + slide_context = _build_slide_context(list(slides)) + prompt = _build_codegen_prompt(template_info, slide_context, custom_prompt) + + # 4. Call LLM + client = _get_gemini_client() + model = _get_model() + + try: + response = client.models.generate_content( + model=model, + contents=prompt, + config=GenerateContentConfig( + max_output_tokens=8192, + temperature=0.1, + ), + ) + code = response.text or "" + except Exception as e: + raise RuntimeError(f"LLM code generation failed: {e}") + + # Clean up code block markers if present + code = code.strip() + if code.startswith("```python"): + code = code[9:] + if code.startswith("```"): + code = code[3:] + if code.endswith("```"): + code = code[:-3] + code = code.strip() + + # 5. Execute generated code + exec_result = _execute_generated_code(code, cleared_path, output_path) + + if not exec_result["success"]: + # Retry once with error feedback + retry_prompt = prompt + f"\n\n## Error from previous attempt\nYour code failed with this error:\n{exec_result['error']}\nFix the issue and output corrected code only." + + try: + response2 = client.models.generate_content( + model=model, + contents=retry_prompt, + config=GenerateContentConfig( + max_output_tokens=8192, + temperature=0.1, + ), + ) + code2 = response2.text or "" + code2 = code2.strip() + if code2.startswith("```python"): + code2 = code2[9:] + if code2.startswith("```"): + code2 = code2[3:] + if code2.endswith("```"): + code2 = code2[:-3] + code2 = code2.strip() + except Exception: + raise RuntimeError(f"Code generation failed: {exec_result['error']}") + + exec_result2 = _execute_generated_code(code2, cleared_path, output_path) + if not exec_result2["success"]: + raise RuntimeError(f"Code execution failed after retry: {exec_result2['error']}") + + # Cleanup temp + try: + os.unlink(cleared_path) + except Exception: + pass + + return {"output_path": output_path, "slide_count": len(slides)} + + +def _execute_generated_code(code: str, template_path: str, output_path: str) -> dict: + """Execute the LLM-generated python-pptx code in a subprocess.""" + # Wrap code with variable definitions + full_code = f""" +template_path = {repr(template_path)} +output_path = {repr(output_path)} + +{code} +""" + try: + result = subprocess.run( + [sys.executable, "-c", full_code], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + return {"success": False, "error": result.stderr[:2000]} + if not os.path.exists(output_path): + return {"success": False, "error": "Code ran but did not produce output file"} + return {"success": True, "error": None} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Code execution timed out (60s)"} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/backend/utils/llm_calls/generate_slide_content.py b/backend/utils/llm_calls/generate_slide_content.py index 8b50f9b..764dab7 100644 --- a/backend/utils/llm_calls/generate_slide_content.py +++ b/backend/utils/llm_calls/generate_slide_content.py @@ -85,6 +85,14 @@ def get_system_prompt( __icon_query__: string, }} + # Diagram Output Format (optional) + When the slide content naturally contains a process/workflow, comparison data, or proportions, + optionally include one of these fields: + - __diagram__: for simple data viz — flowchart (3-6 steps), bar_chart (3-6 items), pie_chart (3-6 slices) + - __mermaid__: for complex structural diagrams — sequence diagrams, class diagrams, state machines, ER diagrams, gantt charts + Only include __diagram__ or __mermaid__ when the content truly calls for it. Never include both. + These fields are completely optional — most slides should not include them. + """ @@ -154,6 +162,49 @@ async def get_slide_content_from_type_and_outline( }, True, ) + response_schema = add_field_in_schema( + response_schema, + { + "__diagram__": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "title": {"type": "string"}, + "flow_steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "description": {"type": "string"}, + }, + }, + }, + "bar_items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "number"}, + }, + }, + }, + }, + } + }, + False, + ) + response_schema = add_field_in_schema( + response_schema, + { + "__mermaid__": { + "type": "string", + "description": "Mermaid diagram syntax for complex structural diagrams", + } + }, + False, + ) try: response = await client.generate_structured( diff --git a/frontend/app/(presentation-generator)/components/DiagramRenderer.tsx b/frontend/app/(presentation-generator)/components/DiagramRenderer.tsx new file mode 100644 index 0000000..8b84e6c --- /dev/null +++ b/frontend/app/(presentation-generator)/components/DiagramRenderer.tsx @@ -0,0 +1,261 @@ +"use client"; + +import React from "react"; + +export interface BarChartItem { + label: string; + value: number; +} + +export interface FlowStep { + label: string; + description?: string; +} + +export interface DiagramData { + type: "flowchart" | "bar_chart" | "pie_chart"; + title?: string; + flow_steps?: FlowStep[]; + bar_items?: BarChartItem[]; +} + +interface DiagramRendererProps { + data: DiagramData; + width?: number; + height?: number; + className?: string; +} + +const COLORS = [ + "#5146E5", "#7C3AED", "#2563EB", "#059669", "#D97706", + "#DC2626", "#0891B2", "#7C2D12", "#1D4ED8", "#065F46", +]; + +export function DiagramRenderer({ data, width, height, className = "" }: DiagramRendererProps) { + if (!data || !data.type) return null; + + return ( +
+ {data.title && ( +
+ {data.title} +
+ )} +
+ {data.type === "flowchart" && } + {data.type === "bar_chart" && } + {data.type === "pie_chart" && } +
+
+ ); +} + +function FlowchartDiagram({ steps }: { steps: FlowStep[] }) { + if (!steps.length) return null; + return ( +
+ {steps.map((step, i) => ( + +
+
{step.label}
+ {step.description && ( +
+ {step.description} +
+ )} +
+ {i < steps.length - 1 && ( +
+ → +
+ )} +
+ ))} +
+ ); +} + +function BarChartDiagram({ items }: { items: BarChartItem[] }) { + if (!items.length) return null; + const maxVal = Math.max(...items.map((i) => Math.abs(i.value)), 1); + return ( +
+ {items.map((item, i) => { + const pct = Math.max((Math.abs(item.value) / maxVal) * 80, 4); + return ( +
+
+ {item.value % 1 === 0 ? item.value : item.value.toFixed(1)} +
+
+
+ {item.label} +
+
+ ); + })} +
+ ); +} + +function PieChartDiagram({ items }: { items: BarChartItem[] }) { + if (!items.length) return null; + + const total = items.reduce((sum, i) => sum + Math.abs(i.value), 0) || 1; + const size = 80; + const cx = size / 2; + const cy = size / 2; + const r = size / 2 - 4; + + let currentAngle = -Math.PI / 2; // Start from top + const slices = items.map((item, i) => { + const fraction = Math.abs(item.value) / total; + const angle = fraction * Math.PI * 2; + const startAngle = currentAngle; + currentAngle += angle; + const endAngle = currentAngle; + + const x1 = cx + r * Math.cos(startAngle); + const y1 = cy + r * Math.sin(startAngle); + const x2 = cx + r * Math.cos(endAngle); + const y2 = cy + r * Math.sin(endAngle); + const largeArc = angle > Math.PI ? 1 : 0; + + return { + d: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`, + color: COLORS[i % COLORS.length], + label: item.label, + pct: Math.round(fraction * 100), + }; + }); + + return ( +
+ + {slices.map((slice, i) => ( + + ))} + +
+ {slices.map((slice, i) => ( +
+
+ + {slice.label} ({slice.pct}%) + +
+ ))} +
+
+ ); +} + +export default DiagramRenderer; diff --git a/frontend/app/(presentation-generator)/components/MermaidRenderer.tsx b/frontend/app/(presentation-generator)/components/MermaidRenderer.tsx new file mode 100644 index 0000000..67ce455 --- /dev/null +++ b/frontend/app/(presentation-generator)/components/MermaidRenderer.tsx @@ -0,0 +1,119 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; + +interface MermaidRendererProps { + code: string; + className?: string; + style?: React.CSSProperties; +} + +let mermaidInitialized = false; + +export function MermaidRenderer({ code, className = "", style }: MermaidRendererProps) { + const containerRef = useRef(null); + const [svgContent, setSvgContent] = useState(""); + const [error, setError] = useState(""); + const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`); + + useEffect(() => { + if (!code || !code.trim()) return; + + let cancelled = false; + + async function render() { + try { + const mermaid = (await import("mermaid")).default; + + if (!mermaidInitialized) { + mermaid.initialize({ + startOnLoad: false, + theme: "neutral", + securityLevel: "loose", + fontFamily: "sans-serif", + }); + mermaidInitialized = true; + } + + const id = idRef.current; + const { svg } = await mermaid.render(id, code.trim()); + + if (!cancelled) { + setSvgContent(svg); + setError(""); + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || "Failed to render diagram"); + } + } + } + + render(); + + return () => { + cancelled = true; + }; + }, [code]); + + if (error) { + return ( +
+
+ Diagram error +
+
+          {code}
+        
+
+ ); + } + + if (!svgContent) { + return ( +
+ Rendering diagram… +
+ ); + } + + return ( +
+ ); +} + +export default MermaidRenderer; diff --git a/frontend/app/(presentation-generator)/components/SlideRenderer.tsx b/frontend/app/(presentation-generator)/components/SlideRenderer.tsx index 22b081c..625e6c7 100644 --- a/frontend/app/(presentation-generator)/components/SlideRenderer.tsx +++ b/frontend/app/(presentation-generator)/components/SlideRenderer.tsx @@ -2,6 +2,8 @@ import React from "react"; import { LayoutSchema, SlideElement, mergeElementsWithContent } from "@/app/hooks/parseLayoutSchema"; +import { DiagramRenderer } from "./DiagramRenderer"; +import { MermaidRenderer } from "./MermaidRenderer"; interface SlideRendererProps { schema: LayoutSchema; @@ -26,6 +28,10 @@ export function SlideRenderer({ const elements = mergeElementsWithContent(schema, content); const { slideWidth, slideHeight, background } = schema; + const diagramData = content?.__diagram__ as any; + const mermaidCode = content?.__mermaid__ as string | undefined; + const hasChartElement = elements.some((e) => e.type === "chart"); + return (
))} + + {/* Floating diagram overlay when no chart element exists */} + {!hasChartElement && mermaidCode && ( +
+ +
+ )} + {!hasChartElement && diagramData && !mermaidCode && ( +
+ +
+ )}
); } function ElementRenderer({ elem, + content, slideWidth, slideHeight, }: { elem: SlideElement; + content?: Record; slideWidth: number; slideHeight: number; }) { @@ -68,7 +114,6 @@ function ElementRenderer({ if (elem.type === "text" || elem.type === "shape") { const displayText = elem.content || elem.defaultContent || ""; if (!displayText && elem.type === "shape") { - // Decorative shape — render as colored block if we have background return (
); @@ -127,6 +172,25 @@ function ElementRenderer({ } if (elem.type === "chart") { + const mermaidCode = content?.__mermaid__ as string | undefined; + const diagramData = content?.__diagram__ as any; + + if (mermaidCode) { + return ( +
+ +
+ ); + } + + if (diagramData) { + return ( +
+ +
+ ); + } + return (
void; +} + +export function TemplateCodegenExport({ presentationId, onClose }: TemplateCodegenExportProps) { + const [file, setFile] = useState(null); + const [customPrompt, setCustomPrompt] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const handleFile = useCallback((f: File) => { + if (!f.name.toLowerCase().endsWith(".pptx")) { + setError("Please upload a .pptx file"); + return; + } + setFile(f); + setError(null); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const dropped = e.dataTransfer.files[0]; + if (dropped) handleFile(dropped); + }, + [handleFile] + ); + + const handleGenerate = async () => { + if (!file) return; + setIsGenerating(true); + setError(null); + + try { + const form = new FormData(); + form.append("presentation_id", presentationId); + form.append("template_file", file); + if (customPrompt.trim()) { + form.append("custom_prompt", customPrompt.trim()); + } + + const response = await fetch("/api/v1/ppt/template-codegen/generate", { + method: "POST", + body: form, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || "Generation failed"); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "presentation-from-template.pptx"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + onClose(); + } catch (err: any) { + setError(err?.message || "Failed to generate"); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

Export from Template

+ +
+ +

+ Upload a branded .pptx template. We'll generate a new presentation using your content and the template's styling. +

+ + {/* Dropzone */} +
{ e.preventDefault(); setIsDragOver(true); }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + e.target.files?.[0] && handleFile(e.target.files[0])} + /> + {file ? ( +
+ + {file.name} +
+ ) : ( +
+ +

Drop your .pptx template here

+

or click to browse

+
+ )} +
+ + {/* Custom instructions */} +
+ +