import io from typing import List, Tuple, Dict, Any, Optional from pptx import Presentation from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE from PIL import Image import matplotlib.pyplot as plt def _add_logo(prs: Presentation, slide, logo_bytes: Optional[bytes]): if not logo_bytes: return img = Image.open(io.BytesIO(logo_bytes)).convert("RGBA") # Resize to fit width<=2.0in, height<=1.0in max_w, max_h = Inches(2.0), Inches(1.0) w, h = img.size ratio = min(max_w / max(w, 1), max_h / max(h, 1)) new_size = (max(1, int(w * ratio)), max(1, int(h * ratio))) resized = img.resize(new_size) b = io.BytesIO() resized.save(b, format="PNG") b.seek(0) # Place top-right with small margin left = prs.slide_width - max_w - Inches(0.5) top = Inches(0.2) slide.shapes.add_picture(b, left, top) def _apply_theme_bg(slide, rgb): fill = slide.background.fill fill.solid() fill.fore_color.rgb = RGBColor(*rgb) def _title_slide(prs, title_text: str, theme_rgb, logo_bytes): slide_layout = prs.slide_layouts[0] # Title slide slide = prs.slides.add_slide(slide_layout) title = slide.shapes.title subtitle = slide.placeholders[1] title.text = title_text subtitle.text = "自動生成プレゼンテーション" # Accent band _apply_theme_bg(slide, theme_rgb) # White rounded rectangle for readability left = Inches(0.6) top = Inches(1.8) width = prs.slide_width - Inches(1.2) height = Inches(2.2) box = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height) box.fill.solid() box.fill.fore_color.rgb = RGBColor(255, 255, 255) box.line.color.rgb = RGBColor(0, 0, 0) box.line.transparency = 0.8 # Reposition title title.left = left + Inches(0.3) title.top = top + Inches(0.3) title.width = width - Inches(0.6) title.height = Inches(1.4) for p in title.text_frame.paragraphs: p.font.size = Pt(40) p.font.bold = True # Subtitle subtitle.left = left + Inches(0.3) subtitle.top = top + Inches(1.6) subtitle.width = width - Inches(0.6) subtitle.height = Inches(0.8) for p in subtitle.text_frame.paragraphs: p.font.size = Pt(16) p.font.bold = False _add_logo(prs, slide, logo_bytes) def _summary_slide(prs, summary: str): if not summary: return slide = prs.slides.add_slide(prs.slide_layouts[1]) # Title and Content slide.shapes.title.text = "エグゼクティブサマリー" tf = slide.placeholders[1].text_frame tf.clear() # Split summary into bullet-ish lines lines = [ln.strip() for ln in summary.splitlines() if ln.strip()] if not lines: lines = [summary] for i, ln in enumerate(lines): p = tf.add_paragraph() if i > 0 else tf.paragraphs[0] p.text = ln p.level = 0 def _section_slide(prs, title: str, bullets: List[str]): slide = prs.slides.add_slide(prs.slide_layouts[1]) slide.shapes.title.text = title[:90] tf = slide.placeholders[1].text_frame tf.clear() if not bullets: bullets = ["(要点なし)"] for i, b in enumerate(bullets[:12]): p = tf.add_paragraph() if i > 0 else tf.paragraphs[0] p.text = b p.level = 0 def _table_slide(prs, title: str, pairs: List[tuple]): slide = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only slide.shapes.title.text = title rows = len(pairs) + 1 cols = 2 left = Inches(0.5) top = Inches(1.8) width = prs.slide_width - Inches(1.0) height = prs.slide_height - Inches(2.6) table = slide.shapes.add_table(rows, cols, left, top, width, height).table table.cell(0, 0).text = "項目" table.cell(0, 1).text = "値" for r, (k, v) in enumerate(pairs, start=1): table.cell(r, 0).text = str(k) table.cell(r, 1).text = str(v) def _chart_slide(prs, title: str, series: List[tuple]): slide = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only slide.shapes.title.text = title # Build bar chart via matplotlib and embed as image labels = [x[0] for x in series] values = [x[1] for x in series] fig = plt.figure(figsize=(8, 4.5)) plt.bar(range(len(values)), values) plt.xticks(range(len(labels)), labels, rotation=20, ha='right') plt.tight_layout() buf = io.BytesIO() fig.savefig(buf, format='png', dpi=200) plt.close(fig) buf.seek(0) left = Inches(0.5) top = Inches(1.6) width = prs.slide_width - Inches(1.0) height = prs.slide_height - Inches(2.2) slide.shapes.add_picture(buf, left, top, width=width, height=height) def _add_footer(prs, theme_rgb): for idx, slide in enumerate(prs.slides, start=1): left = Inches(0.3) top = prs.slide_height - Inches(0.4) width = prs.slide_width - Inches(0.6) height = Inches(0.3) shp = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, left, top, width, height) shp.fill.solid() shp.fill.fore_color.rgb = RGBColor(*theme_rgb) shp.line.fill.background() # Slide number text box tx = slide.shapes.add_textbox(prs.slide_width - Inches(1.0), top - Inches(0.05), Inches(0.8), Inches(0.3)) tf = tx.text_frame p = tf.paragraphs[0] p.text = f"{idx}" p.font.size = Pt(10) p.alignment = PP_ALIGN.RIGHT def build_presentation(output_path: str, title: str, theme_rgb: tuple, logo_bytes: Optional[bytes], executive_summary: Optional[str], sections: List[Tuple[str, str]], bullets_by_section: Dict[int, List[str]], tables: List[Dict[str, Any]], charts: List[Dict[str, Any]]): prs = Presentation() # Title slide _title_slide(prs, title, theme_rgb, logo_bytes) # Summary _summary_slide(prs, executive_summary) # Sections for idx, (sec_title, _body) in enumerate(sections): bullets = bullets_by_section.get(idx, []) _section_slide(prs, sec_title, bullets) # Tables for tbl in tables: _table_slide(prs, tbl.get("title", "表"), tbl.get("pairs", [])) # Charts for ch in charts: _chart_slide(prs, ch.get("title", "チャート"), ch.get("series", [])) _add_footer(prs, theme_rgb) prs.save(output_path)