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())