""" pdf_export.py — Build an illustrated PDF storybook with reportlab. Returns the path to a temporary PDF file. """ import io import os import re import tempfile def _slugify(text: str) -> str: text = re.sub(r"[^\w\s-]", "", text.lower()) text = re.sub(r"[\s_-]+", "_", text).strip("_") return text[:40] def build_pdf( theme: str, hero: str, world: str, beats: list[str], images: list[bytes], ) -> str: """ Lay out beats + images as an A5 picture book. Returns path to a temporary PDF file (caller is responsible for cleanup). """ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY from reportlab.lib import colors from reportlab.lib.pagesizes import A5 from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import cm from reportlab.platypus import ( HRFlowable, Image as RLImage, Paragraph, SimpleDocTemplate, Spacer, ) slug = _slugify(f"{theme} {hero}") or "storyforge" tmp = tempfile.NamedTemporaryFile(prefix=f"{slug}_", suffix=".pdf", delete=False) tmp.close() W, H = A5 margin = 1.8 * cm body_w = W - 2 * margin doc = SimpleDocTemplate( tmp.name, pagesize=A5, leftMargin=margin, rightMargin=margin, topMargin=2.0 * cm, bottomMargin=2.0 * cm, ) gold = colors.HexColor("#d4a843") rust = colors.HexColor("#c85a33") tan = colors.HexColor("#ecdbb8") ink = colors.HexColor("#2c1810") h1 = ParagraphStyle("H1", fontName="Helvetica-Bold", fontSize=20, alignment=TA_CENTER, spaceAfter=4, leading=26, textColor=rust) sub = ParagraphStyle("Sub", fontName="Helvetica", fontSize=10, alignment=TA_CENTER, spaceAfter=3, textColor=colors.HexColor("#5a3e2b")) lbl = ParagraphStyle("Lbl", fontName="Helvetica-Bold", fontSize=8, alignment=TA_CENTER, textColor=rust, spaceAfter=4) body = ParagraphStyle("Body", fontName="Helvetica", fontSize=11, alignment=TA_JUSTIFY, leading=17, spaceAfter=6, textColor=ink) end_style = ParagraphStyle("End", fontName="Helvetica-Bold", fontSize=18, alignment=TA_CENTER, textColor=colors.HexColor("#4a8c5c")) story = [] # ── Cover ───────────────────────────────────────────────────────────────── story.append(Spacer(1, 1.2 * cm)) story.append(Paragraph("📖 StoryForge", h1)) story.append(Paragraph(theme, sub)) if hero: story.append(Paragraph(f"Hero: {hero}", sub)) if world: story.append(Paragraph(f"World: {world}", sub)) if images: story.append(Spacer(1, 0.4 * cm)) _img(story, images[0], body_w, 8 * cm) story.append(Spacer(1, 0.8 * cm)) story.append(HRFlowable(width="80%", color=gold, thickness=1.5)) story.append(Spacer(1, 0.8 * cm)) # ── Beats ───────────────────────────────────────────────────────────────── for i, beat_text in enumerate(beats): story.append(Paragraph(f"— Moment {i + 1} of {len(beats)} —", lbl)) img_bytes = images[i] if i < len(images) else None if img_bytes: _img(story, img_bytes, body_w, 6 * cm) story.append(Spacer(1, 0.25 * cm)) story.append(Paragraph(beat_text, body)) story.append(Spacer(1, 0.5 * cm)) story.append(HRFlowable(width="50%", color=tan, thickness=1)) story.append(Spacer(1, 0.5 * cm)) # ── The End ─────────────────────────────────────────────────────────────── story.append(Paragraph("✨ The End ✨", end_style)) doc.build(story) return tmp.name def _img(story, img_bytes: bytes, max_w: float, max_h: float): from PIL import Image as PILImage from reportlab.platypus import Image as RLImage pil = PILImage.open(io.BytesIO(img_bytes)).convert("RGB") w_px, h_px = pil.size aspect = h_px / w_px w = max_w h = w * aspect if h > max_h: h = max_h w = h / aspect buf = io.BytesIO() pil.save(buf, format="PNG") buf.seek(0) story.append(RLImage(buf, width=w, height=h))