#!/usr/bin/env python3 """ Card generator backend. Renders SVG cards into PNG via rsvg-convert using the same pipeline as the portfolio's featured images. Frontend lives in ./static, logo library in ./logos. """ import json import re import subprocess import tempfile import base64 import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse, unquote ROOT = Path(__file__).resolve().parent STATIC = ROOT / "static" LOGOS = ROOT / "logos" PORT = 6789 # Cache logo SVG inner content + viewBox dimensions LOGO_INNER = {} # stem -> inner svg LOGO_VBOX = {} # stem -> (width, height) from viewBox for f in LOGOS.glob("*.svg"): txt = f.read_text() txt = re.sub(r".*?", "", txt) # Extract viewBox vb = re.search(r'viewBox="([\d.\-\s]+)"', txt) if vb: parts = vb.group(1).split() if len(parts) == 4: LOGO_VBOX[f.stem] = (float(parts[2]), float(parts[3])) else: LOGO_VBOX[f.stem] = (24.0, 24.0) else: LOGO_VBOX[f.stem] = (24.0, 24.0) m = re.search(r"]*>(.*)", txt, re.DOTALL) if m: LOGO_INNER[f.stem] = m.group(1).strip() def build_svg(payload): """Build the SVG card from the payload dict.""" eyebrow = payload.get("eyebrow", "") title = payload.get("title", "") title_2 = payload.get("title_2", "") or "" tagline = payload.get("tagline", "") accent = payload.get("accent", "#3b82f6") logo_color = payload.get("logo_color", "#f0f0f2") logo_kind = payload.get("logo_kind", "preset") # preset | upload | text | none logo_value = payload.get("logo_value", "") # preset key or base64 image data url or text logo_size = int(payload.get("logo_size", 260)) width = int(payload.get("width", 800)) height = int(payload.get("height", 500)) # Escape XML special chars in text inputs def esc(s): return (s.replace("&", "&") .replace("<", "<") .replace(">", ">")) eyebrow_e = esc(eyebrow) title_e = esc(title) title_2_e = esc(title_2) tagline_e = esc(tagline) # Logo region logo_svg = "" if logo_kind == "preset" and logo_value in LOGO_INNER: inner = LOGO_INNER[logo_value] # Strip any existing fill on paths, then add ours (handles FA + simpleicons) inner_clean = re.sub(r'\bfill="[^"]*"', '', inner) inner_colored = re.sub(r'{inner_colored}' elif logo_kind == "upload" and logo_value.startswith("data:image/"): tx = 640 - logo_size / 2 ty = 250 - logo_size / 2 logo_svg = (f'') elif logo_kind == "text" and logo_value: text_e = esc(logo_value) logo_svg = (f'' f'' f'{text_e}' f'') line_y = 350 if title_2 else 270 tag_y = 405 if title_2 else 325 title_2_svg = (f'{title_2_e}' if title_2 else "") return f''' {logo_svg} {eyebrow_e} {title_e} {title_2_svg} {tagline_e} ''' def render_png(svg_text, width=1600, height=1000): """Render SVG to PNG via rsvg-convert at given pixel size for retina.""" with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as svg_f: svg_f.write(svg_text.encode("utf-8")) svg_path = svg_f.name png_path = svg_path.replace(".svg", ".png") try: subprocess.run( ["rsvg-convert", "-w", str(width), "-h", str(height), svg_path, "-o", png_path], check=True, capture_output=True, ) return Path(png_path).read_bytes() finally: try: os.unlink(svg_path) except: pass try: os.unlink(png_path) except: pass class Handler(BaseHTTPRequestHandler): def log_message(self, format, *args): # quieter logs print(f"[{self.address_string()}] {format % args}") def _send(self, code, body, ctype, headers=None): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) if headers: for k, v in headers.items(): self.send_header(k, v) self.end_headers() if isinstance(body, str): body = body.encode("utf-8") self.wfile.write(body) def do_GET(self): parsed = urlparse(self.path) path = unquote(parsed.path) if path == "/" or path == "/index.html": data = (STATIC / "index.html").read_bytes() return self._send(200, data, "text/html; charset=utf-8") if path == "/api/logos": return self._send(200, json.dumps(sorted(LOGO_INNER.keys())), "application/json") if path.startswith("/logos/"): name = path[len("/logos/"):] if not re.match(r"^[a-z0-9_-]+\.svg$", name): return self._send(404, "Not found", "text/plain") fp = LOGOS / name if not fp.exists(): return self._send(404, "Not found", "text/plain") return self._send(200, fp.read_bytes(), "image/svg+xml") if path.startswith("/static/"): name = path[len("/static/"):] if ".." in name or "/" in name: return self._send(404, "Not found", "text/plain") fp = STATIC / name if not fp.exists(): return self._send(404, "Not found", "text/plain") ctype = "text/css" if name.endswith(".css") else \ "application/javascript" if name.endswith(".js") else \ "text/plain" return self._send(200, fp.read_bytes(), ctype) return self._send(404, "Not found", "text/plain") def do_POST(self): parsed = urlparse(self.path) path = parsed.path length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) try: payload = json.loads(raw or b"{}") except json.JSONDecodeError: return self._send(400, "Bad JSON", "text/plain") if path == "/api/svg": svg = build_svg(payload) return self._send(200, svg, "image/svg+xml") if path == "/api/png": svg = build_svg(payload) try: png = render_png(svg, width=int(payload.get("export_width", 1600)), height=int(payload.get("export_height", 1000))) except subprocess.CalledProcessError as e: return self._send(500, f"rsvg-convert failed: {e.stderr.decode()}", "text/plain") filename = (payload.get("filename") or "card") + ".png" return self._send(200, png, "image/png", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) return self._send(404, "Not found", "text/plain") if __name__ == "__main__": print(f"Card generator listening on http://0.0.0.0:{PORT}") print(f" - {len(LOGO_INNER)} logos loaded from {LOGOS}") server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) try: server.serve_forever() except KeyboardInterrupt: server.shutdown()