"""Render a 1200x630 Verdict Card as PNG bytes using Pillow (pure-Python fallback). cairosvg/Cairo is not available on this host, so we draw directly with PIL.ImageDraw. The layout matches the SVG template specified in Day 6 GDD. """ from __future__ import annotations import io from PIL import Image, ImageDraw, ImageFont # ── colour palette ──────────────────────────────────────────────────────────── _BG = (14, 10, 8) # #0e0a08 _BORDER = (184, 135, 70) # #b88746 _TEXT_LIGHT = (241, 231, 208) # #f1e7d0 _TEXT_GOLD = (207, 163, 66) # #cfa342 _TEXT_MUTED = (138, 114, 80) # #8a7250 _STAMPS: dict[str, tuple[str, tuple[int, int, int]]] = { "just": ("JUSTICE SERVED", (31, 91, 43)), # #1f5b2b "wrongful": ("INNOCENT LOST", (110, 18, 18)), # #6e1212 "cold": ("CASE UNSOLVED", (42, 42, 42)), # #2a2a2a "mistrial": ("MISTRIAL", (138, 100, 41)), # #8a6429 } W, H = 1200, 630 def _font(size: int) -> ImageFont.ImageFont: """Return a PIL font at the requested size. Falls back to default.""" # Try common system serif paths on macOS / Linux candidates = [ "/System/Library/Fonts/Supplemental/Georgia.ttf", "/Library/Fonts/Georgia.ttf", "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", ] for path in candidates: try: return ImageFont.truetype(path, size) except (OSError, IOError): pass # Last resort: bitmap default (ignores size) return ImageFont.load_default() def _centered_text(draw: ImageDraw.ImageDraw, y: int, text: str, font: ImageFont.ImageFont, fill: tuple) -> None: """Draw text horizontally centred on the 1200-px canvas.""" bbox = draw.textbbox((0, 0), text, font=font) tw = bbox[2] - bbox[0] draw.text(((W - tw) // 2, y), text, font=font, fill=fill) def render_png( *, title: str, suspect: str | None, method: str, suspicion: float, shadow: float, outcome: str, ) -> bytes: """Return PNG bytes for a 1200×630 verdict card.""" stamp_text, stamp_fill = _STAMPS.get(outcome, ("UNKNOWN", (42, 42, 42))) img = Image.new("RGB", (W, H), _BG) draw = ImageDraw.Draw(img) # ── border ──────────────────────────────────────────────────────────────── draw.rectangle([40, 40, W - 40, H - 40], outline=_BORDER, width=3) # ── fonts ───────────────────────────────────────────────────────────────── f44 = _font(44) f28 = _font(28) f22 = _font(22) f14 = _font(14) f42 = _font(42) # ── title ───────────────────────────────────────────────────────────────── _centered_text(draw, 90, title, f44, _TEXT_LIGHT) # ── convicted line ──────────────────────────────────────────────────────── suspect_str = suspect or "—" _centered_text(draw, 165, f"Convicted: {suspect_str}", f28, _TEXT_LIGHT) # re-draw the name portion in gold (approximate; just recolour the name) name_font = _font(28) prefix = "Convicted: " pbbox = draw.textbbox((0, 0), prefix, font=name_font) pw = pbbox[2] - pbbox[0] full_bbox = draw.textbbox((0, 0), f"Convicted: {suspect_str}", font=name_font) fw = full_bbox[2] - full_bbox[0] x_start = (W - fw) // 2 draw.text((x_start + pw, 165), suspect_str, font=name_font, fill=_TEXT_GOLD) # ── method line ─────────────────────────────────────────────────────────── _centered_text(draw, 215, f"Method: {method}", f22, _TEXT_LIGHT) mbbox = draw.textbbox((0, 0), "Method: ", font=f22) mw = mbbox[2] - mbbox[0] full2 = draw.textbbox((0, 0), f"Method: {method}", font=f22) fw2 = full2[2] - full2[0] x2 = (W - fw2) // 2 draw.text((x2 + mw, 215), method, font=f22, fill=_TEXT_GOLD) # ── scores line ─────────────────────────────────────────────────────────── scores = f"Suspicion: {suspicion:.2f} · Shadow: {shadow:.2f}" _centered_text(draw, 265, scores, f22, _TEXT_LIGHT) # ── stamp ───────────────────────────────────────────────────────────────── stamp_w, stamp_h = 440, 100 cx, cy = W // 2, 460 stamp_rect = [ cx - stamp_w // 2, cy - stamp_h // 2, cx + stamp_w // 2, cy + stamp_h // 2, ] draw.rectangle(stamp_rect, fill=stamp_fill, outline=_TEXT_LIGHT, width=6) _centered_text(draw, cy - stamp_h // 2 + 22, stamp_text, f42, _TEXT_LIGHT) # ── footer ──────────────────────────────────────────────────────────────── _centered_text(draw, 555, "Dempster's Court", f14, _TEXT_MUTED) # ── serialise ───────────────────────────────────────────────────────────── out = io.BytesIO() img.save(out, format="PNG") return out.getvalue()