Source code for src.reporting.powerpoint

"""PowerPoint presentation generation for Share of Search analysis."""

from pathlib import Path
from typing import Dict, List, Any, Optional
from datetime import datetime
import pandas as pd
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pptx.dml.color import RGBColor

from ..utils.errors import ProcessingError
from ..utils.logging import get_logger

logger = get_logger(__name__)


[docs] class PowerPointGenerator: """Generate McKinsey-style PowerPoint presentations.""" # McKinsey brand colors NAVY = RGBColor(0, 51, 102) # #003366 DARK_GRAY = RGBColor(51, 51, 51) # #333333 LIGHT_GRAY = RGBColor(128, 128, 128) # #808080 def __init__(self): """Initialize PowerPoint generator.""" self.prs = Presentation() self.prs.slide_width = Inches(10) self.prs.slide_height = Inches(7.5) def _add_title_slide(self, project_name: str) -> None: """Add professional title slide.""" slide = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # Blank layout # Title title_box = slide.shapes.add_textbox( Inches(1), Inches(2.5), Inches(8), Inches(1.5) ) title_frame = title_box.text_frame title_frame.text = "Share of Search Analysis" title_para = title_frame.paragraphs[0] title_para.font.size = Pt(36) title_para.font.color.rgb = self.NAVY title_para.font.name = 'Arial' title_para.alignment = PP_ALIGN.LEFT # Subtitle subtitle_box = slide.shapes.add_textbox( Inches(1), Inches(4), Inches(8), Inches(0.8) ) subtitle_frame = subtitle_box.text_frame subtitle_frame.text = project_name subtitle_para = subtitle_frame.paragraphs[0] subtitle_para.font.size = Pt(20) subtitle_para.font.color.rgb = self.DARK_GRAY subtitle_para.font.name = 'Arial' subtitle_para.alignment = PP_ALIGN.LEFT # Date and confidential date_box = slide.shapes.add_textbox( Inches(1), Inches(6.5), Inches(8), Inches(0.5) ) date_frame = date_box.text_frame date_frame.text = f"{datetime.now().strftime('%B %Y')} | Confidential" date_para = date_frame.paragraphs[0] date_para.font.size = Pt(10) date_para.font.color.rgb = self.LIGHT_GRAY date_para.font.name = 'Arial' def _add_content_slide( self, title: str, content: str, image_path: Optional[Path] = None ) -> None: """Add content slide with optional image.""" slide = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # Title title_box = slide.shapes.add_textbox( Inches(0.5), Inches(0.4), Inches(9), Inches(0.6) ) title_frame = title_box.text_frame title_frame.text = title title_para = title_frame.paragraphs[0] title_para.font.size = Pt(18) title_para.font.bold = False title_para.font.color.rgb = self.NAVY title_para.font.name = 'Arial' title_para.alignment = PP_ALIGN.LEFT if image_path and image_path.exists(): # Add image slide.shapes.add_picture( str(image_path), Inches(0.5), Inches(1.2), width=Inches(9) ) else: # Add text content content_box = slide.shapes.add_textbox( Inches(0.5), Inches(1.2), Inches(9), Inches(5.5) ) content_frame = content_box.text_frame content_frame.word_wrap = True # Parse content into paragraphs for line in content.split('\n'): if line.strip(): p = content_frame.add_paragraph() p.text = line.strip() p.font.size = Pt(11) p.font.color.rgb = self.DARK_GRAY p.font.name = 'Arial' p.space_before = Pt(6) p.space_after = Pt(6) # Footer footer_box = slide.shapes.add_textbox( Inches(0.5), Inches(7), Inches(9), Inches(0.3) ) footer_frame = footer_box.text_frame footer_frame.text = "Source: Google Trends | Confidential" footer_para = footer_frame.paragraphs[0] footer_para.font.size = Pt(8) footer_para.font.color.rgb = self.LIGHT_GRAY footer_para.font.name = 'Arial'
[docs] def generate_presentation( self, project_name: str, executive_summary: str, competitive_insights: str, metrics_df: pd.DataFrame, chart_paths: Dict[str, Path], market_concentration: Dict[str, Any], output_path: Path ) -> None: """ Generate complete PowerPoint presentation. Args: project_name: Project name executive_summary: Executive summary text competitive_insights: Competitive insights text metrics_df: Metrics DataFrame chart_paths: Dictionary of chart paths market_concentration: Market concentration metrics output_path: Path to save presentation """ try: logger.info("Generating PowerPoint presentation...") # Slide 1: Title self._add_title_slide(project_name) # Slide 2: Executive Summary summary_clean = self._clean_text_for_slide(executive_summary, max_length=600) self._add_content_slide("Executive Summary", summary_clean) # Slide 3: Market Overview (Bar Chart) if 'bar' in chart_paths: self._add_content_slide( "Market Overview - Share Distribution", "", chart_paths['bar'] ) # Slide 4: Temporal Trends (Line Chart) if 'line' in chart_paths: self._add_content_slide( "Temporal Dynamics - Search Trends", "", chart_paths['line'] ) # Slide 5: Competitive Positioning (Area Chart) if 'area' in chart_paths: self._add_content_slide( "Competitive Positioning - Share Evolution", "", chart_paths['area'] ) # Slide 6: Statistical Analysis insights_clean = self._clean_text_for_slide(competitive_insights, max_length=600) self._add_content_slide("Statistical Analysis", insights_clean) # Slide 7: Data Limitations limitations = self._create_limitations_text(market_concentration) self._add_content_slide("Data Quality & Limitations", limitations) # Slide 8: Brand Metrics Table self._add_metrics_slide(metrics_df) # Save presentation self.prs.save(str(output_path)) logger.info(f"PowerPoint presentation saved: {output_path}") except Exception as e: raise ProcessingError(f"Failed to generate PowerPoint: {e}")
def _clean_text_for_slide(self, text: str, max_length: int = 600) -> str: """Clean and truncate text for slide.""" # Remove section headers lines = [] for line in text.split('\n'): line = line.strip() if line and not line.endswith(':'): # Remove markdown and numbers line = line.replace('**', '').replace('*', '') if not line.startswith(('1.', '2.', '3.', '4.', '-')): lines.append(line) full_text = ' '.join(lines) if len(full_text) > max_length: full_text = full_text[:max_length] + '...' return full_text def _create_limitations_text(self, market_concentration: Dict[str, Any]) -> str: """Create data limitations text.""" hhi = market_concentration.get('hhi', 0) text = f"""DATA QUALITY NOTICE Google Trends measurement error: ±5% variability between retrievals (Cebrián & Domenech 2023) Same query on different days shows correlation of only 0.79-0.94 Undisclosed sampling methods used by Google (Choi & Varian 2012) Coverage bias: Excludes non-Google users and specialized search platforms Market Concentration: HHI = {hhi:.0f} INTERPRETATION GUIDELINES This analysis represents search interest patterns, not actual market performance Correlations observed do not imply causation Strategic decisions require additional data sources beyond Google Trends All statistical observations require external validation for causal claims""" return text def _add_metrics_slide(self, metrics_df: pd.DataFrame) -> None: """Add slide with metrics table.""" slide = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # Title title_box = slide.shapes.add_textbox( Inches(0.5), Inches(0.4), Inches(9), Inches(0.6) ) title_frame = title_box.text_frame title_frame.text = "Brand Metrics Summary" title_para = title_frame.paragraphs[0] title_para.font.size = Pt(18) title_para.font.color.rgb = self.NAVY title_para.font.name = 'Arial' # Table rows = len(metrics_df) + 1 cols = 3 table = slide.shapes.add_table( rows, cols, Inches(1), Inches(1.5), Inches(8), Inches(4) ).table # Header row table.cell(0, 0).text = "Brand" table.cell(0, 1).text = "Avg Share (%)" table.cell(0, 2).text = "Volatility" for i in range(cols): cell = table.cell(0, i) cell.text_frame.paragraphs[0].font.size = Pt(11) cell.text_frame.paragraphs[0].font.bold = True cell.text_frame.paragraphs[0].font.color.rgb = self.NAVY # Data rows for idx, (_, row) in enumerate(metrics_df.iterrows(), start=1): table.cell(idx, 0).text = str(row['query']) table.cell(idx, 1).text = f"{row['avg_share']:.1f}%" table.cell(idx, 2).text = f"{row.get('volatility', 0):.2f}" for i in range(cols): cell = table.cell(idx, i) cell.text_frame.paragraphs[0].font.size = Pt(10) cell.text_frame.paragraphs[0].font.color.rgb = self.DARK_GRAY # Footer footer_box = slide.shapes.add_textbox( Inches(0.5), Inches(7), Inches(9), Inches(0.3) ) footer_frame = footer_box.text_frame footer_frame.text = "Source: Google Trends | Note: ±5% measurement error" footer_para = footer_frame.paragraphs[0] footer_para.font.size = Pt(8) footer_para.font.color.rgb = self.LIGHT_GRAY footer_para.font.name = 'Arial'