Use the HEOS mark, in white, for the header and the icon
The word HEOS at the top of the panel becomes the mark itself, and the home-screen icons are rendered from the same file so there is one place to change it. All white on transparent: the panel is dark, and iOS lays a transparent apple-touch-icon over black. make_icons.py no longer draws its own glyph -- it rasterises logo.svg with headless Chromium, which is heavier than the old arithmetic but the only way to render real paths without a system SVG library. The PNGs are committed, so it only has to run when the logo changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+50
-72
@@ -1,88 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draw the home-screen icon, with no image library to install.
|
||||
"""Render static/logo.svg into the home-screen icons.
|
||||
|
||||
python3 tools/make_icons.py
|
||||
|
||||
Writes static/icon-180.png (what iOS uses for the home screen) and
|
||||
static/icon-512.png (Android / the web manifest). Re-run it if you want
|
||||
different colours -- they are the two constants below.
|
||||
Writes static/icon-180.png (what iOS uses on the home screen) and
|
||||
static/icon-512.png (Android / the web manifest): the white mark on a
|
||||
transparent background, which iOS lays over black on the home screen.
|
||||
|
||||
The PNGs are committed, so this only needs running if the logo changes.
|
||||
It rasterises with headless Chromium, which is a heavy thing to install
|
||||
for one job -- if you have librsvg to hand, this does the same:
|
||||
|
||||
rsvg-convert -w 512 -h 512 static/logo.svg -o static/icon-512.png
|
||||
|
||||
Otherwise: pip install playwright && playwright install chromium
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TOP = (0x1D, 0x27, 0x3C) # background gradient, top
|
||||
BOTTOM = (0x0A, 0x0D, 0x14) # background gradient, bottom
|
||||
GLYPH = (0xEE, 0xF2, 0xF9) # the wave itself
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||
SAMPLES = 3 # supersampling per axis, for smooth edges
|
||||
LOGO = STATIC / "logo.svg"
|
||||
SIZES = (180, 512)
|
||||
MARGIN = 0.14 # breathing room, so iOS's rounded mask never clips it
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<style>
|
||||
html, body {{ margin: 0; background: transparent; }}
|
||||
body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }}
|
||||
img {{ width: {inner}px; height: {inner}px; object-fit: contain; }}
|
||||
</style>
|
||||
<img src="logo.svg">
|
||||
"""
|
||||
|
||||
|
||||
def coverage(x: float, y: float, size: float) -> float:
|
||||
"""How much of the glyph covers this point: a dot plus three arcs
|
||||
opening to the right, i.e. the usual 'sound coming out' mark."""
|
||||
cx, cy = 0.33 * size, 0.5 * size
|
||||
dx, dy = x - cx, y - cy
|
||||
distance = math.hypot(dx, dy)
|
||||
def main():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit("needs playwright: pip install playwright && playwright install chromium")
|
||||
|
||||
if distance <= 0.075 * size:
|
||||
return 1.0
|
||||
if not LOGO.exists():
|
||||
sys.exit(f"no logo at {LOGO}")
|
||||
|
||||
angle = abs(math.degrees(math.atan2(dy, dx)))
|
||||
if angle > 50:
|
||||
return 0.0
|
||||
|
||||
half = 0.024 * size
|
||||
for radius in (0.17, 0.27, 0.37):
|
||||
if abs(distance - radius * size) <= half:
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def render(size: int) -> bytes:
|
||||
rows = bytearray()
|
||||
step = 1.0 / SAMPLES
|
||||
for py in range(size):
|
||||
rows.append(0) # PNG filter type 0 for this scanline
|
||||
mix = py / max(1, size - 1)
|
||||
background = tuple(
|
||||
round(TOP[i] + (BOTTOM[i] - TOP[i]) * mix) for i in range(3)
|
||||
)
|
||||
for px in range(size):
|
||||
hits = 0
|
||||
for sy in range(SAMPLES):
|
||||
for sx in range(SAMPLES):
|
||||
if coverage(px + (sx + 0.5) * step, py + (sy + 0.5) * step, size):
|
||||
hits += 1
|
||||
alpha = hits / (SAMPLES * SAMPLES)
|
||||
if alpha == 0:
|
||||
rows.extend(background)
|
||||
else:
|
||||
rows.extend(
|
||||
round(background[i] + (GLYPH[i] - background[i]) * alpha) for i in range(3)
|
||||
)
|
||||
return bytes(rows)
|
||||
|
||||
|
||||
def write_png(path: Path, size: int, raw: bytes):
|
||||
def chunk(kind: bytes, data: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(data)) + kind + data
|
||||
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF))
|
||||
|
||||
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit truecolour
|
||||
path.write_bytes(
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", header)
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
scratch = STATIC / "_icon.html"
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
for size in SIZES:
|
||||
inner = round(size * (1 - 2 * MARGIN))
|
||||
scratch.write_text(PAGE.format(size=size, inner=inner))
|
||||
page = browser.new_page(viewport={"width": size, "height": size})
|
||||
page.goto(scratch.as_uri())
|
||||
page.wait_for_timeout(120) # let the SVG paint
|
||||
target = STATIC / f"icon-{size}.png"
|
||||
page.screenshot(path=target, omit_background=True)
|
||||
page.close()
|
||||
print(f"wrote {target} ({target.stat().st_size} bytes)")
|
||||
browser.close()
|
||||
finally:
|
||||
scratch.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for size in (180, 512):
|
||||
target = STATIC / f"icon-{size}.png"
|
||||
write_png(target, size, render(size))
|
||||
print(f"wrote {target} ({target.stat().st_size} bytes)")
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user