"""Public PNG thumbnail renderer. The model mirrors the React editor style: a fixed-size canvas plus absolutely-positioned elements. Pillow renders that model directly for the public Flask endpoint. """ from __future__ import annotations import io import ipaddress import os import socket from dataclasses import dataclass from typing import Iterable from urllib.parse import urlparse import requests from PIL import Image, ImageDraw, ImageFont, ImageOps CANVAS_SIZE = (1920, 1080) _IMAGE_TIMEOUT_SECONDS = 8 _MAX_IMAGE_BYTES = 12 * 1024 * 1024 @dataclass(frozen=True) class ThumbnailParams: class_name: str chapter_num: str chapter_title: str chapter_title2: str = "" image_url: str = "" year: str = "2082" template: str = "default" def render_thumbnail_png(params: ThumbnailParams) -> bytes: """Render a 1920x1080 PNG for query-string driven thumbnail requests.""" base = Image.new("RGB", CANVAS_SIZE, "#f4c400") draw = ImageDraw.Draw(base) photo = _fetch_image(params.image_url) if photo is None: photo = _placeholder_image() photo = ImageOps.fit(photo.convert("RGB"), (760, 760), method=Image.Resampling.LANCZOS) base.paste(photo, (1050, 160)) _draw_decor(base, draw) _draw_text_layout(draw, params) out = io.BytesIO() base.save(out, format="PNG", optimize=True) return out.getvalue() def _draw_decor(base: Image.Image, draw: ImageDraw.ImageDraw) -> None: draw.rounded_rectangle((90, 110, 975, 910), radius=28, fill="#fff3c4") draw.rounded_rectangle((110, 130, 955, 890), radius=22, outline="#ef4444", width=8) draw.rectangle((0, 0, CANVAS_SIZE[0], 70), fill="#e11d48") draw.rectangle((0, CANVAS_SIZE[1] - 70, CANVAS_SIZE[0], CANVAS_SIZE[1]), fill="#e11d48") draw.polygon([(990, 120), (1840, 120), (1770, 930), (1030, 930)], fill="#dc2626") draw.polygon([(1030, 150), (1800, 150), (1735, 900), (1070, 900)], fill="#991b1b") mask = Image.new("L", CANVAS_SIZE, 0) mask_draw = ImageDraw.Draw(mask) mask_draw.rounded_rectangle((1040, 150, 1820, 930), radius=34, fill=255) shadow = Image.new("RGB", CANVAS_SIZE, "#7f1d1d") base.paste(shadow, mask=mask.point(lambda p: int(p * 0.25))) def _draw_text_layout(draw: ImageDraw.ImageDraw, params: ThumbnailParams) -> None: font_bold = _font(86, bold=True) font_title = _font(116, bold=True) font_title2 = _font(104, bold=True) font_badge = _font(54, bold=True) font_small = _font(44, bold=True) draw.rounded_rectangle((145, 175, 450, 270), radius=18, fill="#dc2626") draw.text((178, 194), f"Class {params.class_name}", font=font_badge, fill="#ffffff") draw.rounded_rectangle((520, 175, 910, 270), radius=18, fill="#111827") draw.text((555, 194), f"Year {params.year}", font=font_badge, fill="#ffffff") draw.text((150, 350), f"Chapter {params.chapter_num}", font=font_bold, fill="#111827") _center_text(draw, params.chapter_title, (140, 460, 930, 600), font_title, "#dc2626") if params.chapter_title2: _center_text(draw, params.chapter_title2, (140, 610, 930, 750), font_title2, "#dc2626") draw.rounded_rectangle((200, 785, 875, 865), radius=18, fill="#dc2626") _center_text(draw, "Complete Nepali Guide", (200, 792, 875, 858), font_small, "#ffffff") def _center_text( draw: ImageDraw.ImageDraw, text: str, box: tuple[int, int, int, int], font: ImageFont.FreeTypeFont | ImageFont.ImageFont, fill: str, ) -> None: bbox = draw.textbbox((0, 0), text, font=font) width = bbox[2] - bbox[0] height = bbox[3] - bbox[1] x = box[0] + ((box[2] - box[0]) - width) / 2 y = box[1] + ((box[3] - box[1]) - height) / 2 - bbox[1] draw.text((x, y), text, font=font, fill=fill) def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: candidates: Iterable[str] = ( os.environ.get("THUMBNAIL_FONT_PATH") or "", r"C:\Windows\Fonts\mangalb.ttf" if bold else r"C:\Windows\Fonts\mangal.ttf", r"C:\Windows\Fonts\Nirmala.ttc", "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Bold.ttf" if bold else "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ) for path in candidates: if path and os.path.isfile(path): return ImageFont.truetype(path, size=size) return ImageFont.load_default() def _fetch_image(url: str) -> Image.Image | None: parsed = urlparse((url or "").strip()) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return None if _is_private_host(parsed.hostname or ""): return None try: response = requests.get(url, timeout=_IMAGE_TIMEOUT_SECONDS, stream=True) response.raise_for_status() content_type = response.headers.get("content-type", "") if content_type and not content_type.lower().startswith("image/"): return None data = bytearray() for chunk in response.iter_content(64 * 1024): data.extend(chunk) if len(data) > _MAX_IMAGE_BYTES: return None return Image.open(io.BytesIO(data)) except Exception: return None def _is_private_host(hostname: str) -> bool: try: addresses = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) except OSError: return True for addr in addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast: return True return False def _placeholder_image() -> Image.Image: image = Image.new("RGB", (760, 760), "#fde68a") draw = ImageDraw.Draw(image) draw.rounded_rectangle((70, 70, 690, 690), radius=44, fill="#f97316") draw.ellipse((210, 150, 550, 490), fill="#fff7ed") draw.rectangle((170, 520, 590, 620), fill="#fff7ed") return image