""" Book builder — assembles pages into storybook HTML and PDF. Features: - Magic Loader: animated wait messages during generation - Coloring Book: outline pages from same FLUX images - Styled PDF covers: scrapbook-style cover matching on-screen UI """ import io import logging import math import os import tempfile import urllib.parse from fpdf import FPDF from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps from config import COLORS logger = logging.getLogger(__name__) FONTS_DIR = os.path.join(os.path.dirname(__file__), "assets", "fonts") FONT_GAEGU = os.path.join(FONTS_DIR, "Gaegu-Bold.ttf") FONT_CAVEAT = os.path.join(FONTS_DIR, "Caveat.ttf") # ============================================================================ # STORYBOOK HTML # ============================================================================ PAGE_HTML = """
Page {page_num}: {alt_text}

{text}

~ {page_num} ~
""" COVER_HTML = """

✨ a DoodleBook story ✨

{cover_art}

{title}

{badge}
""" ENGINE_BADGES = { "flux": 'illustrated by FLUX.2-klein', "sketch": 'preview sketch — connect a GPU for FLUX art', } def build_book_html( pages_images: list, pages_texts: list, title: str, engine: str = "flux", ) -> str: """Build storybook HTML from images and texts.""" badge = ENGINE_BADGES.get(engine, "") cover_art = "" if pages_images: cover_art = ( f'
' f'Cover illustration' f'
' ) cover = COVER_HTML.format(title=title, badge=badge, cover_art=cover_art) pages_html = "" for i, (img_bytes, text) in enumerate(zip(pages_images, pages_texts)): pages_html += PAGE_HTML.format( img_b64=_img_src_for_bytes(img_bytes), text=text, page_num=i + 1, alt_text=(text[:50] + "...") if len(text) > 50 else text, ) return f"""
{cover} {pages_html}
""" # ============================================================================ # MAGIC LOADER (Feature 1) # ============================================================================ def magic_loader_html(stage: str = "story", hero_name: str = "your hero") -> str: """ Animated crayon-styled wait panel with rotating messages. Pure CSS fade cycle — no JS, no Gradio streaming needed. stage: "story" | "images" | "tts" — controls which messages show. """ messages = { "story": [ f"✏️ MiniCPM is dreaming up {hero_name}'s story\u2026", "📖 Writing page by page\u2026", "💡 Did you know? Your whole storybook runs on tiny models!", ], "images": [ f"🎨 FLUX is painting {hero_name}'s 6 pages\u2026", "🖌️ Adding crayon details\u2026", "💡 Did you know? The brain is only 3B parameters!", ], "tts": [ "🔊 VoxCPM is recording the narration\u2026", "🎙️ Warming up the storyteller voice\u2026", "💡 Did you know? The voice runs on a 2B model!", ], } msgs = messages.get(stage, messages["story"]) n = len(msgs) step = 3.0 total = step * n items = "" for i, m in enumerate(msgs): delay = i * step items += ( f'
{m}
\n' ) return f"""
{items}
""" # ============================================================================ # STYLIZED PDF COVER (Feature 3) # ============================================================================ def _load_font(path: str, size: int): """Load TrueType font with fallback to default.""" try: return ImageFont.truetype(path, size) except Exception: try: return ImageFont.truetype("arial.ttf", size) except Exception: return ImageFont.load_default() def _paper_grain(img, rng_seed=42, amount=2000): """Sprinkle subtle specks so cover reads as paper.""" import random rng = random.Random(rng_seed) px = img.load() w, h = img.size for _ in range(amount): x = rng.randint(0, w - 1) y = rng.randint(0, h - 1) r, g, b = px[x, y] s = rng.choice([-10, -6, 6, 10]) px[x, y] = ( max(0, min(255, r + s)), max(0, min(255, g + s)), max(0, min(255, b + s)), ) def _wrap_title(title: str, font, max_w: int) -> list: words = title.split() lines, current = [], "" for w in words: test = f"{current} {w}".strip() if font.getbbox(test)[2] - font.getbbox(test)[0] > max_w: if current: lines.append(current) current = w else: current = test if current: lines.append(current) return lines or [title] def render_cover_image( title: str, badge_text: str = "illustrated by FLUX.2-klein", kind: str = "story", first_page_bytes: bytes = None, ) -> bytes: """ Render a full-page illustrated cover as PNG bytes (A4 @150dpi, 1240x1754). When first_page_bytes is provided (the FLUX page 1 illustration), it fills the cover as a full-bleed background with dark banner overlays for the title. Falls back to cream-background design when no illustration is available. """ W, H = 1240, 1754 sun = (244, 198, 74) leaf = (116, 184, 90) white = (255, 255, 255) ink = (46, 42, 38) berry = (214, 81, 122) if first_page_bytes: # --- Cream card cover: same layout as the in-app HTML cover --- cream = (255, 248, 230) img = Image.new("RGB", (W, H), cream) draw = ImageDraw.Draw(img) _paper_grain(img, amount=5000) # Kicker font_kicker = _load_font(FONT_CAVEAT, 66) draw.text((W // 2, 118), "✨ a DoodleBook story ✨", font=font_kicker, fill=berry, anchor="mm") # FLUX illustration — square-crop & resize to 900 px src = Image.open(io.BytesIO(first_page_bytes)).convert("RGB") min_d = min(src.width, src.height) src = src.crop(((src.width - min_d) // 2, (src.height - min_d) // 2, (src.width + min_d) // 2, (src.height + min_d) // 2)) src = src.resize((900, 900), Image.LANCZOS) # Frame: sun inner (12 px) + ink outer (16 px), rounded corners pad_inner, pad_outer = 12, 16 pad = pad_inner + pad_outer ix, iy = (W - 900) // 2, 200 # top-left of image # Ink outer border draw.rounded_rectangle( [ix - pad, iy - pad, ix + 900 + pad, iy + 900 + pad], radius=30, fill=ink, ) # Sun inner border draw.rounded_rectangle( [ix - pad_inner, iy - pad_inner, ix + 900 + pad_inner, iy + 900 + pad_inner], radius=20, fill=sun, ) # Paste illustration img.paste(src, (ix, iy)) draw = ImageDraw.Draw(img) # Title — below frame font_title = _load_font(FONT_GAEGU, 100) lines = _wrap_title(title, font_title, W - 160) line_h = 118 y0 = iy + 900 + pad + 56 for i, line in enumerate(lines): cy = y0 + i * line_h draw.text((W // 2 + 3, cy + 3), line, font=font_title, fill=sun, anchor="mm") draw.text((W // 2, cy), line, font=font_title, fill=ink, anchor="mm") total_h = len(lines) * line_h # Badge font_badge = _load_font(FONT_GAEGU, 46) bb = font_badge.getbbox(badge_text) tw, th = bb[2] - bb[0], bb[3] - bb[1] bx = W // 2 - (tw + 44) // 2 by = max(y0 + total_h + 60, H - 160) draw.rounded_rectangle([bx, by, bx + tw + 44, by + th + 24], radius=14, fill=leaf) draw.text((W // 2, by + th // 2 + 12), badge_text, font=font_badge, fill=white, anchor="mm") else: # --- Cream fallback cover --- cream = (255, 248, 230) img = Image.new("RGB", (W, H), cream) draw = ImageDraw.Draw(img) _paper_grain(img, amount=6000) font_kicker = _load_font(FONT_CAVEAT, 60) draw.text((W // 2, 520), "✨ a DoodleBook story ✨", font=font_kicker, fill=berry, anchor="mm") font_title = _load_font(FONT_GAEGU, 120) lines = _wrap_title(title, font_title, W - 200) total_h = len(lines) * 140 y0 = 680 for i, line in enumerate(lines): cy = y0 + i * 140 draw.text((W // 2 + 4, cy + 4), line, font=font_title, fill=sun, anchor="mm") draw.text((W // 2, cy), line, font=font_title, fill=ink, anchor="mm") font_badge = _load_font(FONT_GAEGU, 44) bb = font_badge.getbbox(badge_text) tw, th = bb[2] - bb[0], bb[3] - bb[1] bx = W // 2 - (tw + 40) // 2 by = y0 + total_h + 80 draw.rounded_rectangle([bx, by, bx + tw + 40, by + th + 24], radius=16, fill=leaf) draw.text((bx + 20 + tw // 2, by + 12 + th // 2), badge_text, font=font_badge, fill=white, anchor="mm") import random rng = random.Random(77) sx, sy = W // 2 - 180, y0 + total_h + 40 pts = [(sx + j * 10, sy + rng.randint(-5, 5)) for j in range(36)] draw.line(pts, fill=leaf, width=5, joint="curve") _paper_grain(img, amount=2000) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() # ============================================================================ # COLORING BOOK (Feature 2) # ============================================================================ COLORING_COVER_HTML = """

a DoodleBook coloring book

{title}

a coloring book to color in
""" COLORING_PAGE_HTML = """
Coloring page {page_num}

{text}

~ {page_num} ~
""" def build_coloring_html( outline_imgs: list, page_texts: list, title: str, ) -> str: """Build coloring-book HTML — same layout, outline images.""" cover = COLORING_COVER_HTML.format(title=title) pages_html = "" for i, (img_bytes, text) in enumerate(zip(outline_imgs, page_texts)): pages_html += COLORING_PAGE_HTML.format( img_b64=_img_src_for_bytes(img_bytes), text=text, page_num=i + 1, ) return f"""
{cover} {pages_html}
""" def _img_src_for_bytes(img_bytes: bytes) -> str: """Persist an image to temp and return a Gradio-served file URL. Inline base64 made the final HTML payload >10MB for a 6-page book, which caused the frontend to stall even though backend generation succeeded. File-backed URLs keep the final update small and let the browser fetch page images incrementally. """ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp.write(img_bytes) path = tmp.name return f"/gradio_api/file={urllib.parse.quote(path)}" # ============================================================================ # PDF EXPORT # ============================================================================ def _pdf_cover_page(pdf: FPDF, cover_png_bytes: bytes): """Place a styled PNG cover as the current PDF page.""" img = Image.open(io.BytesIO(cover_png_bytes)) with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: img.save(tmp.name, "PNG") pdf.image(tmp.name, x=0, y=0, w=210, h=297) os.unlink(tmp.name) def _safe_text(s: str) -> str: """Make text safe for fpdf's Latin-1 core fonts. The page text comes from the story generator and can contain Unicode punctuation (em-dash —, curly quotes, ellipsis). fpdf's Helvetica is Latin-1 only and RAISES on those, which previously killed PDF generation and left the download button missing. Map the common ones, then drop any remaining non-Latin-1 char so a PDF is ALWAYS produced. """ repl = { "—": "-", "–": "-", "‒": "-", "‘": "'", "’": "'", "“": '"', "”": '"', "…": "...", " ": " ", "​": "", } for k, v in repl.items(): s = s.replace(k, v) return s.encode("latin-1", "replace").decode("latin-1") def export_pdf( pages_images: list, pages_texts: list, title: str, output_path: str = None, ) -> str: """Export storybook as PDF with styled cover.""" if output_path is None: output_path = tempfile.mktemp(suffix=".pdf", prefix="doodlebook_") pdf = FPDF() pdf.set_auto_page_break(auto=False) # Cover: full-bleed FLUX illustration when available cover_png = render_cover_image( title, "illustrated by FLUX.2-klein", "story", first_page_bytes=pages_images[0] if pages_images else None, ) pdf.add_page() _pdf_cover_page(pdf, cover_png) for i, (img_bytes, text) in enumerate(zip(pages_images, pages_texts)): pdf.add_page() img = Image.open(io.BytesIO(img_bytes)) with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: img.save(tmp.name, "PNG") pdf.image(tmp.name, x=10, y=10, w=190) os.unlink(tmp.name) pdf.set_y(210) pdf.set_font("Helvetica", "", 16) pdf.multi_cell(0, 10, _safe_text(text), align="C") pdf.set_y(270) pdf.set_font("Helvetica", "I", 10) pdf.cell(0, 10, f"Page {i+1}", ln=True, align="C") pdf.output(output_path) return output_path def export_coloring_pdf( outline_imgs: list, page_texts: list, title: str, output_path: str = None, ) -> str: """Export coloring book as PDF with styled cover.""" if output_path is None: output_path = tempfile.mktemp(suffix=".pdf", prefix="doodlebook_coloring_") pdf = FPDF() pdf.set_auto_page_break(auto=False) # Cover: first coloring-page illustration as full-bleed background cover_png = render_cover_image( title, "a coloring book to color in", "coloring", first_page_bytes=outline_imgs[0] if outline_imgs else None, ) pdf.add_page() _pdf_cover_page(pdf, cover_png) for i, (img_bytes, text) in enumerate(zip(outline_imgs, page_texts)): pdf.add_page() img = Image.open(io.BytesIO(img_bytes)) with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: img.save(tmp.name, "PNG") pdf.image(tmp.name, x=10, y=10, w=190) os.unlink(tmp.name) pdf.set_y(210) pdf.set_font("Helvetica", "", 14) pdf.multi_cell(0, 10, _safe_text(text), align="C") pdf.set_y(270) pdf.set_font("Helvetica", "I", 10) pdf.cell(0, 10, f"Page {i+1}", ln=True, align="C") pdf.output(output_path) return output_path