# Copyright (c) 2025 Patrick Motsch # All rights reserved. import logging import base64 import io import json import re from datetime import datetime, UTC 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, services=None): super().__init__(services=services) 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 """ # re is already imported at module level # 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.""" # json is already imported at module level 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 # json and re are already imported at module level # 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", []) # Check for three content formats from Phase 5D in elements content_parts = [] for element in elements: element_type = element.get("type", "") if isinstance(element, dict) else "" # Support three content formats from Phase 5D if element_type == "reference": # Document reference format doc_ref = element.get("documentReference", "") label = element.get("label", "Reference") content_parts.append(f"[Reference: {label}]") continue elif element_type == "extracted_text": # Extracted text format content = element.get("content", "") source = element.get("source", "") if content: source_text = f" (Source: {source})" if source else "" content_parts.append(f"{content}{source_text}") continue # 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 (elements[0].get("altText", "Image") if elements else "Image"), "content": "\n\n".join(content_parts) if content_parts else "", # Include reference/extracted_text if present "images": images } # Build slide content based on section type if not content_parts: # Only if we didn't process reference/extracted_text above 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.""" # datetime and UTC are already imported at module level return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")