Bibliothèque de logos et palettes rapides
This commit is contained in:
parent
b4363ae4e8
commit
0626d9867a
1 changed files with 250 additions and 0 deletions
250
server.py
Normal file
250
server.py
Normal 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("&", "&")
|
||||||
|
.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'<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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue