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 import requests import tempfile import re from html import unescape import io # 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) 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") 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 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 else: 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%; } """ slide_html = slide.get('html', '') if not slide_html: title = slide.get('title', '') content = slide.get('content', '') slide_html = f"""
{f'

{title}

' if title else ''} {content if isinstance(content, str) else ''}
{slide_number} / {total_slides}
""" html = f""" {presentation_title} - Slide {slide_number} {slide_html} """ return html def _generate_presentation_index(self, title: str, slides: List[str]) -> str: slide_links = '\n'.join([ f'
  • Slide {i+1}
  • ' for i, slide in enumerate(slides) ]) html = f""" {title} - Presentation
    """ return html @openapi_schema({ "type": "function", "function": { "name": "create_presentation", "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.", "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", "description": "Array of slides with raw HTML and CSS", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the slide (for reference)" }, "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)." }, "css": { "type": "string", "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." } }, "required": ["title", "html", "css"] } } }, "required": ["presentation_name", "title", "slides"] } } }) @usage_example(''' company_overview Company Overview 2024 [ { "title": "Title Slide", "html": "

    Company Overview

    Building the Future Together

    2024

    ", "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;}" }, { "title": "Our Mission", "html": "

    Our Mission

    To empower businesses with innovative solutions that drive growth and success

    • Customer-focused approach
    • Continuous innovation
    • Sustainable practices
    ", "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;}" } ]
    ''') async def create_presentation( self, presentation_name: str, title: str, slides: List[Dict] ) -> 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): custom_css = slide.get('css', '') slide_html = self._generate_slide_html(slide, i, len(slides), title, custom_css) 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) # Save metadata metadata = { "presentation_name": presentation_name, "title": title, "total_slides": len(slides), "created_at": datetime.now().isoformat(), "slides": slide_info, "index_file": index_path, "original_slides_data": slides } 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({ "message": f"Presentation '{title}' created successfully with {len(slides)} slides using custom HTML/CSS", "presentation_path": presentation_dir, "index_file": index_path, "slides": slide_info, "presentation_name": presentation_name, "title": title, "total_slides": len(slides), "note": "Slides created with flat design principles - no gradients, shadows, or animations" }) 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"] } } }) 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: """Create a PPTX presentation from HTML slides using python-pptx""" prs = Presentation() # 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 # Process each slide from the metadata for i, slide_data in enumerate(slides_data): # Get HTML and CSS content from original slides data html_content = slide_data.get('html', '') css_content = slide_data.get('css', '') title = slide_data.get('title', f'Slide {i+1}') # 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) # Save presentation to bytes output = io.BytesIO() prs.save(output) output.seek(0) return output.read() 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) if image_data: # Save to temp file with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp_img: tmp_img.write(image_data) tmp_img.flush() # Add picture to slide left = Inches(2) height = Inches(3) pic = slide.shapes.add_picture(tmp_img.name, left, top_position, height=height) os.unlink(tmp_img.name) 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) 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) async def export_presentation( self, presentation_name: str, format: str = "pptx" ) -> ToolResult: try: await self._ensure_sandbox() 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()) 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)}") if format.lower() == "pptx": slides_data = metadata.get('original_slides_data', []) default_bg_color = '#ffffff' default_text_color = '#000000' 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', 'background_color': default_bg_color, 'text_color': default_text_color }) else: for slide in slides_data: if 'background_color' not in slide: slide['background_color'] = default_bg_color if 'text_color' not in slide: slide['text_color'] = default_text_color try: pptx_data = await self._create_pptx_presentation(metadata, slides_data, None) 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.") 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)}")