mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-18 03:23:28 +03:00
365 lines
15 KiB
Python
Executable File
365 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerate the application icons in bin/assets/icon from their SVG sources.
|
|
|
|
For every application this derives the 256x256 .png runtime window icon and,
|
|
where platform packaging needs them, the Windows .ico, the MinGW .res/.x64.res
|
|
COFF resource objects, and the macOS .icns container. The shared PNG uses
|
|
Breeze-like small-icon margins; the macOS runtime PNG and larger ICNS entries
|
|
use matching transparent insets to fit neighboring Dock icons. Smaller ICNS
|
|
entries keep Breeze-like margins. Windows ICO entries retain their original
|
|
full-size artwork.
|
|
|
|
The binary layouts intentionally mirror the historical artifacts:
|
|
|
|
.ico 32bpp BMP entries (bottom-up BGRA XOR bitmap + AND mask with the bit
|
|
set where alpha == 0) for sizes <= 128, a PNG entry for 256, with
|
|
each application's historical entry sizes and order preserved.
|
|
.icns ic04/ic05 as 'ARGB' magic + per-plane RLE (literal byte n < 0x80
|
|
introduces n+1 raw bytes, repeat byte n >= 0x80 introduces
|
|
(n & 0x7f) + 3 copies of the next byte), PNG payloads for the
|
|
larger types, and the 'info' metadata chunk carried over from the
|
|
original Apple iconutil-generated files.
|
|
.res compiled from <app>.rc + <app>.ico with mingw windres, for both the
|
|
pe-i386 and pe-x86-64 targets.
|
|
|
|
Requires: python3 (stdlib only), inkscape or rsvg-convert, and a mingw-w64
|
|
windres for Windows resources ($WINDRES, x86_64-w64-mingw32-windres, or windres).
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
import zlib
|
|
|
|
PNG_SIZE = 256
|
|
# Breeze's colorful icon grid uses 2px margins at 32px and 4px at 64px.
|
|
# Larger macOS icons match the runtime PNG's artwork bounds so the Dock icon
|
|
# does not change size when the application starts or exits. These are visual
|
|
# design choices, not mandated dimensions in Apple's icon guidelines.
|
|
SMALL_ARTWORK_FRACTION = 0.875
|
|
# SDL's Cocoa backend uses this single PNG while the app is running. Match its
|
|
# artwork fraction in the larger ICNS entries so the Dock shows the same size
|
|
# for the running and closed app.
|
|
MACOS_RUNTIME_ARTWORK_FRACTION = 0.824
|
|
|
|
|
|
def macos_artwork_fraction( size ):
|
|
if size <= 64:
|
|
return SMALL_ARTWORK_FRACTION
|
|
return MACOS_RUNTIME_ARTWORK_FRACTION
|
|
|
|
# Sizes requested from the .icns entry table below (plus the ICO sizes and
|
|
# PNG_SIZE), rendered natively from the SVG instead of resampled.
|
|
ICNS_ENTRIES = [
|
|
( 'ic12', 64, 'png' ),
|
|
( 'ic07', 128, 'png' ),
|
|
( 'ic13', 256, 'png' ),
|
|
( 'ic08', 256, 'png' ),
|
|
( 'ic04', 16, 'argb' ),
|
|
( 'ic14', 512, 'png' ),
|
|
( 'ic09', 512, 'png' ),
|
|
( 'ic05', 32, 'argb' ),
|
|
( 'ic10', 1024, 'png' ),
|
|
( 'ic11', 32, 'png' ),
|
|
( 'info', 0, 'info' ),
|
|
]
|
|
|
|
# The 'info' element is an NSKeyedArchiver property list written by Apple's
|
|
# iconutil. It carries no artwork, so the original bytes are kept verbatim to
|
|
# stay byte-compatible with iconutil output.
|
|
INFO_CHUNK = base64.b64decode(
|
|
"YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8Q"
|
|
"D05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGnCwwXGBkaHlUkbnVsbNMNDg8QExZXTlMua2V5c1pO"
|
|
"Uy5vYmplY3RzViRjbGFzc6IREoACgAOiFBWABIAFgAZUbmFtZV8QFmFzc2V0Y2F0YWxvZy1yZWZl"
|
|
"cmVuY2VUaWNvbtMNDg8bHBagoIAG0h8gISJaJGNsYXNzbmFtZVgkY2xhc3Nlc1xOU0RpY3Rpb25h"
|
|
"cnmiISNYTlNPYmplY3QIERokKTI3SUxRU1thaHB7goWHiYyOkJKXsLW8vb7AxdDZ5ukAAAAAAAAB"
|
|
"AQAAAAAAAAAkAAAAAAAAAAAAAAAAAAAA8g=="
|
|
)
|
|
|
|
# Per-application inputs and outputs. The ICO size lists keep the historical
|
|
# entry sets and order of each file; Windows selects the best size match
|
|
# regardless of order, so this is preserved only to keep artifact diffs minimal.
|
|
APPS = {
|
|
'ee': { 'svg': 'ee-icon.svg', 'ico': [16, 24, 32, 48, 64, 128, 256], 'icns': True, 'res': True },
|
|
'ecode': { 'svg': 'ecode-icon.svg', 'ico': [256, 128, 64, 48, 32, 16], 'icns': True, 'res': True },
|
|
'eterm': { 'svg': 'eterm-icon.svg', 'ico': [16, 24, 32, 48, 64, 72, 96, 128, 256], 'icns': True, 'res': True },
|
|
'eeiv': { 'svg': 'eeiv-icon.svg', 'ico': [16, 24, 32, 48, 64, 72, 96, 128, 256], 'icns': True, 'res': True },
|
|
'eproc': { 'svg': 'eproc-icon.svg', 'ico': [16, 24, 32, 48, 64, 72, 96, 128, 256], 'icns': True, 'res': True },
|
|
}
|
|
|
|
|
|
def decode_png( blob ):
|
|
"""Decode an 8-bit RGBA non-interlaced PNG into row-major RGBA bytes."""
|
|
|
|
if blob[:8] != b'\x89PNG\r\n\x1a\n':
|
|
raise SystemExit( 'not a PNG blob' )
|
|
width = height = 0
|
|
idat = []
|
|
pos = 8
|
|
while pos + 8 <= len( blob ):
|
|
length, ctype = struct.unpack( '>I4s', blob[pos:pos + 8] )
|
|
body = blob[pos + 8:pos + 8 + length]
|
|
if ctype == b'IHDR':
|
|
width, height, depth, color, _, _, interlace = struct.unpack( '>IIBBBBB', body )
|
|
if ( depth, color, interlace ) != ( 8, 6, 0 ):
|
|
raise SystemExit(
|
|
f'unsupported PNG: depth={depth} color={color} interlace={interlace}' )
|
|
elif ctype == b'IDAT':
|
|
idat.append( body )
|
|
pos += 12 + length
|
|
raw = zlib.decompress( b''.join( idat ) )
|
|
stride = width * 4
|
|
out = bytearray( height * stride )
|
|
previous = bytearray( stride )
|
|
source = 0
|
|
for y in range( height ):
|
|
filter_type = raw[source]
|
|
source += 1
|
|
line = bytearray( raw[source:source + stride] )
|
|
source += stride
|
|
for x in range( stride ):
|
|
left = line[x - 4] if x >= 4 else 0
|
|
above = previous[x]
|
|
corner = previous[x - 4] if x >= 4 else 0
|
|
if filter_type == 1:
|
|
line[x] = ( line[x] + left ) & 0xFF
|
|
elif filter_type == 2:
|
|
line[x] = ( line[x] + above ) & 0xFF
|
|
elif filter_type == 3:
|
|
line[x] = ( line[x] + ( left + above ) // 2 ) & 0xFF
|
|
elif filter_type == 4:
|
|
estimate = left + above - corner
|
|
distances = ( abs( estimate - left ), abs( estimate - above ), abs( estimate - corner ) )
|
|
predictor = ( left, above, corner )[distances.index( min( distances ) )]
|
|
line[x] = ( line[x] + predictor ) & 0xFF
|
|
out[y * stride:( y + 1 ) * stride] = line
|
|
previous = line
|
|
return width, height, bytes( out )
|
|
|
|
|
|
def bmp_entry( png ):
|
|
"""32bpp BGRA bottom-up XOR bitmap plus AND mask, as in the historical .ico files."""
|
|
|
|
width, height, pixels = decode_png( png )
|
|
xor = bytearray()
|
|
mask_rows = ( ( width + 31 ) // 32 ) * 4
|
|
mask = bytearray()
|
|
for y in range( height - 1, -1, -1 ):
|
|
for x in range( width ):
|
|
offset = ( y * width + x ) * 4
|
|
r, g, b, a = pixels[offset:offset + 4]
|
|
xor += bytes( ( b, g, r, a ) )
|
|
row = bytearray( mask_rows )
|
|
for x in range( width ):
|
|
if pixels[( y * width + x ) * 4 + 3] == 0:
|
|
row[x // 8] |= 0x80 >> ( x % 8 )
|
|
mask += row
|
|
header = struct.pack( '<IiiHHIIiiII', 40, width, 2 * height, 1, 32, 0, width * height * 4,
|
|
3779, 3779, 0, 0 )
|
|
return header + bytes( xor ) + bytes( mask )
|
|
|
|
|
|
def build_ico( pngs, sizes ):
|
|
entries = []
|
|
for size in sizes:
|
|
if size == PNG_SIZE:
|
|
entries.append( ( size, pngs[size] ) )
|
|
else:
|
|
entries.append( ( size, bmp_entry( pngs[size] ) ) )
|
|
out = struct.pack( '<HHH', 0, 1, len( entries ) )
|
|
offset = 6 + 16 * len( entries )
|
|
for size, blob in entries:
|
|
out += struct.pack( '<BBBBHHII', size % 256, size % 256, 0, 0, 1, 32, len( blob ), offset )
|
|
offset += len( blob )
|
|
for _, blob in entries:
|
|
out += blob
|
|
return out
|
|
|
|
|
|
def rle_encode( plane ):
|
|
"""icns ARGB RLE: n < 0x80 introduces n+1 raw bytes, n >= 0x80 introduces ( n & 0x7f ) + 3 copies."""
|
|
|
|
out = bytearray()
|
|
i = 0
|
|
size = len( plane )
|
|
while i < size:
|
|
run = 1
|
|
while run < 130 and i + run < size and plane[i + run] == plane[i]:
|
|
run += 1
|
|
if run >= 3:
|
|
out += bytes( ( 0x80 | ( run - 3 ), plane[i] ) )
|
|
i += run
|
|
continue
|
|
start = i
|
|
i += run
|
|
while i - start < 128:
|
|
if i >= size:
|
|
break
|
|
run = 1
|
|
while run < 3 and i + run < size and plane[i + run] == plane[i]:
|
|
run += 1
|
|
if run >= 3:
|
|
break
|
|
i += run
|
|
out += bytes( ( i - start - 1, ) ) + bytes( plane[start:i] )
|
|
return bytes( out )
|
|
|
|
|
|
def rle_decode( stream ):
|
|
out = bytearray()
|
|
i = 0
|
|
while i < len( stream ):
|
|
token = stream[i]
|
|
i += 1
|
|
if token & 0x80:
|
|
out += bytes( [stream[i]] ) * ( ( token & 0x7f ) + 3 )
|
|
i += 1
|
|
else:
|
|
count = token + 1
|
|
out += stream[i:i + count]
|
|
i += count
|
|
return bytes( out )
|
|
|
|
|
|
def argb_entry( png ):
|
|
"""ic04/ic05 payload: 'ARGB' magic plus RLE-compressed A, R, G, B planes."""
|
|
|
|
width, height, pixels = decode_png( png )
|
|
planes = [bytearray( width * height ) for _ in range( 4 )]
|
|
for p in range( width * height ):
|
|
r, g, b, a = pixels[p * 4:p * 4 + 4]
|
|
planes[0][p] = a
|
|
planes[1][p] = r
|
|
planes[2][p] = g
|
|
planes[3][p] = b
|
|
encoded = [rle_encode( plane ) for plane in planes]
|
|
blob = b'ARGB' + b''.join( encoded )
|
|
if rle_decode( blob[4:] ) != b''.join( planes ):
|
|
raise SystemExit( 'ARGB RLE round-trip failed' )
|
|
return blob
|
|
|
|
|
|
def build_icns( pngs ):
|
|
chunks = []
|
|
for entry_type, size, encoding in ICNS_ENTRIES:
|
|
if encoding == 'png':
|
|
payload = pngs[size]
|
|
elif encoding == 'argb':
|
|
payload = argb_entry( pngs[size] )
|
|
else:
|
|
payload = INFO_CHUNK
|
|
chunks.append( entry_type.encode() + struct.pack( '>I', 8 + len( payload ) ) + payload )
|
|
body = b''.join( chunks )
|
|
return b'icns' + struct.pack( '>I', 8 + len( body ) ) + body
|
|
|
|
|
|
def render_png( renderer, svg, size, out_path ):
|
|
if os.path.basename( renderer ) == 'inkscape':
|
|
command = [renderer, '--export-type=png', '--export-area-page',
|
|
f'--export-width={size}', f'--export-height={size}',
|
|
'--export-background-opacity=0', f'--export-filename={out_path}', svg]
|
|
else:
|
|
command = [renderer, '-w', str( size ), '-h', str( size ), '-o', out_path, svg]
|
|
subprocess.run( command, check=True, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL )
|
|
with open( out_path, 'rb' ) as handle:
|
|
return handle.read()
|
|
|
|
|
|
def inset_svg( source, destination, fraction ):
|
|
"""Place the original artwork in the center of a transparent macOS canvas."""
|
|
|
|
tree = ET.parse( source )
|
|
root = tree.getroot()
|
|
x, y, width, height = map( float, root.attrib['viewBox'].split() )
|
|
margin_x = width * ( 1 / fraction - 1 ) / 2
|
|
margin_y = height * ( 1 / fraction - 1 ) / 2
|
|
root.set( 'viewBox', f'{x - margin_x} {y - margin_y} '
|
|
f'{width + 2 * margin_x} {height + 2 * margin_y}' )
|
|
tree.write( destination, encoding='utf-8', xml_declaration=True )
|
|
|
|
|
|
def find_windres():
|
|
candidates = [os.environ.get( 'WINDRES' ), 'x86_64-w64-mingw32-windres', 'windres']
|
|
for candidate in candidates:
|
|
if candidate and shutil.which( candidate ):
|
|
return candidate
|
|
raise SystemExit( 'windres not found (install mingw-w64 binutils or set $WINDRES)' )
|
|
|
|
|
|
def generate( app, icon_dir, tmp_dir, renderer, windres, macos_only ):
|
|
spec = APPS[app]
|
|
source = os.path.join( icon_dir, spec['svg'] )
|
|
written = []
|
|
if not macos_only:
|
|
needed = set( spec['ico'] ) | {PNG_SIZE}
|
|
pngs = {size: render_png( renderer, source, size,
|
|
os.path.join( tmp_dir, f'{app}-{size}.png' ) )
|
|
for size in sorted( needed )}
|
|
if spec['ico']:
|
|
with open( os.path.join( icon_dir, f'{app}.ico' ), 'wb' ) as handle:
|
|
handle.write( build_ico( pngs, spec['ico'] ) )
|
|
written.append( f'{app}.ico' )
|
|
if spec['icns']:
|
|
icns_sizes = {size for _, size, _ in ICNS_ENTRIES if size}
|
|
macos_pngs = {}
|
|
for size in sorted( icns_sizes ):
|
|
macos_svg = os.path.join( tmp_dir, f'{app}-macos-{size}.svg' )
|
|
inset_svg( source, macos_svg, macos_artwork_fraction( size ) )
|
|
macos_pngs[size] = render_png( renderer, macos_svg, size,
|
|
os.path.join( tmp_dir, f'{app}-macos-{size}.png' ) )
|
|
shared_svg = os.path.join( tmp_dir, f'{app}-shared.svg' )
|
|
inset_svg( source, shared_svg, SMALL_ARTWORK_FRACTION )
|
|
with open( os.path.join( icon_dir, f'{app}.png' ), 'wb' ) as handle:
|
|
handle.write( render_png( renderer, shared_svg, PNG_SIZE,
|
|
os.path.join( tmp_dir, f'{app}-shared.png' ) ) )
|
|
# SDL_SetWindowIcon uses this single PNG for the running Dock icon.
|
|
runtime_svg = os.path.join( tmp_dir, f'{app}-runtime.svg' )
|
|
inset_svg( source, runtime_svg, MACOS_RUNTIME_ARTWORK_FRACTION )
|
|
with open( os.path.join( icon_dir, f'{app}-macos.png' ), 'wb' ) as handle:
|
|
handle.write( render_png( renderer, runtime_svg, PNG_SIZE,
|
|
os.path.join( tmp_dir, f'{app}-runtime.png' ) ) )
|
|
with open( os.path.join( icon_dir, f'{app}.icns' ), 'wb' ) as handle:
|
|
handle.write( build_icns( macos_pngs ) )
|
|
written.extend( ( f'{app}.png', f'{app}-macos.png', f'{app}.icns' ) )
|
|
if spec['res'] and not macos_only:
|
|
for target, output in ( ( 'pe-i386', f'{app}.res' ), ( 'pe-x86-64', f'{app}.x64.res' ) ):
|
|
subprocess.run( [windres, f'--target={target}', '-O', 'coff', f'{app}.rc', output],
|
|
check=True, cwd=icon_dir )
|
|
written.append( f'{app}.res, {app}.x64.res' )
|
|
print( f'{app}: {", ".join( written )}' )
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser( description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter )
|
|
parser.add_argument( '--app', action='append', choices=sorted( APPS ),
|
|
help='application to regenerate (repeatable; default: all)' )
|
|
parser.add_argument( '--macos-only', action='store_true',
|
|
help='regenerate macOS .icns plus shared and macOS runtime PNG icons' )
|
|
args = parser.parse_args()
|
|
|
|
script_dir = os.path.dirname( os.path.abspath( __file__ ) )
|
|
icon_dir = os.path.join( os.path.abspath( os.path.join( script_dir, '..', '..' ) ),
|
|
'bin', 'assets', 'icon' )
|
|
renderer = shutil.which( 'inkscape' ) or shutil.which( 'rsvg-convert' )
|
|
if not renderer:
|
|
raise SystemExit( 'inkscape or rsvg-convert not found' )
|
|
apps = args.app or list( APPS )
|
|
windres = find_windres() if not args.macos_only and any( APPS[app]['res'] for app in apps ) else None
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
for app in apps:
|
|
generate( app, icon_dir, tmp_dir, renderer, windres, args.macos_only )
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit( main() )
|