289 lines
13 KiB
Python
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()
|