gateway/modules/services/serviceGeneration/renderers/rendererPptx.py
2025-12-23 00:34:15 +01:00

1061 lines
47 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import logging
import base64
import io
from typing import Dict, Any, Optional, Tuple, List
from .rendererBaseTemplate import BaseRenderer
logger = logging.getLogger(__name__)
class RendererPptx(BaseRenderer):
"""Renderer for PowerPoint (.pptx) files using python-pptx library."""
def __init__(self):
super().__init__()
self.supportedFormats = ["pptx", "ppt"]
self.outputMimeType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
@classmethod
def getSupportedFormats(cls) -> list:
"""Get list of supported output formats."""
return ["pptx", "ppt"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None) -> Tuple[str, str]:
"""
Render content as PowerPoint presentation from JSON data.
Args:
extractedContent: JSON content to render as presentation
title: Title for the presentation
userPrompt: User prompt for AI styling
aiService: AI service for styling
**kwargs: Additional rendering options
Returns:
Base64-encoded PowerPoint presentation as string
"""
try:
# Import python-pptx
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pptx.dml.color import RGBColor
import re
# Get style set: default styles, enhanced with AI if userPrompt provided
styles = await self._getStyleSet(userPrompt, aiService)
# Create new presentation
prs = Presentation()
# Set slide size based on user intent (default to 16:9)
slide_size = styles.get("slide_size", "16:9")
if slide_size == "4:3":
prs.slide_width = Inches(10)
prs.slide_height = Inches(7.5)
else: # Default to 16:9
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
# Generate slides from JSON content
slidesData = await self._parseJsonToSlides(extractedContent, title, styles)
logger.info(f"Parsed {len(slidesData)} slides from JSON content")
# Debug: Show first 200 chars of content
logger.info(f"JSON content preview: {str(extractedContent)[:200]}...")
# Store prs reference for image methods
self._currentPresentation = prs
for i, slide_data in enumerate(slidesData):
logger.info(f"Slide {i+1}: '{slide_data.get('title', 'No title')}' - {len(slide_data.get('content', ''))} chars")
# Debug: Show slide content preview
slide_content = slide_data.get('content', '')
if slide_content:
logger.info(f" Content preview: '{slide_content[:100]}...'")
else:
logger.warning(f" ⚠️ Slide {i+1} has NO content!")
# Check if slide has images
hasImages = slide_data.get("images") and len(slide_data.get("images", [])) > 0
# Create slide with appropriate layout based on content
slideLayoutIndex = self._getSlideLayoutIndex(slide_data, styles)
slide_layout = prs.slide_layouts[slideLayoutIndex]
slide = prs.slides.add_slide(slide_layout)
# Set title with AI-generated styling
title_shape = slide.shapes.title
title_shape.text = slide_data.get("title", "Slide")
# Apply title styling
title_style = styles.get("title", {})
if title_shape.text_frame.paragraphs[0].font:
title_shape.text_frame.paragraphs[0].font.size = Pt(title_style.get("font_size", 44))
title_shape.text_frame.paragraphs[0].font.bold = title_style.get("bold", True)
title_color = self._get_safe_color(title_style.get("color", (31, 78, 121)))
title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color)
# Handle images first (if present)
if hasImages:
self._addImagesToSlide(slide, slide_data.get("images", []), styles)
# Set content with AI-generated styling (if not image-only slide)
if slide_content or not hasImages:
content_shape = slide.placeholders[1]
# Format content text with AI styles
text_frame = content_shape.text_frame
text_frame.clear()
# Split content into paragraphs
paragraphs = slide_content.split('\n\n')
for paraIdx, paragraph in enumerate(paragraphs):
if paragraph.strip():
if paraIdx == 0:
p = text_frame.paragraphs[0]
else:
p = text_frame.add_paragraph()
p.text = paragraph.strip()
# Apply AI-generated styling based on content type
if paragraph.startswith('#'):
# Header
p.text = paragraph.lstrip('#').strip()
heading_style = styles.get("heading", {})
p.font.size = Pt(heading_style.get("font_size", 32))
p.font.bold = heading_style.get("bold", True)
heading_color = self._get_safe_color(heading_style.get("color", (47, 47, 47)))
p.font.color.rgb = RGBColor(*heading_color)
elif paragraph.startswith('##'):
# Subheader
p.text = paragraph.lstrip('#').strip()
subheading_style = styles.get("subheading", {})
p.font.size = Pt(subheading_style.get("font_size", 24))
p.font.bold = subheading_style.get("bold", True)
subheading_color = self._get_safe_color(subheading_style.get("color", (79, 79, 79)))
p.font.color.rgb = RGBColor(*subheading_color)
elif paragraph.startswith('*') and paragraph.endswith('*'):
# Bold text
p.text = paragraph.strip('*')
paragraph_style = styles.get("paragraph", {})
p.font.size = Pt(paragraph_style.get("font_size", 18))
p.font.bold = True
paragraph_color = self._get_safe_color(paragraph_style.get("color", (47, 47, 47)))
p.font.color.rgb = RGBColor(*paragraph_color)
else:
# Regular text
paragraph_style = styles.get("paragraph", {})
p.font.size = Pt(paragraph_style.get("font_size", 18))
p.font.bold = paragraph_style.get("bold", False)
paragraph_color = self._get_safe_color(paragraph_style.get("color", (47, 47, 47)))
p.font.color.rgb = RGBColor(*paragraph_color)
# Apply alignment
align = paragraph_style.get("align", "left")
if align == "center":
p.alignment = PP_ALIGN.CENTER
elif align == "right":
p.alignment = PP_ALIGN.RIGHT
else:
p.alignment = PP_ALIGN.LEFT
# If no slides were created, create a default slide
if not slidesData:
slide_layout = prs.slide_layouts[0] # Title slide layout
slide = prs.slides.add_slide(slide_layout)
title_shape = slide.shapes.title
title_shape.text = title
# Apply title styling to default slide
title_style = styles.get("title", {})
if title_shape.text_frame.paragraphs[0].font:
title_shape.text_frame.paragraphs[0].font.size = Pt(title_style.get("font_size", 48))
title_shape.text_frame.paragraphs[0].font.bold = title_style.get("bold", True)
title_color = self._get_safe_color(title_style.get("color", (31, 78, 121)))
title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color)
subtitle_shape = slide.placeholders[1]
subtitle_shape.text = "Generated by PowerOn AI System"
# Apply subtitle styling
paragraph_style = styles.get("paragraph", {})
if subtitle_shape.text_frame.paragraphs[0].font:
subtitle_shape.text_frame.paragraphs[0].font.size = Pt(paragraph_style.get("font_size", 20))
subtitle_shape.text_frame.paragraphs[0].font.bold = paragraph_style.get("bold", False)
paragraph_color = self._get_safe_color(paragraph_style.get("color", (47, 47, 47)))
subtitle_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*paragraph_color)
# Save to buffer
buffer = io.BytesIO()
prs.save(buffer)
buffer.seek(0)
# Convert to base64
pptx_bytes = buffer.getvalue()
pptx_base64 = base64.b64encode(pptx_bytes).decode('utf-8')
logger.info(f"Successfully rendered PowerPoint presentation: {len(pptx_bytes)} bytes")
return pptx_base64, "application/vnd.openxmlformats-officedocument.presentationml.presentation"
except ImportError:
logger.error("python-pptx library not installed. Install with: pip install python-pptx")
return "python-pptx library not installed", "text/plain"
except Exception as e:
logger.error(f"Error rendering PowerPoint presentation: {str(e)}")
return f"Error rendering PowerPoint presentation: {str(e)}", "text/plain"
def _parseContentToSlides(self, content: str, title: str) -> list:
"""
Parse content into slide data structure.
Args:
content: Content to parse
title: Presentation title
Returns:
List of slide data dictionaries
"""
slides = []
# Split content by slide markers or headers
slide_sections = self._splitContentIntoSlides(content)
for i, section in enumerate(slide_sections):
if section.strip():
slide_data = {
"title": f"Slide {i + 1}",
"content": section.strip()
}
# Extract title from content if it starts with #
lines = section.strip().split('\n')
if lines and lines[0].startswith('#'):
# Remove # symbols and clean up title
slide_title = lines[0].lstrip('#').strip()
slide_data["title"] = slide_title
slide_data["content"] = '\n'.join(lines[1:]).strip()
elif lines and lines[0].strip():
# Use first line as title if it looks like a title
first_line = lines[0].strip()
if len(first_line) < 100 and not first_line.endswith('.'):
slide_data["title"] = first_line
slide_data["content"] = '\n'.join(lines[1:]).strip()
slides.append(slide_data)
return slides
def _splitContentIntoSlides(self, content: str) -> list:
"""
Split content into individual slides based on headers and structure.
Args:
content: Content to split
Returns:
List of slide content strings
"""
import re
# First, try to split by major headers (# or ##)
# This is the most common case for AI-generated content
header_pattern = r'^(#{1,2})\s+(.+)$'
lines = content.split('\n')
slides = []
current_slide = []
for line in lines:
# Check if this line is a header
header_match = re.match(header_pattern, line.strip())
if header_match:
# If we have content in current slide, save it
if current_slide:
slide_content = '\n'.join(current_slide).strip()
if slide_content:
slides.append(slide_content)
current_slide = []
# Start new slide with this header
current_slide.append(line)
else:
# Add line to current slide
current_slide.append(line)
# Add the last slide
if current_slide:
slide_content = '\n'.join(current_slide).strip()
if slide_content:
slides.append(slide_content)
# If we found slides with headers, return them
if len(slides) > 1:
return slides
# Fallback: Split by double newlines
sections = content.split('\n\n\n')
if len(sections) > 1:
return [s.strip() for s in sections if s.strip()]
# Another fallback: Split by double newlines
sections = content.split('\n\n')
if len(sections) > 1:
return [s.strip() for s in sections if s.strip()]
# Last resort: return as single slide
return [content.strip()]
def getOutputMimeType(self) -> str:
"""Get MIME type for rendered output."""
return self.outputMimeType
async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]:
"""Get style set - default styles, enhanced with AI if userPrompt provided.
Args:
userPrompt: User's prompt (AI will detect style instructions in any language)
aiService: AI service (used only if userPrompt provided)
templateName: Name of template style set (None = default)
Returns:
Dict with style definitions for all document styles
"""
# Get default style set
defaultStyleSet = self._getDefaultStyleSet()
# Enhance with AI if userPrompt provided (AI handles multilingual style detection)
if userPrompt and aiService:
# AI will naturally detect style instructions in any language
self.logger.info(f"Enhancing styles with AI based on user prompt...")
enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService)
# Convert colors to PPTX format after getting styles
enhancedStyleSet = self._convertColorsFormat(enhancedStyleSet)
return self._validateStylesReadability(enhancedStyleSet)
else:
# Use default styles only
return defaultStyleSet
async def _enhanceStylesWithAI(self, userPrompt: str, defaultStyleSet: Dict[str, Any], aiService) -> Dict[str, Any]:
"""Enhance default styles with AI based on user prompt."""
try:
style_template = self._createProfessionalPptxTemplate(userPrompt, defaultStyleSet)
enhanced_styles = await self._getAiStylesWithPptxColors(aiService, style_template, defaultStyleSet)
return enhanced_styles
except Exception as e:
self.logger.warning(f"AI style enhancement failed: {str(e)}, using default styles")
return defaultStyleSet
def _validateStylesReadability(self, styles: Dict[str, Any]) -> Dict[str, Any]:
"""Validate and fix readability issues in AI-generated styles."""
try:
# Ensure minimum font sizes for PowerPoint readability
min_font_sizes = {
"title": 36,
"heading": 24,
"subheading": 20,
"paragraph": 14,
"bullet_list": 14,
"table_header": 12,
"table_cell": 12
}
for style_name, min_size in min_font_sizes.items():
if style_name in styles:
current_size = styles[style_name].get("font_size", 12)
if current_size < min_size:
styles[style_name]["font_size"] = min_size
return styles
except Exception as e:
logger.warning(f"Style validation failed: {str(e)}")
return self._getDefaultStyleSet()
def _getDefaultStyleSet(self) -> Dict[str, Any]:
"""Default PowerPoint style set - used when no style instructions present."""
return {
"title": {"font_size": 52, "color": "#1B365D", "bold": True, "align": "center"},
"heading": {"font_size": 36, "color": "#2C5F2D", "bold": True, "align": "left"},
"subheading": {"font_size": 28, "color": "#4A90E2", "bold": True, "align": "left"},
"paragraph": {"font_size": 20, "color": "#2F2F2F", "bold": False, "align": "left"},
"bullet_list": {"font_size": 20, "color": "#2F2F2F", "indent": 20},
"table_header": {"font_size": 18, "color": "#FFFFFF", "bold": True, "background": "#1B365D"},
"table_cell": {"font_size": 16, "color": "#2F2F2F", "bold": False, "background": "#F8F9FA"},
"slide_size": "16:9",
"content_per_slide": "concise",
"design_theme": "corporate",
"color_scheme": "professional",
"background_style": "clean",
"accent_colors": ["#1B365D", "#2C5F2D", "#4A90E2", "#6B7280"],
"professional_grade": True,
"executive_ready": True
}
def _createProfessionalPptxTemplate(self, userPrompt: str, style_schema: Dict[str, Any]) -> str:
"""Create a professional PowerPoint-specific AI style template for corporate-quality slides."""
import json
schema_json = json.dumps(style_schema, indent=4)
return f"""Customize the JSON below for professional PowerPoint slides.
User Request: {userPrompt or "Create professional corporate slides"}
Rules:
- Use professional colors (blues, grays, deep greens)
- Large, readable font sizes
- High contrast
- Sophisticated color palettes
Return ONLY this JSON with your changes:
{schema_json}
JSON ONLY. NO OTHER TEXT."""
async def _getAiStylesWithPptxColors(self, aiService, style_template: str, default_styles: Dict[str, Any]) -> Dict[str, Any]:
"""Get AI styles with proper PowerPoint color conversion."""
if not aiService:
return default_styles
try:
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
request_options = AiCallOptions()
request_options.operationType = OperationTypeEnum.DATA_GENERATE
request = AiCallRequest(prompt=style_template, context="", options=request_options)
# Check if AI service is properly configured
if not hasattr(aiService, 'aiObjects') or not aiService.aiObjects:
self.logger.warning("AI service not properly configured, using defaults")
return default_styles
response = await aiService.callAi(request)
# Check if response is valid
if not response:
self.logger.warning("AI service returned no response, using defaults")
return default_styles
import json
import re
# Clean and parse JSON
result = response.content.strip() if response and response.content else ""
# Check if result is empty
if not result:
self.logger.warning("AI styling returned empty response, using defaults")
return default_styles
# Log the raw response for debugging
self.logger.debug(f"AI styling raw response: {result[:200]}...")
# Extract JSON from various formats
json_match = re.search(r'```json\s*\n(.*?)\n```', result, re.DOTALL)
if json_match:
result = json_match.group(1).strip()
elif result.startswith('```json'):
result = re.sub(r'^```json\s*', '', result)
result = re.sub(r'\s*```$', '', result)
elif result.startswith('```'):
result = re.sub(r'^```\s*', '', result)
result = re.sub(r'\s*```$', '', result)
# Try to extract JSON from explanatory text
json_patterns = [
r'\{[^{}]*"title"[^{}]*\}', # Simple JSON object
r'\{.*?"title".*?\}', # JSON with title field
r'\{.*?"font_size".*?\}', # JSON with font_size field
]
for pattern in json_patterns:
json_match = re.search(pattern, result, re.DOTALL)
if json_match:
result = json_match.group(0)
break
# Additional cleanup - remove any leading/trailing whitespace and newlines
result = result.strip()
# Check if result is still empty after cleanup
if not result:
self.logger.warning("AI styling returned empty content after cleanup, using defaults")
return default_styles
# Try to parse JSON
try:
styles = json.loads(result)
self.logger.debug(f"Successfully parsed AI styles: {list(styles.keys())}")
except json.JSONDecodeError as json_error:
self.logger.warning(f"AI styling returned invalid JSON: {json_error}")
self.logger.warning(f"Raw content that failed to parse: {result[:100]}...")
# Try to extract just the JSON part if it's embedded in text
json_start = result.find('{')
json_end = result.rfind('}')
if json_start != -1 and json_end != -1 and json_end > json_start:
json_part = result[json_start:json_end+1]
try:
styles = json.loads(json_part)
self.logger.info("Successfully extracted JSON from explanatory text")
self.logger.debug(f"Extracted AI styles: {list(styles.keys())}")
except json.JSONDecodeError:
self.logger.warning("Could not extract valid JSON from response, using defaults")
return default_styles
else:
return default_styles
# Convert colors to PowerPoint RGB format
styles = self._convertColorsFormat(styles)
return styles
except Exception as e:
self.logger.warning(f"AI styling failed: {str(e)}, using defaults")
return default_styles
def _convertColorsFormat(self, styles: Dict[str, Any]) -> Dict[str, Any]:
"""Convert hex colors to RGB format for PowerPoint compatibility."""
try:
for style_name, style_config in styles.items():
if isinstance(style_config, dict):
for prop, value in style_config.items():
if isinstance(value, str) and value.startswith('#'):
# Convert hex to RGB tuple for PowerPoint
hex_color = value.lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
styles[style_name][prop] = (r, g, b)
elif len(hex_color) == 8: # aRGB format
r = int(hex_color[2:4], 16)
g = int(hex_color[4:6], 16)
b = int(hex_color[6:8], 16)
styles[style_name][prop] = (r, g, b)
return styles
except Exception as e:
self.logger.warning(f"Color conversion failed: {str(e)}")
return styles
def _getSafeColor(self, color_value, default=(0, 0, 0)) -> tuple:
"""Get a safe RGB color tuple for PowerPoint."""
if isinstance(color_value, tuple) and len(color_value) == 3:
return color_value
elif isinstance(color_value, str) and color_value.startswith('#'):
hex_color = color_value.lstrip('#')
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return (r, g, b)
elif len(hex_color) == 8: # aRGB format
r = int(hex_color[2:4], 16)
g = int(hex_color[4:6], 16)
b = int(hex_color[6:8], 16)
return (r, g, b)
return default
async def _parseJsonToSlides(self, json_content: Dict[str, Any], title: str, styles: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Parse JSON content into slide data structure.
Args:
json_content: JSON content to parse
title: Presentation title
styles: AI-generated styles
Returns:
List of slide data dictionaries
"""
slides = []
try:
# Validate JSON structure (standardized schema: {metadata: {...}, documents: [{sections: [...]}]})
if not self._validateJsonStructure(json_content):
raise ValueError("JSON content must follow standardized schema: {metadata: {...}, documents: [{sections: [...]}]}")
# Extract sections and metadata from standardized schema
sections = self._extractSections(json_content)
metadata = self._extractMetadata(json_content)
# Use title from JSON metadata if available, otherwise use provided title
document_title = metadata.get("title", title)
# Create title slide
slides.append({
"title": document_title,
"content": "Generated by PowerOn AI System\n\n" + self._formatTimestamp()
})
# Process sections into slides based on content and user intent
slides.extend(self._createSlidesFromSections(sections, styles))
# If no content slides were created, create a default content slide
if len(slides) == 1: # Only title slide
slides.append({
"title": "Content Overview",
"content": "No structured content found in the source documents.\n\nPlease check the source documents and try again."
})
return slides
except Exception as e:
logger.error(f"Error parsing JSON to slides: {str(e)}")
# Return minimal fallback slides
return [
{
"title": title,
"content": "Error parsing content for presentation"
}
]
def _createSlideFromSection(self, section: Dict[str, Any], styles: Dict[str, Any]) -> Dict[str, Any]:
"""Create a slide from a JSON section."""
try:
# Get section title from data or use default
section_title = "Untitled Section"
if section.get("content_type") == "heading":
# Extract text from elements array
for element in section.get("elements", []):
if isinstance(element, dict) and "text" in element:
section_title = element.get("text", "Untitled Section")
break
elif section.get("title"):
section_title = section.get("title")
content_type = section.get("content_type", "paragraph")
elements = section.get("elements", [])
# Handle image sections specially
if content_type == "image":
# Extract image data
images = []
for element in elements:
if element.get("base64Data"):
images.append({
"base64Data": element.get("base64Data"),
"altText": element.get("altText", "Image"),
"caption": element.get("caption")
})
return {
"title": section_title or element.get("altText", "Image"),
"content": "", # No text content for image slides
"images": images
}
# Build slide content based on section type
content_parts = []
if content_type == "table":
content_parts.append(self._formatTableForSlide(elements))
elif content_type == "list":
content_parts.append(self._formatListForSlide(elements))
elif content_type == "heading":
content_parts.append(self._formatHeadingForSlide(elements))
elif content_type == "paragraph":
content_parts.append(self._formatParagraphForSlide(elements))
elif content_type == "code":
content_parts.append(self._formatCodeForSlide(elements))
else:
content_parts.append(self._format_paragraph_for_slide(elements))
# Combine content parts
slide_content = "\n\n".join(filter(None, content_parts))
return {
"title": section_title,
"content": slide_content,
"images": [] # No images for non-image sections
}
except Exception as e:
logger.warning(f"Error creating slide from section: {str(e)}")
return None
def _formatTableForSlide(self, elements: List[Dict[str, Any]]) -> str:
"""Format table data for slide presentation."""
try:
# Extract table data from elements array
headers = []
rows = []
for element in elements:
if isinstance(element, dict) and "headers" in element and "rows" in element:
headers = element.get("headers", [])
rows = element.get("rows", [])
break
if not headers:
return ""
# Create table representation
table_lines = []
# Add headers
header_line = " | ".join(str(h) for h in headers)
table_lines.append(header_line)
# Add separator
separator = "-" * len(header_line)
table_lines.append(separator)
# Add data rows (limit based on content density)
max_rows = 5 # Default limit
for row in rows[:max_rows]:
row_line = " | ".join(str(cell) for cell in row)
table_lines.append(row_line)
if len(rows) > max_rows:
table_lines.append(f"... and {len(rows) - max_rows} more rows")
return "\n".join(table_lines)
except Exception as e:
logger.warning(f"Error formatting table for slide: {str(e)}")
return ""
def _formatListForSlide(self, list_data: Dict[str, Any]) -> str:
"""Format list data for slide presentation."""
try:
items = list_data.get("items", [])
if not items:
return ""
# Create list representation
list_lines = []
for item in items:
if isinstance(item, dict):
text = item.get("text", "")
list_lines.append(f"{text}")
# Add subitems (limit to 3 for readability)
subitems = item.get("subitems", [])[:3]
for subitem in subitems:
if isinstance(subitem, dict):
list_lines.append(f" - {subitem.get('text', '')}")
else:
list_lines.append(f" - {subitem}")
else:
list_lines.append(f"{str(item)}")
return "\n".join(list_lines)
except Exception as e:
logger.warning(f"Error formatting list for slide: {str(e)}")
return ""
def _formatHeadingForSlide(self, heading_data: Dict[str, Any]) -> str:
"""Format heading data for slide presentation."""
try:
text = heading_data.get("text", "")
level = heading_data.get("level", 1)
if text:
return f"{'#' * level} {text}"
return ""
except Exception as e:
logger.warning(f"Error formatting heading for slide: {str(e)}")
return ""
def _formatParagraphForSlide(self, paragraph_data: Dict[str, Any]) -> str:
"""Format paragraph data for slide presentation."""
try:
text = paragraph_data.get("text", "")
if text:
# Limit paragraph length based on content density
max_length = 200 # Default limit
if len(text) > max_length:
text = text[:max_length] + "..."
return text
return ""
except Exception as e:
logger.warning(f"Error formatting paragraph for slide: {str(e)}")
return ""
def _formatCodeForSlide(self, code_data: Dict[str, Any]) -> str:
"""Format code data for slide presentation."""
try:
code = code_data.get("code", "")
language = code_data.get("language", "")
if code:
# Limit code length based on content density
max_length = 100 # Default limit
if len(code) > max_length:
code = code[:max_length] + "..."
if language:
return f"Code ({language}):\n{code}"
else:
return f"Code:\n{code}"
return ""
except Exception as e:
logger.warning(f"Error formatting code for slide: {str(e)}")
return ""
def _getSlideLayoutIndex(self, slide_data: Dict[str, Any], styles: Dict[str, Any]) -> int:
"""Determine the best professional slide layout based on content."""
try:
content = slide_data.get("content", "")
title = slide_data.get("title", "")
# Check if it's a title slide (first slide)
if not content or "Generated by PowerOn AI System" in content:
return 0 # Title slide layout
# Professional layout selection based on content
if "|" in content and "-" in content:
# Has both tables and lists - use content with caption for professional look
return 2
elif "|" in content:
# Has tables - use content layout for clean table presentation
return 1
elif content.count("") > 2:
# Has many bullet points - use content layout for better readability
return 1
elif len(content) > 200:
# Long content - use content layout for better text flow
return 1
elif title and len(title) > 20:
# Long title - use title and content layout
return 1
else:
# Default to title and content layout for professional appearance
return 1
except Exception as e:
logger.warning(f"Error determining slide layout: {str(e)}")
return 1 # Default to title and content layout
def _createSlidesFromSections(self, sections: List[Dict[str, Any]], styles: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Create slides from sections based on content density and user intent."""
try:
slides = []
content_per_slide = styles.get("content_per_slide", "concise")
# Group sections by type and create slides
current_slide_content = []
current_slide_title = "Content Overview"
for section in sections:
section_type = section.get("content_type", "paragraph")
elements = section.get("elements", [])
if section_type == "heading":
# If we have accumulated content, create a slide
if current_slide_content:
slides.append({
"title": current_slide_title,
"content": "\n\n".join(current_slide_content),
"images": []
})
current_slide_content = []
# Start new slide with heading as title
for element in elements:
if isinstance(element, dict) and "text" in element:
current_slide_title = element.get("text", "Untitled Section")
break
elif section_type == "image":
# Create separate slide for image
if current_slide_content:
slides.append({
"title": current_slide_title,
"content": "\n\n".join(current_slide_content),
"images": []
})
current_slide_content = []
# Extract image data
imageData = []
for element in elements:
if element.get("base64Data"):
imageData.append({
"base64Data": element.get("base64Data"),
"altText": element.get("altText", "Image"),
"caption": element.get("caption")
})
slides.append({
"title": section.get("title") or (imageData[0].get("altText", "Image") if imageData else "Image"),
"content": "",
"images": imageData
})
else:
# Add content to current slide
formatted_content = self._formatSectionContent(section)
if formatted_content:
current_slide_content.append(formatted_content)
# Add final slide if there's content
if current_slide_content:
slides.append({
"title": current_slide_title,
"content": "\n\n".join(current_slide_content),
"images": []
})
return slides
except Exception as e:
logger.warning(f"Error creating slides from sections: {str(e)}")
return []
def _formatSectionContent(self, section: Dict[str, Any]) -> str:
"""Format section content for slide presentation."""
try:
content_type = section.get("content_type", "paragraph")
elements = section.get("elements", [])
# Image sections return empty content (handled separately)
if content_type == "image":
return ""
# Process each element in the section
content_parts = []
for element in elements:
if content_type == "table":
content_parts.append(self._formatTableForSlide([element]))
elif content_type == "list":
content_parts.append(self._formatListForSlide([element]))
elif content_type == "heading":
content_parts.append(self._formatHeadingForSlide([element]))
elif content_type == "paragraph":
content_parts.append(self._formatParagraphForSlide([element]))
elif content_type == "code":
content_parts.append(self._formatCodeForSlide([element]))
else:
content_parts.append(self._format_paragraph_for_slide([element]))
return "\n\n".join(filter(None, content_parts))
except Exception as e:
logger.warning(f"Error formatting section content: {str(e)}")
return ""
def _addImagesToSlide(self, slide, images: List[Dict[str, Any]], styles: Dict[str, Any]) -> None:
"""Add images to a PowerPoint slide."""
try:
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
import base64
import io
if not images:
return
# Get slide dimensions from presentation
if hasattr(self, '_currentPresentation'):
prs = self._currentPresentation
else:
prs = slide.presentation
slideWidth = prs.slide_width
slideHeight = prs.slide_height
titleHeight = Inches(1.5) # Approximate title height
# Available area for images
availableWidth = slideWidth - Inches(1) # Margins
availableHeight = slideHeight - titleHeight - Inches(1) # Title + margins
# Position images
if len(images) == 1:
# Single image: center it
img = images[0]
base64Data = img.get("base64Data")
if base64Data:
imageBytes = base64.b64decode(base64Data)
imageStream = io.BytesIO(imageBytes)
# Get image dimensions
try:
from PIL import Image as PILImage
pilImage = PILImage.open(imageStream)
imgWidth, imgHeight = pilImage.size
# Scale to fit available space (max 80% of slide)
maxWidth = availableWidth * 0.8
maxHeight = availableHeight * 0.8
scale = min(maxWidth / imgWidth, maxHeight / imgHeight, 1.0)
finalWidth = imgWidth * scale
finalHeight = imgHeight * scale
# Center image
left = (slideWidth - finalWidth) / 2
top = titleHeight + (availableHeight - finalHeight) / 2
imageStream.seek(0)
except Exception:
# Fallback: use default size
finalWidth = Inches(6)
finalHeight = Inches(4.5)
left = (slideWidth - finalWidth) / 2
top = titleHeight + Inches(1)
imageStream.seek(0)
# Add image to slide
slide.shapes.add_picture(imageStream, left, top, width=finalWidth, height=finalHeight)
# Add caption if available
caption = img.get("caption") or img.get("altText")
if caption and caption != "Image":
# Add text box below image
captionTop = top + finalHeight + Inches(0.2)
captionBox = slide.shapes.add_textbox(
Inches(1),
captionTop,
slideWidth - Inches(2),
Inches(0.5)
)
captionFrame = captionBox.text_frame
captionFrame.text = caption
captionFrame.paragraphs[0].font.size = Pt(12)
captionFrame.paragraphs[0].font.italic = True
captionFrame.paragraphs[0].alignment = PP_ALIGN.CENTER
else:
# Multiple images: arrange in grid
cols = 2 if len(images) <= 4 else 3
rows = (len(images) + cols - 1) // cols
imgWidth = (availableWidth - Inches(0.5) * (cols - 1)) / cols
imgHeight = (availableHeight - Inches(0.5) * (rows - 1)) / rows
for idx, img in enumerate(images):
base64Data = img.get("base64Data")
if base64Data:
row = idx // cols
col = idx % cols
imageBytes = base64.b64decode(base64Data)
imageStream = io.BytesIO(imageBytes)
left = Inches(0.5) + col * (imgWidth + Inches(0.5))
top = titleHeight + Inches(0.5) + row * (imgHeight + Inches(0.5))
slide.shapes.add_picture(imageStream, left, top, width=imgWidth, height=imgHeight)
except Exception as e:
logger.warning(f"Error adding images to slide: {str(e)}")
def _formatTimestamp(self) -> str:
"""Format current timestamp for presentation generation."""
from datetime import datetime, UTC
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")