from __future__ import annotations import os from pathlib import Path from typing import Optional from PIL import Image, ImageDraw, ImageFont from app.crop import smart_cover_crop # Asset paths — configurable via env so they can be overridden in compose. ASSETS = Path(os.getenv("CARD_ASSETS_DIR", "/app/assets")) FONT_REGULAR = os.getenv("CARD_DEFAULT_FONT", str(ASSETS / "fonts" / "Inter-Regular.ttf")) FONT_BOLD = os.getenv("CARD_BOLD_FONT", str(ASSETS / "fonts" / "Inter-Bold.ttf")) LOGO_PATH = os.getenv("CARD_LOGO_PATH", str(ASSETS / "logo.png")) def _load_font(path: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: """Load a TrueType font, falling back to PIL default if the file is absent.""" try: return ImageFont.truetype(path, size=size) except (OSError, IOError): return ImageFont.load_default() def _truncate_text(text: str, max_chars: int) -> str: """Truncate text with an ellipsis if it exceeds max_chars.""" if len(text) <= max_chars: return text return text[: max_chars - 1] + "\u2026" def _draw_gradient_overlay(base: Image.Image) -> None: """Paint a bottom-weighted dark gradient over base (mutates in-place).""" w, h = base.size overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) for y in range(h): # alpha ramps from 0 at top to ~200 at bottom alpha = int(200 * (y / h) ** 1.5) draw.line((0, y, w, y), fill=(0, 0, 0, alpha)) base.alpha_composite(overlay) def render_nova_artwork_v1( source: Image.Image, width: int, height: int, title: Optional[str], subtitle: Optional[str], username: Optional[str], category: Optional[str], show_logo: bool, ) -> tuple[Image.Image, tuple[int, int, int, int]]: """Render the nova-artwork-v1 card template. Returns (rendered_image_RGBA, crop_box). """ canvas, crop_box = smart_cover_crop(source, width, height) canvas = canvas.convert("RGBA") _draw_gradient_overlay(canvas) draw = ImageDraw.Draw(canvas) pad_x = 48 bottom = height - 48 title_font = _load_font(FONT_BOLD, 54) meta_font = _load_font(FONT_REGULAR, 28) small_font = _load_font(FONT_REGULAR, 22) if category: draw.text( (pad_x, bottom - 156), _truncate_text(category.upper(), 40), font=small_font, fill=(220, 220, 220, 235), ) if title: draw.text( (pad_x, bottom - 116), _truncate_text(title, 60), font=title_font, fill=(255, 255, 255, 255), ) meta_parts = [p for p in [subtitle, username] if p] if meta_parts: draw.text( (pad_x, bottom - 44), " \u2022 ".join(_truncate_text(p, 40) for p in meta_parts), font=meta_font, fill=(235, 235, 235, 245), ) if show_logo: _composite_logo(canvas, width) return canvas, crop_box def _composite_logo(canvas: Image.Image, canvas_width: int) -> None: """Overlay the logo image in the top-right corner. Fails silently if absent.""" try: logo = Image.open(LOGO_PATH).convert("RGBA") logo.thumbnail((160, 60), Image.LANCZOS) x = canvas_width - logo.width - 40 y = 36 canvas.alpha_composite(logo, (x, y)) except Exception: # Logo is optional — missing or unreadable file is not an error pass