storebound/tools/brand-assets.py

289 lines
13 KiB
Python

#!/usr/bin/env python3
"""Every storebound logo file, made in code from one master: brand/master/logo-mark.png (1024 px, transparent).
python3 tools/brand-assets.py write everything below
python3 tools/brand-assets.py --sheet out.png also write a contact sheet of every size (for a look, not committed)
Needs Pillow (`pip install pillow`); a build-time tool only, the CLI keeps zero runtime dependencies. The text (the
small-size monogram, the og image) is set in the system UI font: SF Pro on macOS, else Inter / DejaVu Sans if found.
Writes:
brand/logo-mark-256.png the mark alone, for the README
apps/desktop/src-tauri/icons/ 32/64/128/256 px, 128x128@2x, icon.png (512), icon.icns (iconutil,
macOS only), icon.ico (16/24/32/48/64/256), Square*Logo, StoreLogo
apps/desktop/src/ui/logo-mark.png the mark alone (72 px), next to the "storebound" text in the app
site/favicon.ico (16/32/48), site/icon-192.png, site/icon-512.png (maskable), site/apple-touch-icon.png,
site/img/logo-mark.png + logo-mark@2x.png the mark alone, next to the HTML "storebound" wordmark
site/og.png 1200x630 share image: the mark plus real-font text
Method: trim the master to its alpha bounding box, pad ~6 %, square it, LANCZOS down (never up). The tile behind the
mark (rounded square, navy to indigo, the site's colour tokens) is drawn here and never painted into the master. Below
48 px the mark turns to mush, so that tier is a separate design: a bold "s" set in the font at the target size on the
same tile (rendered directly at 16/24/32, not shrunk from a big image). Do not use `tauri icon` (it shrinks one image to
every size)."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
from PIL import Image, ImageChops, ImageDraw, ImageFont
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
MASTER = os.path.join(ROOT, 'brand/master/logo-mark.png')
TAURI = os.path.join(ROOT, 'apps/desktop/src-tauri/icons')
SITE = os.path.join(ROOT, 'site')
# site/style.css tokens: dark --bg #0b1120, --bg-alt #0f172a; --accent #4f46e5 (light), indigo-700 #4338ca
NAVY = (15, 23, 42)
INDIGO = (67, 56, 202)
WHITE = (248, 250, 252)
SMALL = 48 # below this the monogram tier is used
FONT_CANDIDATES = ['/System/Library/Fonts/SFNS.ttf', '/Library/Fonts/Inter.ttc', '/usr/share/fonts/truetype/inter/Inter.ttc',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf']
MONO_CANDIDATES = ['/System/Library/Fonts/SFNSMono.ttf', '/System/Library/Fonts/Menlo.ttc',
'/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf']
def font(size: float, weight: int = 700, mono: bool = False) -> ImageFont.FreeTypeFont:
for path in MONO_CANDIDATES if mono else FONT_CANDIDATES:
if not os.path.exists(path):
continue
f = ImageFont.truetype(path, size)
try:
axes = f.get_variation_axes()
except OSError:
return f # not a variable font
vals = []
for a in axes:
name = a['name'].decode() if isinstance(a['name'], bytes) else a['name']
if name == 'Weight':
vals.append(max(a['minimum'], min(a['maximum'], weight)))
elif name == 'Optical Size':
vals.append(max(a['minimum'], min(a['maximum'], size)))
else:
vals.append(a['default'])
f.set_variation_by_axes(vals)
return f
sys.exit('brand-assets: no usable font found (looked for ' + ', '.join(MONO_CANDIDATES if mono else FONT_CANDIDATES) + ')')
# ---------- the mark ----------
def load_mark() -> Image.Image:
"""The master, trimmed to its alpha bounding box, padded ~6 % and squared (transparent)."""
im = Image.open(MASTER).convert('RGBA')
box = im.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox()
if not box:
sys.exit('brand-assets: the master has no visible pixels')
im = im.crop(box)
side = round(max(im.size) * 1.12) # ~6 % on each side
sq = Image.new('RGBA', (side, side), (0, 0, 0, 0))
sq.paste(im, ((side - im.width) // 2, (side - im.height) // 2))
return sq
MARK: Image.Image | None = None
def mark(px: int) -> Image.Image:
"""The squared mark at px, LANCZOS down; refuses to upscale."""
global MARK
if MARK is None:
MARK = load_mark()
if px > MARK.width:
sys.exit(f'brand-assets: {px} px would upscale the {MARK.width} px master')
return MARK if px == MARK.width else MARK.resize((px, px), Image.LANCZOS)
# ---------- the tile ----------
def gradient(w: int, h: int, a=NAVY, b=INDIGO) -> Image.Image:
"""Diagonal gradient, top left a to bottom right b."""
v = Image.linear_gradient('L').resize((w, h), Image.BICUBIC)
hz = Image.linear_gradient('L').rotate(90).resize((w, h), Image.BICUBIC)
t = ImageChops.add(v, hz, scale=2.0)
return Image.composite(Image.new('RGB', (w, h), b), Image.new('RGB', (w, h), a), t).convert('RGBA')
def tile(size: int, inset: float, radius: float, full_bleed: bool = False) -> Image.Image:
"""A size px canvas with the rounded gradient square inset by inset*size, drawn 4x and shrunk for clean edges."""
if full_bleed:
return gradient(size, size)
ss = 4
S = size * ss
x0 = round(inset * S)
body = gradient(S - 2 * x0, S - 2 * x0)
m = Image.new('L', body.size, 0)
ImageDraw.Draw(m).rounded_rectangle((0, 0, body.width - 1, body.height - 1), radius=radius * body.width, fill=255)
body.putalpha(m)
out = Image.new('RGBA', (S, S), (0, 0, 0, 0))
out.paste(body, (x0, x0), body)
return out.resize((size, size), Image.LANCZOS)
def on_tile(size: int, inset: float, radius: float, scale: float, full_bleed: bool = False) -> Image.Image:
"""The mark centred on the tile; scale is the mark's share of the tile's side."""
t = tile(size, inset, radius, full_bleed)
inner = size - 2 * round(inset * size) if not full_bleed else size
m = mark(max(1, round(inner * scale)))
t.alpha_composite(m, ((size - m.width) // 2, (size - m.height) // 2))
return t
def monogram(size: int, inset: float = 0.0, radius: float = 0.22) -> Image.Image:
"""Small tier: a bold "s" set at the target size on the same tile."""
inset = max(inset, 0.5 / size) # keep the corners off the canvas edge
t = tile(size, inset, radius)
f = font(size * 0.86, 760)
d = ImageDraw.Draw(t)
l, tp, r, b = d.textbbox((0, 0), 's', font=f)
x = (size - (r - l)) / 2 - l
y = (size - (b - tp)) / 2 - tp
d.text((round(x), round(y)), 's', font=f, fill=WHITE + (255,))
return t
# layouts
def app_icon(size: int) -> Image.Image:
"""macOS / Linux app icon: Apple's grid (824 of 1024, ~22 % corner) with the mark at 86 % of the tile."""
return monogram(size, 0.0625) if size < SMALL else on_tile(size, 100 / 1024, 0.2245, 0.86)
def win_icon(size: int) -> Image.Image:
"""Windows .ico / Store tiles: nearly full canvas."""
return monogram(size) if size < SMALL else on_tile(size, 0.03, 0.2, 0.9)
def save(im: Image.Image, path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
im.save(path, optimize=True)
print(f' {os.path.relpath(path, ROOT)} {im.width}x{im.height} {os.path.getsize(path) / 1024:.1f} KB')
def save_ico(path: str, sizes: list[int], make) -> None:
imgs = [make(s) for s in sizes]
big = imgs[-1]
big.save(path, format='ICO', sizes=[(s, s) for s in sizes], append_images=imgs[:-1], bitmap_format='png')
print(f' {os.path.relpath(path, ROOT)} {"/".join(map(str, sizes))} {os.path.getsize(path) / 1024:.1f} KB')
def write_icns(path: str) -> None:
if not shutil.which('iconutil'):
print(' icon.icns skipped: iconutil is macOS only')
return
with tempfile.TemporaryDirectory() as tmp:
d = os.path.join(tmp, 'icon.iconset')
os.mkdir(d)
for s in (16, 32, 128, 256, 512):
app_icon(s).save(os.path.join(d, f'icon_{s}x{s}.png'))
app_icon(s * 2).save(os.path.join(d, f'icon_{s}x{s}@2x.png'))
subprocess.run(['iconutil', '-c', 'icns', d, '-o', path], check=True)
print(f' {os.path.relpath(path, ROOT)} 16-1024 (iconutil) {os.path.getsize(path) / 1024:.1f} KB')
# ---------- og image ----------
def og() -> Image.Image:
W, H = 1200, 630
im = Image.new('RGBA', (W, H), (11, 17, 32, 255))
def glow(cx, cy, r, color, alpha):
g = Image.radial_gradient('L').resize((r * 2, r * 2), Image.BICUBIC).point(lambda v: int((255 - v) * alpha))
layer = Image.new('RGBA', (r * 2, r * 2), color + (0,))
layer.putalpha(g)
im.alpha_composite(layer, (cx - r, cy - r))
glow(W + 40, -60, 560, (79, 70, 229), 0.42)
glow(-80, H + 80, 420, (190, 24, 93), 0.22)
d = ImageDraw.Draw(im)
m = mark(60)
im.alpha_composite(m, (68, 60))
d.text((140, 90), 'storebound', font=font(30, 700), fill=WHITE, anchor='lm')
h = font(72, 800)
d.text((72, 206), 'Your app or game, from zero', font=h, fill=WHITE, anchor='lm')
x = 72
for part, col in (('to ', WHITE), ('“Submit for review”', (165, 180, 252)), ('.', WHITE)):
d.text((x, 284), part, font=h, fill=col, anchor='lm')
x += d.textlength(part, font=h)
p = font(22, 400)
for i, line in enumerate(('On Google Play and the App Store: records, in-app products and subscriptions, listings in every',
'language, every questionnaire, privacy, icons, screenshots. One config and your AI coding agent.')):
d.text((72, 381 + i * 33, ), line, font=p, fill=(203, 213, 225), anchor='lm')
mono = font(17, 700, mono=True)
x = 72
for label, bg, fg in (('API', (20, 83, 45), (134, 239, 172)), ('LOCAL', (12, 74, 110), (125, 211, 252)),
('UI', (69, 26, 3), (252, 211, 77)), ('YOU', (80, 7, 36), (249, 168, 212))):
w = d.textlength(label, font=mono) + 34
d.rounded_rectangle((x, 528, x + w, 566), radius=19, fill=bg)
d.text((x + w / 2, 547), label, font=mono, fill=fg, anchor='mm')
x += w + 15
d.text((1128, 547), 'storebound.dev', font=font(24, 600, mono=True), fill=(148, 163, 184), anchor='rm')
return im.convert('RGB')
# ---------- contact sheet ----------
def sheet(path: str, items: list[tuple[str, Image.Image]]) -> None:
pad, lab = 16, 18
cells = [(n, i if i.width <= 256 else i.resize((256, round(256 * i.height / i.width)), Image.LANCZOS)) for n, i in items]
W = 1400
rows, row, x = [], [], pad
for n, i in cells:
if x + i.width + pad > W and row:
rows.append(row); row, x = [], pad
row.append((n, i)); x += max(i.width, 70) + pad
rows.append(row)
H = pad + sum(max(i.height for _, i in r) + lab + pad for r in rows)
out = Image.new('RGB', (W, H), (128, 128, 128))
half = Image.new('RGB', (W // 2, H), (245, 245, 245))
out.paste(half, (0, 0))
d = ImageDraw.Draw(out)
y = pad
f = font(12, 500)
for r in rows:
x = pad
for n, i in r:
out.paste(i, (x, y), i if i.mode == 'RGBA' else None)
d.text((x, y + max(j.height for _, j in r) + 3), n, font=f, fill=(20, 20, 20))
x += max(i.width, 70) + pad
y += max(i.height for _, i in r) + lab + pad
out.save(path)
print(f' sheet {path}')
def main() -> None:
print('brand-assets:')
sheet_items: list[tuple[str, Image.Image]] = []
def keep(name, im):
sheet_items.append((name, im)); return im
save(keep('README 256', mark(256)), os.path.join(ROOT, 'brand/logo-mark-256.png'))
# desktop app
for s in (32, 64, 128, 256):
save(keep(f'app {s}', app_icon(s)), os.path.join(TAURI, f'{s}x{s}.png'))
save(app_icon(256), os.path.join(TAURI, '128x128@2x.png'))
save(app_icon(512), os.path.join(TAURI, 'icon.png'))
write_icns(os.path.join(TAURI, 'icon.icns'))
for s in (16, 24):
keep(f'ico {s}', win_icon(s))
keep('ico 48', win_icon(48))
save_ico(os.path.join(TAURI, 'icon.ico'), [16, 24, 32, 48, 64, 256], win_icon)
for s in (30, 44, 71, 89, 107, 142, 150, 284, 310):
save(keep(f'Square{s}', win_icon(s)) if s in (30, 44, 71, 150) else win_icon(s), os.path.join(TAURI, f'Square{s}x{s}Logo.png'))
save(win_icon(50), os.path.join(TAURI, 'StoreLogo.png'))
save(keep('app UI 72', mark(72)), os.path.join(ROOT, 'apps/desktop/src/ui/logo-mark.png'))
# site
save_ico(os.path.join(SITE, 'favicon.ico'), [16, 32, 48], win_icon)
keep('favicon 16', win_icon(16)); keep('favicon 32', win_icon(32))
for s in (192, 512):
save(keep(f'maskable {s}', on_tile(s, 0, 0, 0.66, full_bleed=True)), os.path.join(SITE, f'icon-{s}.png'))
save(keep('apple-touch 180', on_tile(180, 0, 0, 0.8, full_bleed=True)), os.path.join(SITE, 'apple-touch-icon.png'))
save(keep('site 28', mark(28)), os.path.join(SITE, 'img/logo-mark.png'))
save(keep('site 56', mark(56)), os.path.join(SITE, 'img/logo-mark@2x.png'))
save(keep('og', og()), os.path.join(SITE, 'og.png'))
if '--sheet' in sys.argv:
sheet(sys.argv[sys.argv.index('--sheet') + 1], sheet_items)
if __name__ == '__main__':
main()