suna/backend/agent/tools/sb_presentation_tool.py

942 lines
38 KiB
Python
Raw Normal View History

2025-08-06 05:12:46 +08:00
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, Union
import json
import os
import base64
from datetime import datetime
2025-08-06 19:48:37 +08:00
import requests
import tempfile
import re
from html import unescape
import io
2025-08-06 05:12:46 +08:00
2025-08-14 13:17:33 +08:00
# New imports for python-pptx and HTML parsing
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.dml import MSO_THEME_COLOR
from bs4 import BeautifulSoup
import cssutils
import logging
# Suppress cssutils warnings
cssutils.log.setLevel(logging.ERROR)
2025-08-06 19:48:37 +08:00
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
print("PIL/Pillow not available - WEBP images will be skipped in PPTX export")
2025-08-06 05:12:46 +08:00
class SandboxPresentationTool(SandboxToolsBase):
def __init__(self, project_id: str, thread_manager: ThreadManager):
super().__init__(project_id, thread_manager)
self.workspace_path = "/workspace"
self.presentations_dir = "presentations"
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
2025-08-14 13:17:33 +08:00
def _generate_slide_html(self, slide: Dict, slide_number: int, total_slides: int, presentation_title: str, custom_css: Optional[str] = None) -> str:
"""Generate HTML for a single slide - ALWAYS maintains 1920x1080 dimensions"""
if custom_css:
# Ensure custom CSS includes proper slide dimensions
if ".slide" in custom_css and "1920px" not in custom_css:
# Prepend dimension enforcement to custom CSS
css = """
/* ENFORCED: Presentation slide dimensions */
.slide {
width: 1920px !important;
height: 1080px !important;
max-width: 100vw;
max-height: 100vh;
aspect-ratio: 16/9;
transform-origin: center center;
}
@media screen and (max-width: 1920px), screen and (max-height: 1080px) {
.slide {
transform: scale(min(100vw / 1920, 100vh / 1080));
}
}
""" + custom_css
else:
css = custom_css
2025-08-06 05:12:46 +08:00
else:
2025-08-14 13:17:33 +08:00
css = """
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #ffffff;
color: #000000;
}
.slide {
/* CRITICAL: Fixed presentation dimensions 1920x1080 (16:9) */
width: 1920px !important;
height: 1080px !important;
max-width: 100vw;
max-height: 100vh;
aspect-ratio: 16/9;
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
position: relative;
background: #ffffff;
/* Scale to fit viewport if needed */
transform-origin: center center;
}
/* Auto-scale slide to fit viewport while maintaining aspect ratio */
@media screen and (max-width: 1920px), screen and (max-height: 1080px) {
.slide {
transform: scale(min(100vw / 1920, 100vh / 1080));
}
}
h1 {
font-size: 72px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 40px;
color: #000000;
}
h2 {
font-size: 48px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 30px;
color: #333333;
}
p {
font-size: 24px;
line-height: 1.6;
margin-bottom: 20px;
color: #333333;
}
ul {
list-style: none;
margin: 30px 0;
padding-left: 0;
}
li {
font-size: 24px;
line-height: 1.8;
margin: 15px 0;
padding-left: 30px;
position: relative;
color: #333333;
}
li::before {
content: "";
position: absolute;
left: 0;
color: #000000;
}
.slide-number {
position: absolute;
bottom: 40px;
right: 40px;
font-size: 18px;
color: #666666;
}
/* Flat design - no shadows, gradients, or animations */
img {
max-width: 100%;
height: auto;
border: 2px solid #e0e0e0;
}
.content-section {
max-width: 100%;
}
"""
2025-08-06 05:12:46 +08:00
2025-08-14 13:17:33 +08:00
slide_html = slide.get('html', '')
if not slide_html:
title = slide.get('title', '')
content = slide.get('content', '')
slide_html = f"""
<div class="slide">
<div class="content-section">
{f'<h1>{title}</h1>' if title else ''}
{content if isinstance(content, str) else ''}
</div>
<div class="slide-number">{slide_number} / {total_slides}</div>
</div>
"""
2025-08-06 19:48:37 +08:00
2025-08-06 05:12:46 +08:00
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2025-08-14 13:17:33 +08:00
<title>{presentation_title} - Slide {slide_number}</title>
2025-08-06 05:12:46 +08:00
<style>
2025-08-14 13:17:33 +08:00
{css}
2025-08-06 19:48:37 +08:00
</style>
</head>
<body>
2025-08-14 13:17:33 +08:00
{slide_html}
2025-08-06 19:48:37 +08:00
</body>
</html>"""
2025-08-06 05:12:46 +08:00
2025-08-14 13:17:33 +08:00
return html
2025-08-06 05:12:46 +08:00
def _generate_presentation_index(self, title: str, slides: List[str]) -> str:
2025-08-14 13:17:33 +08:00
slide_links = '\n'.join([
f'<li><a href="{slide}" target="slide-frame">Slide {i+1}</a></li>'
for i, slide in enumerate(slides)
])
2025-08-06 05:12:46 +08:00
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2025-08-14 13:17:33 +08:00
<title>{title} - Presentation</title>
2025-08-06 05:12:46 +08:00
<style>
2025-08-14 13:17:33 +08:00
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
2025-08-06 05:12:46 +08:00
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2025-08-14 13:17:33 +08:00
display: flex;
height: 100vh;
2025-08-06 05:12:46 +08:00
background: #f5f5f5;
}}
2025-08-14 13:17:33 +08:00
.sidebar {{
width: 250px;
background: #ffffff;
border-right: 1px solid #e0e0e0;
padding: 20px;
overflow-y: auto;
}}
.sidebar h2 {{
font-size: 20px;
margin-bottom: 20px;
color: #333333;
2025-08-06 05:12:46 +08:00
}}
2025-08-14 13:17:33 +08:00
.sidebar ul {{
2025-08-06 05:12:46 +08:00
list-style: none;
}}
2025-08-14 13:17:33 +08:00
.sidebar li {{
2025-08-06 05:12:46 +08:00
margin: 10px 0;
}}
2025-08-14 13:17:33 +08:00
.sidebar a {{
color: #0066cc;
text-decoration: none;
2025-08-06 05:12:46 +08:00
display: block;
2025-08-14 13:17:33 +08:00
padding: 8px 12px;
border: 1px solid transparent;
transition: all 0.2s;
}}
.sidebar a:hover {{
background: #f0f0f0;
border-color: #e0e0e0;
}}
.content {{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}}
iframe {{
width: 95%;
height: 95%;
border: 1px solid #e0e0e0;
2025-08-06 05:12:46 +08:00
background: white;
}}
2025-08-14 13:17:33 +08:00
.fullscreen-btn {{
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #0066cc;
color: white;
border: none;
cursor: pointer;
font-size: 14px;
z-index: 1000;
}}
.fullscreen-btn:hover {{
background: #0052a3;
2025-08-06 05:12:46 +08:00
}}
</style>
</head>
<body>
2025-08-14 13:17:33 +08:00
<div class="sidebar">
<h2>{title}</h2>
<ul>
{slide_links}
</ul>
</div>
<div class="content">
<iframe name="slide-frame" src="{slides[0] if slides else ''}" frameborder="0"></iframe>
</div>
<button class="fullscreen-btn" onclick="document.querySelector('iframe').requestFullscreen()">
Fullscreen
</button>
2025-08-06 05:12:46 +08:00
</body>
</html>"""
return html
@openapi_schema({
"type": "function",
"function": {
"name": "create_presentation",
2025-08-14 13:17:33 +08:00
"description": "Create a professional presentation by generating raw HTML and CSS for each slide. CRITICAL: Every slide MUST be exactly 1920x1080 pixels (16:9 aspect ratio) - these are standard PowerPoint/presentation dimensions. The agent should create FLAT DESIGN slides with NO gradients, NO shadows, NO animations - just clean, simple, flat colors and typography. Each slide should be self-contained HTML with embedded CSS styling. The .slide class MUST have width: 1920px and height: 1080px.",
2025-08-06 05:12:46 +08:00
"parameters": {
"type": "object",
"properties": {
"presentation_name": {
"type": "string",
"description": "Name of the presentation (used for file naming)"
},
"title": {
"type": "string",
"description": "The main title of the presentation"
},
"slides": {
"type": "array",
2025-08-14 13:17:33 +08:00
"description": "Array of slides with raw HTML and CSS",
2025-08-06 05:12:46 +08:00
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
2025-08-14 13:17:33 +08:00
"description": "The title of the slide (for reference)"
2025-08-06 05:12:46 +08:00
},
2025-08-14 13:17:33 +08:00
"html": {
"type": "string",
"description": "Complete HTML content for the slide. MUST contain a div with class='slide' that will be styled to exactly 1920x1080 pixels. All content should be inside this div. The slide div is your canvas with fixed dimensions of 1920x1080 (16:9 aspect ratio)."
2025-08-06 05:12:46 +08:00
},
2025-08-14 13:17:33 +08:00
"css": {
2025-08-06 05:12:46 +08:00
"type": "string",
2025-08-14 13:17:33 +08:00
"description": "Custom CSS for this slide. CRITICAL REQUIREMENTS: 1) The .slide class MUST have width: 1920px and height: 1080px. 2) FLAT DESIGN only: Use solid colors, NO gradients, NO box-shadows, NO text-shadows, NO animations. 3) Keep typography clean and spacing consistent. The slide dimensions (1920x1080) are mandatory for proper presentation format."
2025-08-06 05:12:46 +08:00
}
},
2025-08-14 13:17:33 +08:00
"required": ["title", "html", "css"]
2025-08-06 05:12:46 +08:00
}
}
},
2025-08-14 13:17:33 +08:00
"required": ["presentation_name", "title", "slides"]
2025-08-06 05:12:46 +08:00
}
}
})
@usage_example('''
<function_calls>
<invoke name="create_presentation">
2025-08-14 13:17:33 +08:00
<parameter name="presentation_name">company_overview</parameter>
<parameter name="title">Company Overview 2024</parameter>
2025-08-06 05:12:46 +08:00
<parameter name="slides">[
{
2025-08-14 13:17:33 +08:00
"title": "Title Slide",
"html": "<div class='slide title-slide'><h1>Company Overview</h1><p class='subtitle'>Building the Future Together</p><p class='date'>2024</p></div>",
"css": "* {margin: 0; padding: 0; box-sizing: border-box;} body {font-family: 'Helvetica Neue', Arial, sans-serif; background: #1a1a1a; color: #ffffff; display: flex; align-items: center; justify-content: center; height: 100vh;} .slide {width: 1920px !important; height: 1080px !important; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 80px;} h1 {font-size: 96px; font-weight: 300; margin-bottom: 30px; letter-spacing: -2px;} .subtitle {font-size: 36px; color: #cccccc; margin-bottom: 60px;} .date {font-size: 24px; color: #999999;}"
2025-08-06 05:12:46 +08:00
},
{
2025-08-14 13:17:33 +08:00
"title": "Our Mission",
"html": "<div class='slide'><h2>Our Mission</h2><div class='content'><p class='statement'>To empower businesses with innovative solutions that drive growth and success</p><ul><li>Customer-focused approach</li><li>Continuous innovation</li><li>Sustainable practices</li></ul></div></div>",
"css": "* {margin: 0; padding: 0; box-sizing: border-box;} body {font-family: 'Helvetica Neue', Arial, sans-serif; background: #ffffff; color: #333333; display: flex; align-items: center; justify-content: center; height: 100vh;} .slide {width: 1920px !important; height: 1080px !important; padding: 120px;} h2 {font-size: 72px; font-weight: 600; margin-bottom: 80px; color: #000000;} .statement {font-size: 36px; line-height: 1.5; margin-bottom: 60px; color: #555555;} ul {list-style: none; padding: 0;} li {font-size: 28px; margin: 20px 0; padding-left: 40px; position: relative;} li:before {content: ''; position: absolute; left: 0; color: #0066cc;}"
2025-08-06 05:12:46 +08:00
}
]</parameter>
</invoke>
</function_calls>
''')
async def create_presentation(
self,
presentation_name: str,
title: str,
2025-08-14 13:17:33 +08:00
slides: List[Dict]
2025-08-06 05:12:46 +08:00
) -> ToolResult:
try:
await self._ensure_sandbox()
await self._ensure_presentations_dir()
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):
return self.fail_response("At least one slide is required.")
safe_name = "".join(c for c in presentation_name if c.isalnum() or c in "-_").lower()
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
slide_files = []
slide_info = []
for i, slide in enumerate(slides, 1):
2025-08-14 13:17:33 +08:00
custom_css = slide.get('css', '')
slide_html = self._generate_slide_html(slide, i, len(slides), title, custom_css)
2025-08-06 05:12:46 +08:00
slide_filename = f"slide_{i:02d}.html"
slide_path = f"{presentation_dir}/{slide_filename}"
full_slide_path = f"{self.workspace_path}/{slide_path}"
await self.sandbox.fs.upload_file(slide_html.encode(), full_slide_path)
slide_files.append(slide_filename)
slide_info.append({
"slide_number": i,
"title": slide.get("title", f"Slide {i}"),
"file": slide_path,
"preview_url": f"/workspace/{slide_path}"
})
index_html = self._generate_presentation_index(title, slide_files)
index_path = f"{presentation_dir}/index.html"
full_index_path = f"{self.workspace_path}/{index_path}"
await self.sandbox.fs.upload_file(index_html.encode(), full_index_path)
2025-08-14 13:17:33 +08:00
# Save metadata
2025-08-06 05:12:46 +08:00
metadata = {
"presentation_name": presentation_name,
"title": title,
"total_slides": len(slides),
"created_at": datetime.now().isoformat(),
"slides": slide_info,
2025-08-06 19:48:37 +08:00
"index_file": index_path,
2025-08-14 13:17:33 +08:00
"original_slides_data": slides
2025-08-06 05:12:46 +08:00
}
metadata_path = f"{presentation_dir}/metadata.json"
full_metadata_path = f"{self.workspace_path}/{metadata_path}"
await self.sandbox.fs.upload_file(json.dumps(metadata, indent=2).encode(), full_metadata_path)
return self.success_response({
2025-08-14 13:17:33 +08:00
"message": f"Presentation '{title}' created successfully with {len(slides)} slides using custom HTML/CSS",
2025-08-06 05:12:46 +08:00
"presentation_path": presentation_dir,
"index_file": index_path,
"slides": slide_info,
"presentation_name": presentation_name,
"title": title,
2025-08-14 13:17:33 +08:00
"total_slides": len(slides),
"note": "Slides created with flat design principles - no gradients, shadows, or animations"
2025-08-06 05:12:46 +08:00
})
except Exception as e:
return self.fail_response(f"Failed to create presentation: {str(e)}")
@openapi_schema({
"type": "function",
"function": {
"name": "export_presentation",
"description": "Export a presentation to PDF or PPTX format. Note: This requires additional tools to be installed in the environment.",
"parameters": {
"type": "object",
"properties": {
"presentation_name": {
"type": "string",
"description": "Name of the presentation to export"
},
"format": {
"type": "string",
"enum": ["pdf", "pptx"],
"description": "Export format"
}
},
"required": ["presentation_name", "format"]
}
}
})
2025-08-06 19:48:37 +08:00
def _clean_html_text(self, html_text: str) -> str:
clean = re.compile('<.*?>')
text = re.sub(clean, '', html_text)
text = unescape(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def _hex_to_rgb(self, hex_color: str) -> tuple:
if not hex_color.startswith('#'):
return (45, 45, 47)
try:
hex_color = hex_color[1:]
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except:
return (45, 45, 47)
async def _download_image_for_pptx(self, url: str) -> Optional[bytes]:
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
response = requests.get(url, timeout=10, headers=headers)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
return None
image_data = response.content
if PIL_AVAILABLE:
try:
with Image.open(io.BytesIO(image_data)) as img:
if img.format in ['WEBP'] or img.format not in ['JPEG', 'PNG', 'GIF', 'BMP', 'TIFF']:
print(f"Converting image from {img.format} to JPEG for PPTX compatibility")
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
elif img.mode != 'RGB':
img = img.convert('RGB')
output = io.BytesIO()
img.save(output, format='JPEG', quality=85)
return output.getvalue()
return image_data
except Exception as convert_error:
print(f"Error converting image format: {convert_error}")
return image_data
else:
if 'webp' in content_type.lower():
print("WEBP image detected but PIL not available - skipping image")
return None
return image_data
except Exception as e:
print(f"Error downloading image: {e}")
return None
async def _create_pptx_presentation(self, metadata: Dict, slides_data: List[Dict], color_scheme = None) -> bytes:
2025-08-14 13:17:33 +08:00
"""Create a PPTX presentation from HTML slides using python-pptx"""
prs = Presentation()
2025-08-06 19:48:37 +08:00
2025-08-14 13:17:33 +08:00
# Set slide size to 16:9 (default is already 16:9 in python-pptx)
prs.slide_width = Inches(13.333) # 1920 pixels at 144 DPI
prs.slide_height = Inches(7.5) # 1080 pixels at 144 DPI
2025-08-06 19:48:37 +08:00
2025-08-14 13:17:33 +08:00
# Process each slide from the metadata
2025-08-06 19:48:37 +08:00
for i, slide_data in enumerate(slides_data):
2025-08-14 13:17:33 +08:00
# Get HTML and CSS content from original slides data
html_content = slide_data.get('html', '')
css_content = slide_data.get('css', '')
2025-08-06 19:48:37 +08:00
title = slide_data.get('title', f'Slide {i+1}')
2025-08-14 13:17:33 +08:00
# If we have HTML content, parse it and convert to PPTX
if html_content or css_content:
await self._add_html_slide_to_pptx(prs, html_content, css_content, title)
else:
# Fallback for old format slides
await self._add_legacy_slide_to_pptx(prs, slide_data)
2025-08-06 19:48:37 +08:00
2025-08-14 13:17:33 +08:00
# Save presentation to bytes
output = io.BytesIO()
prs.save(output)
output.seek(0)
return output.read()
2025-08-06 19:48:37 +08:00
2025-08-14 13:17:33 +08:00
async def _add_html_slide_to_pptx(self, prs, html_content: str, css_content: str, slide_title: str):
"""Convert HTML/CSS slide to PPTX format using BeautifulSoup and python-pptx"""
# Parse CSS to extract styles
styles = self._parse_css_styles(css_content) if css_content else {}
# Parse HTML content
soup = BeautifulSoup(html_content, 'html.parser') if html_content else None
# Add a blank slide with blank layout
blank_slide_layout = prs.slide_layouts[6] # Blank layout
slide = prs.slides.add_slide(blank_slide_layout)
# Extract background color from styles
slide_styles = styles.get('.slide', {})
bg_color = slide_styles.get('background', '#ffffff')
if bg_color and bg_color != 'transparent':
self._set_slide_background(slide, bg_color)
# Find the main slide div
slide_div = soup.find('div', class_='slide') if soup else None
if slide_div:
# Process slide content
await self._process_slide_content(slide, slide_div, styles)
else:
# Fallback: just add the title
self._add_title_to_slide(slide, slide_title)
def _parse_css_styles(self, css_content: str) -> Dict:
"""Parse CSS content and extract styles for each selector"""
styles = {}
try:
sheet = cssutils.parseString(css_content)
for rule in sheet:
if hasattr(rule, 'selectorText') and hasattr(rule, 'style'):
selector = rule.selectorText
rule_styles = {}
for prop in rule.style:
rule_styles[prop.name] = prop.value
styles[selector] = rule_styles
except Exception as e:
print(f"Error parsing CSS: {e}")
return styles
def _set_slide_background(self, slide, color: str):
"""Set slide background color"""
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}")
async def _process_slide_content(self, slide, slide_div, styles: Dict):
"""Process HTML content and add to PPTX slide"""
# Track vertical position for elements
top_position = Inches(0.5)
# Process each element in the slide
for element in slide_div.children:
if not hasattr(element, 'name'):
continue
if element.name == 'h1':
top_position = self._add_heading(slide, element, styles, 1, top_position)
elif element.name == 'h2':
top_position = self._add_heading(slide, element, styles, 2, top_position)
elif element.name == 'h3':
top_position = self._add_heading(slide, element, styles, 3, top_position)
elif element.name == 'p':
top_position = self._add_paragraph(slide, element, styles, top_position)
elif element.name == 'ul':
top_position = self._add_bullet_list(slide, element, styles, top_position)
elif element.name == 'ol':
top_position = self._add_numbered_list(slide, element, styles, top_position)
elif element.name == 'div':
# Recursively process div contents
for child in element.children:
if hasattr(child, 'name'):
if child.name == 'h1':
top_position = self._add_heading(slide, child, styles, 1, top_position)
elif child.name == 'h2':
top_position = self._add_heading(slide, child, styles, 2, top_position)
elif child.name == 'p':
top_position = self._add_paragraph(slide, child, styles, top_position)
elif child.name == 'ul':
top_position = self._add_bullet_list(slide, child, styles, top_position)
elif element.name == 'img':
top_position = await self._add_image(slide, element, top_position)
def _add_heading(self, slide, element, styles: Dict, level: int, top_position):
"""Add a heading to the slide"""
text = element.get_text(strip=True)
if not text:
return top_position
# Determine font size based on heading level
font_sizes = {1: 48, 2: 36, 3: 28}
font_size = font_sizes.get(level, 24)
# Get styles for this heading level
selector = f'h{level}'
element_styles = styles.get(selector, {})
# Extract styling
color = element_styles.get('color', '#000000')
# Add text box
left = Inches(1)
width = Inches(11.333) # Leave margins
height = Inches(1)
text_box = slide.shapes.add_textbox(left, top_position, width, height)
text_frame = text_box.text_frame
text_frame.clear()
p = text_frame.add_paragraph()
p.text = text
p.font.size = Pt(font_size)
p.font.bold = True
# Set color
rgb = self._hex_to_rgb(color)
p.font.color.rgb = RGBColor(rgb[0], rgb[1], rgb[2])
# Center align for h1
if level == 1:
p.alignment = PP_ALIGN.CENTER
return top_position + height + Inches(0.2)
def _add_paragraph(self, slide, element, styles: Dict, top_position):
"""Add a paragraph to the slide"""
text = element.get_text(strip=True)
if not text:
return top_position
# Get paragraph styles
element_styles = styles.get('p', {})
color = element_styles.get('color', '#333333')
# Check for special classes
if 'class' in element.attrs:
for cls in element['class']:
class_styles = styles.get(f'.{cls}', {})
if 'color' in class_styles:
color = class_styles['color']
# Add text box
left = Inches(1)
width = Inches(11.333)
height = Inches(0.8)
text_box = slide.shapes.add_textbox(left, top_position, width, height)
text_frame = text_box.text_frame
text_frame.clear()
p = text_frame.add_paragraph()
p.text = text
p.font.size = Pt(18)
rgb = self._hex_to_rgb(color)
p.font.color.rgb = RGBColor(rgb[0], rgb[1], rgb[2])
# Check for center alignment
if 'subtitle' in element.get('class', []) or 'date' in element.get('class', []):
p.alignment = PP_ALIGN.CENTER
return top_position + height + Inches(0.1)
def _add_bullet_list(self, slide, element, styles: Dict, top_position):
"""Add a bullet list to the slide"""
items = element.find_all('li')
if not items:
return top_position
# Get list styles
element_styles = styles.get('li', {})
color = element_styles.get('color', '#333333')
# Calculate height needed
height = Inches(0.5 * len(items))
# Add text box
left = Inches(1.5)
width = Inches(10.833)
text_box = slide.shapes.add_textbox(left, top_position, width, height)
text_frame = text_box.text_frame
text_frame.clear()
for i, item in enumerate(items):
p = text_frame.add_paragraph() if i > 0 else text_frame.paragraphs[0]
p.text = item.get_text(strip=True)
p.font.size = Pt(16)
p.level = 0
rgb = self._hex_to_rgb(color)
p.font.color.rgb = RGBColor(rgb[0], rgb[1], rgb[2])
return top_position + height + Inches(0.2)
def _add_numbered_list(self, slide, element, styles: Dict, top_position):
"""Add a numbered list to the slide"""
items = element.find_all('li')
if not items:
return top_position
# Similar to bullet list but with numbers
height = Inches(0.5 * len(items))
left = Inches(1.5)
width = Inches(10.833)
text_box = slide.shapes.add_textbox(left, top_position, width, height)
text_frame = text_box.text_frame
text_frame.clear()
for i, item in enumerate(items, 1):
p = text_frame.add_paragraph() if i > 1 else text_frame.paragraphs[0]
p.text = f"{i}. {item.get_text(strip=True)}"
p.font.size = Pt(16)
return top_position + height + Inches(0.2)
async def _add_image(self, slide, element, top_position):
"""Add an image to the slide"""
src = element.get('src', '')
if not src:
return top_position
try:
# Download image
image_data = await self._download_image_for_pptx(src)
2025-08-06 19:48:37 +08:00
if image_data:
2025-08-14 13:17:33 +08:00
# Save to temp file
2025-08-06 19:48:37 +08:00
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp_img:
tmp_img.write(image_data)
tmp_img.flush()
2025-08-14 13:17:33 +08:00
# Add picture to slide
left = Inches(2)
height = Inches(3)
pic = slide.shapes.add_picture(tmp_img.name, left, top_position, height=height)
2025-08-06 19:48:37 +08:00
os.unlink(tmp_img.name)
2025-08-14 13:17:33 +08:00
return top_position + height + Inches(0.2)
except Exception as e:
print(f"Error adding image: {e}")
return top_position
def _add_title_to_slide(self, slide, title: str):
"""Add a simple title to slide as fallback"""
left = Inches(1)
top = Inches(3)
width = Inches(11.333)
height = Inches(1.5)
2025-08-06 19:48:37 +08:00
2025-08-14 13:17:33 +08:00
text_box = slide.shapes.add_textbox(left, top, width, height)
text_frame = text_box.text_frame
text_frame.clear()
p = text_frame.add_paragraph()
p.text = title
p.font.size = Pt(44)
p.font.bold = True
p.alignment = PP_ALIGN.CENTER
async def _add_legacy_slide_to_pptx(self, prs, slide_data: Dict):
"""Handle legacy slide format (fallback)"""
blank_slide_layout = prs.slide_layouts[6]
slide = prs.slides.add_slide(blank_slide_layout)
title = slide_data.get('title', '')
content = slide_data.get('content', {})
# Set background
bg_color = slide_data.get('background_color', '#ffffff')
self._set_slide_background(slide, bg_color)
# Add title
if title:
self._add_title_to_slide(slide, title)
2025-08-06 19:48:37 +08:00
2025-08-06 05:12:46 +08:00
async def export_presentation(
self,
presentation_name: str,
2025-08-06 19:48:37 +08:00
format: str = "pptx"
2025-08-06 05:12:46 +08:00
) -> ToolResult:
try:
2025-08-06 19:48:37 +08:00
await self._ensure_sandbox()
2025-08-06 05:12:46 +08:00
safe_name = "".join(c for c in presentation_name if c.isalnum() or c in "-_").lower()
presentation_dir = f"{self.presentations_dir}/{safe_name}"
metadata_path = f"{self.workspace_path}/{presentation_dir}/metadata.json"
try:
metadata_content = await self.sandbox.fs.download_file(metadata_path)
metadata = json.loads(metadata_content.decode())
2025-08-06 19:48:37 +08:00
except Exception as e:
return self.fail_response(f"Presentation '{presentation_name}' (safe_name: '{safe_name}') not found at path '{metadata_path}'. Error: {str(e)}")
2025-08-06 05:12:46 +08:00
2025-08-06 19:48:37 +08:00
if format.lower() == "pptx":
slides_data = metadata.get('original_slides_data', [])
2025-08-14 13:17:33 +08:00
default_bg_color = '#ffffff'
default_text_color = '#000000'
2025-08-06 19:48:37 +08:00
if not slides_data:
slides_data = []
for slide_info in metadata.get('slides', []):
slides_data.append({
'title': slide_info.get('title', f"Slide {slide_info.get('slide_number', 1)}"),
'content': {'subtitle': 'Content from HTML slide'},
'layout': 'default',
2025-08-14 13:17:33 +08:00
'background_color': default_bg_color,
'text_color': default_text_color
2025-08-06 19:48:37 +08:00
})
else:
for slide in slides_data:
if 'background_color' not in slide:
2025-08-14 13:17:33 +08:00
slide['background_color'] = default_bg_color
2025-08-06 19:48:37 +08:00
if 'text_color' not in slide:
2025-08-14 13:17:33 +08:00
slide['text_color'] = default_text_color
2025-08-06 19:48:37 +08:00
try:
2025-08-14 13:17:33 +08:00
pptx_data = await self._create_pptx_presentation(metadata, slides_data, None)
2025-08-06 19:48:37 +08:00
except Exception as e:
return self.fail_response(f"PPTX generation failed: {str(e)}")
pptx_filename = f"{safe_name}.pptx"
pptx_path = f"{presentation_dir}/{pptx_filename}"
full_pptx_path = f"{self.workspace_path}/{pptx_path}"
print(f"PPTX Debug - safe_name: {safe_name}")
print(f"PPTX Debug - pptx_filename: {pptx_filename}")
print(f"PPTX Debug - pptx_path: {pptx_path}")
print(f"PPTX Debug - full_pptx_path: {full_pptx_path}")
print(f"PPTX Debug - pptx_data size: {len(pptx_data)} bytes")
await self.sandbox.fs.upload_file(pptx_data, full_pptx_path)
try:
file_info = await self.sandbox.fs.get_file_info(full_pptx_path)
print(f"PPTX Debug - File created successfully: {file_info.size} bytes")
except Exception as e:
print(f"PPTX Debug - Error verifying file: {str(e)}")
return self.fail_response(f"Failed to verify PPTX file creation: {str(e)}")
return self.success_response({
"message": f"Presentation exported successfully as PPTX",
"export_file": pptx_path,
"download_url": f"/workspace/{pptx_path}",
"format": "pptx",
"presentation_name": presentation_name,
"file_size": len(pptx_data)
})
else:
return self.fail_response(f"Export format '{format}' not yet implemented. Only PPTX is currently supported.")
2025-08-06 05:12:46 +08:00
2025-08-06 19:48:37 +08:00
except ImportError:
return self.fail_response("PPTX export requires 'aspose-slides' library. Please install it: pip install aspose-slides")
except Exception as e:
return self.fail_response(f"Failed to export presentation: {str(e)}")