Regenerate installer icon using full-size artwork
|
|
@ -1,8 +1,10 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Generate optimized icon PNGs (32/64/128/256) with a tighter composition and rebuild icon.ico.
|
Generate icon PNGs/ICO for the desktop installer using the high-resolution Raven artwork.
|
||||||
|
|
||||||
This script avoids external imaging dependencies by parsing and writing PNGs directly.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -14,11 +16,8 @@ from binascii import crc32
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons"
|
ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons"
|
||||||
BASE_TRANSPARENT = ICON_DIR / "logo-raven.png"
|
BASE_IMAGE = ICON_DIR / "logo-raven-fund-azul.png"
|
||||||
BACKGROUND_SOURCE = ICON_DIR / "logo-raven-fund-azul.png"
|
|
||||||
|
|
||||||
TARGET_SIZES = [32, 64, 128, 256, 512]
|
TARGET_SIZES = [32, 64, 128, 256, 512]
|
||||||
FILL_RATIO = 0.9 # portion of the canvas occupied by the raven figure
|
|
||||||
|
|
||||||
|
|
||||||
def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]:
|
def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]:
|
||||||
|
|
@ -27,7 +26,7 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]
|
||||||
raise ValueError(f"{path} is not a PNG")
|
raise ValueError(f"{path} is not a PNG")
|
||||||
pos = 8
|
pos = 8
|
||||||
width = height = bit_depth = color_type = None
|
width = height = bit_depth = color_type = None
|
||||||
chunks: list[bytes] = []
|
compressed_parts = []
|
||||||
while pos < len(data):
|
while pos < len(data):
|
||||||
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
||||||
pos += 4
|
pos += 4
|
||||||
|
|
@ -35,18 +34,19 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]
|
||||||
pos += 4
|
pos += 4
|
||||||
chunk = data[pos : pos + length]
|
chunk = data[pos : pos + length]
|
||||||
pos += length
|
pos += length
|
||||||
pos += 4 # CRC, ignored (validated by reader)
|
pos += 4 # CRC
|
||||||
if ctype == b"IHDR":
|
if ctype == b"IHDR":
|
||||||
width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk)
|
width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk)
|
||||||
if bit_depth != 8 or color_type not in (2, 6):
|
if bit_depth != 8 or color_type not in (2, 6):
|
||||||
raise ValueError("Only 8-bit RGB/RGBA PNGs are supported")
|
raise ValueError("Only 8-bit RGB/RGBA PNGs are supported")
|
||||||
elif ctype == b"IDAT":
|
elif ctype == b"IDAT":
|
||||||
chunks.append(chunk)
|
compressed_parts.append(chunk)
|
||||||
elif ctype == b"IEND":
|
elif ctype == b"IEND":
|
||||||
break
|
break
|
||||||
if width is None or height is None or bit_depth is None or color_type is None:
|
if width is None or height is None or bit_depth is None or color_type is None:
|
||||||
raise ValueError(f"{path} missing IHDR")
|
raise ValueError("PNG missing IHDR chunk")
|
||||||
raw = zlib.decompress(b"".join(chunks))
|
|
||||||
|
raw = zlib.decompress(b"".join(compressed_parts))
|
||||||
bpp = 4 if color_type == 6 else 3
|
bpp = 4 if color_type == 6 else 3
|
||||||
stride = width * bpp
|
stride = width * bpp
|
||||||
rows = []
|
rows = []
|
||||||
|
|
@ -57,19 +57,19 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]
|
||||||
idx += 1
|
idx += 1
|
||||||
row = bytearray(raw[idx : idx + stride])
|
row = bytearray(raw[idx : idx + stride])
|
||||||
idx += stride
|
idx += stride
|
||||||
if filter_type == 1: # Sub
|
if filter_type == 1:
|
||||||
for i in range(stride):
|
for i in range(stride):
|
||||||
left = row[i - bpp] if i >= bpp else 0
|
left = row[i - bpp] if i >= bpp else 0
|
||||||
row[i] = (row[i] + left) & 0xFF
|
row[i] = (row[i] + left) & 0xFF
|
||||||
elif filter_type == 2: # Up
|
elif filter_type == 2:
|
||||||
for i in range(stride):
|
for i in range(stride):
|
||||||
row[i] = (row[i] + prev[i]) & 0xFF
|
row[i] = (row[i] + prev[i]) & 0xFF
|
||||||
elif filter_type == 3: # Average
|
elif filter_type == 3:
|
||||||
for i in range(stride):
|
for i in range(stride):
|
||||||
left = row[i - bpp] if i >= bpp else 0
|
left = row[i - bpp] if i >= bpp else 0
|
||||||
up = prev[i]
|
up = prev[i]
|
||||||
row[i] = (row[i] + ((left + up) // 2)) & 0xFF
|
row[i] = (row[i] + ((left + up) // 2)) & 0xFF
|
||||||
elif filter_type == 4: # Paeth
|
elif filter_type == 4:
|
||||||
for i in range(stride):
|
for i in range(stride):
|
||||||
left = row[i - bpp] if i >= bpp else 0
|
left = row[i - bpp] if i >= bpp else 0
|
||||||
up = prev[i]
|
up = prev[i]
|
||||||
|
|
@ -89,24 +89,20 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]
|
||||||
raise ValueError(f"Unsupported PNG filter type {filter_type}")
|
raise ValueError(f"Unsupported PNG filter type {filter_type}")
|
||||||
rows.append(bytes(row))
|
rows.append(bytes(row))
|
||||||
prev[:] = row
|
prev[:] = row
|
||||||
|
|
||||||
pixels: list[list[tuple[int, int, int, int]]] = []
|
pixels: list[list[tuple[int, int, int, int]]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row_pixels = []
|
|
||||||
if color_type == 6:
|
if color_type == 6:
|
||||||
for i in range(0, len(row), 4):
|
pixels.append([tuple(row[i : i + 4]) for i in range(0, len(row), 4)])
|
||||||
row_pixels.append(tuple(row[i : i + 4]))
|
|
||||||
else:
|
else:
|
||||||
for i in range(0, len(row), 3):
|
pixels.append([tuple(row[i : i + 3] + b"\xff") for i in range(0, len(row), 3)])
|
||||||
r, g, b = row[i : i + 3]
|
|
||||||
row_pixels.append((r, g, b, 255))
|
|
||||||
pixels.append(row_pixels)
|
|
||||||
return width, height, pixels
|
return width, height, pixels
|
||||||
|
|
||||||
|
|
||||||
def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None:
|
def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None:
|
||||||
raw = bytearray()
|
raw = bytearray()
|
||||||
for row in pixels:
|
for row in pixels:
|
||||||
raw.append(0) # no filter
|
raw.append(0) # filter type 0
|
||||||
for r, g, b, a in row:
|
for r, g, b, a in row:
|
||||||
raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF))
|
raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF))
|
||||||
compressed = zlib.compress(raw, level=9)
|
compressed = zlib.compress(raw, level=9)
|
||||||
|
|
@ -127,83 +123,50 @@ def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int,
|
||||||
path.write_bytes(out)
|
path.write_bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def crop_content(pixels: list[list[tuple[int, int, int, int]]]) -> list[list[tuple[int, int, int, int]]]:
|
def bilinear_sample(pixels: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]:
|
||||||
height = len(pixels)
|
height = len(pixels)
|
||||||
width = len(pixels[0])
|
width = len(pixels[0])
|
||||||
min_x, min_y = width, height
|
x = min(max(x, 0.0), width - 1.0)
|
||||||
max_x = max_y = -1
|
y = min(max(y, 0.0), height - 1.0)
|
||||||
for y, row in enumerate(pixels):
|
|
||||||
for x, (_, _, _, a) in enumerate(row):
|
|
||||||
if a > 0:
|
|
||||||
if x < min_x:
|
|
||||||
min_x = x
|
|
||||||
if y < min_y:
|
|
||||||
min_y = y
|
|
||||||
if x > max_x:
|
|
||||||
max_x = x
|
|
||||||
if y > max_y:
|
|
||||||
max_y = y
|
|
||||||
if max_x < min_x or max_y < min_y:
|
|
||||||
return pixels
|
|
||||||
cropped = [row[min_x : max_x + 1] for row in pixels[min_y : max_y + 1]]
|
|
||||||
return cropped
|
|
||||||
|
|
||||||
|
|
||||||
def bilinear_sample(data: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]:
|
|
||||||
height = len(data)
|
|
||||||
width = len(data[0])
|
|
||||||
if x < 0:
|
|
||||||
x = 0
|
|
||||||
if y < 0:
|
|
||||||
y = 0
|
|
||||||
if x > width - 1:
|
|
||||||
x = width - 1
|
|
||||||
if y > height - 1:
|
|
||||||
y = height - 1
|
|
||||||
x0 = int(math.floor(x))
|
x0 = int(math.floor(x))
|
||||||
x1 = min(x0 + 1, width - 1)
|
|
||||||
y0 = int(math.floor(y))
|
y0 = int(math.floor(y))
|
||||||
|
x1 = min(x0 + 1, width - 1)
|
||||||
y1 = min(y0 + 1, height - 1)
|
y1 = min(y0 + 1, height - 1)
|
||||||
dx = x - x0
|
dx = x - x0
|
||||||
dy = y - y0
|
dy = y - y0
|
||||||
|
|
||||||
def lerp(a: float, b: float, t: float) -> float:
|
def lerp(a: float, b: float, t: float) -> float:
|
||||||
return a + (b - a) * t
|
return a + (b - a) * t
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for channel in range(4):
|
for channel in range(4):
|
||||||
c00 = data[y0][x0][channel]
|
c00 = pixels[y0][x0][channel]
|
||||||
c10 = data[y0][x1][channel]
|
c10 = pixels[y0][x1][channel]
|
||||||
c01 = data[y1][x0][channel]
|
c01 = pixels[y1][x0][channel]
|
||||||
c11 = data[y1][x1][channel]
|
c11 = pixels[y1][x1][channel]
|
||||||
top = lerp(c00, c10, dx)
|
top = lerp(c00, c10, dx)
|
||||||
bottom = lerp(c01, c11, dx)
|
bottom = lerp(c01, c11, dx)
|
||||||
value = lerp(top, bottom, dy)
|
result.append(int(round(lerp(top, bottom, dy))))
|
||||||
result.append(int(round(value)))
|
|
||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
def resize_with_background(
|
def resize_image(pixels: list[list[tuple[int, int, int, int]]], target: int) -> list[list[tuple[int, int, int, int]]]:
|
||||||
src: list[list[tuple[int, int, int, int]]],
|
src_height = len(pixels)
|
||||||
canvas_size: int,
|
src_width = len(pixels[0])
|
||||||
fill_ratio: float,
|
scale = min(target / src_width, target / src_height)
|
||||||
background: tuple[int, int, int],
|
dest_width = max(1, int(round(src_width * scale)))
|
||||||
) -> list[list[tuple[int, int, int, int]]]:
|
dest_height = max(1, int(round(src_height * scale)))
|
||||||
src_h = len(src)
|
offset_x = (target - dest_width) // 2
|
||||||
src_w = len(src[0])
|
offset_y = (target - dest_height) // 2
|
||||||
scale = fill_ratio * min(canvas_size / src_w, canvas_size / src_h)
|
|
||||||
dest_w = max(1, int(round(src_w * scale)))
|
background = (0, 0, 0, 0)
|
||||||
dest_h = max(1, int(round(src_h * scale)))
|
canvas = [[background for _ in range(target)] for _ in range(target)]
|
||||||
offset_x = (canvas_size - dest_w) // 2
|
|
||||||
offset_y = (canvas_size - dest_h) // 2
|
for dy in range(dest_height):
|
||||||
canvas = [
|
|
||||||
[(background[0], background[1], background[2], 255) for _ in range(canvas_size)]
|
|
||||||
for _ in range(canvas_size)
|
|
||||||
]
|
|
||||||
for dy in range(dest_h):
|
|
||||||
src_y = (dy + 0.5) / scale - 0.5
|
src_y = (dy + 0.5) / scale - 0.5
|
||||||
for dx in range(dest_w):
|
for dx in range(dest_width):
|
||||||
src_x = (dx + 0.5) / scale - 0.5
|
src_x = (dx + 0.5) / scale - 0.5
|
||||||
r, g, b, a = bilinear_sample(src, src_x, src_y)
|
canvas[offset_y + dy][offset_x + dx] = bilinear_sample(pixels, src_x, src_y)
|
||||||
canvas[offset_y + dy][offset_x + dx] = (r, g, b, a)
|
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -213,18 +176,15 @@ def build_ico(output: Path, png_paths: list[Path]) -> None:
|
||||||
for path in png_paths:
|
for path in png_paths:
|
||||||
data = path.read_bytes()
|
data = path.read_bytes()
|
||||||
width, height, _ = read_png(path)
|
width, height, _ = read_png(path)
|
||||||
entry = {
|
entries.append(
|
||||||
"width": width if width < 256 else 0,
|
{
|
||||||
"height": height if height < 256 else 0,
|
"width": width if width < 256 else 0,
|
||||||
"colors": 0,
|
"height": height if height < 256 else 0,
|
||||||
"reserved": 0,
|
"size": len(data),
|
||||||
"planes": 1,
|
"offset": offset,
|
||||||
"bit_count": 32,
|
"payload": data,
|
||||||
"size": len(data),
|
}
|
||||||
"offset": offset,
|
)
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
entries.append(entry)
|
|
||||||
offset += len(data)
|
offset += len(data)
|
||||||
|
|
||||||
header = struct.pack("<HHH", 0, 1, len(entries))
|
header = struct.pack("<HHH", 0, 1, len(entries))
|
||||||
|
|
@ -235,46 +195,43 @@ def build_ico(output: Path, png_paths: list[Path]) -> None:
|
||||||
"<BBBBHHII",
|
"<BBBBHHII",
|
||||||
entry["width"],
|
entry["width"],
|
||||||
entry["height"],
|
entry["height"],
|
||||||
entry["colors"],
|
0,
|
||||||
entry["reserved"],
|
0,
|
||||||
entry["planes"],
|
1,
|
||||||
entry["bit_count"],
|
32,
|
||||||
entry["size"],
|
entry["size"],
|
||||||
entry["offset"],
|
entry["offset"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
body.extend(entry["data"])
|
body.extend(entry["payload"])
|
||||||
output.write_bytes(body)
|
output.write_bytes(body)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
base_w, base_h, base_pixels = read_png(BASE_TRANSPARENT)
|
width, height, pixels = read_png(BASE_IMAGE)
|
||||||
background_w, background_h, bg_pixels = read_png(BACKGROUND_SOURCE)
|
if width != height:
|
||||||
background_color = bg_pixels[background_h // 2][background_w // 2][:3]
|
raise ValueError("Base icon must be square")
|
||||||
|
|
||||||
cropped = crop_content(base_pixels)
|
|
||||||
|
|
||||||
generated: list[Path] = []
|
generated: list[Path] = []
|
||||||
for size in TARGET_SIZES:
|
for size in TARGET_SIZES:
|
||||||
dest_pixels = resize_with_background(cropped, size, FILL_RATIO, background_color)
|
resized = resize_image(pixels, size)
|
||||||
out_path = ICON_DIR / f"icon-{size}.png"
|
out_path = ICON_DIR / f"icon-{size}.png"
|
||||||
write_png(out_path, size, size, dest_pixels)
|
write_png(out_path, size, size, resized)
|
||||||
generated.append(out_path)
|
generated.append(out_path)
|
||||||
print(f"Generated {out_path} ({size}x{size})")
|
print(f"Generated {out_path} ({size}x{size})")
|
||||||
|
|
||||||
# Update base PNG used by some bundlers
|
largest = max(generated, key=lambda p: int(p.stem.split("-")[-1]))
|
||||||
biggest = max(generated, key=lambda p: int(p.stem.split("-")[-1]))
|
(ICON_DIR / "icon.png").write_bytes(largest.read_bytes())
|
||||||
(ICON_DIR / "icon.png").write_bytes(biggest.read_bytes())
|
|
||||||
|
|
||||||
# Build ICO with largest size first (common preference)
|
ico_sources = sorted(
|
||||||
ordered = sorted(
|
|
||||||
[p for p in generated if int(p.stem.split("-")[-1]) <= 256],
|
[p for p in generated if int(p.stem.split("-")[-1]) <= 256],
|
||||||
key=lambda p: int(p.stem.split("-")[-1]),
|
key=lambda p: int(p.stem.split("-")[-1]),
|
||||||
)
|
)
|
||||||
build_ico(ICON_DIR / "icon.ico", ordered)
|
build_ico(ICON_DIR / "icon.ico", ico_sources)
|
||||||
print("icon.ico rebuilt with revised artwork.")
|
print("icon.ico rebuilt.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 189 KiB |