diff --git a/apps/desktop/scripts/build_raven_icon.py b/apps/desktop/scripts/generate_icon_assets.py similarity index 53% rename from apps/desktop/scripts/build_raven_icon.py rename to apps/desktop/scripts/generate_icon_assets.py index 52f3577..73b0eec 100644 --- a/apps/desktop/scripts/build_raven_icon.py +++ b/apps/desktop/scripts/generate_icon_assets.py @@ -1,8 +1,10 @@ #!/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 @@ -14,11 +16,8 @@ from binascii import crc32 from pathlib import Path ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons" -BASE_TRANSPARENT = ICON_DIR / "logo-raven.png" -BACKGROUND_SOURCE = ICON_DIR / "logo-raven-fund-azul.png" - +BASE_IMAGE = ICON_DIR / "logo-raven-fund-azul.png" 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]]]]: @@ -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") pos = 8 width = height = bit_depth = color_type = None - chunks: list[bytes] = [] + compressed_parts = [] while pos < len(data): length = struct.unpack(">I", data[pos : pos + 4])[0] pos += 4 @@ -35,18 +34,19 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]] pos += 4 chunk = data[pos : pos + length] pos += length - pos += 4 # CRC, ignored (validated by reader) + 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": - chunks.append(chunk) + 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(f"{path} missing IHDR") - raw = zlib.decompress(b"".join(chunks)) + 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 = [] @@ -57,19 +57,19 @@ def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]] idx += 1 row = bytearray(raw[idx : idx + stride]) idx += stride - if filter_type == 1: # Sub + 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: # Up + elif filter_type == 2: for i in range(stride): row[i] = (row[i] + prev[i]) & 0xFF - elif filter_type == 3: # Average + 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: # Paeth + elif filter_type == 4: for i in range(stride): left = row[i - bpp] if i >= bpp else 0 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}") rows.append(bytes(row)) prev[:] = row + pixels: list[list[tuple[int, int, int, int]]] = [] for row in rows: - row_pixels = [] if color_type == 6: - for i in range(0, len(row), 4): - row_pixels.append(tuple(row[i : i + 4])) + pixels.append([tuple(row[i : i + 4]) for i in range(0, len(row), 4)]) else: - 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) + 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) # no filter + 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) @@ -127,83 +123,50 @@ def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, 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) width = len(pixels[0]) - min_x, min_y = width, height - max_x = max_y = -1 - 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 + x = min(max(x, 0.0), width - 1.0) + y = min(max(y, 0.0), height - 1.0) x0 = int(math.floor(x)) - x1 = min(x0 + 1, width - 1) 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 = data[y0][x0][channel] - c10 = data[y0][x1][channel] - c01 = data[y1][x0][channel] - c11 = data[y1][x1][channel] + 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) - value = lerp(top, bottom, dy) - result.append(int(round(value))) + result.append(int(round(lerp(top, bottom, dy)))) return tuple(result) -def resize_with_background( - src: list[list[tuple[int, int, int, int]]], - canvas_size: int, - fill_ratio: float, - background: tuple[int, int, int], -) -> list[list[tuple[int, int, int, int]]]: - src_h = len(src) - src_w = len(src[0]) - scale = fill_ratio * min(canvas_size / src_w, canvas_size / src_h) - dest_w = max(1, int(round(src_w * scale))) - dest_h = max(1, int(round(src_h * scale))) - offset_x = (canvas_size - dest_w) // 2 - offset_y = (canvas_size - dest_h) // 2 - canvas = [ - [(background[0], background[1], background[2], 255) for _ in range(canvas_size)] - for _ in range(canvas_size) - ] - for dy in range(dest_h): +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_w): + for dx in range(dest_width): 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] = (r, g, b, a) + canvas[offset_y + dy][offset_x + dx] = bilinear_sample(pixels, src_x, src_y) return canvas @@ -213,18 +176,15 @@ def build_ico(output: Path, png_paths: list[Path]) -> None: for path in png_paths: data = path.read_bytes() width, height, _ = read_png(path) - entry = { - "width": width if width < 256 else 0, - "height": height if height < 256 else 0, - "colors": 0, - "reserved": 0, - "planes": 1, - "bit_count": 32, - "size": len(data), - "offset": offset, - "data": data, - } - entries.append(entry) + 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(" None: " None: - base_w, base_h, base_pixels = read_png(BASE_TRANSPARENT) - background_w, background_h, bg_pixels = read_png(BACKGROUND_SOURCE) - background_color = bg_pixels[background_h // 2][background_w // 2][:3] - - cropped = crop_content(base_pixels) + 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: - dest_pixels = resize_with_background(cropped, size, FILL_RATIO, background_color) + resized = resize_image(pixels, size) 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) print(f"Generated {out_path} ({size}x{size})") - # Update base PNG used by some bundlers - biggest = max(generated, key=lambda p: int(p.stem.split("-")[-1])) - (ICON_DIR / "icon.png").write_bytes(biggest.read_bytes()) + largest = max(generated, key=lambda p: int(p.stem.split("-")[-1])) + (ICON_DIR / "icon.png").write_bytes(largest.read_bytes()) - # Build ICO with largest size first (common preference) - ordered = sorted( + 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", ordered) - print("icon.ico rebuilt with revised artwork.") + build_ico(ICON_DIR / "icon.ico", ico_sources) + print("icon.ico rebuilt.") if __name__ == "__main__": main() + diff --git a/apps/desktop/src-tauri/icons/icon-128.png b/apps/desktop/src-tauri/icons/icon-128.png index 92b8de7..73fae2a 100644 Binary files a/apps/desktop/src-tauri/icons/icon-128.png and b/apps/desktop/src-tauri/icons/icon-128.png differ diff --git a/apps/desktop/src-tauri/icons/icon-256.png b/apps/desktop/src-tauri/icons/icon-256.png index f352481..598c10f 100644 Binary files a/apps/desktop/src-tauri/icons/icon-256.png and b/apps/desktop/src-tauri/icons/icon-256.png differ diff --git a/apps/desktop/src-tauri/icons/icon-32.png b/apps/desktop/src-tauri/icons/icon-32.png index cc872c2..cfd6dd7 100644 Binary files a/apps/desktop/src-tauri/icons/icon-32.png and b/apps/desktop/src-tauri/icons/icon-32.png differ diff --git a/apps/desktop/src-tauri/icons/icon-512.png b/apps/desktop/src-tauri/icons/icon-512.png index 63202ef..76464d1 100644 Binary files a/apps/desktop/src-tauri/icons/icon-512.png and b/apps/desktop/src-tauri/icons/icon-512.png differ diff --git a/apps/desktop/src-tauri/icons/icon-64.png b/apps/desktop/src-tauri/icons/icon-64.png index cee1d09..5c551e4 100644 Binary files a/apps/desktop/src-tauri/icons/icon-64.png and b/apps/desktop/src-tauri/icons/icon-64.png differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index ed0a29c..c970697 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 63202ef..76464d1 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ