File size: 18,795 Bytes
ad38045
325cb74
ad38045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
"""

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)