Regenerate installer icon using full-size artwork

This commit is contained in:
Esdras Renan 2025-10-19 01:33:35 -03:00
parent 275daa7c6e
commit f3cb9038b7
8 changed files with 70 additions and 113 deletions

View file

@ -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, "width": width if width < 256 else 0,
"height": height if height < 256 else 0, "height": height if height < 256 else 0,
"colors": 0,
"reserved": 0,
"planes": 1,
"bit_count": 32,
"size": len(data), "size": len(data),
"offset": offset, "offset": offset,
"data": data, "payload": 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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before After
Before After