"""The share card: one 1200x630 OG composite per published awakening. ``compose_og(image_jpeg, record)`` renders the link-unfurl image for the /o/{id} share page — the photo on the left, the soul on the right: name in Cormorant Garamond, the grudge in italic, the wordmark underneath. Porcelain séance palette (web/style.css is the source of truth for the colors). Pure CPU + Pillow, deterministic, generated ONCE at publish time and persisted as ``{id}_og.jpg`` next to the record's other media. Callers treat it as best-effort: any exception here is caught at the publish site and the record ships without a composite (the share page then falls back to the plain photo). Fonts: ``server/assets/cormorant-garamond[-italic].ttf`` (OFL, shipped with the Space). If they are somehow absent, Pillow's default font keeps the composite alive — ugly beats absent for a link preview. """ from __future__ import annotations import io from pathlib import Path from typing import Any, Mapping OG_WIDTH = 1200 OG_HEIGHT = 630 PHOTO_WIDTH = 560 # left pane; the soul speaks on the remaining 640px # web/style.css :root, transcribed (keep in sync by eye, not by import). PORCELAIN = (11, 10, 9) CARD_BG = (17, 16, 13) AMBER = (232, 168, 73) BONE = (239, 230, 212) BONE_DIM = (148, 142, 130) ASSETS_DIR = Path(__file__).resolve().parent / "assets" FONT_REGULAR = ASSETS_DIR / "cormorant-garamond.ttf" FONT_ITALIC = ASSETS_DIR / "cormorant-garamond-italic.ttf" def _font(path: Path, size: int): from PIL import ImageFont try: return ImageFont.truetype(str(path), size=size) except Exception: # noqa: BLE001 — a missing font must not kill the card try: return ImageFont.load_default(size=size) except TypeError: # pragma: no cover — ancient Pillow without size= return ImageFont.load_default() def _wrap(draw: Any, text: str, font: Any, max_width: int, max_lines: int) -> list[str]: """Greedy word wrap by measured pixel width; ellipsize when out of lines.""" lines: list[str] = [] current = "" for word in text.split(): candidate = f"{current} {word}".strip() if not current or draw.textlength(candidate, font=font) <= max_width: current = candidate continue if len(lines) == max_lines - 1: # no room for another line lines.append(current.rstrip(".,;: ") + "…") return lines lines.append(current) current = word if current: lines.append(current) return lines def _display_name(record: Mapping[str, Any]) -> str: """Mirror web/main.js displayName: 'the rusted fire hydrant'.""" parts = [record.get("condition"), record.get("object")] name = " ".join(str(p) for p in parts if p) return f"the {name}".lower() if name else "an awakened thing" def compose_og(image_jpeg: bytes, record: Mapping[str, Any]) -> bytes: """Photo left, soul right -> JPEG bytes (1200x630). May raise; callers catch and publish without the composite (best-effort by contract).""" from PIL import Image, ImageDraw photo = Image.open(io.BytesIO(image_jpeg)).convert("RGB") canvas = Image.new("RGB", (OG_WIDTH, OG_HEIGHT), PORCELAIN) # Left pane: cover-crop the photo into PHOTO_WIDTH x OG_HEIGHT. scale = max(PHOTO_WIDTH / photo.width, OG_HEIGHT / photo.height) resized = photo.resize( (max(1, round(photo.width * scale)), max(1, round(photo.height * scale))) ) left = (resized.width - PHOTO_WIDTH) // 2 top = (resized.height - OG_HEIGHT) // 2 canvas.paste(resized.crop((left, top, left + PHOTO_WIDTH, top + OG_HEIGHT)), (0, 0)) draw = ImageDraw.Draw(canvas) # Hairline seam between photo and panel (the gallery wall edge). draw.rectangle( (PHOTO_WIDTH, 0, PHOTO_WIDTH + 1, OG_HEIGHT), fill=(60, 45, 22) ) panel_x = PHOTO_WIDTH + 56 panel_w = OG_WIDTH - panel_x - 56 # the label, lowercase + letterspaced by hand (PIL has no tracking) label_font = _font(FONT_REGULAR, 26) draw.text( (panel_x, 64), " ".join("pareidolia"), font=label_font, fill=AMBER ) # the name name_font = _font(FONT_REGULAR, 58) name_lines = _wrap(draw, _display_name(record), name_font, panel_w, 2) y = 124 for line in name_lines: draw.text((panel_x, y), line, font=name_font, fill=BONE) y += 62 # the grudge, in its own voice lines = record.get("lines") or {} grudge = str(lines.get("grudge") or "").strip() if grudge: grudge_font = _font(FONT_ITALIC, 40) quote_lines = _wrap(draw, f"“{grudge}”", grudge_font, panel_w, 5) y += 26 for line in quote_lines: draw.text((panel_x, y), line, font=grudge_font, fill=BONE) y += 48 # the claim, bottom-anchored tag_font = _font(FONT_ITALIC, 28) draw.text( (panel_x, OG_HEIGHT - 78), "everything secretly has a face.", font=tag_font, fill=BONE_DIM, ) out = io.BytesIO() canvas.save(out, format="JPEG", quality=88) return out.getvalue()