#!/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()