""" builders/build_pptx.py -- PowerPoint presentation builder. Generates a widescreen (16:9) PPTX presentation summarizing the financial analysis and AI narrative, strictly matching user-defined theme, colors, and layout structure. """ import os import sys import datetime from pathlib import Path from typing import Dict, List # import python-pptx classes from pptx import Presentation from pptx.util import Inches, Pt from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN from pptx.enum.shapes import MSO_SHAPE from pptx.enum.shapes import MSO_CONNECTOR # Color Palette defined in User Rules COLOR_DARK_TEXT = RGBColor(0, 0, 0) # 000000 (Black) COLOR_LIGHT_BG = RGBColor(255, 255, 255) # FFFFFF (White) COLOR_DARK_GREEN = RGBColor(0, 132, 0) # 008400 (Green Bar 1) COLOR_LIGHT_GREEN = RGBColor(149, 203, 137) # 95CB89 (Green Bar 2) COLOR_GRAY_LINE = RGBColor(0, 0, 0) # 000000 (Black bottom line) COLOR_DARK_SLATE = RGBColor(69, 95, 81) # 455F51 (Dark slate green) COLOR_LIGHT_GRAY = RGBColor(227, 222, 209) # E3DED1 (Light warm gray/beige) COLOR_ACCENT = RGBColor(84, 158, 57) # 549E39 (Accent green) FONT_MAJOR = "Helvetica Neue Bold" FONT_MINOR = "Helvetica Neue Light" FONT_MAJOR_FALLBACK = "Arial" FONT_MINOR_FALLBACK = "Arial" def _apply_theme_decorations(slide, prs): """ Applies the layout rectangles and bottom line strictly defined in the Slide Master Layout. """ # 1. Decorative top-left dark green bar # rect: x: 0.15, y: 0.0, w: 0.36, h: 1.16 rect1 = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0.15), Inches(0.0), Inches(0.36), Inches(1.16) ) rect1.fill.solid() rect1.fill.fore_color.rgb = COLOR_DARK_GREEN rect1.line.fill.background() # No border line # 2. Decorative top-left light green bar # rect: x: 0.55, y: 0.0, w: 0.11, h: 1.39 rect2 = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(0.0), Inches(0.11), Inches(1.39) ) rect2.fill.solid() rect2.fill.fore_color.rgb = COLOR_LIGHT_GREEN rect2.line.fill.background() # No border line # 3. Decorative bottom line # line: x: 0.67, y: 6.83, w: 12.0, h: 0.0 connector = slide.shapes.add_connector( MSO_CONNECTOR.STRAIGHT, Inches(0.67), Inches(6.83), Inches(12.67), Inches(6.83) ) connector.line.color.rgb = COLOR_GRAY_LINE connector.line.width = Pt(1) def _add_slide_title(slide, text: str): """ Adds the Slide Title using Layout 7 Title configurations: placeholder: x: 0.81, y: 0.29, w: 11.74, h: 0.38, align: left, valign: top """ tx_box = slide.shapes.add_textbox( Inches(0.81), Inches(0.29), Inches(11.74), Inches(0.38) ) tf = tx_box.text_frame tf.word_wrap = True tf.margin_top = 0 tf.margin_bottom = 0 tf.margin_left = 0 tf.margin_right = 0 p = tf.paragraphs[0] p.text = text p.font.name = FONT_MAJOR p.font.size = Pt(20) p.font.bold = True p.font.color.rgb = COLOR_DARK_TEXT p.alignment = PP_ALIGN.LEFT def _add_bullet_points(slide, bullets: List[str], top_inch=1.56, height_inch=5.14): """ Adds bullet points body text block using Master 2 Body: placeholder: x: 0.67, y: top, w: 12.0, h: height """ tx_box = slide.shapes.add_textbox( Inches(0.67), Inches(top_inch), Inches(12.0), Inches(height_inch) ) tf = tx_box.text_frame tf.word_wrap = True tf.margin_top = 0 tf.margin_bottom = 0 tf.margin_left = 0 tf.margin_right = 0 for i, b in enumerate(bullets): p = tf.add_paragraph() if i > 0 else tf.paragraphs[0] p.text = "• " + b p.font.name = FONT_MINOR p.font.size = Pt(15) p.font.color.rgb = COLOR_DARK_TEXT p.space_after = Pt(8) p.line_spacing = 1.2 def _style_table_cell(cell, text: str, font_name=FONT_MINOR, font_size=11, bold=False, text_color=COLOR_DARK_TEXT, bg_color=None, align=PP_ALIGN.CENTER): """Styles cell texts and background fills.""" cell.text = text p = cell.text_frame.paragraphs[0] p.alignment = align p.font.name = font_name p.font.size = Pt(font_size) p.font.bold = bold p.font.color.rgb = text_color if bg_color: cell.fill.solid() cell.fill.fore_color.rgb = bg_color def build_presentation(data: Dict, output_path: str): """ Assembles a widescreen PowerPoint report from scraped financial data and AI models output. """ prs = Presentation() # Strictly configure widescreen 16:9 aspect ratio prs.slide_width = Inches(13.333) prs.slide_height = Inches(7.5) blank_layout = prs.slide_layouts[6] # Blank slide template company_name = data.get("company", {}).get("name", "Unknown Company") ticker = data.get("company", {}).get("ticker", "N/A") industry = data.get("company", {}).get("industry", "N/A") sector = data.get("company", {}).get("sector", "N/A") date_str = data.get("company", {}).get("report_date", datetime.date.today().isoformat()) years = data.get("years", []) # ========================================== # SLIDE 1: Title Slide # ========================================== slide_title = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_title, prs) # Title Text Frame title_box = slide_title.shapes.add_textbox( Inches(1.0), Inches(2.2), Inches(11.3), Inches(2.0) ) tf = title_box.text_frame tf.word_wrap = True p1 = tf.paragraphs[0] p1.text = company_name p1.font.name = FONT_MAJOR p1.font.size = Pt(40) p1.font.bold = True p1.font.color.rgb = COLOR_DARK_TEXT p2 = tf.add_paragraph() p2.text = f"Financial Analysis & Investment Insights ({ticker})" p2.font.name = FONT_MINOR p2.font.size = Pt(22) p2.font.color.rgb = COLOR_DARK_SLATE p2.space_before = Pt(8) # Subtitle Info (Date / Authors) sub_box = slide_title.shapes.add_textbox( Inches(1.0), Inches(5.0), Inches(11.3), Inches(1.2) ) tf_sub = sub_box.text_frame p_sub = tf_sub.paragraphs[0] p_sub.text = f"Report Date: {date_str} | Sector: {sector} | Industry: {industry}" p_sub.font.name = FONT_MINOR p_sub.font.size = Pt(13) p_sub.font.color.rgb = COLOR_DARK_SLATE p_sub2 = tf_sub.add_paragraph() p_sub2.text = "Disclaimer: This report is for informational purposes only and does not constitute investment advice." p_sub2.font.name = FONT_MINOR p_sub2.font.size = Pt(11) p_sub2.font.color.rgb = COLOR_DARK_SLATE p_sub2.space_before = Pt(6) # ========================================== # DYNAMIC AI SLIDES (From ppt_json) # ========================================== import json import re ppt_json_str = data.get("ppt_json", "") ai_slides = [] if ppt_json_str: match = re.search(r'\[.*\]', ppt_json_str, re.DOTALL) if match: try: ai_slides = json.loads(match.group(0)) except Exception: pass if ai_slides: for slide_data in ai_slides: title = slide_data.get("title", "Analysis Slide") bullets = slide_data.get("bullets", []) if not bullets: continue s = prs.slides.add_slide(blank_layout) _apply_theme_decorations(s, prs) _add_slide_title(s, title) clean_bullets = [str(b).strip() for b in bullets if b] _add_bullet_points(s, clean_bullets) else: # Fallback to Exec Summary if JSON parsing failed exec_summary_text = data.get("executive_summary", "") if exec_summary_text and len(exec_summary_text) > 50: slide_exec = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_exec, prs) _add_slide_title(slide_exec, "Executive Summary") paragraphs = [p.strip() for p in exec_summary_text.split('\n') if p.strip()] _add_bullet_points(slide_exec, paragraphs[:5]) # ========================================== # SLIDE 3: Key Financial Highlights Table # ========================================== if years: slide_metrics = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_metrics, prs) _add_slide_title(slide_metrics, "Key Financial Performance") # Build Grid Table cols = len(years) + 1 rows = 6 table_shape = slide_metrics.shapes.add_table( rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.8) ) table = table_shape.table # Column Headers _style_table_cell(table.cell(0, 0), "Financial Metric", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT) for idx, y in enumerate(years): _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE) metrics = [ ("Revenue", "revenue", "income_statement", 1.0), ("Gross Profit", "gross_profit", "income_statement", 1.0), ("Operating Income (EBIT)", "operating_income", "income_statement", 1.0), ("Net Income", "net_income", "income_statement", 1.0), ("Operating Cash Flow (OCF)", "cash_from_operations", "cash_flow", 1.0) ] currency = data.get("company", {}).get("currency", "USD") units = data.get("company", {}).get("units", "millions") for r_idx, (m_label, dict_key, sheet_name, scale) in enumerate(metrics): curr_row = r_idx + 1 bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG # Label cell _style_table_cell(table.cell(curr_row, 0), f"{m_label} ({currency} {units.capitalize()})", FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT) # Values cells sheet_data = data.get(sheet_name, {}) val_list = sheet_data.get(dict_key, []) for c_idx, y in enumerate(years): val_str = "N/A" if c_idx < len(val_list) and val_list[c_idx] is not None: val_str = f"{val_list[c_idx]:,.1f}" _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color) # Brief Narrative summary underneath table rev_commentary = data.get("trend_analysis", {}).get("revenue_and_profitability", "") if rev_commentary: comment_box = slide_metrics.shapes.add_textbox(Inches(0.8), Inches(5.6), Inches(11.7), Inches(1.1)) tf_c = comment_box.text_frame tf_c.word_wrap = True p_c = tf_c.paragraphs[0] p_c.text = "Key Takeaway: " + (rev_commentary[:260] + "..." if len(rev_commentary) > 260 else rev_commentary) p_c.font.name = FONT_MINOR p_c.font.size = Pt(12) p_c.font.italic = True p_c.font.color.rgb = COLOR_DARK_SLATE # ========================================== # SLIDE 4: Dupont ROE Decomposition # ========================================== dp_data = data.get("dupont", {}) if dp_data and "years" in dp_data: slide_dp = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_dp, prs) _add_slide_title(slide_dp, "DuPont ROE Analysis (Latest 3 Years)") dp_years = dp_data.get("years", []) cols = len(dp_years) + 1 rows = 5 table_shape = slide_dp.shapes.add_table( rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2) ) table = table_shape.table # Headers _style_table_cell(table.cell(0, 0), "DuPont Component", FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT) for idx, y in enumerate(dp_years): _style_table_cell(table.cell(0, idx+1), str(y), FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE) components = [ ("Net Profit Margin (%)", "Net Margin"), ("Asset Turnover (x)", "Asset Turnover"), ("Equity Multiplier (x)", "Equity Multiplier"), ("Return on Equity (ROE %)", "ROE") ] for r_idx, (c_label, key) in enumerate(components): curr_row = r_idx + 1 bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG is_bold = (curr_row == 4) # Bold ROE row _style_table_cell(table.cell(curr_row, 0), c_label, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT) vals_list = dp_data.get(key, []) for c_idx, y in enumerate(dp_years): val_str = "N/A" if c_idx < len(vals_list) and vals_list[c_idx] is not None: val_val = vals_list[c_idx] if "%" in c_label or "ROE" in key: val_str = f"{val_val * 100:.1f}%" if val_val < 1.0 else f"{val_val:.1f}%" else: val_str = f"{val_val:.2f}x" _style_table_cell(table.cell(curr_row, c_idx+1), val_str, FONT_MAJOR if is_bold else FONT_MINOR, 11, is_bold, COLOR_DARK_TEXT, bg_color) # DuPont Commentary Text Box commentary = data.get("trend_analysis", {}).get("balance_sheet_strength", "") if commentary: comment_box = slide_dp.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6)) tf_c = comment_box.text_frame tf_c.word_wrap = True p_c = tf_c.paragraphs[0] p_c.text = "DuPont Drivers & Balance Sheet Strength: " + (commentary[:380] + "..." if len(commentary) > 380 else commentary) p_c.font.name = FONT_MINOR p_c.font.size = Pt(12) p_c.font.color.rgb = COLOR_DARK_TEXT p_c.line_spacing = 1.2 # ========================================== # SLIDE 5: Competitor Benchmarking # ========================================== peers_list = data.get("peers", []) if peers_list: slide_peers = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_peers, prs) _add_slide_title(slide_peers, "Competitor Benchmarking") # Build Grid Table cols = 5 rows = min(len(peers_list) + 1, 6) table_shape = slide_peers.shapes.add_table( rows, cols, Inches(0.8), Inches(1.6), Inches(11.7), Inches(3.2) ) table = table_shape.table # Headers headers = ["Company", "Revenue Growth", "Operating Margin", "P/E Ratio", "ROE"] for c_idx, h in enumerate(headers): _style_table_cell(table.cell(0, c_idx), h, FONT_MAJOR, 12, True, COLOR_LIGHT_BG, COLOR_DARK_SLATE, PP_ALIGN.LEFT if c_idx == 0 else PP_ALIGN.CENTER) for r_idx in range(rows - 1): peer = peers_list[r_idx] curr_row = r_idx + 1 bg_color = COLOR_LIGHT_GRAY if curr_row % 2 == 1 else COLOR_LIGHT_BG # Format peer outputs rev_gr = peer.get("revenue_growth", "N/A") if isinstance(rev_gr, float): rev_gr = f"{rev_gr*100:.1f}%" op_marg = peer.get("operating_margin", "N/A") if isinstance(op_marg, float): op_marg = f"{op_marg*100:.1f}%" pe_r = peer.get("pe_ratio", "N/A") if isinstance(pe_r, float): pe_r = f"{pe_r:.1f}x" roe_val = peer.get("roe", "N/A") if isinstance(roe_val, float): roe_val = f"{roe_val*100:.1f}%" _style_table_cell(table.cell(curr_row, 0), peer.get("name", "N/A"), FONT_MAJOR, 11, False, COLOR_DARK_TEXT, bg_color, PP_ALIGN.LEFT) _style_table_cell(table.cell(curr_row, 1), str(rev_gr), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color) _style_table_cell(table.cell(curr_row, 2), str(op_marg), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color) _style_table_cell(table.cell(curr_row, 3), str(pe_r), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color) _style_table_cell(table.cell(curr_row, 4), str(roe_val), FONT_MINOR, 11, False, COLOR_DARK_TEXT, bg_color) # Peer comparison commentary peer_comm = data.get("peer_comparison_commentary", "") if peer_comm: comment_box = slide_peers.shapes.add_textbox(Inches(0.8), Inches(5.0), Inches(11.7), Inches(1.6)) tf_c = comment_box.text_frame tf_c.word_wrap = True p_c = tf_c.paragraphs[0] p_c.text = "Benchmarking Insights: " + (peer_comm[:380] + "..." if len(peer_comm) > 380 else peer_comm) p_c.font.name = FONT_MINOR p_c.font.size = Pt(12) p_c.font.color.rgb = COLOR_DARK_TEXT p_c.line_spacing = 1.2 # ========================================== # SLIDE 6: Financial Visualizations # ========================================== try: from builders.chart_builder import generate_all_charts charts = generate_all_charts(data) for chart_key, title in [ ("revenue_net_income", "Revenue & Net Income Trend"), ("margins", "Profitability Margins"), ("cash_flow", "Cash Flow & CAPEX Overview") ]: if charts.get(chart_key): slide_chart = prs.slides.add_slide(blank_layout) _apply_theme_decorations(slide_chart, prs) _add_slide_title(slide_chart, title) # Add picture centrally slide_chart.shapes.add_picture( charts[chart_key], Inches(1.5), Inches(1.5), width=Inches(10.0) ) except Exception as e: import sys print(f"Warning: Could not generate PPTX charts: {e}", file=sys.stderr) # Save Presentation os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) prs.save(output_path)