import io import os import tempfile import urllib.request import urllib.parse from pptx import Presentation from pptx.util import Inches, Pt, Emu from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN from pptx.oxml.ns import qn from lxml import etree SLIDE_W = Inches(13.33) SLIDE_H = Inches(7.5) THEMES = { "Professional": { "primary": RGBColor(0x1B, 0x6C, 0xA8), "secondary": RGBColor(0x19, 0xA8, 0x8A), "dark": RGBColor(0x0D, 0x1B, 0x2A), "light": RGBColor(0xF4, 0xF8, 0xFF), "white": RGBColor(0xFF, 0xFF, 0xFF), "text_dark": RGBColor(0x0D, 0x1B, 0x2A), "text_muted": RGBColor(0x5A, 0x7A, 0x9A), }, "Creative": { "primary": RGBColor(0x8B, 0x5C, 0xF6), "secondary": RGBColor(0xF5, 0x9E, 0x0B), "dark": RGBColor(0x1E, 0x1B, 0x4B), "light": RGBColor(0xF9, 0xF7, 0xFF), "white": RGBColor(0xFF, 0xFF, 0xFF), "text_dark": RGBColor(0x1E, 0x1B, 0x4B), "text_muted": RGBColor(0x6D, 0x28, 0xD9), }, "Academic": { "primary": RGBColor(0x15, 0x56, 0x24), "secondary": RGBColor(0xD9, 0x77, 0x06), "dark": RGBColor(0x0F, 0x2D, 0x17), "light": RGBColor(0xF2, 0xFB, 0xF3), "white": RGBColor(0xFF, 0xFF, 0xFF), "text_dark": RGBColor(0x0F, 0x2D, 0x17), "text_muted": RGBColor(0x3A, 0x6A, 0x4A), }, "Startup": { "primary": RGBColor(0xEF, 0x44, 0x44), "secondary": RGBColor(0x06, 0xB6, 0xD4), "dark": RGBColor(0x09, 0x09, 0x0B), "light": RGBColor(0xFA, 0xFA, 0xFC), "white": RGBColor(0xFF, 0xFF, 0xFF), "text_dark": RGBColor(0x09, 0x09, 0x0B), "text_muted": RGBColor(0x55, 0x60, 0x70), }, } # ── Image fetching ───────────────────────────────────────────────────────── IMAGE_SOURCES = [ # loremflickr — keyword-based, reliable, no API key lambda kw, w, h: f"https://loremflickr.com/{w}/{h}/{urllib.parse.quote(kw)}", # picsum — random beautiful photo (fallback) lambda kw, w, h: f"https://picsum.photos/seed/{urllib.parse.quote(kw)}/{w}/{h}", ] def _fetch_image(keyword: str, width: int = 560, height: int = 420) -> str | None: if not keyword: return None for source_fn in IMAGE_SOURCES: try: url = source_fn(keyword.strip(), width, height) tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0 SlideAI/1.0"} ) with urllib.request.urlopen(req, timeout=10) as resp: data = resp.read() if len(data) > 2000: # real image, not an error page tmp.write(data) tmp.close() return tmp.name tmp.close() except Exception: continue return None # ── Drawing helpers ──────────────────────────────────────────────────────── def _blank_slide(prs): return prs.slides.add_slide(prs.slide_layouts[6]) def _fill(shape, color): shape.fill.solid() shape.fill.fore_color.rgb = color shape.line.fill.background() def _set_bg(slide, color): slide.background.fill.solid() slide.background.fill.fore_color.rgb = color def _rect(slide, l, t, w, h, color): s = slide.shapes.add_shape(1, l, t, w, h) _fill(s, color) return s def _oval(slide, l, t, w, h, color, alpha=0.15): s = slide.shapes.add_shape(9, l, t, w, h) _fill(s, color) try: sf = s.element.spPr.find(qn("a:solidFill")) if sf is not None: sc = sf.find(qn("a:srgbClr")) if sc is not None: ae = etree.SubElement(sc, qn("a:alpha")) ae.set("val", str(int(alpha * 100000))) except Exception: pass return s def _txbox(slide, text, l, t, w, h, size, bold, color, align=PP_ALIGN.LEFT, italic=False): tb = slide.shapes.add_textbox(l, t, w, h) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = align r = p.add_run() r.text = text r.font.name = "Calibri" r.font.size = Pt(size) r.font.bold = bold r.font.italic = italic r.font.color.rgb = color return tb def _spc(para, before_pt=0, after_pt=0): pPr = para._p.get_or_add_pPr() if before_pt: sb = etree.SubElement(pPr, qn("a:spcBef")) etree.SubElement(sb, qn("a:spcPts")).set("val", str(before_pt * 100)) if after_pt: sa = etree.SubElement(pPr, qn("a:spcAft")) etree.SubElement(sa, qn("a:spcPts")).set("val", str(after_pt * 100)) # ── Slide builders ───────────────────────────────────────────────────────── def _build_title_slide(prs, data, t, total): slide = _blank_slide(prs) _set_bg(slide, t["dark"]) # Background image with dark overlay img_path = _fetch_image(data.get("image_keyword", ""), 1200, 700) if img_path: try: pic = slide.shapes.add_picture(img_path, 0, 0, SLIDE_W, SLIDE_H) slide.shapes._spTree.remove(pic._element) slide.shapes._spTree.insert(2, pic._element) ov = _rect(slide, 0, 0, SLIDE_W, SLIDE_H, t["dark"]) sf = ov.element.spPr.find(qn("a:solidFill")) if sf is not None: sc = sf.find(qn("a:srgbClr")) if sc is not None: ae = etree.SubElement(sc, qn("a:alpha")) ae.set("val", "78000") except Exception: pass _oval(slide, Inches(9.5), Inches(-1.5), Inches(6), Inches(6), t["primary"], 0.22) _oval(slide, Inches(-0.8), Inches(4.8), Inches(3.5), Inches(3.5), t["secondary"], 0.18) _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"]) _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["primary"]) tb = slide.shapes.add_textbox(Inches(0.5), Inches(1.8), Inches(12), Inches(2.5)) tf = tb.text_frame; tf.word_wrap = True p = tf.paragraphs[0]; p.alignment = PP_ALIGN.LEFT r = p.add_run() r.text = data.get("title", "Presentation") r.font.name = "Calibri"; r.font.size = Pt(52) r.font.bold = True; r.font.color.rgb = t["white"] subtitle = data.get("subtitle", "") if subtitle: tb2 = slide.shapes.add_textbox(Inches(0.5), Inches(4.5), Inches(10), Inches(1)) tf2 = tb2.text_frame; tf2.word_wrap = True p2 = tf2.paragraphs[0]; p2.alignment = PP_ALIGN.LEFT r2 = p2.add_run() r2.text = subtitle r2.font.name = "Calibri Light"; r2.font.size = Pt(24) r2.font.color.rgb = t["secondary"] _rect(slide, Inches(0.5), Inches(6.2), Inches(2.4), Inches(0.5), t["primary"]) _txbox(slide, f"{total} slides", Inches(0.5), Inches(6.2), Inches(2.4), Inches(0.5), 11, False, t["white"], PP_ALIGN.CENTER) slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "") def _build_content_slide(prs, data, slide_num, total, t): slide = _blank_slide(prs) _set_bg(slide, t["light"]) img_path = _fetch_image(data.get("image_keyword", ""), 560, 430) has_image = img_path is not None # ── Header band ────────────────────────────────────────────────────── _rect(slide, 0, 0, SLIDE_W, Inches(1.4), t["primary"]) _rect(slide, SLIDE_W - Inches(1.4), 0, Inches(1.4), Inches(1.4), t["secondary"]) _txbox(slide, f"{slide_num:02d}", SLIDE_W - Inches(1.4), Inches(0.05), Inches(1.4), Inches(1.3), 36, True, t["white"], PP_ALIGN.CENTER) _txbox(slide, data.get("title", ""), Inches(0.5), Inches(0.12), SLIDE_W - Inches(2.2), Inches(1.15), 26, True, t["white"]) # ── Left accent ─────────────────────────────────────────────────────── _rect(slide, 0, Inches(1.4), Inches(0.06), SLIDE_H - Inches(1.75), t["secondary"]) # ── Bullets ─────────────────────────────────────────────────────────── bullets = data.get("bullets", []) text_w = Inches(7.6) if has_image else Inches(12.8) cb = slide.shapes.add_textbox(Inches(0.3), Inches(1.6), text_w, Inches(5.55)) tf = cb.text_frame tf.word_wrap = True ICONS = ["①", "②", "③", "④", "⑤", "⑥", "⑦"] for idx, bullet in enumerate(bullets[:7]): p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph() p.alignment = PP_ALIGN.LEFT if idx > 0: _spc(p, before_pt=5) rn = p.add_run() rn.text = f"{ICONS[idx]} " rn.font.name = "Segoe UI" rn.font.size = Pt(17) rn.font.bold = True rn.font.color.rgb = t["primary"] rt = p.add_run() rt.text = bullet rt.font.name = "Calibri" rt.font.size = Pt(17) rt.font.bold = False rt.font.color.rgb = t["text_dark"] # ── Image panel ─────────────────────────────────────────────────────── if has_image: il = Inches(8.2); it = Inches(1.55) iw = Inches(4.85); ih = Inches(5.55) # Border frame _rect(slide, il - Inches(0.07), it - Inches(0.07), iw + Inches(0.14), ih + Inches(0.14), t["secondary"]) try: slide.shapes.add_picture(img_path, il, it, iw, ih) except Exception: _rect(slide, il, it, iw, ih, t["primary"]) _txbox(slide, data.get("image_keyword", "").title(), il + Inches(0.3), it + Inches(2.2), iw - Inches(0.6), Inches(1), 14, False, t["white"], PP_ALIGN.CENTER) # ── Footer ──────────────────────────────────────────────────────────── _rect(slide, 0, SLIDE_H - Inches(0.32), SLIDE_W, Inches(0.32), t["primary"]) _txbox(slide, f"Slide {slide_num} of {total}", SLIDE_W - Inches(2.5), SLIDE_H - Inches(0.32), Inches(2.4), Inches(0.32), 9, False, t["white"], PP_ALIGN.RIGHT) slide.notes_slide.notes_text_frame.text = data.get("speaker_notes", "") def _build_closing_slide(prs, title, t): slide = _blank_slide(prs) _set_bg(slide, t["dark"]) _oval(slide, Inches(-1), Inches(-1), Inches(5), Inches(5), t["secondary"], 0.15) _oval(slide, Inches(10), Inches(3.5), Inches(5), Inches(5), t["primary"], 0.18) _rect(slide, 0, SLIDE_H - Inches(0.08), SLIDE_W, Inches(0.08), t["secondary"]) _rect(slide, 0, Inches(1.5), Inches(0.06), Inches(4.5), t["secondary"]) _txbox(slide, "Thank You", Inches(0.5), Inches(1.8), Inches(12), Inches(2), 60, True, t["white"]) _txbox(slide, title, Inches(0.5), Inches(4.1), Inches(10), Inches(0.8), 22, False, t["secondary"]) _rect(slide, Inches(0.5), Inches(5.1), Inches(3), Inches(0.05), t["primary"]) _txbox(slide, "Questions & Discussion", Inches(0.5), Inches(5.3), Inches(8), Inches(0.6), 16, False, t["white"]) # ── Entry point ──────────────────────────────────────────────────────────── def build_pptx(presentation_data: dict, style: str) -> bytes: t = THEMES.get(style, THEMES["Professional"]) prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H slides = presentation_data.get("slides", []) pres_title = presentation_data.get("title", "Presentation") total = len(slides) + 1 # +1 for closing slide title_slides = [s for s in slides if s.get("type") == "title"] content_slides = [s for s in slides if s.get("type") != "title"] for s in title_slides: _build_title_slide(prs, s, t, total) for i, s in enumerate(content_slides): _build_content_slide(prs, s, i + 2, total, t) _build_closing_slide(prs, pres_title, t) buf = io.BytesIO() prs.save(buf) buf.seek(0) return buf.getvalue()