Regenerate installer icon using full-size artwork
This commit is contained in:
parent
275daa7c6e
commit
f3cb9038b7
8 changed files with 70 additions and 113 deletions
237
apps/desktop/scripts/generate_icon_assets.py
Normal file
237
apps/desktop/scripts/generate_icon_assets.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate icon PNGs/ICO for the desktop installer using the high-resolution Raven artwork.
|
||||
|
||||
The script reads the square logo (`logo-raven-fund-azul.png`) and resizes it to the
|
||||
target sizes with a simple bilinear filter implemented with the Python standard library,
|
||||
avoiding additional dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from binascii import crc32
|
||||
from pathlib import Path
|
||||
|
||||
ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons"
|
||||
BASE_IMAGE = ICON_DIR / "logo-raven-fund-azul.png"
|
||||
TARGET_SIZES = [32, 64, 128, 256, 512]
|
||||
|
||||
|
||||
def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]:
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise ValueError(f"{path} is not a PNG")
|
||||
pos = 8
|
||||
width = height = bit_depth = color_type = None
|
||||
compressed_parts = []
|
||||
while pos < len(data):
|
||||
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
||||
pos += 4
|
||||
ctype = data[pos : pos + 4]
|
||||
pos += 4
|
||||
chunk = data[pos : pos + length]
|
||||
pos += length
|
||||
pos += 4 # CRC
|
||||
if ctype == b"IHDR":
|
||||
width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk)
|
||||
if bit_depth != 8 or color_type not in (2, 6):
|
||||
raise ValueError("Only 8-bit RGB/RGBA PNGs are supported")
|
||||
elif ctype == b"IDAT":
|
||||
compressed_parts.append(chunk)
|
||||
elif ctype == b"IEND":
|
||||
break
|
||||
if width is None or height is None or bit_depth is None or color_type is None:
|
||||
raise ValueError("PNG missing IHDR chunk")
|
||||
|
||||
raw = zlib.decompress(b"".join(compressed_parts))
|
||||
bpp = 4 if color_type == 6 else 3
|
||||
stride = width * bpp
|
||||
rows = []
|
||||
idx = 0
|
||||
prev = bytearray(stride)
|
||||
for _ in range(height):
|
||||
filter_type = raw[idx]
|
||||
idx += 1
|
||||
row = bytearray(raw[idx : idx + stride])
|
||||
idx += stride
|
||||
if filter_type == 1:
|
||||
for i in range(stride):
|
||||
left = row[i - bpp] if i >= bpp else 0
|
||||
row[i] = (row[i] + left) & 0xFF
|
||||
elif filter_type == 2:
|
||||
for i in range(stride):
|
||||
row[i] = (row[i] + prev[i]) & 0xFF
|
||||
elif filter_type == 3:
|
||||
for i in range(stride):
|
||||
left = row[i - bpp] if i >= bpp else 0
|
||||
up = prev[i]
|
||||
row[i] = (row[i] + ((left + up) // 2)) & 0xFF
|
||||
elif filter_type == 4:
|
||||
for i in range(stride):
|
||||
left = row[i - bpp] if i >= bpp else 0
|
||||
up = prev[i]
|
||||
up_left = prev[i - bpp] if i >= bpp else 0
|
||||
p = left + up - up_left
|
||||
pa = abs(p - left)
|
||||
pb = abs(p - up)
|
||||
pc = abs(p - up_left)
|
||||
if pa <= pb and pa <= pc:
|
||||
pr = left
|
||||
elif pb <= pc:
|
||||
pr = up
|
||||
else:
|
||||
pr = up_left
|
||||
row[i] = (row[i] + pr) & 0xFF
|
||||
elif filter_type not in (0,):
|
||||
raise ValueError(f"Unsupported PNG filter type {filter_type}")
|
||||
rows.append(bytes(row))
|
||||
prev[:] = row
|
||||
|
||||
pixels: list[list[tuple[int, int, int, int]]] = []
|
||||
for row in rows:
|
||||
if color_type == 6:
|
||||
pixels.append([tuple(row[i : i + 4]) for i in range(0, len(row), 4)])
|
||||
else:
|
||||
pixels.append([tuple(row[i : i + 3] + b"\xff") for i in range(0, len(row), 3)])
|
||||
return width, height, pixels
|
||||
|
||||
|
||||
def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None:
|
||||
raw = bytearray()
|
||||
for row in pixels:
|
||||
raw.append(0) # filter type 0
|
||||
for r, g, b, a in row:
|
||||
raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF))
|
||||
compressed = zlib.compress(raw, level=9)
|
||||
|
||||
def chunk(name: bytes, payload: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">I", len(payload))
|
||||
+ name
|
||||
+ payload
|
||||
+ struct.pack(">I", crc32(name + payload) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
out = bytearray(b"\x89PNG\r\n\x1a\n")
|
||||
out += chunk(b"IHDR", ihdr)
|
||||
out += chunk(b"IDAT", compressed)
|
||||
out += chunk(b"IEND", b"")
|
||||
path.write_bytes(out)
|
||||
|
||||
|
||||
def bilinear_sample(pixels: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]:
|
||||
height = len(pixels)
|
||||
width = len(pixels[0])
|
||||
x = min(max(x, 0.0), width - 1.0)
|
||||
y = min(max(y, 0.0), height - 1.0)
|
||||
x0 = int(math.floor(x))
|
||||
y0 = int(math.floor(y))
|
||||
x1 = min(x0 + 1, width - 1)
|
||||
y1 = min(y0 + 1, height - 1)
|
||||
dx = x - x0
|
||||
dy = y - y0
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
result = []
|
||||
for channel in range(4):
|
||||
c00 = pixels[y0][x0][channel]
|
||||
c10 = pixels[y0][x1][channel]
|
||||
c01 = pixels[y1][x0][channel]
|
||||
c11 = pixels[y1][x1][channel]
|
||||
top = lerp(c00, c10, dx)
|
||||
bottom = lerp(c01, c11, dx)
|
||||
result.append(int(round(lerp(top, bottom, dy))))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def resize_image(pixels: list[list[tuple[int, int, int, int]]], target: int) -> list[list[tuple[int, int, int, int]]]:
|
||||
src_height = len(pixels)
|
||||
src_width = len(pixels[0])
|
||||
scale = min(target / src_width, target / src_height)
|
||||
dest_width = max(1, int(round(src_width * scale)))
|
||||
dest_height = max(1, int(round(src_height * scale)))
|
||||
offset_x = (target - dest_width) // 2
|
||||
offset_y = (target - dest_height) // 2
|
||||
|
||||
background = (0, 0, 0, 0)
|
||||
canvas = [[background for _ in range(target)] for _ in range(target)]
|
||||
|
||||
for dy in range(dest_height):
|
||||
src_y = (dy + 0.5) / scale - 0.5
|
||||
for dx in range(dest_width):
|
||||
src_x = (dx + 0.5) / scale - 0.5
|
||||
canvas[offset_y + dy][offset_x + dx] = bilinear_sample(pixels, src_x, src_y)
|
||||
return canvas
|
||||
|
||||
|
||||
def build_ico(output: Path, png_paths: list[Path]) -> None:
|
||||
entries = []
|
||||
offset = 6 + 16 * len(png_paths)
|
||||
for path in png_paths:
|
||||
data = path.read_bytes()
|
||||
width, height, _ = read_png(path)
|
||||
entries.append(
|
||||
{
|
||||
"width": width if width < 256 else 0,
|
||||
"height": height if height < 256 else 0,
|
||||
"size": len(data),
|
||||
"offset": offset,
|
||||
"payload": data,
|
||||
}
|
||||
)
|
||||
offset += len(data)
|
||||
|
||||
header = struct.pack("<HHH", 0, 1, len(entries))
|
||||
body = bytearray(header)
|
||||
for entry in entries:
|
||||
body.extend(
|
||||
struct.pack(
|
||||
"<BBBBHHII",
|
||||
entry["width"],
|
||||
entry["height"],
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
32,
|
||||
entry["size"],
|
||||
entry["offset"],
|
||||
)
|
||||
)
|
||||
for entry in entries:
|
||||
body.extend(entry["payload"])
|
||||
output.write_bytes(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
width, height, pixels = read_png(BASE_IMAGE)
|
||||
if width != height:
|
||||
raise ValueError("Base icon must be square")
|
||||
|
||||
generated: list[Path] = []
|
||||
for size in TARGET_SIZES:
|
||||
resized = resize_image(pixels, size)
|
||||
out_path = ICON_DIR / f"icon-{size}.png"
|
||||
write_png(out_path, size, size, resized)
|
||||
generated.append(out_path)
|
||||
print(f"Generated {out_path} ({size}x{size})")
|
||||
|
||||
largest = max(generated, key=lambda p: int(p.stem.split("-")[-1]))
|
||||
(ICON_DIR / "icon.png").write_bytes(largest.read_bytes())
|
||||
|
||||
ico_sources = sorted(
|
||||
[p for p in generated if int(p.stem.split("-")[-1]) <= 256],
|
||||
key=lambda p: int(p.stem.split("-")[-1]),
|
||||
)
|
||||
build_ico(ICON_DIR / "icon.ico", ico_sources)
|
||||
print("icon.ico rebuilt.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue