Bibliothèque de logos et palettes rapides

This commit is contained in:
Yasser Boulayoune 2026-05-12 16:26:00 +02:00
parent b4363ae4e8
commit 0626d9867a

250
server.py Normal file
View file

@ -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"<title>.*?</title>", "", 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"<svg[^>]*>(.*)</svg>", 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
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'<path ', f'<path fill="{logo_color}" ', inner_clean)
# Scale based on actual viewBox so FA (512) and simpleicons (24) both fit
vb_w, vb_h = LOGO_VBOX.get(logo_value, (24.0, 24.0))
max_dim = max(vb_w, vb_h)
scale = logo_size / max_dim
rendered_w = vb_w * scale
rendered_h = vb_h * scale
# centered at (640, 250)
tx = 640 - rendered_w / 2
ty = 250 - rendered_h / 2
logo_svg = f'<g transform="translate({tx:.1f}, {ty:.1f}) scale({scale:.4f})">{inner_colored}</g>'
elif logo_kind == "upload" and logo_value.startswith("data:image/"):
tx = 640 - logo_size / 2
ty = 250 - logo_size / 2
logo_svg = (f'<image x="{tx:.1f}" y="{ty:.1f}" '
f'width="{logo_size}" height="{logo_size}" '
f'href="{logo_value}" '
'preserveAspectRatio="xMidYMid meet"/>')
elif logo_kind == "text" and logo_value:
text_e = esc(logo_value)
logo_svg = (f'<g transform="translate(640, 250)">'
f'<circle cx="0" cy="0" r="{logo_size/2:.0f}" fill="#15151a" '
f'stroke="{logo_color}" stroke-width="2"/>'
f'<text x="0" y="{logo_size*0.16:.0f}" '
f'font-family="Fraunces" font-weight="700" '
f'font-size="{logo_size*0.52:.0f}" fill="{logo_color}" '
f'text-anchor="middle">{text_e}</text>'
f'</g>')
line_y = 350 if title_2 else 270
tag_y = 405 if title_2 else 325
title_2_svg = (f'<text x="56" y="290" font-family="Fraunces" font-weight="700" '
f'font-size="58" fill="#f5f5f7" letter-spacing="-1">{title_2_e}</text>'
if title_2 else "")
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 800 500">
<defs>
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="{accent}" stop-opacity="0.28"/>
<stop offset="60%" stop-color="{accent}" stop-opacity="0.05"/>
<stop offset="100%" stop-color="{accent}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0a0a0e"/>
<stop offset="100%" stop-color="#13131a"/>
</linearGradient>
<linearGradient id="accentLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="{accent}" stop-opacity="1"/>
<stop offset="100%" stop-color="{accent}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="800" height="500" fill="url(#bg)"/>
<rect x="0" y="0" width="800" height="4" fill="url(#accentLine)"/>
<circle cx="640" cy="250" r="180" fill="url(#halo)"/>
{logo_svg}
<text x="56" y="80" font-family="Syne" font-weight="600" font-size="13" fill="{accent}" letter-spacing="4">{eyebrow_e}</text>
<text x="56" y="225" font-family="Fraunces" font-weight="700" font-size="58" fill="#f5f5f7" letter-spacing="-1">{title_e}</text>
{title_2_svg}
<rect x="56" y="{line_y}" width="56" height="3" fill="{accent}"/>
<text x="56" y="{tag_y}" font-family="DM Sans" font-weight="500" font-size="20" fill="#c8c8d0">{tagline_e}</text>
</svg>
'''
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()