255 lines
9.7 KiB
Python
255 lines
9.7 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):
|
||
|
|
"""Half-block console art from the minimum tier, ~18 columns wide."""
|
||
|
|
a = mark_png(k, "min", 20, "#000").split()[3]
|
||
|
|
cols, rows = a.size
|
||
|
|
lines = []
|
||
|
|
for y in range(0, rows - 1, 2):
|
||
|
|
s = ""
|
||
|
|
for x in range(cols):
|
||
|
|
t = a.getpixel((x, y)) > 110
|
||
|
|
b = a.getpixel((x, y + 1)) > 110
|
||
|
|
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 main():
|
||
|
|
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())
|