#!/usr/bin/env python3 # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT """iconBake.py - a program's icon, from one picture, for each system that shows programs by an icon. The picture is a PNG (or anything PIL reads), best square, with transparency; it is fitted into each icon's size keeping its shape, and centred. A picture of 64 pixels or less is taken for pixel art and enlarged by whole pixels; anything else is resampled smoothly. Where an icon has few colours it takes the nearest (the 8-bit Windows icon dithers to its own 256), and a pixel at least half transparent is transparent. iconBake.py ico PICTURE OUT.ico Windows 95 on (Win32): 16, 32 and 48 pixels, each in 256 colours and in 32-bit iconBake.py ico16 PICTURE OUT.ico Windows 3.x (Win16): 32x32 in the 16 VGA colours iconBake.py bmp PICTURE OUT.bmp DVX: 32x32, 24-bit, on DVX's grey iconBake.py png PICTURE OUT.png WxH a picture WxH (the PSP's 144x80 ICON0, a web page's icon) iconBake.py info PICTURE OUT.info STACK the Amiga's Workbench: a tool's icon in its four colours, STACK bytes of stack when started from it iconBake.py be PICTURE OUT32 OUT16 BeOS and Haiku: 32x32 and 16x16 in the BeOS palette, raw, for xres (ICON, MICN) iconBake.py c PICTURE OUT.c SIZE the SDL3 backend's window icon: SIZE x SIZE RGBA as C iconBake.py iigs PICTURE OUT NAME the Apple IIgs Finder: an icon file ($CA) giving the program NAME its icons, in the Finder's 16 colours """ import math import struct import sys from PIL import Image # A picture this small or smaller is pixel art: enlarged by whole pixels. PIXEL_ART_MAX = 64 ALPHA_OPAQUE_MIN = 128 # Windows: the sizes Win32 icons come in, and the 16 colours of a Windows # 3.x icon (the VGA's, in Windows' order). ICO_SIZES = (16, 32, 48) ICO16_SIZE = 32 VGA16 = ( (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ) ICO_TYPE = 1 BITMAPINFOHEADER_BYTES = 40 ICONDIR_BYTES = 6 ICONDIRENTRY_BYTES = 16 # DVX: its icons' size and the grey they sit on (its own apps'). DVX_SIZE = 32 DVX_GREY = (192, 192, 192) # The Amiga: a Workbench tool icon (struct DiskObject, workbench.h) of # AMIGA_W x AMIGA_H Workbench pixels, which are twice as tall as wide, in # the four colours Workbench 2.0 on has (grey, black, white, blue). AMIGA_W = 48 AMIGA_H = 24 AMIGA_DEPTH = 2 AMIGA_COLOURS = ((170, 170, 170), (0, 0, 0), (255, 255, 255), (102, 136, 187)) WB_DISKMAGIC = 0xE310 WB_DISKVERSION = 1 WB_DISKREVISION = 1 WBTOOL = 3 NO_ICON_POSITION = 0x80000000 GFLG_GADGIMAGE = 0x0004 GACT_RELVERIFY_IMMEDIATE = 0x0003 GTYP_BOOLGADGET = 0x0001 # Any non-zero pointer: on disk it says the structure follows. PRESENT = 1 # BeOS and Haiku: 32x32 and 16x16 icons in the system palette (Haiku's # headers/private/interface/Palette.h, MIT; index 255 is transparent, # B_TRANSPARENT_MAGIC_CMAP8). BE_LARGE = 32 BE_SMALL = 16 BE_TRANSPARENT = 255 BE_PALETTE = ( 0, 0, 0, 8, 8, 8, 16, 16, 16, 24, 24, 24, 32, 32, 32, 40, 40, 40, 48, 48, 48, 56, 56, 56, 64, 64, 64, 72, 72, 72, 80, 80, 80, 88, 88, 88, 96, 96, 96, 104, 104, 104, 112, 112, 112, 120, 120, 120, 128, 128, 128, 136, 136, 136, 144, 144, 144, 152, 152, 152, 160, 160, 160, 168, 168, 168, 176, 176, 176, 184, 184, 184, 192, 192, 192, 200, 200, 200, 208, 208, 208, 216, 216, 216, 224, 224, 224, 232, 232, 232, 240, 240, 240, 248, 248, 248, 0, 0, 255, 0, 0, 229, 0, 0, 204, 0, 0, 179, 0, 0, 154, 0, 0, 129, 0, 0, 105, 0, 0, 80, 0, 0, 55, 0, 0, 30, 255, 0, 0, 228, 0, 0, 203, 0, 0, 178, 0, 0, 153, 0, 0, 128, 0, 0, 105, 0, 0, 80, 0, 0, 55, 0, 0, 30, 0, 0, 0, 255, 0, 0, 228, 0, 0, 203, 0, 0, 178, 0, 0, 153, 0, 0, 128, 0, 0, 105, 0, 0, 80, 0, 0, 55, 0, 0, 30, 0, 0, 152, 51, 255, 255, 255, 203, 255, 255, 203, 255, 203, 203, 255, 152, 203, 255, 102, 203, 255, 51, 203, 255, 0, 152, 255, 255, 152, 255, 203, 152, 255, 152, 152, 255, 102, 152, 255, 51, 152, 255, 0, 102, 255, 255, 102, 255, 203, 102, 255, 152, 102, 255, 102, 102, 255, 51, 102, 255, 0, 51, 255, 255, 51, 255, 203, 51, 255, 152, 51, 255, 102, 51, 255, 51, 51, 255, 0, 255, 152, 255, 255, 152, 203, 255, 152, 152, 255, 152, 102, 255, 152, 51, 255, 152, 0, 0, 102, 255, 0, 102, 203, 203, 203, 255, 203, 203, 203, 203, 203, 152, 203, 203, 102, 203, 203, 51, 203, 203, 0, 152, 203, 255, 152, 203, 203, 152, 203, 152, 152, 203, 102, 152, 203, 51, 152, 203, 0, 102, 203, 255, 102, 203, 203, 102, 203, 152, 102, 203, 102, 102, 203, 51, 102, 203, 0, 51, 203, 255, 51, 203, 203, 51, 203, 152, 51, 203, 102, 51, 203, 51, 51, 203, 0, 255, 102, 255, 255, 102, 203, 255, 102, 152, 255, 102, 102, 255, 102, 51, 255, 102, 0, 0, 102, 152, 0, 102, 102, 203, 152, 255, 203, 152, 203, 203, 152, 152, 203, 152, 102, 203, 152, 51, 203, 152, 0, 152, 152, 255, 152, 152, 203, 152, 152, 152, 152, 152, 102, 152, 152, 51, 152, 152, 0, 102, 152, 255, 102, 152, 203, 102, 152, 152, 102, 152, 102, 102, 152, 51, 102, 152, 0, 51, 152, 255, 51, 152, 203, 51, 152, 152, 51, 152, 102, 51, 152, 51, 51, 152, 0, 230, 134, 0, 255, 51, 203, 255, 51, 152, 255, 51, 102, 255, 51, 51, 255, 51, 0, 0, 102, 51, 0, 102, 0, 203, 102, 255, 203, 102, 203, 203, 102, 152, 203, 102, 102, 203, 102, 51, 203, 102, 0, 152, 102, 255, 152, 102, 203, 152, 102, 152, 152, 102, 102, 152, 102, 51, 152, 102, 0, 102, 102, 255, 102, 102, 203, 102, 102, 152, 102, 102, 102, 102, 102, 51, 102, 102, 0, 51, 102, 255, 51, 102, 203, 51, 102, 152, 51, 102, 102, 51, 102, 51, 51, 102, 0, 255, 0, 255, 255, 0, 203, 255, 0, 152, 255, 0, 102, 255, 0, 51, 255, 175, 19, 0, 51, 255, 0, 51, 203, 203, 51, 255, 203, 51, 203, 203, 51, 152, 203, 51, 102, 203, 51, 51, 203, 51, 0, 152, 51, 255, 152, 51, 203, 152, 51, 152, 152, 51, 102, 152, 51, 51, 152, 51, 0, 102, 51, 255, 102, 51, 203, 102, 51, 152, 102, 51, 102, 102, 51, 51, 102, 51, 0, 51, 51, 255, 51, 51, 203, 51, 51, 152, 51, 51, 102, 51, 51, 51, 51, 51, 0, 255, 203, 102, 255, 203, 152, 255, 203, 203, 255, 203, 255, 0, 51, 152, 0, 51, 102, 0, 51, 51, 0, 51, 0, 203, 0, 255, 203, 0, 203, 203, 0, 152, 203, 0, 102, 203, 0, 51, 255, 227, 70, 152, 0, 255, 152, 0, 203, 152, 0, 152, 152, 0, 102, 152, 0, 51, 152, 0, 0, 102, 0, 255, 102, 0, 203, 102, 0, 152, 102, 0, 102, 102, 0, 51, 102, 0, 0, 51, 0, 255, 51, 0, 203, 51, 0, 152, 51, 0, 102, 51, 0, 51, 51, 0, 0, 255, 203, 51, 255, 203, 0, 255, 255, 0, 255, 255, 51, 255, 255, 102, 255, 255, 152, 255, 255, 203, 255, 255, 255, ) # The Apple IIgs Finder: an icon file (file type $CA; Apple's File Type Note # $CA, "Finder Icons File") in a volume's Icons folder, holding one icon data # record that gives a GS/OS application (file type $B3) of the program's name # its icons, normal and small. Each is a QuickDraw II icon (Toolbox Reference # volume 2, QuickDraw II Auxiliary): iconType, iconSize (the image's bytes), # iconHeight, iconWidth, the image at 4 bits a pixel (rows of whole bytes, # the even pixel in the high nibble), then a mask of the same shape, non-zero # where the icon is opaque. They are colour icons (iconType bit 15), whose # colours the Finder leaves alone. The Finder runs in 640 mode, where # QuickDraw draws each 4-bit icon pixel as two screen pixels, its high two # bits then its low two, and in the Finder's palette the first of the pair # is black, red, green, or white and the second black, blue, yellow, or white # (IIGS_FIRST, IIGS_SECOND: measured in MAME, an icon at an even screen x), # so an icon pixel is one of 16 colours a pair makes, seen as their mean. An # icon pixel is some 1.2 times as tall as wide; the big icon is the size of # the Finder's own (its folder is 24 pixels wide). IIGS_BIG = (24, 20) IIGS_SMALL = (12, 10) IIGS_PIXEL_ASPECT = 1.2 IIGS_FIRST = ((0, 0, 0), (255, 0, 0), (0, 255, 0), (255, 255, 255)) IIGS_SECOND = ((0, 0, 0), (0, 0, 255), (255, 255, 0), (255, 255, 255)) IIGS_HALF_BITS = 2 IIGS_HALF_MASK = 3 IIGS_BLACK = 0x0 IIGS_WHITE = 0xF IIGS_OPAQUE = 0xF IIGS_ICON_TYPE_COLOUR = 0x8000 IIGS_ICON_FILE_ID = 0x0001 IIGS_APPLICATION_TYPE = 0x00B3 IIGS_FILE_NAME_BYTES = 16 IIGS_BOSS_BYTES = 64 IIGS_NAME_BYTES = 16 # A pixel with less colour than this (CIELAB chroma) may be shown in a grey; # a more colourful one keeps a colour, or black or white: brown fur nearest # a grey by lightness reads better in the nearest colour. IIGS_GREY_CHROMA = 12 # CIE94 (graphic arts): how much a difference in chroma and in hue counts, # against the colour's own chroma. CIE94_CHROMA = 0.045 CIE94_HUE = 0.015 # sRGB to CIE XYZ (D65), and CIELAB's white point and curve. XYZ_FROM_RGB = ((0.4124, 0.3576, 0.1805), (0.2126, 0.7152, 0.0722), (0.0193, 0.1192, 0.9505)) LAB_WHITE = (0.95047, 1.0, 1.08883) LAB_EPSILON = 0.008856 LAB_KAPPA = 7.787 def fit(picture, w, h): """picture fitted into w x h, its shape kept, centred on transparency.""" pw, ph = picture.size scale = min(w / pw, h / ph) if max(pw, ph) <= PIXEL_ART_MAX and scale >= 2: scale = int(scale) sized = picture.resize((pw * scale, ph * scale), Image.NEAREST) else: sized = picture.resize((max(1, round(pw * scale)), max(1, round(ph * scale))), Image.LANCZOS) out = Image.new("RGBA", (w, h), (0, 0, 0, 0)) out.paste(sized, ((w - sized.size[0]) // 2, (h - sized.size[1]) // 2)) return out def opaque(image): """Each pixel's opacity: True where it is at least half opaque.""" return [a >= ALPHA_OPAQUE_MIN for a in image.getchannel("A").getdata()] def toColours(image, colours, dither, background): """The image's pixels as indices into colours: over background where transparent, dithered (Floyd-Steinberg) or the nearest.""" flat = Image.new("RGBA", image.size, background + (255,)) flat.alpha_composite(image) palette = Image.new("P", (1, 1)) entries = [c for rgb in colours for c in rgb] palette.putpalette(entries + entries[:3] * (256 - len(colours))) mode = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE return list(flat.convert("RGB").quantize(palette=palette, dither=mode).getdata()) def rows(values, w, h, bottomUp): """values (row after row) as a list of rows, last first if bottomUp.""" out = [values[y * w:(y + 1) * w] for y in range(h)] return out[::-1] if bottomUp else out def padTo(data, align): return data + bytes((-len(data)) % align) def andMask(mask, w, h): """A Windows icon's AND mask: 1 where transparent, bottom-up rows of 1 bit a pixel padded to DWORDs.""" out = b"" for row in rows(mask, w, h, True): bits = 0 data = bytearray() for x, isOpaque in enumerate(row): bits = (bits << 1) | (0 if isOpaque else 1) if x % 8 == 7: data.append(bits) bits = 0 if w % 8: data.append(bits << (8 - w % 8)) out += padTo(bytes(data), 4) return out def icoImage(image, bitCount, colours): """One icon image: its BITMAPINFOHEADER, colour table, XOR bits and AND mask. bitCount 32: BGRA; 4 or 8: indices into colours.""" w, h = image.size mask = opaque(image) if bitCount == 32: # Black where wholly transparent: a system that draws through the # AND mask XORs this in. xor = b"" for row in rows(list(image.getdata()), w, h, True): xor += b"".join(struct.pack("> (x % 16) planes += b"".join(struct.pack(">H", word) for word in words) gadget = struct.pack(">IhhhhHHHIIIiIHI", 0, 0, 0, AMIGA_W, AMIGA_H + 1, GFLG_GADGIMAGE, GACT_RELVERIFY_IMMEDIATE, GTYP_BOOLGADGET, PRESENT, 0, 0, 0, 0, 0, WB_DISKREVISION) header = struct.pack(">HH", WB_DISKMAGIC, WB_DISKVERSION) + gadget + struct.pack(">BBIIIIIII", WBTOOL, 0, 0, 0, NO_ICON_POSITION, NO_ICON_POSITION, 0, 0, int(stack)) imageStruct = struct.pack(">hhhhhIBBI", 0, 0, AMIGA_W, AMIGA_H, AMIGA_DEPTH, PRESENT, (1 << AMIGA_DEPTH) - 1, 0, 0) open(out, "wb").write(header + imageStruct + planes) def bakeBe(picture, out32, out16): colours = tuple(BE_PALETTE[i:i + 3] for i in range(0, len(BE_PALETTE), 3)) for size, out in ((BE_LARGE, out32), (BE_SMALL, out16)): image = fit(picture, size, size) indices = toColours(image, colours[:BE_TRANSPARENT], False, (0, 0, 0)) open(out, "wb").write(bytes(i if isOpaque else BE_TRANSPARENT for i, isOpaque in zip(indices, opaque(image)))) def bakeC(picture, out, size): size = int(size) image = fit(picture, size, size) data = bytes(c for pixel in image.getdata() for c in pixel) lines = [] for i in range(0, len(data), 16): lines.append(" " + ", ".join("0x%02X" % b for b in data[i:i + 16]) + ",") text = ("// Generated by tools/iconBake.py: the program's window icon, %d x %d\n" "// RGBA pixels, row after row (src/hosted/sdl3/sdl3Backend.c).\n\n" "#include \n\n" "const uint16_t gHostedIconSize = %du;\n" "const uint8_t gHostedIconRgba[] = {\n%s\n};\n") % (size, size, size, "\n".join(lines)) open(out, "w").write(text) def pascal(text, size): """text as a Pascal string (a length byte first) in size bytes.""" data = text.encode("ascii")[:size - 1] return bytes((len(data),)) + data + bytes(size - 1 - len(data)) def linear(value): """An sRGB channel (0-255) as linear light, 0-1.""" value /= 255 return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 def lab(rgb): """An sRGB colour in CIELAB.""" channels = [linear(v) for v in rgb] xyz = [sum(m * c for m, c in zip(row, channels)) / white for row, white in zip(XYZ_FROM_RGB, LAB_WHITE)] f = [t ** (1 / 3) if t > LAB_EPSILON else LAB_KAPPA * t + 16 / 116 for t in xyz] return (116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])) def chroma(colour): return math.hypot(colour[1], colour[2]) def cie94(reference, sample): """CIE94's colour difference of sample from reference (both CIELAB).""" c1 = chroma(reference) dc = c1 - chroma(sample) dh2 = max(0.0, (reference[1] - sample[1]) ** 2 + (reference[2] - sample[2]) ** 2 - dc ** 2) return math.sqrt((reference[0] - sample[0]) ** 2 + (dc / (1 + CIE94_CHROMA * c1)) ** 2 + dh2 / (1 + CIE94_HUE * c1) ** 2) def iigsColours(): """The 16 colours of a Finder icon pixel, each the mean of its pair, in CIELAB; and which of them a colourful pixel may take.""" colours = [] for value in range(16): first = IIGS_FIRST[value >> IIGS_HALF_BITS] second = IIGS_SECOND[value & IIGS_HALF_MASK] colours.append(lab(tuple((a + b) / 2 for a, b in zip(first, second)))) keeps = [v for v in range(16) if v in (IIGS_BLACK, IIGS_WHITE) or chroma(colours[v]) >= IIGS_GREY_CHROMA] return colours, keeps def iigsIcon(picture, w, h): """A QuickDraw II colour icon of the picture at w x h icon pixels: black on its edge, within it the nearest of the Finder's 16 colours.""" image = fit(picture, w, round(h * IIGS_PIXEL_ASPECT)).resize((w, h), Image.LANCZOS) mask = opaque(image) rgb = list(image.convert("RGB").getdata()) colours, keeps = iigsColours() def inside(x, y): return 0 <= x < w and 0 <= y < h and mask[y * w + x] rowBytes = (w + 1) // 2 pixels = bytearray(rowBytes * h) shape = bytearray(rowBytes * h) for y in range(h): for x in range(w): if not mask[y * w + x]: continue edge = not all(inside(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))) if edge: value = IIGS_BLACK else: colour = lab(rgb[y * w + x]) pool = range(16) if chroma(colour) < IIGS_GREY_CHROMA else keeps value = min(pool, key=lambda v: cie94(colour, colours[v])) shift = 4 if x % 2 == 0 else 0 pixels[y * rowBytes + x // 2] |= value << shift shape[y * rowBytes + x // 2] |= IIGS_OPAQUE << shift return struct.pack("