| """ |
| 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") |
|
|
|
|
| |
| |
| |
|
|
| PAGE_HTML = """ |
| <div class="book-page" style="page-break-after: always;"> |
| <div class="page-art"> |
| <img src="{img_b64}" alt="Page {page_num}: {alt_text}"/> |
| </div> |
| <p class="page-text">{text}</p> |
| <span class="page-num">~ {page_num} ~</span> |
| </div> |
| """ |
|
|
| COVER_HTML = """ |
| <div class="book-cover"> |
| <p class="cover-kicker">β¨ a DoodleBook story β¨</p> |
| {cover_art} |
| <h2 class="cover-title">{title}</h2> |
| {badge} |
| </div> |
| """ |
|
|
| ENGINE_BADGES = { |
| "flux": '<span class="engine-badge flux">illustrated by FLUX.2-klein</span>', |
| "sketch": '<span class="engine-badge sketch">preview sketch β connect a GPU for FLUX art</span>', |
| } |
|
|
|
|
| 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'<div class="cover-art">' |
| f'<img src="{_img_src_for_bytes(pages_images[0])}" alt="Cover illustration"/>' |
| f'</div>' |
| ) |
| 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"""<div class="book-container"> |
| {cover} |
| {pages_html} |
| </div>""" |
|
|
|
|
| |
| |
| |
|
|
| 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'<div class="ml-msg" style="animation-delay:{delay:.1f}s;">{m}</div>\n' |
| ) |
|
|
| return f""" |
| <div class="magic-loader"> |
| <div class="ml-spinner"></div> |
| <div class="ml-stack"> |
| {items} |
| </div> |
| <style> |
| .magic-loader {{ |
| text-align:center; padding:36px 20px; font-family:'Gaegu',cursive; |
| font-size:22px; color:#2e2a26; position:relative; min-height:120px; |
| }} |
| .ml-spinner {{ |
| width:36px; height:36px; margin:0 auto 14px; |
| border:4px solid #efe0c2; border-top-color:#ef6a3a; |
| border-radius:50%; animation:ml-spin 1s linear infinite; |
| }} |
| @keyframes ml-spin {{ to {{ transform:rotate(360deg); }} }} |
| .ml-stack {{ position:relative; height:64px; }} |
| .ml-msg {{ |
| position:absolute; inset:0; display:flex; align-items:center; |
| justify-content:center; text-align:center; padding:0 10px; |
| opacity:0; animation:ml-cycle {total:.1f}s infinite; |
| }} |
| @keyframes ml-cycle {{ |
| 0%,24% {{ opacity:1; }} |
| 33%,100% {{ opacity:0; }} |
| }} |
| @media (prefers-reduced-motion:reduce) {{ |
| .ml-spinner {{ animation:none; }} |
| .ml-msg {{ animation:none; opacity:1; position:static; margin:6px 0; }} |
| }} |
| </style> |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
|
|
| 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 = (255, 248, 230) |
| img = Image.new("RGB", (W, H), cream) |
| draw = ImageDraw.Draw(img) |
| _paper_grain(img, amount=5000) |
|
|
| |
| font_kicker = _load_font(FONT_CAVEAT, 66) |
| draw.text((W // 2, 118), "β¨ a DoodleBook story β¨", |
| font=font_kicker, fill=berry, anchor="mm") |
|
|
| |
| 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) |
|
|
| |
| pad_inner, pad_outer = 12, 16 |
| pad = pad_inner + pad_outer |
| ix, iy = (W - 900) // 2, 200 |
| |
| draw.rounded_rectangle( |
| [ix - pad, iy - pad, ix + 900 + pad, iy + 900 + pad], |
| radius=30, fill=ink, |
| ) |
| |
| draw.rounded_rectangle( |
| [ix - pad_inner, iy - pad_inner, ix + 900 + pad_inner, iy + 900 + pad_inner], |
| radius=20, fill=sun, |
| ) |
| |
| img.paste(src, (ix, iy)) |
| draw = ImageDraw.Draw(img) |
|
|
| |
| 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 |
|
|
| |
| 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 = (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_COVER_HTML = """ |
| <div class="book-cover coloring-cover"> |
| <p class="cover-kicker">a DoodleBook coloring book</p> |
| <h2 class="cover-title">{title}</h2> |
| <span class="engine-badge sketch">a coloring book to color in</span> |
| </div> |
| """ |
|
|
| COLORING_PAGE_HTML = """ |
| <div class="book-page" style="page-break-after: always;"> |
| <div class="page-art coloring-art"> |
| <img src="{img_b64}" alt="Coloring page {page_num}"/> |
| </div> |
| <p class="page-text coloring-text">{text}</p> |
| <span class="page-num">~ {page_num} ~</span> |
| </div> |
| """ |
|
|
|
|
| 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"""<div class="book-container"> |
| {cover} |
| {pages_html} |
| </div>""" |
|
|
|
|
| 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)}" |
|
|
|
|
| |
| |
| |
|
|
| 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_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_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 |
|
|