Newer
Older
veriq / backend / og.py
"""VerIQ — OG image generation (1200x630 PNG cards for social scrapers).

Scrapers (X/Twitter, Telegram, Facebook) don't render SVG and need a real
PNG. We draw the card with Pillow: dark brutalist background, huge IQ number,
percentile line, bell-curve strip with a marker, signature badge, verify URL.
Fonts are bundled in backend/fonts/ so the card renders identically anywhere.
Cached on disk per result_id (design version prefixed).
"""
import os
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

CACHE_DIR = Path(os.environ.get("VERIQ_OG_CACHE", "/var/lib/veriq/og_cache"))

W, H = 1200, 630
BG = (13, 13, 15)
LINE = (42, 42, 51)
CURVE_FILL = (38, 47, 66)
TEXT = (232, 232, 234)
MUTED = (138, 138, 148)
ACCENT = (122, 162, 247)
GOOD = (158, 206, 106)
BAD = (247, 118, 142)
WARN = (224, 175, 104)

DESIGN = "r2"  # bump to invalidate the on-disk cache after card redesign

_FONT_DIRS = [
    str(Path(__file__).resolve().parent / "fonts"),  # bundled (repo)
    "/usr/share/fonts/truetype/dejavu/",
    "/usr/share/fonts/dejavu/",
]


def _font(name, size):
    for d in _FONT_DIRS:
        p = Path(d) / name
        if p.exists():
            return ImageFont.truetype(str(p), size)
    return ImageFont.load_default()


def _fonts():
    return {
        "logo": _font("DejaVuSans-Bold.ttf", 40),
        "domain": _font("DejaVuSans.ttf", 26),
        "big": _font("DejaVuSans-Bold.ttf", 250),
        "sub": _font("DejaVuSans-Bold.ttf", 42),
        "small": _font("DejaVuSans.ttf", 26),
        "badge": _font("DejaVuSans-Bold.ttf", 28),
        "url": _font("DejaVuSans-Bold.ttf", 32),
    }


def _center(draw, text, y, font, fill):
    bbox = draw.textbbox((0, 0), text, font=font)
    w = bbox[2] - bbox[0]
    draw.text(((W - w) / 2, y), text, font=font, fill=fill)


def _bell_strip(d, iq, y, h):
    """Bell-curve strip: subtle filled gaussian + accent marker at iq."""
    x0, x1 = 200, W - 200
    lo, hi = 55.0, 145.0
    mu, sd = 100.0, 15.0

    def fx(iqv):
        t = (iqv - lo) / (hi - lo)
        return x0 + t * (x1 - x0)

    def gauss(iqv):
        return ((iqv - mu) / sd) ** 2

    g_edge = gauss(lo)  # 9.0 — density at strip edges
    pts = [(x0, y + h)]
    steps = 120
    for i in range(steps + 1):
        iqv = lo + (hi - lo) * i / steps
        t = 1.0 - gauss(iqv) / g_edge  # 1 at center, 0 at edges
        pts.append((fx(iqv), y + h - t * (h - 6)))
    pts.append((x1, y + h))
    d.polygon(pts, fill=CURVE_FILL)

    # baseline
    d.line([x0, y + h, x1, y + h], fill=LINE, width=2)

    # marker at user's iq
    mx = fx(max(lo, min(hi, iq)))
    d.line([mx, y - 6, mx, y + h], fill=ACCENT, width=5)
    d.polygon([(mx - 9, y - 16), (mx + 9, y - 16), (mx, y - 4)], fill=ACCENT)


def generate_card(iq, percentile, signature_valid, flagged, result_id):
    """Render the OG card, return path to cached PNG."""
    try:
        CACHE_DIR.mkdir(parents=True, exist_ok=True)
        cache = CACHE_DIR
    except PermissionError:
        import tempfile
        cache = Path(tempfile.gettempdir()) / "veriq_og"
        cache.mkdir(parents=True, exist_ok=True)
    out = cache / (DESIGN + "-" + result_id + ".png")
    if out.exists():
        return out

    img = Image.new("RGB", (W, H), BG)
    d = ImageDraw.Draw(img)
    f = _fonts()

    # frame
    d.rectangle([8, 8, W - 9, H - 9], outline=LINE, width=2)
    # accent corner blocks (brutalist)
    d.rectangle([8, 8, 8 + 56, 8 + 10], fill=ACCENT)
    d.rectangle([8, 8, 8 + 10, 8 + 56], fill=ACCENT)
    d.rectangle([W - 9 - 56, H - 9 - 10, W - 9, H - 9], fill=ACCENT)
    d.rectangle([W - 9 - 10, H - 9 - 56, W - 9, H - 9], fill=ACCENT)

    # logo top-left, domain top-right
    d.text((48, 40), "Ver", font=f["logo"], fill=TEXT)
    logo_w = d.textbbox((0, 0), "Ver", font=f["logo"])[2]
    d.text((48 + logo_w, 40), "IQ", font=f["logo"], fill=ACCENT)
    dom = "veriq.gnexus.space"
    dbbox = d.textbbox((0, 0), dom, font=f["domain"])
    d.text((W - 48 - (dbbox[2] - dbbox[0]), 48), dom, font=f["domain"], fill=MUTED)

    # huge score — the hero
    _center(d, str(iq), 88, f["big"], TEXT)

    # percentile line
    _center(d, "higher than " + str(percentile) + "% of test takers", 352,
            f["sub"], ACCENT)

    # bell-curve strip with marker
    _bell_strip(d, iq, 418, 74)

    # badges row
    y = 516
    if signature_valid:
        badge_text = "SIGNATURE VALID"
        badge_color = GOOD
    else:
        badge_text = "SIGNATURE INVALID"
        badge_color = BAD
    bbox = d.textbbox((0, 0), badge_text, font=f["badge"])
    bw = bbox[2] - bbox[0] + 48
    bx = (W - bw) / 2
    d.rectangle([bx, y, bx + bw, y + 52], outline=badge_color, width=3)
    d.text((bx + 24, y + 9), badge_text, font=f["badge"], fill=badge_color)
    if flagged:
        fx = bx + bw + 16
        fb = d.textbbox((0, 0), "FLAGGED", font=f["badge"])
        fw = fb[2] - fb[0] + 40
        d.rectangle([fx, y, fx + fw, y + 52], outline=WARN, width=3)
        d.text((fx + 20, y + 9), "FLAGGED", font=f["badge"], fill=WARN)

    # verify URL — the call to action
    _center(d, "veriq.gnexus.space/v/" + result_id, 582, f["url"], TEXT)

    img.save(out, "PNG", optimize=True)
    return out