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:
parent
6ea431bc75
commit
587f5ef6e1
13 changed files with 1366 additions and 12 deletions
70
backend/api/v1/ppt/endpoints/template_codegen.py
Normal file
70
backend/api/v1/ppt/endpoints/template_codegen.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
19
backend/models/diagram_data.py
Normal file
19
backend/models/diagram_data.py
Normal 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
|
||||
350
backend/services/template_codegen_service.py
Normal file
350
backend/services/template_codegen_service.py
Normal 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)}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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'll generate a new presentation using your content and the template'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;
|
||||
|
|
@ -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>
|
||||
)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
58
frontend/app/api/mermaid-to-image/route.ts
Normal file
58
frontend/app/api/mermaid-to-image/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue