Spaces:
Running
Running
| 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() | |