Add 3 sandbox features: diagrams, mermaid, and template code-gen

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-19 18:47:31 +00:00
parent 6ea431bc75
commit 587f5ef6e1
13 changed files with 1366 additions and 12 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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'<Override\s+PartName="' + re.escape(part_name) + 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'<Relationship\s[^>]*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 <p:sldIdLst> entries
content = re.sub(r'<p:sldId\s[^/]*/>', '', 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)}

View file

@ -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(

View file

@ -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 (
<div
className={`diagram-renderer ${className}`}
style={{
width: width ? `${width}px` : "100%",
height: height ? `${height}px` : "100%",
display: "flex",
flexDirection: "column",
padding: "8px",
fontFamily: "sans-serif",
overflow: "hidden",
}}
>
{data.title && (
<div
style={{
fontSize: "13px",
fontWeight: 700,
color: "#1e293b",
marginBottom: "6px",
textAlign: "center",
flexShrink: 0,
}}
>
{data.title}
</div>
)}
<div style={{ flex: 1, overflow: "hidden", minHeight: 0 }}>
{data.type === "flowchart" && <FlowchartDiagram steps={data.flow_steps || []} />}
{data.type === "bar_chart" && <BarChartDiagram items={data.bar_items || []} />}
{data.type === "pie_chart" && <PieChartDiagram items={data.bar_items || []} />}
</div>
</div>
);
}
function FlowchartDiagram({ steps }: { steps: FlowStep[] }) {
if (!steps.length) return null;
return (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 0,
overflowX: "auto",
overflowY: "hidden",
}}
>
{steps.map((step, i) => (
<React.Fragment key={i}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: COLORS[i % COLORS.length],
color: "white",
borderRadius: "6px",
padding: "6px 8px",
minWidth: "70px",
maxWidth: "100px",
textAlign: "center",
flexShrink: 1,
}}
>
<div style={{ fontSize: "10px", fontWeight: 700, lineHeight: 1.2 }}>{step.label}</div>
{step.description && (
<div style={{ fontSize: "8px", opacity: 0.85, marginTop: "2px", lineHeight: 1.2 }}>
{step.description}
</div>
)}
</div>
{i < steps.length - 1 && (
<div
style={{
color: "#94a3b8",
fontSize: "16px",
flexShrink: 0,
padding: "0 2px",
}}
>
</div>
)}
</React.Fragment>
))}
</div>
);
}
function BarChartDiagram({ items }: { items: BarChartItem[] }) {
if (!items.length) return null;
const maxVal = Math.max(...items.map((i) => Math.abs(i.value)), 1);
return (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-end",
justifyContent: "space-around",
height: "100%",
paddingBottom: "20px",
gap: "4px",
position: "relative",
}}
>
{items.map((item, i) => {
const pct = Math.max((Math.abs(item.value) / maxVal) * 80, 4);
return (
<div
key={i}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
flex: 1,
height: "100%",
}}
>
<div
style={{
fontSize: "8px",
color: "#334155",
marginBottom: "2px",
fontWeight: 600,
}}
>
{item.value % 1 === 0 ? item.value : item.value.toFixed(1)}
</div>
<div
style={{
width: "100%",
height: `${pct}%`,
background: COLORS[i % COLORS.length],
borderRadius: "3px 3px 0 0",
minHeight: "4px",
}}
/>
<div
style={{
fontSize: "7px",
color: "#64748b",
marginTop: "3px",
textAlign: "center",
width: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
</div>
</div>
);
})}
</div>
);
}
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 (
<div style={{ display: "flex", alignItems: "center", height: "100%", gap: "8px" }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
{slices.map((slice, i) => (
<path key={i} d={slice.d} fill={slice.color} stroke="white" strokeWidth={1} />
))}
</svg>
<div style={{ display: "flex", flexDirection: "column", gap: "3px", overflow: "hidden" }}>
{slices.map((slice, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "4px", fontSize: "8px" }}>
<div
style={{
width: "8px",
height: "8px",
background: slice.color,
borderRadius: "2px",
flexShrink: 0,
}}
/>
<span style={{ color: "#334155", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{slice.label} ({slice.pct}%)
</span>
</div>
))}
</div>
</div>
);
}
export default DiagramRenderer;

View file

@ -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<HTMLDivElement>(null);
const [svgContent, setSvgContent] = useState<string>("");
const [error, setError] = useState<string>("");
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 (
<div
className={className}
style={{
...style,
overflow: "auto",
fontSize: "10px",
fontFamily: "monospace",
background: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: "4px",
padding: "8px",
color: "#64748b",
}}
>
<div style={{ color: "#dc2626", marginBottom: "4px", fontSize: "9px", fontWeight: 600 }}>
Diagram error
</div>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, fontSize: "8px" }}>
{code}
</pre>
</div>
);
}
if (!svgContent) {
return (
<div
className={className}
style={{
...style,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#94a3b8",
fontSize: "10px",
}}
>
Rendering diagram
</div>
);
}
return (
<div
ref={containerRef}
className={className}
style={{
...style,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}
export default MermaidRenderer;

View file

@ -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 (
<div
className={`relative overflow-hidden ${className}`}
@ -39,20 +45,60 @@ export function SlideRenderer({
<ElementRenderer
key={elem.id}
elem={elem}
content={content}
slideWidth={slideWidth}
slideHeight={slideHeight}
/>
))}
{/* Floating diagram overlay when no chart element exists */}
{!hasChartElement && mermaidCode && (
<div
style={{
position: "absolute",
bottom: "4%",
right: "2%",
width: "45%",
height: "40%",
background: "rgba(255,255,255,0.95)",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.15)",
zIndex: 10,
padding: "6px",
}}
>
<MermaidRenderer code={mermaidCode} style={{ width: "100%", height: "100%" }} />
</div>
)}
{!hasChartElement && diagramData && !mermaidCode && (
<div
style={{
position: "absolute",
bottom: "4%",
right: "2%",
width: "45%",
height: "35%",
background: "rgba(255,255,255,0.95)",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.15)",
zIndex: 10,
}}
>
<DiagramRenderer data={diagramData} />
</div>
)}
</div>
);
}
function ElementRenderer({
elem,
content,
slideWidth,
slideHeight,
}: {
elem: SlideElement;
content?: Record<string, unknown>;
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 (
<div style={{ ...posStyle, background: "rgba(255,255,255,0.1)" }} />
);
@ -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 (
<div style={posStyle}>
<MermaidRenderer code={mermaidCode} style={{ width: "100%", height: "100%" }} />
</div>
);
}
if (diagramData) {
return (
<div style={posStyle}>
<DiagramRenderer data={diagramData} />
</div>
);
}
return (
<div
style={{

View file

@ -0,0 +1,175 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, FileText, Loader2, CheckCircle } from "lucide-react";
interface TemplateCodegenExportProps {
presentationId: string;
onClose: () => void;
}
export function TemplateCodegenExport({ presentationId, onClose }: TemplateCodegenExportProps) {
const [file, setFile] = useState<File | null>(null);
const [customPrompt, setCustomPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-900">Export from Template</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-gray-500 mb-4">
Upload a branded .pptx template. We&apos;ll generate a new presentation using your content and the template&apos;s styling.
</p>
{/* Dropzone */}
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors mb-4 ${
isDragOver ? "border-[#5146E5] bg-purple-50" : file ? "border-green-400 bg-green-50" : "border-gray-300 hover:border-[#5146E5]"
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".pptx"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{file ? (
<div className="flex items-center justify-center gap-2 text-green-700">
<CheckCircle className="w-5 h-5" />
<span className="font-medium text-sm">{file.name}</span>
</div>
) : (
<div>
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">Drop your .pptx template here</p>
<p className="text-xs text-gray-400 mt-1">or click to browse</p>
</div>
)}
</div>
{/* Custom instructions */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Custom instructions (optional)
</label>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="e.g. Use a formal tone, include company logo on title slide..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[#5146E5]/30"
rows={3}
/>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-4">
{error}
</div>
)}
<div className="flex gap-3">
<Button variant="ghost" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={handleGenerate}
disabled={!file || isGenerating}
className="flex-1 bg-[#5146E5] text-white hover:bg-[#4038c7]"
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating
</>
) : (
<>
<FileText className="w-4 h-4 mr-2" />
Generate PPTX
</>
)}
</Button>
</div>
</div>
</div>
);
}
export default TemplateCodegenExport;

View file

@ -10,6 +10,8 @@ import { useCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { updateSlideContent } from "@/store/slices/presentationGeneration";
import { useDispatch } from "react-redux";
import { Loader2 } from "lucide-react";
import { DiagramRenderer } from "./DiagramRenderer";
import { MermaidRenderer } from "./MermaidRenderer";
@ -83,10 +85,13 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
}
const LayoutComp = Layout as React.ComponentType<{ data: any }>;
const diagramData = slide.content?.__diagram__ as any;
const mermaidCode = slide.content?.__mermaid__ as string | undefined;
if (isEditMode) {
return (
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
<div ref={containerRef} className={`w-full h-full `}>
<div ref={containerRef} className={`w-full h-full relative`}>
<EditableLayoutWrapper
slideIndex={slide.index}
@ -121,7 +126,16 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
</TiptapTextReplacer>
</EditableLayoutWrapper>
{mermaidCode && (
<div style={{ position: "absolute", bottom: "4%", right: "2%", width: "45%", height: "38%", background: "rgba(255,255,255,0.95)", borderRadius: "6px", boxShadow: "0 2px 12px rgba(0,0,0,0.15)", zIndex: 10, padding: "6px" }}>
<MermaidRenderer code={mermaidCode} style={{ width: "100%", height: "100%" }} />
</div>
)}
{!mermaidCode && diagramData && (
<div style={{ position: "absolute", bottom: "4%", right: "2%", width: "45%", height: "35%", background: "rgba(255,255,255,0.95)", borderRadius: "6px", boxShadow: "0 2px 12px rgba(0,0,0,0.15)", zIndex: 10 }}>
<DiagramRenderer data={diagramData} />
</div>
)}
</div>
</SlideErrorBoundary>
@ -130,11 +144,23 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
}
return (
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
<LayoutComp data={{
...slide.content,
_logo_url__: theme ? theme.logo_url : null,
__companyName__: (theme && theme.company_name) ? theme.company_name : null,
}} />
<div className="relative w-full h-full">
<LayoutComp data={{
...slide.content,
_logo_url__: theme ? theme.logo_url : null,
__companyName__: (theme && theme.company_name) ? theme.company_name : null,
}} />
{mermaidCode && (
<div style={{ position: "absolute", bottom: "4%", right: "2%", width: "45%", height: "38%", background: "rgba(255,255,255,0.95)", borderRadius: "6px", boxShadow: "0 2px 12px rgba(0,0,0,0.15)", zIndex: 10, padding: "6px" }}>
<MermaidRenderer code={mermaidCode} style={{ width: "100%", height: "100%" }} />
</div>
)}
{!mermaidCode && diagramData && (
<div style={{ position: "absolute", bottom: "4%", right: "2%", width: "45%", height: "35%", background: "rgba(255,255,255,0.95)", borderRadius: "6px", boxShadow: "0 2px 12px rgba(0,0,0,0.15)", zIndex: 10 }}>
<DiagramRenderer data={diagramData} />
</div>
)}
</div>
</SlideErrorBoundary>
)
};

View file

@ -6,7 +6,7 @@ import {
Loader2,
Redo2,
Undo2,
FileCode2,
} from "lucide-react";
import React, { useState } from "react";
import Wrapper from "@/components/Wrapper";
@ -36,6 +36,7 @@ import ToolTip from "@/components/ToolTip";
import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { clearHistory } from "@/store/slices/undoRedoSlice";
import Logo from "@/components/Logo";
import { TemplateCodegenExport } from "../../components/TemplateCodegenExport";
const Header = ({
presentation_id,
@ -46,6 +47,7 @@ const Header = ({
}) => {
const [open, setOpen] = useState(false);
const [showLoader, setShowLoader] = useState(false);
const [showTemplateCodegen, setShowTemplateCodegen] = useState(false);
const router = useRouter();
const dispatch = useDispatch();
@ -162,13 +164,22 @@ const Header = ({
handleExportPptx();
}}
variant="ghost"
className={`w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6" : ""}`}
className={`pb-4 border-b rounded-none border-gray-300 w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`}
>
<Image src={PPTXIMAGE} alt="pptx export" width={30} height={30} />
Export as PPTX
</Button>
<Button
onClick={() => {
setOpen(false);
setShowTemplateCodegen(true);
}}
variant="ghost"
className={`w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6" : ""}`}
>
<FileCode2 className="w-7 h-7 mr-2" />
Export from Template
</Button>
</div>
);
@ -277,6 +288,12 @@ const Header = ({
</Wrapper>
</div>
{showTemplateCodegen && (
<TemplateCodegenExport
presentationId={presentation_id}
onClose={() => setShowTemplateCodegen(false)}
/>
)}
</>
);
};

View file

@ -50,6 +50,8 @@ export async function POST(request: NextRequest) {
layoutMap.set(layout.layout_id, layout);
}
const baseUrl = process.env.NEXT_INTERNAL_URL || `http://localhost:${process.env.PORT || 3000}`;
for (const slideData of slides) {
const slide = pres.addSlide();
const layoutData = layoutMap.get(slideData.layout);
@ -61,6 +63,9 @@ export async function POST(request: NextRequest) {
renderFallback(slide, slideData.content);
}
// Add diagram/mermaid to slide
await addDiagramToSlide(pres, slide, slideData.content, baseUrl);
if (slideData.speaker_note) {
slide.addNotes(slideData.speaker_note);
}
@ -219,6 +224,143 @@ function renderFallback(
}
}
async function addDiagramToSlide(
pres: pptxgen,
slide: pptxgen.Slide,
content: Record<string, unknown>,
baseUrl: string
): Promise<void> {
const mermaidCode = content?.__mermaid__ as string | undefined;
const diagramData = content?.__diagram__ as any;
// Mermaid: render to image via API
if (mermaidCode) {
try {
const resp = await fetch(`${baseUrl}/api/mermaid-to-image`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: mermaidCode, width: 900, height: 500 }),
});
if (resp.ok) {
const { image } = await resp.json();
if (image) {
slide.addImage({
data: `image/png;base64,${image}`,
x: 1.0,
y: 1.5,
w: 11.0,
h: 5.5,
});
return;
}
}
} catch {
// Skip mermaid export on failure
}
return;
}
if (!diagramData || !diagramData.type) return;
const type = diagramData.type as string;
if (type === "bar_chart" || type === "pie_chart") {
const items: Array<{ label: string; value: number }> = diagramData.bar_items || [];
if (!items.length) return;
const dataLabels = items.map((i: any) => i.label);
const dataValues = items.map((i: any) => Number(i.value) || 0);
const chartType = type === "pie_chart" ? "pie" : "bar";
try {
slide.addChart(pres.ChartType[chartType === "pie" ? "pie" : "bar"] as any, [
{
name: diagramData.title || "Data",
labels: dataLabels,
values: dataValues,
},
], {
x: 1.0,
y: 1.5,
w: 11.0,
h: 5.0,
showTitle: !!diagramData.title,
title: diagramData.title || "",
showLegend: true,
legendPos: "b",
});
} catch {
// Skip chart if pptxgenjs rejects it
}
} else if (type === "flowchart") {
const steps: Array<{ label: string; description?: string }> = diagramData.flow_steps || [];
if (!steps.length) return;
const colors = ["5146E5", "7C3AED", "2563EB", "059669", "D97706", "DC2626"];
const boxW = Math.min(11.0 / steps.length - 0.2, 2.5);
const boxH = 1.2;
const startX = (13.333 - (boxW + 0.3) * steps.length) / 2;
const startY = 3.0;
steps.forEach((step, i) => {
const x = startX + i * (boxW + 0.3);
const color = colors[i % colors.length];
try {
slide.addShape(pres.ShapeType.rect, {
x,
y: startY,
w: boxW,
h: boxH,
fill: { color },
line: { color, width: 0 },
});
slide.addText(step.label, {
x,
y: startY,
w: boxW,
h: step.description ? boxH * 0.55 : boxH,
fontSize: 10,
color: "FFFFFF",
bold: true,
align: "center",
valign: "middle",
wrap: true,
});
if (step.description) {
slide.addText(step.description, {
x,
y: startY + boxH * 0.55,
w: boxW,
h: boxH * 0.45,
fontSize: 7,
color: "FFFFFF",
align: "center",
valign: "top",
wrap: true,
});
}
// Arrow connector
if (i < steps.length - 1) {
slide.addText("→", {
x: x + boxW,
y: startY,
w: 0.3,
h: boxH,
fontSize: 14,
color: "94A3B8",
align: "center",
valign: "middle",
});
}
} catch {
// Skip shape if pptxgenjs rejects
}
});
}
}
// ─── helpers ────────────────────────────────────────────────────────────────
/**

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import puppeteer from "puppeteer";
export async function POST(request: NextRequest) {
const { code, width = 900, height = 500 } = await request.json();
if (!code || typeof code !== "string") {
return NextResponse.json({ error: "code is required" }, { status: 400 });
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; background: white; display: flex; align-items: center; justify-content: center; }
#diagram { max-width: 100%; max-height: 100%; }
.mermaid svg { max-width: 100%; height: auto; }
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
</head>
<body>
<div class="mermaid" id="diagram">
${code}
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'neutral', securityLevel: 'loose' });
</script>
</body>
</html>`;
let browser;
try {
browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
});
const page = await browser.newPage();
await page.setViewport({ width, height });
await page.setContent(html, { waitUntil: "networkidle0", timeout: 15000 });
// Wait for mermaid to render
await page.waitForSelector(".mermaid svg", { timeout: 10000 }).catch(() => null);
await new Promise((r) => setTimeout(r, 500));
const screenshotBuffer = await page.screenshot({ type: "png", fullPage: false });
const base64 = Buffer.from(screenshotBuffer).toString("base64");
return NextResponse.json({ image: base64 });
} catch (err: any) {
console.error("[mermaid-to-image] Error:", err);
return NextResponse.json({ error: err?.message || "Failed to render" }, { status: 500 });
} finally {
await browser?.close();
}
}