diff --git a/docs/push.md b/docs/push.md index ed0dab5..b42cc51 100644 --- a/docs/push.md +++ b/docs/push.md @@ -53,6 +53,25 @@ `index.html` carries the manifest link, `theme-color`, and `apple-touch-icon`. +Every icon is generated by `scripts/gen_pwa_icons.py`, which redraws the mark +from `logo.svg`'s geometry with Pillow (no SVG rasterizer needed) — regenerate +the whole set after touching the logo: + +```bash +.venv/bin/python scripts/gen_pwa_icons.py # rewrite all five +.venv/bin/python scripts/gen_pwa_icons.py --check # measure what is on disk +``` + +The mark's outer circle takes 73.4% of the canvas in **all five** files, the +proportion it has in `logo.svg` itself and the one the Android app icon shows +inside its launcher tile. A maskable icon may be cropped to a mask as tight as +a circle of 80% of the canvas, so the maskable files keep the same 73.4% — +their mark's outer radius is 36.7%, comfortably inside that circle. They were +once generated at ~21%, which shrank the mark to a fragment of the logo; the +`--check` pass fails loudly if the scale ever drifts again. `dist/` carries a +copy of these files, so rebuild and commit it (`npm run build`) whenever the +icons change. + ## Web push Backend: `navi/push/` (`store.py` asyncpg store, `service.py` fan-out), diff --git a/scripts/gen_pwa_icons.py b/scripts/gen_pwa_icons.py new file mode 100644 index 0000000..4c882bd --- /dev/null +++ b/scripts/gen_pwa_icons.py @@ -0,0 +1,152 @@ +"""Regenerate the PWA icon set from the Navi logo geometry. + +The mark is defined once, in `webclient/public/images/logo.svg` (a 256-unit +viewBox): an open circle arc (r=90, stroke 8, dash 520/40 of the circumference +— a 331 deg arc whose opening sits just above 3 o'clock) plus a rounded plus +(half-arm 40, stroke 10). The Android adaptive icon +(`android-client/.../drawable/ic_launcher_foreground.xml`) draws the same mark +with the same proportions on its own 108-unit canvas. + +This script redraws the mark with Pillow (already a project dependency, so no +SVG rasterizer is needed) and writes the sizes `manifest.webmanifest` and +`index.html` reference. + +`INK` below is the fraction of the canvas spanned by the mark's outer circle, +i.e. by the arc. Every target uses 0.734 — the mark exactly as `logo.svg` +places it in its 256-unit box, which is also how much of the launcher tile the +mark covers in the Android app icon: + + * "any" icons and the apple-touch-icon fill their tile, as before; + * maskable icons use the same 0.734. Chrome crops a maskable icon to a mask + no smaller than a circle of 80% of the canvas; the mark's outer radius is + 36.7% of the canvas, so it stays inside that circle with room to spare. + Earlier revisions of the maskable files were generated at ~0.21, which left + the mark looking like a fragment of the logo rather than the logo. + +Usage: + .venv/bin/python scripts/gen_pwa_icons.py # rewrite the icons + .venv/bin/python scripts/gen_pwa_icons.py --check # measure what is on disk +""" + +from __future__ import annotations + +import argparse +import math +from pathlib import Path + +from PIL import Image, ImageDraw + +IMAGES = Path(__file__).resolve().parents[1] / "webclient" / "public" / "images" + +# --- logo.svg, in its own 256-unit viewBox -------------------------------- +ARC_R = 90.0 +ARC_STROKE = 8.0 +ARC_DASH = 520.0 # of a 2*pi*90 = 565.49 circumference +CROSS_HALF = 40.0 +CROSS_STROKE = 10.0 + +ARC_SWEEP = 360.0 * ARC_DASH / (2 * math.pi * ARC_R) +INK_UNITS = 2 * (ARC_R + ARC_STROKE / 2) # 188 + +BG = (0x16, 0x16, 0x1E) # manifest background_color / theme_color +FG = (0x00, 0xB5, 0xD8) + +INK = 0.734 +SUPERSAMPLE = 8 + +TARGETS = ( + ("icon-180.png", 180, INK), + ("icon-192.png", 192, INK), + ("icon-512.png", 512, INK), + ("icon-maskable-192.png", 192, INK), + ("icon-maskable-512.png", 512, INK), +) + + +def _cap(draw: ImageDraw.ImageDraw, cx: float, cy: float, x: float, y: float, r: float) -> None: + """Round line cap: a filled circle of radius r centred on (x, y).""" + draw.ellipse([x - r, y - r, x + r, y + r], fill=FG) + + +def render(size: int, ink: float) -> Image.Image: + """Render the mark at `size` px, its outer circle spanning `ink` of the canvas.""" + su = size * SUPERSAMPLE + scale = ink * su / INK_UNITS # logo.svg units -> supersampled pixels + c = su / 2 + + r = ARC_R * scale + arc_w = max(1, round(ARC_STROKE * scale)) + half = CROSS_HALF * scale + cross_w = max(1, round(CROSS_STROKE * scale)) + + img = Image.new("RGB", (su, su), BG) + d = ImageDraw.Draw(img) + + # Pillow strokes a thick arc inward from the given bbox, so pass the outer + # radius and let it fill towards the centreline. + outer = r + arc_w / 2 + d.arc( + [c - outer, c - outer, c + outer, c + outer], + start=0, + end=ARC_SWEEP, + fill=FG, + width=arc_w, + ) # 0 deg is 3 o'clock, angles grow clockwise — same as the SVG dash start + for deg in (0.0, ARC_SWEEP): + a = math.radians(deg) + _cap(d, c, c, c + r * math.cos(a), c + r * math.sin(a), arc_w / 2) + + d.line([c - half, c, c + half, c], fill=FG, width=cross_w) + d.line([c, c - half, c, c + half], fill=FG, width=cross_w) + for x, y in ((c - half, c), (c + half, c), (c, c - half), (c, c + half)): + _cap(d, c, c, x, y, cross_w / 2) + + return img.resize((size, size), Image.LANCZOS) + + +def measure(path: Path) -> tuple[int, int, float]: + """Ink bounding box and its fraction of the canvas, for a written icon. + + Edges are counted at 50% coverage rather than "any pixel that is not the + background" — an antialiased edge fades out over a pixel or two, and the + loose threshold would report the mark a pixel wider on each side. + """ + im = Image.open(path).convert("RGB") + w, h = im.size + px = im.load() + bg = px[0, 0] + threshold = 0.5 * sum(abs(a - b) for a, b in zip(bg, FG)) + minx, miny, maxx, maxy = w, h, -1, -1 + for y in range(h): + for x in range(w): + p = px[x, y] + if sum(abs(a - b) for a, b in zip(bg, p)) >= threshold: + minx, miny = min(minx, x), min(miny, y) + maxx, maxy = max(maxx, x), max(maxy, y) + if maxx < 0: + raise SystemExit(f"{path.name}: no ink found") + return maxx - minx + 1, maxy - miny + 1, (maxx - minx + 1) / w + + +def verdict(w: int, h: int, expected: int) -> str: + """`ok` when the mark covers the canvas as asked — 1 px of slack for the + antialiased edge, which lands either side of the 50% line by a fraction.""" + return "ok" if abs(w - expected) <= 1 and abs(h - expected) <= 1 else "SUSPECT" + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--check", action="store_true", help="measure on-disk icons, write nothing") + args = ap.parse_args() + + for name, size, ink in TARGETS: + path = IMAGES / name + expected = round(ink * size) + if not args.check: + render(size, ink).save(path, optimize=True) + w, h, frac = measure(path) + print(f"{name:24s} {size}x{size} ink {w}x{h} = {frac:.1%} expected {ink:.1%} {verdict(w, h, expected)}") + + +if __name__ == "__main__": + main() diff --git a/webclient/dist/images/icon-180.png b/webclient/dist/images/icon-180.png index 4d77545..c741b7b 100644 --- a/webclient/dist/images/icon-180.png +++ b/webclient/dist/images/icon-180.png Binary files differ diff --git a/webclient/dist/images/icon-192.png b/webclient/dist/images/icon-192.png index d3374f3..c80bebb 100644 --- a/webclient/dist/images/icon-192.png +++ b/webclient/dist/images/icon-192.png Binary files differ diff --git a/webclient/dist/images/icon-512.png b/webclient/dist/images/icon-512.png index 6f32493..0c6d7ed 100644 --- a/webclient/dist/images/icon-512.png +++ b/webclient/dist/images/icon-512.png Binary files differ diff --git a/webclient/dist/images/icon-maskable-192.png b/webclient/dist/images/icon-maskable-192.png index 4677643..c80bebb 100644 --- a/webclient/dist/images/icon-maskable-192.png +++ b/webclient/dist/images/icon-maskable-192.png Binary files differ diff --git a/webclient/dist/images/icon-maskable-512.png b/webclient/dist/images/icon-maskable-512.png index 84866b1..0c6d7ed 100644 --- a/webclient/dist/images/icon-maskable-512.png +++ b/webclient/dist/images/icon-maskable-512.png Binary files differ diff --git a/webclient/public/images/icon-180.png b/webclient/public/images/icon-180.png index 4d77545..c741b7b 100644 --- a/webclient/public/images/icon-180.png +++ b/webclient/public/images/icon-180.png Binary files differ diff --git a/webclient/public/images/icon-192.png b/webclient/public/images/icon-192.png index d3374f3..c80bebb 100644 --- a/webclient/public/images/icon-192.png +++ b/webclient/public/images/icon-192.png Binary files differ diff --git a/webclient/public/images/icon-512.png b/webclient/public/images/icon-512.png index 6f32493..0c6d7ed 100644 --- a/webclient/public/images/icon-512.png +++ b/webclient/public/images/icon-512.png Binary files differ diff --git a/webclient/public/images/icon-maskable-192.png b/webclient/public/images/icon-maskable-192.png index 4677643..c80bebb 100644 --- a/webclient/public/images/icon-maskable-192.png +++ b/webclient/public/images/icon-maskable-192.png Binary files differ diff --git a/webclient/public/images/icon-maskable-512.png b/webclient/public/images/icon-maskable-512.png index 84866b1..0c6d7ed 100644 --- a/webclient/public/images/icon-maskable-512.png +++ b/webclient/public/images/icon-maskable-512.png Binary files differ