suna/backend/agent/tools/sb_presentation_tool_v2.py

1886 lines
72 KiB
Python

import asyncio
from agentpress.tool import ToolResult, openapi_schema, usage_example
from sandbox.tool_base import SandboxToolsBase
from agentpress.thread_manager import ThreadManager
from typing import List, Dict, Optional, Any
import json
import base64
import io
from datetime import datetime
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
import tempfile
import os
import hashlib
from PIL import Image
import re
import asyncio
import random
import httpx
class SandboxPresentationToolV2(SandboxToolsBase):
def __init__(self, project_id: str, thread_manager: ThreadManager):
super().__init__(project_id, thread_manager)
self.presentations_dir = "presentations"
self.images_cache = {}
async def _ensure_presentations_dir(self):
full_path = f"{self.workspace_path}/{self.presentations_dir}"
try:
await self.sandbox.fs.create_folder(full_path, "755")
except:
pass
def _get_display_url(self, image_url: str) -> str:
if not image_url:
return ""
if image_url.startswith("unsplash:"):
keyword = image_url.replace("unsplash:", "").strip()
return f"https://source.unsplash.com/1920x1080/?{keyword}"
return image_url
async def _download_and_cache_image(self, image_url: str, presentation_dir: str) -> Optional[str]:
"""Download an image and cache it locally. Returns the relative path from workspace root."""
if not image_url:
return None
# Check if already cached
if image_url in self.images_cache:
return self.images_cache[image_url]
try:
download_url = self._get_display_url(image_url)
# Generate unique filename based on URL
url_hash = hashlib.md5(image_url.encode()).hexdigest()[:8]
images_dir = f"{presentation_dir}/images"
full_images_dir = f"{self.workspace_path}/{images_dir}"
# Ensure images directory exists
try:
await self.sandbox.fs.create_folder(full_images_dir, "755")
except:
pass
image_data: bytes | None = None
# Try to download the image
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
resp = await client.get(download_url, headers=headers)
resp.raise_for_status()
image_data = resp.content
except Exception as e:
# Fallback to curl if httpx fails
try:
tmp_path = f"/tmp/img_{url_hash}"
cmd = f"/bin/sh -c 'curl -fsSL -A \"Mozilla/5.0\" \"{download_url}\" -o {tmp_path}'"
res = await self.sandbox.process.exec(cmd, timeout=30)
if getattr(res, "exit_code", 1) == 0:
image_data = await self.sandbox.fs.download_file(tmp_path)
try:
await self.sandbox.process.exec(f"/bin/sh -c 'rm -f {tmp_path}'", timeout=10)
except:
pass
except Exception:
image_data = None
if not image_data:
print(f"Failed to download image from {download_url}")
return None
# Process and save the image
try:
img = Image.open(io.BytesIO(image_data))
output = io.BytesIO()
# Convert to RGB if necessary
if img.mode in ("RGBA", "LA", "P"):
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
img = background
else:
img = img.convert("RGB")
img.save(output, format="JPEG", quality=90)
image_data = output.getvalue()
filename = f"img_{url_hash}.jpg"
except Exception:
# If image processing fails, save as-is
filename = f"img_{url_hash}.jpg"
# Save the image
image_path = f"{images_dir}/{filename}"
full_image_path = f"{self.workspace_path}/{image_path}"
await self.sandbox.fs.upload_file(image_data, full_image_path)
# Cache and return the relative path
self.images_cache[image_url] = image_path
return image_path
except Exception as e:
print(f"Failed to download image {image_url}: {e}")
return None
def _safe_name_variants(self, name: str) -> List[str]:
"""Generate safe name variants for file/folder naming."""
base = "".join(c if c.isalnum() or c in "-_" else '-' for c in name).lower()
while "--" in base:
base = base.replace("--", "-")
while "__" in base:
base = base.replace("__", "_")
hyphen = base.replace("_", "-")
underscore = base.replace("-", "_")
variants = []
for v in [base, hyphen, underscore]:
if v not in variants:
variants.append(v)
return variants
async def _process_slide_images(self, slide: Dict, presentation_dir: str) -> Dict:
"""Process all images in a slide and download them if needed."""
content = slide.get("content", {})
# Process single image
if "image" in content:
image_info = content["image"]
if isinstance(image_info, str):
# Convert string to dict format
local_path = await self._download_and_cache_image(image_info, presentation_dir)
if local_path:
content["image"] = {
"url": image_info,
"local_path": local_path
}
elif isinstance(image_info, dict) and "url" in image_info:
# Download if not already downloaded
if "local_path" not in image_info:
local_path = await self._download_and_cache_image(
image_info["url"],
presentation_dir
)
if local_path:
image_info["local_path"] = local_path
# Process image grid
if "images" in content:
processed_images = []
for img in content["images"]:
if isinstance(img, str):
local_path = await self._download_and_cache_image(img, presentation_dir)
if local_path:
processed_images.append({
"url": img,
"local_path": local_path
})
else:
processed_images.append({"url": img})
elif isinstance(img, dict):
if "url" in img and "local_path" not in img:
local_path = await self._download_and_cache_image(
img["url"],
presentation_dir
)
if local_path:
img["local_path"] = local_path
processed_images.append(img)
content["images"] = processed_images
return slide
@openapi_schema({
"type": "function",
"function": {
"name": "create_presentation",
"description": """
Create a professional presentation using structured JSON format.
The presentation will be saved as JSON and can be previewed in the browser or exported to PPTX.
IMPORTANT: Generate a structured JSON with specific layout types. Each slide must have:
- layout: One of 'title', 'title-bullets', 'title-content', 'two-column', 'image-text',
'quote', 'section', 'blank', 'hero-image', 'image-grid', 'comparison', 'timeline', 'stats'
- content: Object with fields specific to that layout type
Images can be specified as:
- URLs (will be downloaded and embedded)
- Unsplash search terms using "unsplash:keyword" format
- Local file paths in the workspace
The tool will handle all formatting and ensure consistency across preview and export.
THEME SELECTION: Instead of a fixed theme, choose a theme that best fits the content:
- 'corporate-blue': Professional blue theme for business presentations
- 'modern-purple': Modern purple/pink gradient for tech/startup
- 'minimal-mono': Black and white minimalist design
- 'ocean-teal': Calming teal and aqua colors
- 'sunset-warm': Warm orange and red tones
- 'forest-green': Natural green palette
- 'midnight-dark': Dark mode with bright accents
- 'pastel-soft': Soft pastel colors
- 'bold-contrast': High contrast bold colors
- 'elegant-gold': Sophisticated gold and navy
Select the theme that best matches the presentation's content and purpose.
""",
"parameters": {
"type": "object",
"properties": {
"presentation_name": {
"type": "string",
"description": "Name for the presentation (used for file naming)"
},
"title": {
"type": "string",
"description": "Main title of the presentation"
},
"subtitle": {
"type": "string",
"description": "Optional subtitle or tagline"
},
"theme": {
"type": "string",
"enum": ["corporate-blue", "modern-purple", "minimal-mono", "ocean-teal",
"sunset-warm", "forest-green", "midnight-dark", "pastel-soft",
"bold-contrast", "elegant-gold"],
"description": "Visual theme - choose based on content and purpose"
},
"slides": {
"type": "array",
"description": "Array of slide objects with layout and content",
"items": {
"type": "object",
"properties": {
"layout": {
"type": "string",
"enum": ["title", "title-bullets", "title-content", "two-column", "image-text",
"quote", "section", "blank", "hero-image", "image-grid",
"comparison", "timeline", "stats"],
"description": "Layout type for the slide"
},
"content": {
"type": "object",
"description": "Content object specific to the layout type",
"properties": {
"title": {"type": "string"},
"subtitle": {"type": "string"},
"bullets": {"type": "array", "items": {"type": "string"}},
"text": {"type": "string"},
"left_content": {"type": "object"},
"right_content": {"type": "object"},
"image": {
"type": "object",
"properties": {
"url": {"type": "string"},
"alt": {"type": "string"},
"position": {"type": "string", "enum": ["left", "right", "center", "background"]}
}
},
"quote": {"type": "string"},
"author": {"type": "string"},
"notes": {"type": "string"}
}
}
},
"required": ["layout", "content"]
}
}
},
"required": ["presentation_name", "title", "slides"]
}
}
})
@usage_example('''
<function_calls>
<invoke name="create_presentation">
<parameter name="presentation_name">company_overview</parameter>
<parameter name="title">Company Overview 2024</parameter>
<parameter name="subtitle">Innovation Through Technology</parameter>
<parameter name="theme">modern-purple</parameter>
<parameter name="slides">[
{
"layout": "hero-image",
"content": {
"title": "Welcome to the Future",
"subtitle": "Where Innovation Meets Excellence",
"image": {
"url": "unsplash:technology office",
"position": "background"
}
}
},
{
"layout": "stats",
"content": {
"title": "Our Impact in Numbers",
"stats": [
{"value": "50K+", "label": "Active Users"},
{"value": "$2.5M", "label": "Revenue"},
{"value": "98%", "label": "Satisfaction"},
{"value": "15", "label": "Countries"}
]
}
},
{
"layout": "image-text",
"content": {
"title": "Our Mission",
"text": "We empower businesses with cutting-edge AI technology to transform their operations, enhance productivity, and unlock new possibilities. Our platform combines powerful automation with human creativity.",
"image": {
"url": "unsplash:artificial intelligence",
"position": "right"
}
}
},
{
"layout": "image-grid",
"content": {
"title": "Our Products in Action",
"images": [
{"url": "unsplash:dashboard analytics", "caption": "Real-time Analytics"},
{"url": "unsplash:team collaboration", "caption": "Team Collaboration"},
{"url": "unsplash:mobile app", "caption": "Mobile Experience"},
{"url": "unsplash:data visualization", "caption": "Data Insights"}
]
}
},
{
"layout": "title-bullets",
"content": {
"title": "Key Features",
"bullets": [
"AI-powered automation for repetitive tasks",
"Real-time collaboration and communication",
"Advanced analytics and reporting",
"Enterprise-grade security and compliance",
"24/7 customer support and training"
]
}
},
{
"layout": "quote",
"content": {
"quote": "This platform has transformed how we work. We've saved 40% of our time and increased productivity by 60%.",
"author": "Sarah Johnson, CTO at TechCorp"
}
},
{
"layout": "section",
"content": {
"title": "Let's Build Together",
"subtitle": "Start Your Journey Today"
}
},
{
"layout": "title",
"content": {
"title": "Thank You",
"subtitle": "Questions? Let's Connect!",
"notes": "Contact us at hello@company.com"
}
}
]</parameter>
</invoke>
</function_calls>
''')
async def create_presentation(
self,
presentation_name: str,
title: str,
slides: List[Dict[str, Any]],
subtitle: Optional[str] = None,
theme: str = "corporate-blue"
) -> ToolResult:
try:
await self._ensure_sandbox()
await self._ensure_presentations_dir()
# Validate inputs
if not presentation_name:
return self.fail_response("Presentation name is required.")
if not title:
return self.fail_response("Presentation title is required.")
if not slides or not isinstance(slides, list) or len(slides) == 0:
return self.fail_response("At least one slide is required.")
# Validate slide structure
for i, slide in enumerate(slides):
if 'layout' not in slide:
return self.fail_response(f"Slide {i+1} missing 'layout' field")
if 'content' not in slide:
return self.fail_response(f"Slide {i+1} missing 'content' field")
# Create presentation directory
safe_name = self._safe_name_variants(presentation_name)[0]
presentation_dir = f"{self.presentations_dir}/{safe_name}"
full_presentation_path = f"{self.workspace_path}/{presentation_dir}"
try:
await self.sandbox.fs.create_folder(full_presentation_path, "755")
except:
pass
# Process all images in slides (download them during creation)
download_errors = []
processed_slides = []
for slide in slides:
try:
processed_slide = await self._process_slide_images(slide.copy(), presentation_dir)
processed_slides.append(processed_slide)
except Exception as e:
download_errors.append(f"Error processing slide images: {str(e)}")
processed_slides.append(slide)
# Create presentation data
presentation_data = {
"version": "2.0",
"metadata": {
"title": title,
"subtitle": subtitle,
"theme": theme,
"created_at": datetime.now().isoformat(),
"presentation_name": presentation_name,
"total_slides": len(processed_slides)
},
"slides": processed_slides,
"theme_config": self._get_theme_config(theme)
}
# Save JSON
json_filename = "presentation.json"
json_path = f"{presentation_dir}/{json_filename}"
full_json_path = f"{self.workspace_path}/{json_path}"
json_content = json.dumps(presentation_data, indent=2)
await self.sandbox.fs.upload_file(
json_content.encode('utf-8'),
full_json_path
)
# Generate HTML preview with downloaded images
preview_html = self._generate_html_preview(presentation_data)
preview_path = f"{presentation_dir}/preview.html"
full_preview_path = f"{self.workspace_path}/{preview_path}"
await self.sandbox.fs.upload_file(
preview_html.encode('utf-8'),
full_preview_path
)
result = {
"message": f"Successfully created presentation '{title}' with {theme} theme",
"presentation_name": safe_name,
"json_file": json_path,
"preview_url": f"/workspace/{preview_path}",
"total_slides": len(processed_slides),
"theme": theme,
"slides": [
{
"slide_number": i + 1,
"layout": slide["layout"],
"title": slide["content"].get("title", f"Slide {i+1}")
}
for i, slide in enumerate(processed_slides)
]
}
if download_errors:
result["warnings"] = download_errors
return self.success_response(result)
except Exception as e:
return self.fail_response(f"Failed to create presentation: {str(e)}")
def _get_theme_config(self, theme: str) -> Dict[str, Any]:
themes = {
"corporate-blue": {
"colors": {
"primary": "#1e3a8a",
"secondary": "#3b82f6",
"accent": "#f59e0b",
"background": "#ffffff",
"text": "#1e293b",
"text_light": "#64748b"
},
"fonts": {
"heading": "Arial, sans-serif",
"body": "Arial, sans-serif"
}
},
"modern-purple": {
"colors": {
"primary": "#7c3aed",
"secondary": "#ec4899",
"accent": "#06b6d4",
"background": "#ffffff",
"text": "#1e293b",
"text_light": "#64748b"
},
"fonts": {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
}
},
"minimal-mono": {
"colors": {
"primary": "#000000",
"secondary": "#525252",
"accent": "#000000",
"background": "#ffffff",
"text": "#000000",
"text_light": "#737373"
},
"fonts": {
"heading": "Helvetica Neue, sans-serif",
"body": "Helvetica Neue, sans-serif"
}
},
"ocean-teal": {
"colors": {
"primary": "#0d9488",
"secondary": "#06b6d4",
"accent": "#0284c7",
"background": "#ffffff",
"text": "#134e4a",
"text_light": "#5eead4"
},
"fonts": {
"heading": "Roboto, sans-serif",
"body": "Roboto, sans-serif"
}
},
"sunset-warm": {
"colors": {
"primary": "#dc2626",
"secondary": "#f97316",
"accent": "#fbbf24",
"background": "#ffffff",
"text": "#7c2d12",
"text_light": "#ea580c"
},
"fonts": {
"heading": "Poppins, sans-serif",
"body": "Open Sans, sans-serif"
}
},
"forest-green": {
"colors": {
"primary": "#14532d",
"secondary": "#16a34a",
"accent": "#84cc16",
"background": "#ffffff",
"text": "#14532d",
"text_light": "#22c55e"
},
"fonts": {
"heading": "Montserrat, sans-serif",
"body": "Source Sans Pro, sans-serif"
}
},
"midnight-dark": {
"colors": {
"primary": "#f3f4f6",
"secondary": "#60a5fa",
"accent": "#f472b6",
"background": "#111827",
"text": "#f9fafb",
"text_light": "#d1d5db"
},
"fonts": {
"heading": "Space Grotesk, sans-serif",
"body": "Inter, sans-serif"
}
},
"pastel-soft": {
"colors": {
"primary": "#c084fc",
"secondary": "#fda4af",
"accent": "#86efac",
"background": "#fef3c7",
"text": "#451a03",
"text_light": "#92400e"
},
"fonts": {
"heading": "Quicksand, sans-serif",
"body": "Nunito, sans-serif"
}
},
"bold-contrast": {
"colors": {
"primary": "#dc2626",
"secondary": "#000000",
"accent": "#fbbf24",
"background": "#ffffff",
"text": "#000000",
"text_light": "#525252"
},
"fonts": {
"heading": "Bebas Neue, sans-serif",
"body": "Roboto, sans-serif"
}
},
"elegant-gold": {
"colors": {
"primary": "#1e293b",
"secondary": "#f59e0b",
"accent": "#92400e",
"background": "#fffbeb",
"text": "#1e293b",
"text_light": "#475569"
},
"fonts": {
"heading": "Playfair Display, serif",
"body": "Lato, sans-serif"
}
}
}
return themes.get(theme, themes["corporate-blue"])
def _generate_html_preview(self, presentation_data: Dict) -> str:
theme = presentation_data["theme_config"]
colors = theme["colors"]
fonts = theme["fonts"]
is_dark = colors["background"].startswith("#1") or colors["background"].startswith("#0")
slides_html = []
for i, slide in enumerate(presentation_data["slides"]):
slide_html = self._render_slide_html(slide, i + 1, theme)
slides_html.append(slide_html)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{presentation_data['metadata']['title']}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: {fonts['body']};
background: {'#1a1a1a' if is_dark else '#f5f5f5'};
color: {colors['text']};
padding: 20px;
}}
.presentation-header {{
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: {colors['background']};
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.presentation-title {{
font-family: {fonts['heading']};
font-size: 32px;
color: {colors['primary']};
margin-bottom: 10px;
}}
.presentation-subtitle {{
font-size: 18px;
color: {colors['text_light']};
}}
.slides-container {{
max-width: 1280px;
margin: 0 auto;
}}
.slide {{
width: 100%;
aspect-ratio: 16/9;
background: {colors['background']};
margin-bottom: 30px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
padding: 60px;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}}
.slide-number {{
position: absolute;
bottom: 20px;
right: 20px;
color: {colors['text_light']};
font-size: 14px;
opacity: 0.6;
}}
/* Layout-specific styles */
.layout-title {{
justify-content: center;
align-items: center;
text-align: center;
}}
.layout-title h1 {{
font-family: {fonts['heading']};
font-size: 72px;
color: {colors['primary']};
margin-bottom: 30px;
font-weight: 700;
}}
.layout-title .subtitle {{
font-size: 32px;
color: {colors['secondary']};
font-weight: 300;
}}
.layout-title-bullets h2 {{
font-family: {fonts['heading']};
font-size: 48px;
color: {colors['primary']};
margin-bottom: 40px;
font-weight: 600;
}}
.layout-title-bullets ul {{
list-style: none;
padding-left: 0;
}}
.layout-title-bullets li {{
font-size: 24px;
margin-bottom: 20px;
padding-left: 40px;
position: relative;
color: {colors['text']};
line-height: 1.5;
}}
.layout-title-bullets li:before {{
content: "";
position: absolute;
left: 0;
color: {colors['accent']};
font-size: 24px;
}}
.layout-two-column {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
}}
.layout-two-column > h2 {{
grid-column: 1 / -1;
font-family: {fonts['heading']};
font-size: 48px;
color: {colors['primary']};
margin-bottom: 20px;
}}
.column h3 {{
font-size: 28px;
color: {colors['secondary']};
margin-bottom: 20px;
}}
.layout-quote {{
justify-content: center;
align-items: center;
text-align: center;
position: relative;
}}
.layout-quote.with-overlay {{
padding: 0;
}}
.layout-quote .overlay {{
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
border-radius: 10px;
}}
.layout-quote .quote-content {{
padding: 60px;
position: relative;
z-index: 1;
}}
.layout-quote .quote-text {{
font-size: 36px;
font-style: italic;
color: {colors['primary']};
margin-bottom: 30px;
line-height: 1.5;
font-weight: 300;
}}
.layout-quote .quote-author {{
font-size: 24px;
color: {colors['secondary']};
}}
.layout-section {{
justify-content: center;
align-items: center;
text-align: center;
background: {colors['primary']};
color: {colors['background']};
}}
.layout-section h1 {{
font-family: {fonts['heading']};
font-size: 72px;
margin-bottom: 20px;
font-weight: 700;
}}
.layout-section .subtitle {{
font-size: 32px;
opacity: 0.9;
font-weight: 300;
}}
.layout-title-content h2 {{
font-family: {fonts['heading']};
font-size: 48px;
color: {colors['primary']};
margin-bottom: 40px;
}}
.layout-title-content .content-text {{
font-size: 24px;
line-height: 1.8;
color: {colors['text']};
}}
/* Image layouts */
.layout-image-text {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
}}
.layout-image-text.image-right {{
grid-template-columns: 1fr 1fr;
}}
.layout-image-text.image-left {{
grid-template-columns: 1fr 1fr;
direction: rtl;
}}
.layout-image-text .text-content {{
direction: ltr;
}}
.layout-image-text img {{
width: 100%;
height: auto;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}}
.layout-hero-image {{
position: relative;
padding: 0;
background-size: cover;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
}}
.layout-hero-image .overlay {{
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
}}
.layout-hero-image .content {{
position: relative;
z-index: 1;
text-align: center;
color: white;
padding: 60px;
}}
.layout-hero-image h1 {{
font-size: 72px;
margin-bottom: 30px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
font-weight: 700;
}}
.layout-hero-image .subtitle {{
font-size: 32px;
opacity: 0.95;
font-weight: 300;
}}
.layout-image-grid {{
padding: 40px;
}}
.layout-image-grid h2 {{
font-size: 48px;
margin-bottom: 40px;
text-align: center;
color: {colors['primary']};
}}
.layout-image-grid .grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
}}
.layout-image-grid .grid-item {{
position: relative;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}}
.layout-image-grid img {{
width: 100%;
height: 250px;
object-fit: cover;
}}
.layout-image-grid .caption {{
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.7);
color: white;
padding: 15px;
font-size: 16px;
}}
.layout-stats {{
padding: 60px;
}}
.layout-stats h2 {{
font-size: 48px;
margin-bottom: 60px;
text-align: center;
color: {colors['primary']};
}}
.layout-stats .stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 40px;
text-align: center;
}}
.layout-stats .stat-item {{
padding: 30px;
background: {'rgba(255,255,255,0.1)' if is_dark else 'rgba(0,0,0,0.03)'};
border-radius: 15px;
border: 2px solid {colors['accent']};
}}
.layout-stats .stat-value {{
font-size: 48px;
font-weight: bold;
color: {colors['accent']};
margin-bottom: 10px;
}}
.layout-stats .stat-label {{
font-size: 18px;
color: {colors['text_light']};
}}
.navigation {{
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
background: {colors['background']};
padding: 10px 20px;
border-radius: 50px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
z-index: 1000;
}}
.nav-button {{
padding: 8px 16px;
background: {colors['primary']};
color: {colors['background'] if colors['primary'] != '#000000' else '#ffffff'};
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}}
.nav-button:hover {{
background: {colors['secondary']};
transform: translateY(-2px);
}}
.nav-button:disabled {{
background: #ccc;
cursor: not-allowed;
}}
@media print {{
.navigation {{
display: none;
}}
.slide {{
page-break-after: always;
box-shadow: none;
margin-bottom: 0;
}}
}}
</style>
</head>
<body>
<div class="presentation-header">
<h1 class="presentation-title">{presentation_data['metadata']['title']}</h1>
{f'<p class="presentation-subtitle">{presentation_data["metadata"]["subtitle"]}</p>' if presentation_data['metadata'].get('subtitle') else ''}
</div>
<div class="slides-container">
{''.join(slides_html)}
</div>
<div class="navigation">
<button class="nav-button" onclick="window.print()">Print/PDF</button>
<button class="nav-button" onclick="scrollToSlide(0)">First</button>
<button class="nav-button" onclick="scrollToPrevious()">Previous</button>
<button class="nav-button" onclick="scrollToNext()">Next</button>
<button class="nav-button" onclick="scrollToSlide(-1)">Last</button>
</div>
<script>
let currentSlide = 0;
const slides = document.querySelectorAll('.slide');
function scrollToSlide(index) {{
if (index === -1) index = slides.length - 1;
if (index >= 0 && index < slides.length) {{
slides[index].scrollIntoView({{ behavior: 'smooth', block: 'center' }});
currentSlide = index;
}}
}}
function scrollToNext() {{
if (currentSlide < slides.length - 1) {{
scrollToSlide(currentSlide + 1);
}}
}}
function scrollToPrevious() {{
if (currentSlide > 0) {{
scrollToSlide(currentSlide - 1);
}}
}}
// Keyboard navigation
document.addEventListener('keydown', (e) => {{
if (e.key === 'ArrowRight' || e.key === ' ') scrollToNext();
if (e.key === 'ArrowLeft') scrollToPrevious();
if (e.key === 'Home') scrollToSlide(0);
if (e.key === 'End') scrollToSlide(-1);
}});
</script>
</body>
</html>"""
return html
def _render_slide_html(self, slide: Dict, slide_number: int, theme: Dict) -> str:
"""Render a slide to HTML using local image paths."""
layout = slide["layout"]
content = slide["content"]
# Helper function to get image URL for HTML
def get_html_image_url(image_info):
if isinstance(image_info, dict):
if 'local_path' in image_info and image_info['local_path']:
# Use the local downloaded image
return f"/workspace/{image_info['local_path']}"
elif 'url' in image_info:
# Fallback to original URL if local path not available
return self._get_display_url(image_info['url'])
elif isinstance(image_info, str):
return self._get_display_url(image_info)
return ""
if layout == "title":
return f"""
<div class="slide layout-title">
<h1>{content.get('title', '')}</h1>
{f'<p class="subtitle">{content["subtitle"]}</p>' if content.get('subtitle') else ''}
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "title-bullets":
bullets_html = '\n'.join([f'<li>{bullet}</li>' for bullet in content.get('bullets', [])])
return f"""
<div class="slide layout-title-bullets">
<h2>{content.get('title', '')}</h2>
<ul>{bullets_html}</ul>
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "two-column":
left = content.get('left_content', {})
right = content.get('right_content', {})
left_bullets = '\n'.join([f'<li>{b}</li>' for b in left.get('bullets', [])])
right_bullets = '\n'.join([f'<li>{b}</li>' for b in right.get('bullets', [])])
return f"""
<div class="slide layout-two-column">
<h2>{content.get('title', '')}</h2>
<div class="column">
{f'<h3>{left.get("subtitle", "")}</h3>' if left.get('subtitle') else ''}
{f'<ul>{left_bullets}</ul>' if left_bullets else ''}
{f'<p>{left.get("text", "")}</p>' if left.get('text') else ''}
</div>
<div class="column">
{f'<h3>{right.get("subtitle", "")}</h3>' if right.get('subtitle') else ''}
{f'<ul>{right_bullets}</ul>' if right_bullets else ''}
{f'<p>{right.get("text", "")}</p>' if right.get('text') else ''}
</div>
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "quote":
image_info = content.get('image', {})
image_url = get_html_image_url(image_info)
style = f'background-image: url({image_url}); background-size: cover; background-position: center;' if image_url else ''
overlay_class = 'with-overlay' if image_url else ''
return f"""
<div class="slide layout-quote {overlay_class}" style="{style}">
{f'<div class="overlay"></div>' if image_url else ''}
<div class="quote-content" style="{'position: relative; z-index: 1;' if image_url else ''}">
<blockquote class="quote-text" style="{'color: white;' if image_url else ''}">"{content.get('quote', '')}"</blockquote>
{f'<p class="quote-author" style="{"color: white;" if image_url else ""}">— {content["author"]}</p>' if content.get('author') else ''}
</div>
<span class="slide-number" style="{'color: white;' if image_url else ''}">{slide_number}</span>
</div>"""
elif layout == "section":
return f"""
<div class="slide layout-section">
<h1>{content.get('title', '')}</h1>
{f'<p class="subtitle">{content["subtitle"]}</p>' if content.get('subtitle') else ''}
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "title-content":
return f"""
<div class="slide layout-title-content">
<h2>{content.get('title', '')}</h2>
<p class="content-text">{content.get('text', '')}</p>
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "image-text":
image_info = content.get('image', {})
image_url = get_html_image_url(image_info)
image_position = 'right'
if isinstance(image_info, dict):
image_position = image_info.get('position', 'right')
return f"""
<div class="slide layout-image-text image-{image_position}">
<div class="text-content">
<h2>{content.get('title', '')}</h2>
<p style="font-size: 24px; line-height: 1.6;">{content.get('text', '')}</p>
</div>
<div class="image-content">
{f'<img src="{image_url}" alt="{content.get("title", "")}" />' if image_url else '<div style="background: #f0f0f0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #999;">Image not available</div>'}
</div>
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "hero-image":
image_info = content.get('image', {})
image_url = get_html_image_url(image_info)
style = f'background-image: url({image_url}); background-size: cover; background-position: center;' if image_url else 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);'
return f"""
<div class="slide layout-hero-image" style="{style}">
<div class="overlay"></div>
<div class="content">
<h1>{content.get('title', '')}</h1>
{f'<p class="subtitle">{content["subtitle"]}</p>' if content.get('subtitle') else ''}
</div>
<span class="slide-number" style="color: white;">{slide_number}</span>
</div>"""
elif layout == "image-grid":
images = content.get('images', [])
grid_html = []
for img in images:
img_url = get_html_image_url(img)
caption = img.get('caption', '') if isinstance(img, dict) else ''
if img_url:
grid_html.append(f"""
<div class="grid-item">
<img src="{img_url}" alt="{caption}" />
{f'<div class="caption">{caption}</div>' if caption else ''}
</div>""")
else:
grid_html.append(f"""
<div class="grid-item">
<div style="background: #f0f0f0; width: 100%; height: 250px; display: flex; align-items: center; justify-content: center; color: #999;">Image not available</div>
{f'<div class="caption">{caption}</div>' if caption else ''}
</div>""")
return f"""
<div class="slide layout-image-grid">
<h2>{content.get('title', '')}</h2>
<div class="grid">
{''.join(grid_html)}
</div>
<span class="slide-number">{slide_number}</span>
</div>"""
elif layout == "stats":
stats = content.get('stats', [])
stats_html = []
for stat in stats:
if isinstance(stat, dict):
stats_html.append(f"""
<div class="stat-item">
<div class="stat-value">{stat.get('value', '')}</div>
<div class="stat-label">{stat.get('label', '')}</div>
</div>""")
return f"""
<div class="slide layout-stats">
<h2>{content.get('title', '')}</h2>
<div class="stats-grid">
{''.join(stats_html)}
</div>
<span class="slide-number">{slide_number}</span>
</div>"""
else:
return f"""
<div class="slide layout-blank">
<span class="slide-number">{slide_number}</span>
</div>"""
@openapi_schema({
"type": "function",
"function": {
"name": "export_presentation",
"description": "Export a JSON presentation to PPTX format with professional formatting.",
"parameters": {
"type": "object",
"properties": {
"presentation_name": {
"type": "string",
"description": "Name of the presentation to export"
},
"format": {
"type": "string",
"enum": ["pptx"],
"description": "Export format (currently only PPTX supported)"
}
},
"required": ["presentation_name"]
}
}
})
async def export_presentation(
self,
presentation_name: str,
format: str = "pptx"
) -> ToolResult:
try:
await self._ensure_sandbox()
# Resolve the existing presentation directory
resolved_name = None
last_error = None
for candidate in self._safe_name_variants(presentation_name):
try:
json_path_try = f"{self.presentations_dir}/{candidate}/presentation.json"
full_json_path_try = f"{self.workspace_path}/{json_path_try}"
json_bytes = await self.sandbox.fs.download_file(full_json_path_try)
resolved_name = candidate
json_content = json_bytes.decode('utf-8')
presentation_data = json.loads(json_content)
break
except Exception as e:
last_error = e
continue
if resolved_name is None:
return self.fail_response(f"Presentation '{presentation_name}' not found. Tried: {', '.join(self._safe_name_variants(presentation_name))}. Last error: {str(last_error)}")
if format.lower() != "pptx":
return self.fail_response(f"Format '{format}' not supported. Only 'pptx' is supported.")
# Images should already be downloaded, but check and download any missing ones
presentation_dir = f"{self.presentations_dir}/{resolved_name}"
download_errors = []
for slide in presentation_data["slides"]:
content = slide.get("content", {})
# Check for missing images and download them
if "image" in content:
image_info = content["image"]
if isinstance(image_info, dict) and "url" in image_info and "local_path" not in image_info:
local_path = await self._download_and_cache_image(
image_info["url"],
presentation_dir
)
if local_path:
image_info["local_path"] = local_path
else:
download_errors.append(f"Failed to download image: {image_info.get('url', 'unknown')}")
if "images" in content:
for img in content["images"]:
if isinstance(img, dict) and "url" in img and "local_path" not in img:
local_path = await self._download_and_cache_image(
img["url"],
presentation_dir
)
if local_path:
img["local_path"] = local_path
else:
download_errors.append(f"Failed to download image: {img.get('url', 'unknown')}")
# Create PPTX
pptx_bytes = await self._create_pptx_from_json(presentation_data)
pptx_filename = f"{resolved_name}.pptx"
pptx_path = f"{self.presentations_dir}/{resolved_name}/{pptx_filename}"
full_pptx_path = f"{self.workspace_path}/{pptx_path}"
await self.sandbox.fs.upload_file(pptx_bytes, full_pptx_path)
result = {
"message": f"Successfully exported presentation to PPTX",
"export_file": f"/workspace/{pptx_path}",
"format": "pptx",
"file_size": len(pptx_bytes),
"presentation_name": resolved_name
}
if download_errors:
result["warnings"] = download_errors
result["message"] += f" (Note: {len(download_errors)} images could not be downloaded and will be missing from the PPTX)"
return self.success_response(result)
except Exception as e:
return self.fail_response(f"Failed to export presentation: {str(e)}")
async def _create_pptx_from_json(self, presentation_data: Dict) -> bytes:
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
theme = presentation_data["theme_config"]
colors = theme["colors"]
for slide_data in presentation_data["slides"]:
layout = slide_data["layout"]
content = slide_data["content"]
if layout == "title":
self._add_title_slide(prs, content, colors)
elif layout == "title-bullets":
self._add_bullets_slide(prs, content, colors)
elif layout == "two-column":
self._add_two_column_slide(prs, content, colors)
elif layout == "quote":
self._add_quote_slide(prs, content, colors)
elif layout == "section":
self._add_section_slide(prs, content, colors)
elif layout == "title-content":
self._add_content_slide(prs, content, colors)
elif layout == "image-text":
await self._add_image_text_slide_async(prs, content, colors)
elif layout == "hero-image":
await self._add_hero_image_slide_async(prs, content, colors)
elif layout == "image-grid":
await self._add_image_grid_slide_async(prs, content, colors)
elif layout == "stats":
self._add_stats_slide(prs, content, colors)
else:
self._add_blank_slide(prs, colors)
output = io.BytesIO()
prs.save(output)
output.seek(0)
return output.read()
def _add_title_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[0]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title = slide.shapes.title
title.text = content.get("title", "")
self._format_text(title.text_frame.paragraphs[0], 72, True, colors["primary"])
if content.get("subtitle") and hasattr(slide.placeholders, '1'):
subtitle = slide.placeholders[1]
subtitle.text = content["subtitle"]
self._format_text(subtitle.text_frame.paragraphs[0], 32, False, colors["secondary"])
def _add_bullets_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[1]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title = slide.shapes.title
title.text = content.get("title", "")
self._format_text(title.text_frame.paragraphs[0], 48, True, colors["primary"])
if content.get("bullets"):
body = slide.placeholders[1]
tf = body.text_frame
tf.clear()
for i, bullet in enumerate(content["bullets"]):
p = tf.add_paragraph() if i > 0 else tf.paragraphs[0]
p.text = bullet
p.level = 0
self._format_text(p, 24, False, colors["text"])
def _add_two_column_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[3]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title = slide.shapes.title
title.text = content.get("title", "")
self._format_text(title.text_frame.paragraphs[0], 48, True, colors["primary"])
left_content = content.get("left_content", {})
if len(slide.placeholders) > 1:
left_box = slide.placeholders[1]
tf = left_box.text_frame
tf.clear()
if left_content.get("subtitle"):
p = tf.paragraphs[0]
p.text = left_content["subtitle"]
self._format_text(p, 28, True, colors["secondary"])
if left_content.get("bullets"):
for bullet in left_content["bullets"]:
p = tf.add_paragraph()
p.text = bullet
p.level = 0
self._format_text(p, 20, False, colors["text"])
right_content = content.get("right_content", {})
if len(slide.placeholders) > 2:
right_box = slide.placeholders[2]
tf = right_box.text_frame
tf.clear()
if right_content.get("subtitle"):
p = tf.paragraphs[0]
p.text = right_content["subtitle"]
self._format_text(p, 28, True, colors["secondary"])
if right_content.get("bullets"):
for bullet in right_content["bullets"]:
p = tf.add_paragraph()
p.text = bullet
p.level = 0
self._format_text(p, 20, False, colors["text"])
def _add_quote_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
left = Inches(2)
top = Inches(2)
width = Inches(9.333)
height = Inches(3)
text_box = slide.shapes.add_textbox(left, top, width, height)
tf = text_box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = f'"{content.get("quote", "")}"'
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 36, False, colors["primary"], italic=True)
if content.get("author"):
author_box = slide.shapes.add_textbox(
Inches(2), Inches(5), Inches(9.333), Inches(1)
)
p = author_box.text_frame.paragraphs[0]
p.text = f"{content['author']}"
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 24, False, colors["secondary"])
def _add_section_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["primary"])
title_box = slide.shapes.add_textbox(
Inches(1), Inches(2.5), Inches(11.333), Inches(2)
)
p = title_box.text_frame.paragraphs[0]
p.text = content.get("title", "")
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 72, True, colors["background"])
if content.get("subtitle"):
subtitle_box = slide.shapes.add_textbox(
Inches(1), Inches(4.5), Inches(11.333), Inches(1)
)
p = subtitle_box.text_frame.paragraphs[0]
p.text = content["subtitle"]
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 32, False, colors["background"])
def _add_content_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[1]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title = slide.shapes.title
title.text = content.get("title", "")
self._format_text(title.text_frame.paragraphs[0], 48, True, colors["primary"])
if content.get("text"):
body = slide.placeholders[1]
tf = body.text_frame
tf.clear()
p = tf.paragraphs[0]
p.text = content["text"]
p.space_after = Pt(12)
self._format_text(p, 24, False, colors["text"])
tf.word_wrap = True
def _add_blank_slide(self, prs, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
async def _add_image_to_slide(self, slide, image_path: str, left, top, width=None, height=None):
try:
if not image_path:
return None
full_path = f"{self.workspace_path}/{image_path}"
try:
file_info = await self.sandbox.fs.get_file_info(full_path)
if file_info.is_dir:
print(f"Path is a directory, not an image: {image_path}")
return None
except:
print(f"Image file not found: {image_path}")
return None
image_data = await self.sandbox.fs.download_file(full_path)
ext = image_path.split('.')[-1] if '.' in image_path else 'jpg'
with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmp_img:
tmp_img.write(image_data)
tmp_img.flush()
try:
if width and height:
pic = slide.shapes.add_picture(tmp_img.name, left, top, width, height)
elif width:
pic = slide.shapes.add_picture(tmp_img.name, left, top, width=width)
elif height:
pic = slide.shapes.add_picture(tmp_img.name, left, top, height=height)
else:
pic = slide.shapes.add_picture(tmp_img.name, left, top)
os.unlink(tmp_img.name)
return pic
except Exception as e:
os.unlink(tmp_img.name)
print(f"Failed to add picture to slide: {e}")
return None
except Exception as e:
print(f"Failed to add image to slide: {e}")
return None
async def _add_image_text_slide_async(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(12.333), Inches(1))
title_frame = title_box.text_frame
p = title_frame.paragraphs[0]
p.text = content.get("title", "")
self._format_text(p, 36, True, colors["primary"])
image_info = content.get("image", {})
image_position = 'right'
if isinstance(image_info, dict):
image_position = image_info.get('position', 'right')
if image_position == 'right':
text_box = slide.shapes.add_textbox(Inches(0.5), Inches(2), Inches(5.5), Inches(4.5))
else:
text_box = slide.shapes.add_textbox(Inches(7), Inches(2), Inches(5.5), Inches(4.5))
text_frame = text_box.text_frame
text_frame.word_wrap = True
p = text_frame.paragraphs[0]
p.text = content.get("text", "")
self._format_text(p, 20, False, colors["text"])
if isinstance(image_info, dict) and "local_path" in image_info:
if image_position == 'right':
await self._add_image_to_slide(
slide,
image_info["local_path"],
Inches(7), Inches(2),
width=Inches(5.5)
)
else:
await self._add_image_to_slide(
slide,
image_info["local_path"],
Inches(0.5), Inches(2),
width=Inches(5.5)
)
async def _add_hero_image_slide_async(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
image_added = False
image_info = content.get("image", {})
if isinstance(image_info, dict) and "local_path" in image_info:
pic = await self._add_image_to_slide(
slide,
image_info["local_path"],
Inches(0), Inches(0),
width=Inches(13.333), height=Inches(7.5)
)
if pic:
image_added = True
try:
slide.shapes._spTree.remove(pic._element)
slide.shapes._spTree.insert(2, pic._element)
except:
pass
if image_added:
rect = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(2), Inches(2),
Inches(9.333), Inches(3.5)
)
rect.fill.solid()
rect.fill.fore_color.rgb = RGBColor(0, 0, 0)
rect.fill.transparency = 0.4
rect.line.fill.background()
title_color = "#FFFFFF"
subtitle_color = "#FFFFFF"
else:
self._set_slide_background(slide, colors["primary"])
title_color = colors["background"]
subtitle_color = colors["background"]
title_box = slide.shapes.add_textbox(Inches(2.5), Inches(2.5), Inches(8.333), Inches(1.5))
title_frame = title_box.text_frame
p = title_frame.paragraphs[0]
p.text = content.get("title", "")
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 60, True, title_color)
if content.get("subtitle"):
subtitle_box = slide.shapes.add_textbox(Inches(2.5), Inches(4), Inches(8.333), Inches(1))
p = subtitle_box.text_frame.paragraphs[0]
p.text = content["subtitle"]
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 28, False, subtitle_color)
async def _add_image_grid_slide_async(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title_box = slide.shapes.add_textbox(Inches(1), Inches(0.5), Inches(11.333), Inches(1))
p = title_box.text_frame.paragraphs[0]
p.text = content.get("title", "")
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 36, True, colors["primary"])
images = content.get("images", [])
if images:
num_images = min(len(images), 6)
if num_images <= 2:
cols = 2
elif num_images <= 4:
cols = 2
else:
cols = 3
rows = (num_images + cols - 1) // cols
img_width = Inches(3.5)
img_height = Inches(2.5)
h_spacing = Inches(0.5)
v_spacing = Inches(0.5)
total_width = cols * img_width.inches + (cols - 1) * h_spacing.inches
total_height = rows * img_height.inches + (rows - 1) * v_spacing.inches
start_left = (13.333 - total_width) / 2
start_top = 2 + (5.5 - total_height) / 2
for i, img in enumerate(images[:num_images]):
if isinstance(img, dict) and "local_path" in img:
row = i // cols
col = i % cols
left = Inches(start_left) + col * (img_width + h_spacing)
top = Inches(start_top) + row * (img_height + v_spacing)
pic = await self._add_image_to_slide(
slide,
img["local_path"],
left, top,
width=img_width, height=img_height
)
if pic and img.get("caption"):
caption_box = slide.shapes.add_textbox(
left, top + img_height - Inches(0.5),
img_width, Inches(0.5)
)
p = caption_box.text_frame.paragraphs[0]
p.text = img["caption"]
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 12, False, colors["text"])
caption_box.fill.solid()
caption_box.fill.fore_color.rgb = RGBColor(255, 255, 255)
caption_box.fill.transparency = 0.2
def _add_stats_slide(self, prs, content: Dict, colors: Dict):
slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
self._set_slide_background(slide, colors["background"])
title_box = slide.shapes.add_textbox(Inches(1), Inches(0.5), Inches(11.333), Inches(1))
p = title_box.text_frame.paragraphs[0]
p.text = content.get("title", "")
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 42, True, colors["primary"])
stats = content.get("stats", [])
if stats:
num_stats = min(len(stats), 4)
stat_width = Inches(2.5)
stat_height = Inches(2)
h_spacing = Inches(0.5)
total_width = num_stats * stat_width.inches + (num_stats - 1) * h_spacing.inches
start_left = (13.333 - total_width) / 2
top = Inches(3)
for i, stat in enumerate(stats[:num_stats]):
if isinstance(stat, dict):
left = Inches(start_left) + i * (stat_width + h_spacing)
stat_box = slide.shapes.add_textbox(left, top, stat_width, stat_height)
tf = stat_box.text_frame
tf.clear()
p = tf.paragraphs[0]
p.text = str(stat.get("value", ""))
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 48, True, colors["accent"])
p = tf.add_paragraph()
p.text = stat.get("label", "")
p.alignment = PP_ALIGN.CENTER
self._format_text(p, 18, False, colors["text_light"])
def _set_slide_background(self, slide, color: str):
if color and color != "transparent":
try:
rgb = self._hex_to_rgb(color)
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = RGBColor(rgb[0], rgb[1], rgb[2])
except Exception as e:
print(f"Error setting background: {e}")
def _format_text(self, paragraph, size: int, bold: bool, color: str, italic: bool = False):
paragraph.font.size = Pt(size)
paragraph.font.bold = bold
paragraph.font.italic = italic
if color:
rgb = self._hex_to_rgb(color)
paragraph.font.color.rgb = RGBColor(rgb[0], rgb[1], rgb[2])
def _hex_to_rgb(self, hex_color: str) -> tuple:
if not hex_color or not hex_color.startswith('#'):
return (0, 0, 0)
try:
hex_color = hex_color[1:]
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except:
return (0, 0, 0)