arblant
railway-clean-ip-scanner-pc / assets / make_icon.py
make_icon.py135 lines · 4.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
"""Regenerate assets/icon.ico (+ assets/icon.png) from the approved artwork.

Source of truth: ~/workspace/railway-scanner/icon-options/chatgpt-icon.png
(the aurora-bolt artwork the user approved). Run from the repo root:

    python assets/make_icon.py [path-to-source-png]

Why hand-rolled instead of Pillow's ICO writer: the previous icon.ico
stored EVERY size (including 16/20/24/32/48/64) as PNG-compressed
entries. Some Windows shell surfaces (Explorer views, taskbar/shortcut
rendering, icon-cache paths) fail to draw PNG-compressed small entries
and fall back to a generic/default icon. The maximally compatible
layout — the one Windows itself documents — is:

  * 16/20/24/32/48/64 px -> BMP/DIB entries (32 bpp BGRA + AND mask)
  * 128/256 px           -> PNG entries

This script writes exactly that layout and then re-parses the file and
asserts the entry formats, so a bad icon can never be committed silently.
"""
from __future__ import annotations

import io
import os
import struct
import sys

from PIL import Image

BMP_SIZES = [16, 20, 24, 32, 48, 64]
PNG_SIZES = [128, 256]
ALL_SIZES = BMP_SIZES + PNG_SIZES


def _dib_bytes(img: Image.Image) -> bytes:
    """One BMP/DIB icon entry: BITMAPINFOHEADER + bottom-up BGRA + AND mask."""
    img = img.convert("RGBA")
    w, h = img.size
    px = img.load()
    xor = bytearray()
    for y in range(h - 1, -1, -1):  # bottom-up
        for x in range(w):
            r, g, b, a = px[x, y]
            xor += bytes((b, g, r, a))
    and_row = ((w + 31) // 32) * 4  # 1bpp mask rows padded to 32 bits
    and_mask = bytes(and_row * h)   # all zero: alpha channel rules
    header = struct.pack(
        "<IiiHHIIiiII",
        40,            # biSize
        w,             # biWidth
        2 * h,         # biHeight (XOR + AND)
        1,             # biPlanes
        32,            # biBitCount
        0,             # biCompression = BI_RGB
        len(xor) + len(and_mask),
        0, 0, 0, 0,
    )
    return header + bytes(xor) + and_mask


def _png_bytes(img: Image.Image) -> bytes:
    buf = io.BytesIO()
    img.convert("RGBA").save(buf, format="PNG")
    return buf.getvalue()


def build_ico(src: Image.Image) -> bytes:
    entries = []  # (size, bitcount, payload)
    for size in BMP_SIZES:
        resized = src.resize((size, size), Image.LANCZOS)
        entries.append((size, 32, _dib_bytes(resized)))
    for size in PNG_SIZES:
        resized = src.resize((size, size), Image.LANCZOS)
        entries.append((size, 32, _png_bytes(resized)))

    out = io.BytesIO()
    out.write(struct.pack("<HHH", 0, 1, len(entries)))
    offset = 6 + 16 * len(entries)
    blobs = []
    for size, bitcount, payload in entries:
        out.write(struct.pack(
            "<BBBBHHII",
            size if size < 256 else 0,
            size if size < 256 else 0,
            0, 0, 1, bitcount, len(payload), offset))
        blobs.append(payload)
        offset += len(payload)
    for payload in blobs:
        out.write(payload)
    return out.getvalue()


def verify(ico: bytes) -> None:
    reserved, typ, count = struct.unpack("<HHH", ico[:6])
    assert reserved == 0 and typ == 1 and count == len(ALL_SIZES), "bad header"
    off = 6
    for i in range(count):
        w, h, _cc, _res, _planes, bc, size, offset = struct.unpack(
            "<BBBBHHII", ico[off:off + 16])
        off += 16
        px = w or 256
        is_png = ico[offset:offset + 4] == b"\x89PNG"
        if px in BMP_SIZES:
            assert not is_png, f"{px}px must be BMP/DIB, got PNG"
            dib_h = struct.unpack("<i", ico[offset + 8:offset + 12])[0]
            assert dib_h == 2 * px, f"{px}px DIB height wrong"
        else:
            assert is_png, f"{px}px must be PNG"
        assert bc == 32, f"{px}px bitcount {bc} != 32"
        print(f"  entry {px}x{px}: {'PNG' if is_png else 'BMP/DIB'} 32bpp {size} B")


def main() -> int:
    here = os.path.dirname(os.path.abspath(__file__))
    default_src = os.path.expanduser(
        "~/workspace/railway-scanner/icon-options/chatgpt-icon.png")
    src_path = sys.argv[1] if len(sys.argv) > 1 else default_src
    src = Image.open(src_path).convert("RGBA")
    print(f"source: {src_path} {src.size}")

    ico = build_ico(src)
    verify(ico)
    ico_path = os.path.join(here, "icon.ico")
    with open(ico_path, "wb") as f:
        f.write(ico)
    print(f"wrote {ico_path} ({len(ico)} B)")

    png_path = os.path.join(here, "icon.png")
    src.resize((256, 256), Image.LANCZOS).save(png_path, format="PNG")
    print(f"wrote {png_path} (256x256)")
    return 0


if __name__ == "__main__":
    sys.exit(main())