diff --git a/server.py b/server.py
new file mode 100644
index 0000000..14b636b
--- /dev/null
+++ b/server.py
@@ -0,0 +1,250 @@
+#!/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()