furtka/website/tools/logo-assets.py
Daniel Maksymilian Syrnicki c3c59b4bea
Some checks failed
CI / lint (push) Failing after 26s
CI / validate-json (push) Successful in 22s
CI / markdown-links (push) Successful in 14s
Deploy site / deploy (push) Successful in 4s
CI / test (push) Successful in 1m27s
feat(website): share card for /logo/ (og:image) so links to the vote get a preview
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 14:33:28 +02:00

291 lines
12 KiB
Python

#!/usr/bin/env python3
"""Generate the logo-poll showcase assets for /logo/ from one geometry source.
python3 website/tools/logo-assets.py
Writes website/static/img/logo/{m,r}/*.png and website/data/logo/{m,r}.json.
Needs: cairosvg, Pillow, fontTools (pip). Run it on the dev box and commit
the output — the site build itself needs nothing beyond Hugo.
Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill).
Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch).
"""
import base64
import io
import json
import os
import sys
import cairosvg
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
SITE = os.path.dirname(HERE)
FONT = os.path.join(HERE, "FamiljenGrotesk-SemiBold.ttf")
XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
# ---------------------------------------------------------------- geometry
M_FLAT = (
"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6"
"H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"
)
M_MIN = (
"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32"
"a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"
)
R_FRAME = "M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"
R_OUTER = "M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z"
R_HOLE = "M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"
R_SPOKES = "M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9"
R_ARMS = "M52 22H79M52 37H69"
MARKS = {
"m": {
"vb": (0, 0, 64, 64),
"box": (6, 2, 52, 58), # x, y, w, h of the drawn mark inside the viewBox
"color": "#c03a28",
"ink": "#0d0d0f",
"grad": ("#d95a45", "#c03a28", "#8a2618"),
},
"r": {
"vb": (0, 0, 104, 114),
"box": (2, 2, 100, 110),
"color": "#af6428",
"ink": "#151515",
"grad": ("#c9803f", "#af6428", "#9a561f"),
},
}
def body(k, tier, color="currentColor", uid=""):
"""SVG inner markup for mark k at tier display | flat | min."""
if k == "m":
if tier == "display":
g = MARKS["m"]["grad"]
return (
f'<defs><linearGradient id="g{uid}" x1=".15" y1="0" x2=".7" y2="1">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".55" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
f'<clipPath id="c{uid}" clip-rule="evenodd"><path d="{M_FLAT}"/></clipPath></defs>'
f'<path fill="url(#g{uid})" fill-rule="evenodd" d="{M_FLAT}"/>'
f'<g clip-path="url(#c{uid})"><path fill="#fff" opacity=".14" '
f'd="M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z"/></g>'
)
path = M_MIN if tier == "min" else M_FLAT
return f'<path fill="{color}" fill-rule="evenodd" d="{path}"/>'
if tier == "min":
return (
f'<g fill="none" stroke="{color}" stroke-width="11" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="M52 8V106M8 65H96"/>'
f'<path d="M52 23H78M52 39H68" stroke-linecap="round"/></g>'
f'<circle cx="52" cy="65" r="19" fill="{color}"/>'
)
defs = (
f'<clipPath id="c{uid}" clip-rule="evenodd"><path clip-rule="evenodd" '
f'd="{R_OUTER} {R_HOLE}"/></clipPath>'
)
if tier == "display":
g = MARKS["r"]["grad"]
defs += (
f'<linearGradient id="g{uid}" gradientUnits="userSpaceOnUse" x1="0" y1="2" x2="0" y2="112">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".6" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
)
color = f"url(#g{uid})"
return (
f"<defs>{defs}</defs>"
f'<g fill="none" stroke="{color}" stroke-width="8" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="{R_SPOKES}" clip-path="url(#c{uid})"/>'
f'<path d="{R_ARMS}" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g>'
)
def svg(k, tier, color="currentColor", uid="", cls="pm", standalone=False):
x, y, w, h = MARKS[k]["vb"]
ns = f" {XMLNS}" if standalone else ""
return (
f'<svg class="{cls}"{ns} viewBox="{x} {y} {w} {h}" aria-hidden="true" focusable="false">'
f"{body(k, tier, color, uid)}</svg>"
)
# ---------------------------------------------------------------- raster
def raster(svgtxt, w, h):
png = cairosvg.svg2png(bytestring=svgtxt.encode(), output_width=w, output_height=h)
return Image.open(io.BytesIO(png)).convert("RGBA")
def mark_png(k, tier, height, color=None):
_, _, w, h = MARKS[k]["vb"]
return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height)
# ---------------------------------------------------------------- wordmark
_font = TTFont(FONT)
_upm = _font["head"].unitsPerEm
_cap = _font["OS/2"].sCapHeight or int(_upm * 0.7)
_glyphs = _font.getGlyphSet()
_cmap = _font.getBestCmap()
def wordmark(text, size, x0=0.0, baseline=0.0, tracking=-0.028):
"""Outline text as one SVG path; returns (path_d, advance_width)."""
s = size / _upm
parts, x = [], x0
for ch in text:
name = _cmap[ord(ch)]
pen = SVGPathPen(_glyphs)
g = _glyphs[name]
g.draw(pen)
d = pen.getCommands()
if d:
parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>')
x += g.width * s + tracking * size
return "".join(parts), x - x0 - tracking * size
def lockup(k, stacked=False):
"""Mark + 'furtka', outlined, currentColor. Returns an inline SVG string."""
bx, by, bw, bh = MARKS[k]["box"]
H = 58.0 # mark height in lockup units
sc = H / bh
mw = bw * sc
size = H * 0.72 * _upm / _cap # cap height = 72 % of the mark height
if stacked:
paths, tw = wordmark("furtka", size)
gap = 12
total_w = max(mw, tw)
total_h = H + gap + size * _cap / _upm + size * 0.22
mx = (total_w - mw) / 2
tx = (total_w - tw) / 2
base = H + gap + size * _cap / _upm
paths, _ = wordmark("furtka", size, x0=tx, baseline=base)
inner = (
f'<g transform="translate({mx:.2f} 0) scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 0 {total_w:.2f} {total_h:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
gap = H * 0.28
paths, tw = wordmark("furtka", size, x0=mw + gap, baseline=H)
total_w = mw + gap + tw
inner = (
f'<g transform="scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 -4 {total_w:.2f} {H + 6:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
def lockup_png(k, W, Hpx, stacked, mark_h):
"""Rasterise the lockup on the mark's ink ground (splash / social card)."""
ink = MARKS[k]["ink"]
col = MARKS[k]["color"]
inner = lockup(k, stacked).replace("currentColor", col)
inner = inner.replace('<svg class="pm"', f"<svg {XMLNS}")
vb = inner.split('viewBox="')[1].split('"')[0].split()
vw, vh = float(vb[2]), float(vb[3])
scale = mark_h / 58.0
w, h = round(vw * scale), round(vh * scale)
im = Image.new("RGBA", (W, Hpx), ink)
im.alpha_composite(raster(inner, w, h), ((W - w) // 2, (Hpx - h) // 2))
return im
# ---------------------------------------------------------------- tiles etc.
def tile(k, size, pad=0.18, radius=None):
im = Image.new("RGBA", (size, size), MARKS[k]["ink"])
m = mark_png(k, "flat", int(size * (1 - 2 * pad)))
im.alpha_composite(m, ((size - m.width) // 2, (size - m.height) // 2))
if radius:
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius, fill=255)
im.putalpha(mask)
return im
def banner(k, cols=30):
"""Half-block console art (~30 columns) from the interface tier.
Sampled by coverage: the mark is rendered 4x and box-averaged down to
one sample per half-character, then thresholded at 50 %.
"""
_, _, w, h = MARKS[k]["vb"]
rows = round(cols * h / w)
rows += rows % 2
a = raster(svg(k, "flat", "#000", standalone=True), cols * 4, rows * 4).split()[3]
a = a.resize((cols, rows), Image.BOX)
lines = []
for y in range(0, rows, 2):
s = ""
for x in range(cols):
t = a.getpixel((x, y)) > 128
b = a.getpixel((x, y + 1)) > 128
s += "" if t and b else "" if t else "" if b else " "
lines.append(s.rstrip())
return "\n".join(lines)
def b64png(im):
buf = io.BytesIO()
im.save(buf, "PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def vote_card(path):
"""1200x630 share card for /logo/: M on cream, R on ink, side by side."""
W, H = 1200, 630
im = Image.new("RGBA", (W, H), "#f7f6f3")
ImageDraw.Draw(im).rectangle([W // 2, 0, W, H], fill=MARKS["r"]["ink"])
for k, cx in (("m", W // 4), ("r", 3 * W // 4)):
m = mark_png(k, "flat", 330)
im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2))
size = 46
d = ImageDraw.Draw(im)
for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")):
paths, tw = wordmark(text, size)
# letters via the same outlines: rasterise a tiny SVG for crisp text
svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
f'<g fill="{col}">{paths}</g></svg>')
t = raster(svgtxt, round(tw), round(size * 1.3))
im.alpha_composite(t, (cx - t.width // 2, 500))
# url line, centred on the seam
paths, tw = wordmark("furtka.org/logo", 40)
svgtxt = (f'<svg {XMLNS} viewBox="0 -40 {tw:.0f} 52"><g fill="#c03a28">{paths}</g></svg>')
t = raster(svgtxt, round(tw), 52)
pad = 18
box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3")
ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc")
box.alpha_composite(t, (pad, pad))
im.alpha_composite(box, (W // 2 - box.width // 2, 40))
im.convert("RGB").save(path, optimize=True)
def main():
vote_card(os.path.join(SITE, "static", "img", "logo", "vote-card.png"))
for k in MARKS:
out = os.path.join(SITE, "static", "img", "logo", k)
os.makedirs(out, exist_ok=True)
tile(k, 192, radius=42).save(os.path.join(out, "tile.png"), optimize=True)
tile(k, 192, radius=96).save(os.path.join(out, "tile-round.png"), optimize=True)
lockup_png(k, 960, 540, True, 190).save(os.path.join(out, "splash.png"), optimize=True)
lockup_png(k, 600, 315, False, 130).save(os.path.join(out, "social.png"), optimize=True)
data = {
"display": svg(k, "display", uid=f"{k}d"),
"flat": svg(k, "flat"),
"min": svg(k, "min"),
"lockup_h": lockup(k),
"lockup_s": lockup(k, stacked=True),
"tab16": b64png(mark_png(k, "min", 16)),
"full16": b64png(mark_png(k, "flat", 16)),
"banner": banner(k),
"color": MARKS[k]["color"],
"ink": MARKS[k]["ink"],
}
with open(os.path.join(SITE, "data", "logo", f"{k}.json"), "w") as f:
json.dump(data, f, indent=1, ensure_ascii=False)
print(k, "ok")
if __name__ == "__main__":
sys.exit(main())