208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate ELY Browser app icon — source PNG + all platform sizes."""
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
import math
|
|
import os
|
|
import json
|
|
|
|
RENDER = 3072
|
|
FINAL = 1024
|
|
GRAD_SMALL = 512 # larger base for smoother gradient
|
|
|
|
BASE = os.path.dirname(os.path.dirname(__file__))
|
|
OUT_DIR = os.path.join(BASE, "generated-icons")
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
|
|
def create_gradient(size):
|
|
img = Image.new("RGB", (size, size))
|
|
for y in range(size):
|
|
for x in range(size):
|
|
nx, ny = x / size, y / size
|
|
r, g, b = 236.0, 224.0, 243.0
|
|
|
|
dx = (nx - 0.80) * 1.7
|
|
dy = (ny - 0.14) * 2.1
|
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
|
w = max(0.0, 1.0 - d) ** 1.7
|
|
r += (255 - r) * w; g += (195 - g) * w; b += (212 - b) * w
|
|
|
|
dx = (nx - 0.17) * 1.8
|
|
dy = (ny - 0.87) * 1.7
|
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
|
w = max(0.0, 1.0 - d) ** 1.7
|
|
r += (170 - r) * w; g += (190 - g) * w; b += (255 - b) * w
|
|
|
|
dx = (nx - 0.22) * 2.0
|
|
dy = (ny - 0.22) * 2.0
|
|
d = min(1.0, math.sqrt(dx * dx + dy * dy))
|
|
w = max(0.0, 1.0 - d) ** 2.2 * 0.35
|
|
r += (255 - r) * w; g += (222 - g) * w; b += (208 - b) * w
|
|
|
|
img.putpixel((x, y), (min(255, int(r)), min(255, int(g)), min(255, int(b))))
|
|
return img
|
|
|
|
|
|
def draw_e_rounded(draw, cx, cy, sz):
|
|
"""Draw geometric E with slightly rounded bar ends."""
|
|
e_h = int(sz * 0.40)
|
|
e_w = int(sz * 0.30)
|
|
mid_w = int(e_w * 0.70)
|
|
bar = int(sz * 0.064)
|
|
cr = int(bar * 0.28) # corner radius for exposed ends
|
|
|
|
left = cx - e_w // 2
|
|
top = cy - e_h // 2
|
|
right = left + e_w
|
|
bottom = top + e_h
|
|
mid_y = cy
|
|
white = (255, 255, 255, 255)
|
|
|
|
# Vertical bar — full height, rounded top-left and bottom-left corners
|
|
draw.rounded_rectangle([left, top, left + bar, bottom], radius=cr, fill=white)
|
|
# Top bar — overlaps vertical bar at left end
|
|
draw.rounded_rectangle([left, top, right, top + bar], radius=cr, fill=white)
|
|
# Middle bar (shorter)
|
|
draw.rounded_rectangle(
|
|
[left, mid_y - bar // 2, left + mid_w, mid_y + bar // 2],
|
|
radius=cr, fill=white,
|
|
)
|
|
# Bottom bar
|
|
draw.rounded_rectangle([left, bottom - bar, right, bottom], radius=cr, fill=white)
|
|
|
|
|
|
def main():
|
|
print("Creating gradient (512x512)...")
|
|
grad_small = create_gradient(GRAD_SMALL)
|
|
grad = grad_small.resize((RENDER, RENDER), Image.BICUBIC)
|
|
# Slight blur to eliminate any banding
|
|
grad = grad.filter(ImageFilter.GaussianBlur(radius=2))
|
|
|
|
print("Building icon at 3072...")
|
|
corner_r = int(RENDER * 0.223)
|
|
mask = Image.new("L", (RENDER, RENDER), 0)
|
|
ImageDraw.Draw(mask).rounded_rectangle(
|
|
[0, 0, RENDER - 1, RENDER - 1], radius=corner_r, fill=255
|
|
)
|
|
|
|
icon = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
|
icon.paste(grad.convert("RGBA"), mask=mask)
|
|
|
|
cx, cy = RENDER // 2, RENDER // 2
|
|
|
|
# Subtle drop shadow for the E
|
|
shadow = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
|
draw_e_rounded(ImageDraw.Draw(shadow), cx, cy + int(RENDER * 0.005), RENDER)
|
|
# Convert white shadow pixels to dark translucent
|
|
sd = shadow.load()
|
|
for sy in range(RENDER):
|
|
for sx in range(RENDER):
|
|
_, _, _, a = sd[sx, sy]
|
|
if a > 0:
|
|
sd[sx, sy] = (25, 18, 12, int(a * 0.15))
|
|
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=int(RENDER * 0.01)))
|
|
icon = Image.alpha_composite(icon, shadow)
|
|
|
|
# White E glyph
|
|
glyph = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
|
|
draw_e_rounded(ImageDraw.Draw(glyph), cx, cy, RENDER)
|
|
icon = Image.alpha_composite(icon, glyph)
|
|
|
|
# Downscale to 1024
|
|
source = icon.resize((FINAL, FINAL), Image.LANCZOS)
|
|
src_path = os.path.join(OUT_DIR, "icon-source-1024.png")
|
|
source.save(src_path, "PNG")
|
|
print(f" Source: {src_path}")
|
|
|
|
# ---- Generate all platform sizes from source ----
|
|
print("Generating platform sizes...")
|
|
|
|
ios_dir = os.path.join(OUT_DIR, "ios")
|
|
android_dir = os.path.join(OUT_DIR, "android")
|
|
web_dir = os.path.join(OUT_DIR, "web")
|
|
macos_dir = os.path.join(OUT_DIR, "macos")
|
|
for d in [ios_dir, android_dir, web_dir, macos_dir]:
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
# iOS
|
|
ios_sizes = [40, 60, 58, 87, 80, 120, 180, 76, 152, 167, 1024]
|
|
for s in ios_sizes:
|
|
resized = source.resize((s, s), Image.LANCZOS)
|
|
resized.save(os.path.join(ios_dir, f"icon-{s}.png"), "PNG")
|
|
print(f" iOS: {len(ios_sizes)} sizes")
|
|
|
|
# Write Contents.json for Xcode
|
|
contents = {
|
|
"images": [
|
|
{"size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x"},
|
|
{"size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x"},
|
|
{"size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x"},
|
|
{"size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x"},
|
|
{"size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x"},
|
|
{"size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x"},
|
|
{"size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x"},
|
|
{"size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x"},
|
|
{"size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x"},
|
|
{"size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x"},
|
|
{"size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x"},
|
|
{"size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x"},
|
|
],
|
|
"info": {"version": 1, "author": "xcode"},
|
|
}
|
|
with open(os.path.join(ios_dir, "Contents.json"), "w") as f:
|
|
json.dump(contents, f, indent=2)
|
|
|
|
# Android
|
|
android_sizes = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}
|
|
for density, s in android_sizes.items():
|
|
resized = source.resize((s, s), Image.LANCZOS)
|
|
resized.save(os.path.join(android_dir, f"ic_launcher_{density}.png"), "PNG")
|
|
source.resize((512, 512), Image.LANCZOS).save(
|
|
os.path.join(android_dir, "playstore-512.png"), "PNG"
|
|
)
|
|
print(f" Android: {len(android_sizes) + 1} sizes")
|
|
|
|
# Web + PWA
|
|
web_sizes = [16, 32, 48, 72, 96, 128, 144, 152, 192, 384, 512]
|
|
for s in web_sizes:
|
|
resized = source.resize((s, s), Image.LANCZOS)
|
|
resized.save(os.path.join(web_dir, f"icon-{s}.png"), "PNG")
|
|
# Apple touch icon
|
|
source.resize((180, 180), Image.LANCZOS).save(
|
|
os.path.join(web_dir, "apple-touch-icon.png"), "PNG"
|
|
)
|
|
# Favicon .ico (multi-size)
|
|
ico_16 = source.resize((16, 16), Image.LANCZOS)
|
|
ico_32 = source.resize((32, 32), Image.LANCZOS)
|
|
ico_48 = source.resize((48, 48), Image.LANCZOS)
|
|
ico_16.save(os.path.join(web_dir, "favicon.ico"), format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
|
print(f" Web/PWA: {len(web_sizes) + 2} assets")
|
|
|
|
# macOS .iconset (for iconutil → .icns)
|
|
iconset_dir = os.path.join(macos_dir, "AppIcon.iconset")
|
|
os.makedirs(iconset_dir, exist_ok=True)
|
|
macos_sizes = {
|
|
"icon_16x16.png": 16,
|
|
"icon_16x16@2x.png": 32,
|
|
"icon_32x32.png": 32,
|
|
"icon_32x32@2x.png": 64,
|
|
"icon_128x128.png": 128,
|
|
"icon_128x128@2x.png": 256,
|
|
"icon_256x256.png": 256,
|
|
"icon_256x256@2x.png": 512,
|
|
"icon_512x512.png": 512,
|
|
"icon_512x512@2x.png": 1024,
|
|
}
|
|
for name, s in macos_sizes.items():
|
|
resized = source.resize((s, s), Image.LANCZOS)
|
|
resized.save(os.path.join(iconset_dir, name), "PNG")
|
|
print(f" macOS: {len(macos_sizes)} sizes in AppIcon.iconset")
|
|
|
|
print("\nDone! All icons in: generated-icons/")
|
|
print("To build macOS .icns: iconutil -c icns generated-icons/macos/AppIcon.iconset")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|