joeylib2/tools/iconBake.py

611 lines
28 KiB
Python
Executable file

#!/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 OUT.hvif
BeOS and Haiku: 32x32 and
16x16 in the BeOS palette,
raw, for xres (ICON, MICN),
and the 32x32 one as
Haiku's vector icon (VICN)
iconBake.py c PICTURE OUT.c SIZE the SDL3 backend's window
icon: SIZE x SIZE RGBA as C
iconBake.py icns PICTURE OUT.icns macOS: an app's icon, 16
pixels up to 512 (or the
picture's size), and twice
that for Retina screens
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 io
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
# Haiku's vector icon (HVIF, src/libs/icon/flat_icon in Haiku): the 16x16
# and 32x32 icons' pixels as outlines on its 64-unit grid, a shape a colour,
# hinted so every edge falls on a pixel. The 16x16 shapes show below 32
# pixels and the 32x32 ones from there up (scale 0.5 is 32 pixels; a level of
# detail byte is HVIF_LOD_UNITS to 1), so each size draws exactly its own
# pixels. A section holds at most 255 of anything; an icon needing more gets
# no vector icon.
HVIF_MAGIC = b"ncif"
HVIF_GRID = 64
HVIF_STYLE_SOLID_COLOR = 1
HVIF_PATH_CLOSED = 0x02
HVIF_PATH_NO_CURVES = 0x08
HVIF_SHAPE_PATH_SOURCE = 10
HVIF_SHAPE_HINTING = 0x04
HVIF_SHAPE_LOD_SCALE = 0x08
HVIF_LOD_UNITS = 63.75
HVIF_LOD_SMALL_MAX = 31 # just under scale 0.5, 32 pixels
HVIF_LOD_LARGE_MIN = 31
HVIF_LOD_LARGE_MAX = 255 # scale 4
HVIF_COORD_BIAS = 32
HVIF_MAX_ITEMS = 255
HVIF_OPAQUE = 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,
)
# macOS: an app's icon file (.icns) is "icns" and the file's length, then an
# element for each size: its type, its length (these 8 bytes included) and
# the picture as a PNG. The @2x types are for Retina screens, twice the
# pixels of the size they stand for. Sizes past the first at least as big as
# the picture add nothing macOS's own enlarging would not, so are left out,
# unless it is pixel art, which fit() enlarges sharply.
ICNS_MAGIC = b"icns"
ICNS_HEADER = ">4sI"
ICNS_ELEMENTS = (
(b"icp4", 16), (b"icp5", 32), (b"ic11", 32), (b"ic12", 64), (b"ic07", 128),
(b"ic13", 256), (b"ic08", 256), (b"ic14", 512), (b"ic09", 512),
)
# 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("<BBBB", b, g, r, a) if a else bytes(4) for r, g, b, a in row)
table = b""
else:
# Index 0, black, where transparent (the 16 VGA colours start with
# black too).
indices = toColours(image, colours, bitCount == 8, (0, 0, 0))
indices = [i if isOpaque else 0 for i, isOpaque in zip(indices, mask)]
xor = b""
for row in rows(indices, w, h, True):
if bitCount == 4:
data = bytes((row[x] << 4) | (row[x + 1] if x + 1 < w else 0) for x in range(0, w, 2))
else:
data = bytes(row)
xor += padTo(data, 4)
table = b"".join(struct.pack("<BBBB", b, g, r, 0) for r, g, b in colours)
andBits = andMask(mask, w, h)
header = struct.pack("<IiiHHIIiiII", BITMAPINFOHEADER_BYTES, w, h * 2, 1, bitCount, 0, len(xor) + len(andBits), 0, 0, len(colours) if bitCount <= 8 else 0, 0)
return header + table + xor + andBits
def icoFile(images):
"""images: (width, height, colourCount, bitCount, data) into a .ico."""
out = struct.pack("<HHH", 0, ICO_TYPE, len(images))
offset = ICONDIR_BYTES + ICONDIRENTRY_BYTES * len(images)
for w, h, count, bitCount, data in images:
out += struct.pack("<BBBBHHII", w % 256, h % 256, count % 256, 0, 1, bitCount, len(data), offset)
offset += len(data)
return out + b"".join(image[4] for image in images)
def medianCut(image):
"""256 colours for an 8-bit icon of image: black (where transparent:
the AND mask's partner), then its own colours, cut down."""
flat = Image.new("RGBA", image.size, (0, 0, 0, 255))
flat.alpha_composite(image)
palette = flat.convert("RGB").quantize(colors=255, method=Image.Quantize.MEDIANCUT).getpalette()[:255 * 3]
palette = [0, 0, 0] + palette + [0] * (255 * 3 - len(palette))
return tuple(tuple(palette[i:i + 3]) for i in range(0, 256 * 3, 3))
def bakeIco(picture, out):
images = []
for size in ICO_SIZES:
image = fit(picture, size, size)
images.append((size, size, 0, 8, icoImage(image, 8, medianCut(image))))
images.append((size, size, 0, 32, icoImage(image, 32, None)))
open(out, "wb").write(icoFile(images))
def bakeIco16(picture, out):
image = fit(picture, ICO16_SIZE, ICO16_SIZE)
open(out, "wb").write(icoFile([(ICO16_SIZE, ICO16_SIZE, len(VGA16), 4, icoImage(image, 4, VGA16))]))
def bakeBmp(picture, out):
flat = Image.new("RGBA", (DVX_SIZE, DVX_SIZE), DVX_GREY + (255,))
flat.alpha_composite(fit(picture, DVX_SIZE, DVX_SIZE))
flat.convert("RGB").save(out, "BMP")
def bakePng(picture, out, size):
w, h = (int(v) for v in size.lower().split("x"))
fit(picture, w, h).save(out, "PNG")
def bakeInfo(picture, out, stack):
"""A WBTOOL DiskObject: the header, its gadget, the image (struct Image
and its planes, colour 0 where transparent) and nothing more."""
image = fit(picture, AMIGA_W, AMIGA_H * 2).resize((AMIGA_W, AMIGA_H), Image.LANCZOS)
mask = opaque(image)
# Workbench's grey is the background alone: the picture is drawn in the
# other three.
figure = toColours(image, AMIGA_COLOURS[1:], False, (255, 255, 255))
indices = [i + 1 if isOpaque else 0 for i, isOpaque in zip(figure, mask)]
rowWords = (AMIGA_W + 15) // 16
planes = b""
for plane in range(AMIGA_DEPTH):
for row in rows(indices, AMIGA_W, AMIGA_H, False):
words = [0] * rowWords
for x, index in enumerate(row):
if index & (1 << plane):
words[x // 16] |= 0x8000 >> (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, outVector):
colours = tuple(BE_PALETTE[i:i + 3] for i in range(0, len(BE_PALETTE), 3))
lods = {BE_SMALL: (0, HVIF_LOD_SMALL_MAX), BE_LARGE: (HVIF_LOD_LARGE_MIN, HVIF_LOD_LARGE_MAX)}
sizes = []
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))
pixels = bytes(i if isOpaque else BE_TRANSPARENT for i, isOpaque in zip(indices, opaque(image)))
open(out, "wb").write(pixels)
sizes.append((size, pixels, colours) + lods[size])
vector = hvif(sizes)
if vector is None:
sys.stderr.write("iconBake.py: the icon needs more than %d paths or shapes; no vector icon\n" % HVIF_MAX_ITEMS)
vector = b""
open(outVector, "wb").write(vector)
def hvifContours(pixels, size, colour):
"""The outlines of colour's pixels in the size x size palette pixels, as
closed loops of corner points: each pixel side with another colour (or
the edge) beyond it, directed so the colour is on its right, chained
head to tail. Outer edges come out clockwise and holes the other way,
so a fill under either rule leaves the holes empty."""
def at(x, y):
return 0 <= x < size and 0 <= y < size and pixels[y * size + x] == colour
edges = {}
for y in range(size):
for x in range(size):
if not at(x, y):
continue
if not at(x, y - 1):
edges.setdefault((x, y), []).append((x + 1, y))
if not at(x + 1, y):
edges.setdefault((x + 1, y), []).append((x + 1, y + 1))
if not at(x, y + 1):
edges.setdefault((x + 1, y + 1), []).append((x, y + 1))
if not at(x - 1, y):
edges.setdefault((x, y + 1), []).append((x, y))
loops = []
while edges:
first = next(iter(edges))
loop = [first]
point = first
while True:
ahead = edges[point].pop()
if not edges[point]:
del edges[point]
if ahead == first:
break
loop.append(ahead)
point = ahead
# The corners alone: drop each point on a straight run.
corners = [loop[i] for i in range(len(loop)) if (loop[i - 1][0] == loop[i][0]) != (loop[i][0] == loop[(i + 1) % len(loop)][0])]
loops.append(corners)
return loops
def hvif(sizes):
"""An HVIF of the palette pictures in sizes ((size, pixels, colours,
lodMin, lodMax), BE_TRANSPARENT clear): each colour's outlines a closed
path each, a shape a colour and size, hinted, and shown only at the
scales [lodMin, lodMax] (HVIF_LOD_UNITS a unit of icon scale, 64 pixels
being 1). None when that is more of anything than HVIF holds."""
styles = []
paths = []
shapes = []
for size, pixels, colours, lodMin, lodMax in sizes:
units = HVIF_GRID // size
for colour in sorted(set(pixels) - {BE_TRANSPARENT}):
if colours[colour] not in styles:
styles.append(colours[colour])
first = len(paths)
for loop in hvifContours(pixels, size, colour):
if len(loop) > HVIF_MAX_ITEMS:
return None
paths.append(bytes((HVIF_PATH_CLOSED | HVIF_PATH_NO_CURVES, len(loop))) + bytes(HVIF_COORD_BIAS + v * units for point in loop for v in point))
shapes.append(bytes((HVIF_SHAPE_PATH_SOURCE, styles.index(colours[colour]), len(paths) - first)) + bytes(range(first, len(paths))) + bytes((HVIF_SHAPE_HINTING | HVIF_SHAPE_LOD_SCALE, lodMin, lodMax)))
if max(len(styles), len(paths), len(shapes)) > HVIF_MAX_ITEMS:
return None
out = bytearray(HVIF_MAGIC)
out.append(len(styles))
for colour in styles:
out += bytes((HVIF_STYLE_SOLID_COLOR,) + tuple(colour) + (HVIF_OPAQUE,))
out.append(len(paths))
for path in paths:
out += path
out.append(len(shapes))
for shape in shapes:
out += shape
return bytes(out)
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 <stdint.h>\n\n"
"const uint16_t jlgHostedIconSize = %du;\n"
"const uint8_t jlgHostedIconRgba[] = {\n%s\n};\n") % (size, size, size, "\n".join(lines))
open(out, "w").write(text)
def bakeIcns(picture, out):
sizes = sorted({size for _, size in ICNS_ELEMENTS})
largest = sizes[-1]
if max(picture.size) > PIXEL_ART_MAX:
largest = next((size for size in sizes if size >= max(picture.size)), largest)
pngs = {}
elements = b""
for kind, size in ICNS_ELEMENTS:
if size > largest:
continue
if size not in pngs:
data = io.BytesIO()
fit(picture, size, size).save(data, "PNG")
pngs[size] = data.getvalue()
elements += struct.pack(ICNS_HEADER, kind, struct.calcsize(ICNS_HEADER) + len(pngs[size])) + pngs[size]
open(out, "wb").write(struct.pack(ICNS_HEADER, ICNS_MAGIC, struct.calcsize(ICNS_HEADER) + len(elements)) + elements)
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("<HHHH", IIGS_ICON_TYPE_COLOUR, len(pixels), h, w) + bytes(pixels) + bytes(shape)
def bakeIigs(picture, out, name):
icons = iigsIcon(picture, *IIGS_BIG) + iigsIcon(picture, *IIGS_SMALL)
record = pascal("", IIGS_BOSS_BYTES) + pascal(name, IIGS_NAME_BYTES) + struct.pack("<HH", IIGS_APPLICATION_TYPE, 0) + icons
header = struct.pack("<IHI", 0, IIGS_ICON_FILE_ID, 0) + pascal(name, IIGS_FILE_NAME_BYTES)
# The record's length counts its own length word; a zero length ends the
# list.
open(out, "wb").write(header + struct.pack("<H", 2 + len(record)) + record + struct.pack("<H", 0))
BAKES = {
"ico": (bakeIco, 0),
"ico16": (bakeIco16, 0),
"bmp": (bakeBmp, 0),
"png": (bakePng, 1),
"info": (bakeInfo, 1),
"be": (bakeBe, 2),
"c": (bakeC, 1),
"icns": (bakeIcns, 0),
"iigs": (bakeIigs, 1),
}
def main(argv):
if len(argv) < 4 or argv[1] not in BAKES or len(argv) != 4 + BAKES[argv[1]][1]:
sys.stderr.write(__doc__)
return 2
picture = Image.open(argv[2]).convert("RGBA")
BAKES[argv[1]][0](picture, *argv[3:])
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))