Initial
This commit is contained in:
268
icnsutil.py
Executable file
268
icnsutil.py
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
|
||||
|
||||
class IcnsType(object):
|
||||
'''
|
||||
Namespace for the ICNS format.
|
||||
'''
|
||||
# https://en.wikipedia.org/wiki/Apple_Icon_Image_format
|
||||
TYPES = {
|
||||
'ICON': (32, '32×32 1-bit mono icon'),
|
||||
'ICN#': (32, '32×32 1-bit mono icon with 1-bit mask'),
|
||||
'icm#': (16, '16×12 1 bit mono icon with 1-bit mask'),
|
||||
'icm4': (16, '16×12 4 bit icon'),
|
||||
'icm8': (16, '16×12 8 bit icon'),
|
||||
'ics#': (16, '16×16 1-bit mask'),
|
||||
'ics4': (16, '16×16 4-bit icon'),
|
||||
'ics8': (16, '16x16 8 bit icon'),
|
||||
'is32': (16, '16×16 24-bit icon'),
|
||||
's8mk': (16, '16x16 8-bit mask'),
|
||||
'icl4': (32, '32×32 4-bit icon'),
|
||||
'icl8': (32, '32×32 8-bit icon'),
|
||||
'il32': (32, '32x32 24-bit icon'),
|
||||
'l8mk': (32, '32×32 8-bit mask'),
|
||||
'ich#': (48, '48×48 1-bit mask'),
|
||||
'ich4': (48, '48×48 4-bit icon'),
|
||||
'ich8': (48, '48×48 8-bit icon'),
|
||||
'ih32': (48, '48×48 24-bit icon'),
|
||||
'h8mk': (48, '48×48 8-bit mask'),
|
||||
'it32': (128, '128×128 24-bit icon'),
|
||||
't8mk': (128, '128×128 8-bit mask'),
|
||||
'icp4': (16, '16x16 icon in JPEG 2000 or PNG format'),
|
||||
'icp5': (32, '32x32 icon in JPEG 2000 or PNG format'),
|
||||
'icp6': (64, '64x64 icon in JPEG 2000 or PNG format'),
|
||||
'ic07': (128, '128x128 icon in JPEG 2000 or PNG format'),
|
||||
'ic08': (256, '256×256 icon in JPEG 2000 or PNG format'),
|
||||
'ic09': (512, '512×512 icon in JPEG 2000 or PNG format'),
|
||||
'ic10': (1024, '1024×1024 in 10.7 (or 512x512@2x "retina" in 10.8) icon in JPEG 2000 or PNG format'),
|
||||
'ic11': (32, '16x16@2x "retina" icon in JPEG 2000 or PNG format'),
|
||||
'ic12': (64, '32x32@2x "retina" icon in JPEG 2000 or PNG format'),
|
||||
'ic13': (256, '128x128@2x "retina" icon in JPEG 2000 or PNG format'),
|
||||
'ic14': (512, '256x256@2x "retina" icon in JPEG 2000 or PNG format'),
|
||||
'ic04': (16, '16x16 ARGB'),
|
||||
'ic05': (32, '32x32 ARGB'),
|
||||
'icsB': (36, '36x36'),
|
||||
'icsb': (18, '18x18 '),
|
||||
|
||||
'TOC ': (0, '"Table of Contents" a list of all image types in the file, and their sizes (added in Mac OS X 10.7)'),
|
||||
'icnV': (0, '4-byte big endian float - equal to the bundle version number of Icon Composer.app that created to icon'),
|
||||
'name': (0, 'Unknown'),
|
||||
'info': (0, 'Info binary plist. Usage unknown'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def size_of(x):
|
||||
return IcnsType.TYPES[x][0]
|
||||
|
||||
@staticmethod
|
||||
def is_bitmap(x):
|
||||
return x in ['ICON', 'ICN#', 'icm#', 'icm4', 'icm8', 'ics#', 'ics4',
|
||||
'ics8', 'is32', 's8mk', 'icl4', 'icl8', 'il32', 'l8mk',
|
||||
'ich#', 'ich4', 'ich8', 'ih32', 'h8mk', 'it32', 't8mk']
|
||||
|
||||
@staticmethod
|
||||
def is_retina(x): # all of these are macOS 10.8+
|
||||
return x in ['ic10', 'ic11', 'ic12', 'ic13', 'ic14']
|
||||
|
||||
@staticmethod
|
||||
def is_argb(x):
|
||||
return x in ['ic04', 'ic05']
|
||||
|
||||
@staticmethod
|
||||
def is_meta(x):
|
||||
return x in ['TOC ', 'icnV', 'name', 'info']
|
||||
|
||||
@staticmethod
|
||||
def is_compressable(x):
|
||||
return x in ['is32', 'il32', 'ih32', 'it32', 'ic04', 'ic05']
|
||||
|
||||
@staticmethod
|
||||
def is_mask(x):
|
||||
return x.endswith('mk') or x.endswith('#')
|
||||
|
||||
@staticmethod
|
||||
def description(x):
|
||||
size = IcnsType.size_of(x)
|
||||
if size == 0:
|
||||
return f'{x}'
|
||||
if IcnsType.is_mask(x):
|
||||
return f'{size}-mask'
|
||||
if IcnsType.is_retina(x):
|
||||
return f'{size // 2}@2x'
|
||||
return f'{size}'
|
||||
|
||||
@staticmethod
|
||||
def guess_type(size, retina):
|
||||
tmp = [(k, v[-1]) for k, v in IcnsType.TYPES.items() if v[0] == size]
|
||||
# Support only PNG/JP2k types
|
||||
tmp = [k for k, desc in tmp if desc.endswith('PNG format')]
|
||||
for x in tmp:
|
||||
if retina == IcnsType.is_retina(x):
|
||||
return x
|
||||
return tmp[0]
|
||||
|
||||
|
||||
def extract(fname, *, png_only=False):
|
||||
'''
|
||||
Read an ICNS file and export all media entries to the same directory.
|
||||
'''
|
||||
with open(fname, 'rb') as fpr:
|
||||
def read_img():
|
||||
# Read ICNS type
|
||||
kind = fpr.read(4).decode('utf8')
|
||||
if kind == '':
|
||||
return None, None, None
|
||||
|
||||
# Read media byte size (incl. +8 for header)
|
||||
size = struct.unpack('>I', fpr.read(4))[0]
|
||||
# Determine file format
|
||||
data = fpr.read(size - 8)
|
||||
if data[1:4] == b'PNG':
|
||||
ext = 'png'
|
||||
elif data[:6] == b'bplist':
|
||||
ext = 'plist'
|
||||
elif IcnsType.is_argb(kind):
|
||||
ext = 'argb'
|
||||
else:
|
||||
ext = 'bin'
|
||||
if not (IcnsType.is_bitmap(kind) or IcnsType.is_meta(kind)):
|
||||
print('Unsupported image format', data[:6], 'for', kind)
|
||||
|
||||
# Optional args
|
||||
if png_only and ext != 'png':
|
||||
data = None
|
||||
|
||||
# Write data out to a file
|
||||
if data:
|
||||
suffix = IcnsType.description(kind)
|
||||
with open(f'{fname}-{suffix}.{ext}', 'wb') as fpw:
|
||||
fpw.write(data)
|
||||
return kind, size, data
|
||||
|
||||
# Check whether it is an actual ICNS file
|
||||
ext = fpr.read(4)
|
||||
if ext != b'icns':
|
||||
raise ValueError('Not an ICNS file.')
|
||||
|
||||
# Ignore total size
|
||||
_ = struct.unpack('>I', fpr.read(4))[0]
|
||||
# Read media entries as long as there is something to read
|
||||
while True:
|
||||
kind, size, data = read_img()
|
||||
if not kind:
|
||||
break
|
||||
print(f'{kind}: {size} bytes, {IcnsType.description(kind)}')
|
||||
|
||||
|
||||
def compose(fname, images, *, toc=True):
|
||||
'''
|
||||
Create a new ICNS file from multiple PNG source files.
|
||||
Retina images should be ending in "@2x".
|
||||
'''
|
||||
def image_dimensions(fname):
|
||||
with open(fname, 'rb') as fp:
|
||||
head = fp.read(8)
|
||||
if head == b'\x89PNG\x0d\x0a\x1a\x0a': # PNG
|
||||
_ = fp.read(8)
|
||||
return struct.unpack('>ii', fp.read(8))
|
||||
elif head == b'\x00\x00\x00\x0CjP ': # JPEG 2000
|
||||
raise ValueError('JPEG 2000 is not supported!')
|
||||
else: # ICNS does not support other types (except binary and argb)
|
||||
raise ValueError('Unsupported image format.')
|
||||
|
||||
book = []
|
||||
for x in images:
|
||||
# Determine ICNS type
|
||||
w, h = image_dimensions(x)
|
||||
if w != h:
|
||||
raise ValueError(f'Image must be square! {x} is {w}x{h} instead.')
|
||||
is_retina = x.endswith('@2x.png')
|
||||
kind = IcnsType.guess_type(w, is_retina)
|
||||
# Check if type is unique
|
||||
if any(True for x, _, _ in book if x == kind):
|
||||
raise ValueError(f'Image with same size ({kind}). File: {x}')
|
||||
# Read image data
|
||||
with open(x, 'rb') as fp:
|
||||
data = fp.read()
|
||||
book.append((kind, len(data) + 8, data)) # + data header
|
||||
|
||||
total = sum(x for _, x, _ in book) + 8 # + file header
|
||||
with open(fname, 'wb') as fp:
|
||||
# Magic number
|
||||
fp.write(b'icns')
|
||||
# Total file size
|
||||
if toc:
|
||||
toc_size = len(book) * 8 + 8
|
||||
total += toc_size
|
||||
fp.write(struct.pack('>I', total))
|
||||
# Table of contents (if enabled)
|
||||
if toc:
|
||||
fp.write(b'TOC ')
|
||||
fp.write(struct.pack('>I', toc_size))
|
||||
for kind, size, _ in book:
|
||||
fp.write(kind.encode('utf8'))
|
||||
fp.write(struct.pack('>I', size))
|
||||
# Media files
|
||||
for kind, size, data in book:
|
||||
fp.write(kind.encode('utf8'))
|
||||
fp.write(struct.pack('>I', size))
|
||||
fp.write(data)
|
||||
|
||||
|
||||
# Main entry
|
||||
|
||||
def show_help():
|
||||
print('''Usage:
|
||||
extract: {0} input.icns [--png-only]
|
||||
--png-only: Do not extract ARGB, binary, and meta files.
|
||||
|
||||
compose: {0} output.icns [-f] [--no-toc] 16.png 16@2x.png ...
|
||||
-f: Force overwrite output file.
|
||||
--no-toc: Do not write TOC to file.
|
||||
|
||||
Note: Icon dimensions are read directly from file.
|
||||
However, the suffix "@2x" will set the retina flag accordingly.
|
||||
'''.format(os.path.basename(sys.argv[0])))
|
||||
exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Parse optional args
|
||||
def has_arg(x):
|
||||
if x in args:
|
||||
args.remove(x)
|
||||
return True
|
||||
force = has_arg('-f')
|
||||
png_only = has_arg('--png-only')
|
||||
no_toc = has_arg('--no-toc')
|
||||
|
||||
# Check for valid syntax
|
||||
if not args:
|
||||
return show_help()
|
||||
|
||||
target, *media = args
|
||||
try:
|
||||
# Compose new icon
|
||||
if media:
|
||||
if not os.path.splitext(target)[1]:
|
||||
target += '.icns' # for the lazy people
|
||||
if not force and os.path.exists(target):
|
||||
raise IOError(f'File "{target}" already exists. Force overwrite with -f.')
|
||||
compose(target, media, toc=not no_toc)
|
||||
# Extract from existing icon
|
||||
else:
|
||||
if not os.path.isfile(target):
|
||||
raise IOError(f'File "{target}" does not exist.')
|
||||
extract(target, png_only=png_only)
|
||||
|
||||
except Exception as x:
|
||||
print(x)
|
||||
exit(1)
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user