611 lines
28 KiB
Python
Executable file
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))
|