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''' + + + + + + + + + + + + + + + + + + + {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()