#!/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'''
'''
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()