furtka/website/tools/logo-assets.py

255 lines
9.7 KiB
Python
Raw Normal View History

#!/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())