""" ppt_builder.py — PPTX board-pack generation for the SHEQ Analysis Tool. """ from __future__ import annotations import logging import os from datetime import datetime from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE from pptx.enum.text import PP_ALIGN from pptx.util import Inches, Pt from analysis_engine import AnalysisResults log = logging.getLogger(__name__) DEEP_BLUE = RGBColor(0x0B, 0x32, 0x54) SKY_BLUE = RGBColor(0x13, 0xB5, 0xEA) DARK_GREEN = RGBColor(0x00, 0x6E, 0x47) AMBER = RGBColor(0xD9, 0x77, 0x06) RED = RGBColor(0xDC, 0x26, 0x26) GREY = RGBColor(0x64, 0x74, 0x8B) OFF_WHITE = RGBColor(0xF0, 0xF5, 0xFA) WHITE = RGBColor(0xFF, 0xFF, 0xFF) def _slide_title(slide, title: str, subtitle: str | None = None) -> None: tx = slide.shapes.add_textbox(Inches(0.45), Inches(0.25), Inches(12.1), Inches(0.8)) tf = tx.text_frame p = tf.paragraphs[0] r = p.add_run() r.text = title r.font.name = "Source Sans Pro" r.font.size = Pt(24) r.font.bold = True r.font.color.rgb = DEEP_BLUE if subtitle: p2 = tf.add_paragraph() r2 = p2.add_run() r2.text = subtitle r2.font.name = "Source Sans Pro" r2.font.size = Pt(11) r2.font.color.rgb = GREY def _add_banner(slide, title: str, subtitle: str) -> None: shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, Inches(0), Inches(0), Inches(13.33), Inches(1.3)) shape.fill.solid() shape.fill.fore_color.rgb = DEEP_BLUE shape.line.fill.background() tf = shape.text_frame tf.clear() p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = title r.font.name = "Source Sans Pro" r.font.size = Pt(26) r.font.bold = True r.font.color.rgb = WHITE p2 = tf.add_paragraph() r2 = p2.add_run() r2.text = subtitle r2.font.name = "Source Sans Pro" r2.font.size = Pt(12) r2.font.color.rgb = RGBColor(0xD7, 0xF2, 0xFF) def _add_text_card(slide, left, top, width, height, title: str, body: str, fill=OFF_WHITE, accent=SKY_BLUE) -> None: shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height) shape.fill.solid() shape.fill.fore_color.rgb = fill shape.line.color.rgb = WHITE tf = shape.text_frame tf.clear() p = tf.paragraphs[0] r = p.add_run() r.text = title r.font.name = "Source Sans Pro" r.font.size = Pt(11) r.font.bold = True r.font.color.rgb = accent p2 = tf.add_paragraph() r2 = p2.add_run() r2.text = body r2.font.name = "Source Sans Pro" r2.font.size = Pt(10) r2.font.color.rgb = DEEP_BLUE def _add_metric_card(slide, left, top, width, height, label: str, value: str, fill=OFF_WHITE) -> None: shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height) shape.fill.solid() shape.fill.fore_color.rgb = fill shape.line.color.rgb = WHITE tf = shape.text_frame tf.clear() p = tf.paragraphs[0] p.alignment = PP_ALIGN.CENTER r = p.add_run() r.text = label r.font.name = "Source Sans Pro" r.font.size = Pt(9) r.font.color.rgb = GREY p2 = tf.add_paragraph() p2.alignment = PP_ALIGN.CENTER r2 = p2.add_run() r2.text = value r2.font.name = "Source Sans Pro" r2.font.size = Pt(18) r2.font.bold = True r2.font.color.rgb = DEEP_BLUE def _add_bullets(slide, left, top, width, height, title: str, items: list[str], accent=DEEP_BLUE) -> None: tx = slide.shapes.add_textbox(left, top, width, height) tf = tx.text_frame tf.clear() p = tf.paragraphs[0] r = p.add_run() r.text = title r.font.name = "Source Sans Pro" r.font.size = Pt(15) r.font.bold = True r.font.color.rgb = accent for item in items: para = tf.add_paragraph() para.level = 0 para.text = item para.font.name = "Source Sans Pro" para.font.size = Pt(11) para.font.color.rgb = DEEP_BLUE para.bullet = True def _add_chart(slide, image_path: str | None, left, top, width, height=None) -> None: if image_path and os.path.exists(image_path): if height is None: slide.shapes.add_picture(image_path, left, top, width=width) else: slide.shapes.add_picture(image_path, left, top, width=width, height=height) def build_presentation(results: AnalysisResults, output_dir: str) -> str: os.makedirs(output_dir, exist_ok=True) prs = Presentation() prs.slide_width = Inches(13.333) prs.slide_height = Inches(7.5) blank = prs.slide_layouts[6] # Title slide = prs.slides.add_slide(blank) _add_banner(slide, "SHEQ Safety Performance Board Pack", "Safety Energy, event hotspots, and leadership action priorities") dq = results.data_quality _add_text_card( slide, Inches(0.6), Inches(1.6), Inches(12.0), Inches(1.0), "Scope", f"Events: {dq.get('events', {}).get('date_from', 'N/A')} to {dq.get('events', {}).get('date_to', 'N/A')} | " f"Safety Energy: {dq.get('safety_energy', {}).get('date_from', 'N/A')} to {dq.get('safety_energy', {}).get('date_to', 'N/A')}", ) ev = results.events_summary lead = results.leading_summary trends = results.trends metric_y = Inches(3.0) card_w = Inches(2.85) gap = Inches(0.2) metrics = [ ("Events", str(ev.get("total", 0))), ("Moderate+ Events", str(ev.get("serious_count", 0))), ("MV Events", str(ev.get("motor_vehicle", {}).get("count", 0))), ("CCC Avg Quality", f"{trends.get('activity_insights', {}).get('CCC', {}).get('avg_quality', 0):.1f}"), ] for i, (label, value) in enumerate(metrics): _add_metric_card(slide, Inches(0.6) + i * (card_w + gap), metric_y, card_w, Inches(1.3), label, value) _add_text_card(slide, Inches(0.6), Inches(4.9), Inches(12.0), Inches(1.2), "Generated", datetime.now().strftime("%d %B %Y")) # Executive summary slide = prs.slides.add_slide(blank) _slide_title(slide, "Executive Summary", "What leaders should know right now") _add_chart(slide, results.charts.get("quality_mix"), Inches(0.55), Inches(1.1), Inches(5.9)) _add_chart(slide, results.charts.get("project_quadrant"), Inches(6.75), Inches(1.1), Inches(5.95)) _add_bullets(slide, Inches(0.6), Inches(4.55), Inches(6.0), Inches(2.2), "Key Messages", trends.get("executive_summary", [])[:4], accent=SKY_BLUE) _add_bullets(slide, Inches(6.8), Inches(4.55), Inches(5.9), Inches(2.2), "Priority Actions", results.recommendations[:4], accent=RED) # Events hotspots slide = prs.slides.add_slide(blank) _slide_title(slide, "Event Hotspots", "Where event burden and serious consequences are concentrated") _add_chart(slide, results.charts.get("serious_hotspots"), Inches(0.55), Inches(1.05), Inches(6.0)) _add_chart(slide, results.charts.get("events_monthly"), Inches(6.75), Inches(1.05), Inches(5.95)) event_notes = [] if ev.get("serious_projects"): top_p = next(iter(ev["serious_projects"].items())) event_notes.append(f"Highest serious-event project: {top_p[0]} ({top_p[1]} serious events)") if ev.get("serious_time_buckets"): top_t = next(iter(ev["serious_time_buckets"].items())) event_notes.append(f"Most common serious-event timing: {top_t[0]} ({top_t[1]} events)") if ev.get("motor_vehicle", {}).get("count", 0): event_notes.append( f"Motor vehicle events: {ev['motor_vehicle']['count']} total, " f"{ev['motor_vehicle'].get('serious_count', 0)} moderate+" ) _add_bullets(slide, Inches(0.6), Inches(4.75), Inches(12.0), Inches(1.9), "Leadership Focus", event_notes, accent=SKY_BLUE) # Leading activity quality slide = prs.slides.add_slide(blank) _slide_title(slide, "Leading Activity Quality", "Whether LLC, CCC, and OCC records look meaningful and actionable") _add_chart(slide, results.charts.get("quality_trend"), Inches(0.55), Inches(1.0), Inches(6.0)) _add_chart(slide, results.charts.get("quality_mix"), Inches(6.75), Inches(1.0), Inches(5.95)) quality_notes = [] for atype in ["CCC", "OCC", "LLC"]: insight = trends.get("activity_insights", {}).get(atype, {}) if insight: quality_notes.append( f"{atype}: quality {insight.get('avg_quality', 0):.1f}/100, shallow {insight.get('shallow_pct', 0):.1f}%, " f"follow-up {insight.get('follow_up_pct', 0):.1f}%" ) _add_bullets(slide, Inches(0.6), Inches(4.85), Inches(12.0), Inches(1.7), "Quality Readout", quality_notes, accent=SKY_BLUE) # Projects and locations slide = prs.slides.add_slide(blank) _slide_title(slide, "Projects and Locations", "Which areas appear strongest and which need direct intervention") _add_chart(slide, results.charts.get("project_quadrant"), Inches(0.55), Inches(1.05), Inches(6.0)) _add_chart(slide, results.charts.get("low_value_units"), Inches(6.75), Inches(1.05), Inches(5.95)) proj_watch = results.se_events_rel.get("project_comparison", {}).get("watch", [])[:3] loc_watch = results.se_events_rel.get("location_comparison", {}).get("watch", [])[:3] watch_items = [ f"Project watch: {r.get('project')} | events {r.get('events')} | serious {r.get('serious_events')}" for r in proj_watch ] + [ f"Location watch: {r.get('location')} | events {r.get('events')} | serious {r.get('serious_events')}" for r in loc_watch ] _add_bullets(slide, Inches(0.6), Inches(4.85), Inches(12.0), Inches(1.7), "Watchlist", watch_items[:6], accent=RED) # Recommendations slide = prs.slides.add_slide(blank) _slide_title(slide, "Recommended Actions", "Executive actions generated from the analysis") left_items = results.recommendations[:5] right_items = results.recommendations[5:10] _add_bullets(slide, Inches(0.6), Inches(1.15), Inches(5.8), Inches(5.7), "Immediate Priorities", left_items, accent=RED) _add_bullets(slide, Inches(6.8), Inches(1.15), Inches(5.8), Inches(5.7), "Next Priorities", right_items, accent=SKY_BLUE) output_path = os.path.join( output_dir, f"SHEQ_Safety_Performance_{datetime.now().strftime('%Y%m%d_%H%M')}.pptx", ) prs.save(output_path) log.info("PPTX saved to %s", output_path) return output_path