gateway/modules/services/serviceGeneration/renderers/rendererPptx.py
2025-12-29 01:40:30 +01:00

1451 lines
67 KiB
Python

# 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, List
from .rendererBaseTemplate import BaseRenderer
from modules.datamodels.datamodelDocument import RenderedDocument
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) -> List[RenderedDocument]:
"""
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: use styles from metadata if available, otherwise enhance with AI
styles = await self._getStyleSet(extractedContent, 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):
slide_sections = slide_data.get("sections", [])
slide_images = list(slide_data.get("images", [])) # Make copy so we can append
slide_content = slide_data.get('content', '')
hasSections = slide_sections and len(slide_sections) > 0
logger.info(f"Slide {i+1}: '{slide_data.get('title', 'No title')}' - sections: {len(slide_sections)}, images: {len(slide_images)}, content: {len(slide_content)} chars")
# Determine layout: first slide (i==0) uses title slide layout, others use title+content
if i == 0:
slideLayoutIndex = 0 # Title slide layout
else:
slideLayoutIndex = 1 # Title and content layout
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 - LEFT ALIGNED by default
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._getSafeColor(title_style.get("color", (31, 78, 121)))
title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color)
# Set left alignment for title
title_shape.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT
# Render sections with proper PowerPoint objects (tables, lists, etc.)
if hasSections:
# Use content placeholder for structured content (only if layout has placeholder[1])
try:
content_shape = slide.placeholders[1]
text_frame = content_shape.text_frame
text_frame.clear()
except (AttributeError, IndexError):
# Layout might not have placeholder[1], create textbox instead
from pptx.util import Inches
left = Inches(0.5)
top = Inches(1.5)
width = prs.slide_width - Inches(1)
height = prs.slide_height - top - Inches(0.5)
textbox = slide.shapes.add_textbox(left, top, width, height)
text_frame = textbox.text_frame
text_frame.word_wrap = True
# Track vertical position for multiple content types
current_y = Inches(1.5) # Start below title
for section in slide_sections:
section_type = section.get("content_type", "paragraph")
elements = section.get("elements", [])
# Check if section has image content_type
if section_type == "image":
# Extract images from this section
for element in elements:
if isinstance(element, dict) and element.get("type") == "image":
content = element.get("content", {})
if isinstance(content, dict):
base64Data = content.get("base64Data")
if base64Data:
slide_images.append({
"base64Data": base64Data,
"altText": content.get("altText", "Image"),
"caption": content.get("caption", "")
})
continue # Skip rendering image sections as text
# Handle sections without elements (e.g., headings that create slides)
if not elements:
continue
for element in elements:
if not isinstance(element, dict):
continue
# Check element type first, fall back to section type
element_type = element.get("type", "")
if not element_type:
element_type = section_type
# Skip image elements - they're handled separately
if element_type == "image":
content = element.get("content", {})
if isinstance(content, dict):
base64Data = content.get("base64Data")
if base64Data:
slide_images.append({
"base64Data": base64Data,
"altText": content.get("altText", "Image"),
"caption": content.get("caption", "")
})
continue
if element_type == "table":
# Render as actual PowerPoint table
self._addTableToSlide(slide, element, styles, current_y)
current_y += Inches(2) # Space for table
elif element_type == "bullet_list" or element_type == "list":
# Render as actual PowerPoint bullet list
if text_frame:
self._addBulletListToSlide(slide, element, styles, text_frame)
elif element_type == "heading":
# Render as heading in text frame
if text_frame:
self._addHeadingToSlide(slide, element, styles, text_frame)
elif element_type == "paragraph":
# Render as paragraph in text frame
if text_frame:
self._addParagraphToSlide(slide, element, styles, text_frame)
elif element_type == "code_block" or element_type == "code":
# Render as formatted code block
if text_frame:
self._addCodeBlockToSlide(slide, element, styles, text_frame)
elif element_type == "extracted_text":
# Render extracted text as paragraph with styling
if text_frame:
content = element.get("content", "")
source = element.get("source", "")
if content:
paragraph_style = styles.get("paragraph", {})
p = text_frame.add_paragraph()
p.text = content
p.font.size = Pt(paragraph_style.get("font_size", 18))
p.font.bold = paragraph_style.get("bold", False)
p.font.color.rgb = RGBColor(*self._getSafeColor(paragraph_style.get("color", (47, 47, 47))))
p.alignment = PP_ALIGN.LEFT # Left align by default
if source:
p.add_run(f" (Source: {source})").font.italic = True
elif element_type == "reference":
# Render reference
if text_frame:
label = element.get("label", "Reference")
p = text_frame.add_paragraph()
p.text = f"[Reference: {label}]"
p.font.italic = True
p.alignment = PP_ALIGN.LEFT
else:
# Fallback: try to render as paragraph
if text_frame:
content = element.get("content", "")
if isinstance(content, dict):
text = content.get("text", "")
elif isinstance(content, str):
text = content
else:
text = ""
if text:
self._addParagraphToSlide(slide, element, styles, text_frame)
# Handle images after processing sections (images may have been extracted from sections)
hasImages = len(slide_images) > 0
if hasImages:
self._addImagesToSlide(slide, slide_images, styles)
# Fallback: if no sections but has content text, render as before
elif slide_content and not hasImages:
content_shape = slide.placeholders[1]
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
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._getSafeColor(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._getSafeColor(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")
# Determine filename from document or title
documents = extractedContent.get("documents", [])
if documents and isinstance(documents[0], dict):
filename = documents[0].get("filename")
if not filename:
filename = self._determineFilename(title, "application/vnd.openxmlformats-officedocument.presentationml.presentation")
else:
filename = self._determineFilename(title, "application/vnd.openxmlformats-officedocument.presentationml.presentation")
# Extract metadata for document type and other info
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
return [
RenderedDocument(
documentData=pptx_bytes,
mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation",
filename=filename,
documentType=documentType,
metadata=metadata if isinstance(metadata, dict) else None
)
]
except ImportError:
logger.error("python-pptx library not installed. Install with: pip install python-pptx")
fallbackContent = "python-pptx library not installed"
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
return [
RenderedDocument(
documentData=fallbackContent.encode('utf-8'),
mimeType="text/plain",
filename=self._determineFilename(title, "text/plain"),
documentType=documentType,
metadata=metadata if isinstance(metadata, dict) else None
)
]
except Exception as e:
logger.error(f"Error rendering PowerPoint presentation: {str(e)}")
fallbackContent = f"Error rendering PowerPoint presentation: {str(e)}"
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
return [
RenderedDocument(
documentData=fallbackContent.encode('utf-8'),
mimeType="text/plain",
filename=self._determineFilename(title, "text/plain"),
documentType=documentType,
metadata=metadata if isinstance(metadata, dict) else None
)
]
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, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]:
"""Get style set - use styles from document generation metadata if available,
otherwise enhance default styles with AI if userPrompt provided.
WICHTIG: In a dynamic scalable AI system, styling should come from document generation,
not be generated separately by renderers. Only fall back to AI if styles not provided.
Args:
extractedContent: Document content with metadata (may contain styles)
userPrompt: User's prompt (AI will detect style instructions in any language)
aiService: AI service (used only if styles not in metadata and 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()
# FIRST: Check if styles are provided in document generation metadata (preferred approach)
if extractedContent:
metadata = extractedContent.get("metadata", {})
if isinstance(metadata, dict):
styles = metadata.get("styles")
if styles and isinstance(styles, dict):
self.logger.debug("Using styles from document generation metadata")
enhancedStyleSet = self._convertColorsFormat(styles)
return self._validateStylesReadability(enhancedStyleSet)
# FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata)
if userPrompt and aiService:
self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...")
enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService)
# Colors already converted in _getAiStylesWithPptxColors
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. Uses base _getAiStyles for debug file writing."""
if not aiService:
return default_styles
try:
# Use base template method which handles debug file writing
enhanced_styles = await self._getAiStyles(aiService, style_template, default_styles)
# Convert colors to PPTX format (RGB tuples)
return self._convertColorsFormat(enhanced_styles)
except Exception as e:
self.logger.warning(f"AI style enhancement 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 from nested content structure
images = []
for element in elements:
if isinstance(element, dict):
# Extract from nested content structure
content = element.get("content", {})
if isinstance(content, dict):
base64Data = content.get("base64Data")
altText = content.get("altText", "Image")
caption = content.get("caption", "")
else:
# Fallback to direct element fields
base64Data = element.get("base64Data")
altText = element.get("altText", "Image")
caption = element.get("caption", "")
if base64Data:
images.append({
"base64Data": base64Data,
"altText": altText,
"caption": 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._formatParagraphForSlide(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, element: Dict[str, Any]) -> str:
"""Format table data for slide presentation."""
try:
# Extract table data from element - handle nested content structure
if not isinstance(element, dict):
return ""
# Extract from nested content structure
content = element.get("content", {})
if not isinstance(content, dict):
return ""
headers = content.get("headers", [])
rows = content.get("rows", [])
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:
# Extract from nested content structure
content = list_data.get("content", {})
if not isinstance(content, dict):
return ""
items = content.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:
# Extract from nested content structure
content = heading_data.get("content", {})
if not isinstance(content, dict):
return ""
text = content.get("text", "")
level = content.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:
# Extract from nested content structure
content = paragraph_data.get("content", {})
if isinstance(content, dict):
text = content.get("text", "")
elif isinstance(content, str):
text = content
else:
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:
# Extract from nested content structure
content = code_data.get("content", {})
if not isinstance(content, dict):
return ""
code = content.get("code", "")
language = content.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: each heading creates a new slide, content accumulates until next heading."""
try:
slides = []
current_slide_sections = [] # Store sections (not formatted text) for proper rendering
current_slide_title = "Content Overview"
for section in sections:
section_type = section.get("content_type", "paragraph")
elements = section.get("elements", [])
# Skip sections with no elements (unless they're headings that should create new slides)
if not elements and section_type != "heading":
continue
if section_type == "heading":
# If we have accumulated content, create a slide
if current_slide_sections:
slides.append({
"title": current_slide_title,
"sections": current_slide_sections.copy(), # Store sections for proper rendering
"images": []
})
current_slide_sections = []
# Start new slide with heading as title
heading_found = False
for element in elements:
if isinstance(element, dict):
# Extract from nested content structure
content = element.get("content", {})
if isinstance(content, dict):
heading_text = content.get("text", "")
elif isinstance(content, str):
heading_text = content
else:
heading_text = ""
if heading_text:
current_slide_title = heading_text
heading_found = True
break
# If no heading text found but this is a heading section, use section ID or default
if not heading_found:
current_slide_title = section.get("id", "Untitled Section")
elif section_type == "image":
# Create separate slide for image
if current_slide_sections:
slides.append({
"title": current_slide_title,
"sections": current_slide_sections.copy(),
"images": []
})
current_slide_sections = []
# Extract image data
imageData = []
for element in elements:
if isinstance(element, dict):
# Extract from nested content structure
content = element.get("content", {})
if isinstance(content, dict):
base64Data = content.get("base64Data")
altText = content.get("altText", "Image")
caption = content.get("caption", "")
else:
# Fallback to direct element fields
base64Data = element.get("base64Data")
altText = element.get("altText", "Image")
caption = element.get("caption", "")
if base64Data:
imageData.append({
"base64Data": base64Data,
"altText": altText,
"caption": caption
})
slides.append({
"title": section.get("title") or (imageData[0].get("altText", "Image") if imageData else "Image"),
"sections": [],
"images": imageData
})
else:
# Add section to current slide (will be rendered properly)
current_slide_sections.append(section)
# Add final slide if there's content
if current_slide_sections:
slides.append({
"title": current_slide_title,
"sections": current_slide_sections.copy(),
"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 == "bullet_list" or 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_block" or content_type == "code":
content_parts.append(self._formatCodeForSlide(element))
else:
content_parts.append(self._formatParagraphForSlide(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
from pptx.dml.color import RGBColor
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 90% of slide for better visibility)
# Convert PIL pixels to PowerPoint points (1 inch = 72 points, typical screen DPI = 96)
# Conversion: pixels * (72/96) = points
imgWidthPoints = imgWidth * (72.0 / 96.0)
imgHeightPoints = imgHeight * (72.0 / 96.0)
maxWidth = availableWidth * 0.9
maxHeight = availableHeight * 0.9
scale = min(maxWidth / imgWidthPoints, maxHeight / imgHeightPoints, 1.0)
finalWidth = imgWidthPoints * scale
finalHeight = imgHeightPoints * 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.error(f"Error embedding images in PPTX slide: {str(e)}")
def _addTableToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], top: float) -> None:
"""Add a PowerPoint table to slide."""
try:
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pptx.dml.color import RGBColor
# Extract from nested content structure
content = element.get("content", {})
if not isinstance(content, dict):
return
headers = content.get("headers", [])
rows = content.get("rows", [])
if not headers:
return
# Calculate table dimensions
num_cols = len(headers)
num_rows = len(rows) + 1 # +1 for header row
left = Inches(0.5)
# Get presentation from stored reference or slide
if hasattr(self, '_currentPresentation'):
prs = self._currentPresentation
else:
prs = slide.presentation
width = prs.slide_width - Inches(1)
row_height = Inches(0.4)
# Create table
table_shape = slide.shapes.add_table(num_rows, num_cols, left, top, width, row_height * num_rows)
table = table_shape.table
# Set column widths
col_width = width / num_cols
for col_idx in range(num_cols):
table.columns[col_idx].width = col_width
# Add headers with styling
header_style = styles.get("table_header", {})
header_bg_color = self._getSafeColor(header_style.get("background", (31, 78, 121)))
header_text_color = self._getSafeColor(header_style.get("text_color", (255, 255, 255)))
header_font_size = header_style.get("font_size", 18)
for col_idx, header in enumerate(headers):
cell = table.cell(0, col_idx)
cell.text = str(header)
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(*header_bg_color)
cell.text_frame.paragraphs[0].font.bold = header_style.get("bold", True)
cell.text_frame.paragraphs[0].font.size = Pt(header_font_size)
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*header_text_color)
align = header_style.get("align", "center")
if align == "left":
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT
elif align == "right":
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.RIGHT
else:
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
# Add data rows with styling
cell_style = styles.get("table_cell", {})
cell_bg_color = self._getSafeColor(cell_style.get("background", (255, 255, 255)))
cell_text_color = self._getSafeColor(cell_style.get("text_color", (47, 47, 47)))
cell_font_size = cell_style.get("font_size", 16)
for row_idx, row_data in enumerate(rows, 1):
for col_idx, cell_data in enumerate(row_data[:num_cols]):
cell = table.cell(row_idx, col_idx)
cell.text = str(cell_data)
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(*cell_bg_color)
cell.text_frame.paragraphs[0].font.size = Pt(cell_font_size)
cell.text_frame.paragraphs[0].font.bold = cell_style.get("bold", False)
cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*cell_text_color)
align = cell_style.get("align", "left")
if align == "center":
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
elif align == "right":
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.RIGHT
else:
cell.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT
except Exception as e:
logger.warning(f"Error adding table to slide: {str(e)}")
def _addBulletListToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None:
"""Add bullet list to slide text frame."""
try:
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
# Extract from nested content structure
content = element.get("content", {})
if not isinstance(content, dict):
return
items = content.get("items", [])
if not items:
return
list_style = styles.get("bullet_list", {})
for item in items:
p = text_frame.add_paragraph()
if isinstance(item, dict):
p.text = item.get("text", "")
else:
p.text = str(item)
p.level = 0
p.font.size = Pt(list_style.get("font_size", 18))
p.font.color.rgb = RGBColor(*self._getSafeColor(list_style.get("color", (47, 47, 47))))
p.alignment = PP_ALIGN.LEFT # Left align bullet lists
p.space_before = Pt(6)
# Enable bullet points - set bullet type to enable bullets
try:
from pptx.enum.text import MSO_AUTO_NUMBER
p.paragraph_format.bullet.type = MSO_AUTO_NUMBER.BULLET
except (ImportError, AttributeError):
# Fallback: bullets are usually enabled by default when level is set
# Just ensure level is set (already done above)
pass
except Exception as e:
logger.warning(f"Error adding bullet list to slide: {str(e)}")
def _addHeadingToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None:
"""Add heading to slide text frame."""
try:
from pptx.util import Pt
from pptx.dml.color import RGBColor
# Extract from nested content structure
content = element.get("content", {})
if not isinstance(content, dict):
return
text = content.get("text", "")
level = content.get("level", 1)
if text:
p = text_frame.add_paragraph()
p.text = text
p.level = min(level - 1, 2) # PowerPoint supports 0-2 levels
heading_style = styles.get("heading", {})
p.font.size = Pt(heading_style.get("font_size", 32))
p.font.bold = heading_style.get("bold", True)
p.font.color.rgb = RGBColor(*self._getSafeColor(heading_style.get("color", (47, 47, 47))))
except Exception as e:
logger.warning(f"Error adding heading to slide: {str(e)}")
def _addParagraphToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None:
"""Add paragraph to slide text frame."""
try:
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
# Extract from nested content structure
content = element.get("content", {})
if isinstance(content, dict):
text = content.get("text", "")
elif isinstance(content, str):
text = content
else:
text = ""
if text:
p = text_frame.add_paragraph()
p.text = text
paragraph_style = styles.get("paragraph", {})
p.font.size = Pt(paragraph_style.get("font_size", 18))
p.font.bold = paragraph_style.get("bold", False)
p.font.color.rgb = RGBColor(*self._getSafeColor(paragraph_style.get("color", (47, 47, 47))))
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
except Exception as e:
logger.warning(f"Error adding paragraph to slide: {str(e)}")
def _addCodeBlockToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None:
"""Add code block to slide text frame."""
try:
from pptx.util import Pt
from pptx.dml.color import RGBColor
# Extract from nested content structure
content = element.get("content", {})
if not isinstance(content, dict):
return
code = content.get("code", "")
language = content.get("language", "")
if code:
code_style = styles.get("code_block", {})
code_font = code_style.get("font", "Courier New")
code_font_size = code_style.get("font_size", 9)
code_color = self._getSafeColor(code_style.get("color", (47, 47, 47)))
p = text_frame.add_paragraph()
if language:
p.text = f"Code ({language}):"
p.font.bold = True
p = text_frame.add_paragraph()
p.text = code
p.font.name = code_font
p.font.size = Pt(code_font_size)
p.font.color.rgb = RGBColor(*code_color)
except Exception as e:
logger.warning(f"Error adding code block 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")