From fc1e8749f25e79e223e97b3d4305d4b0b2bba325 Mon Sep 17 00:00:00 2001 From: relikd Date: Fri, 20 Aug 2021 13:34:00 +0200 Subject: [PATCH] Initial --- LICENSE | 7 ++ README.md | 46 +++++++++ icnsutil.py | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 LICENSE create mode 100755 README.md create mode 100755 icnsutil.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..724c90c --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2021 Oleg Geier + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..bdfa86d --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# ICNS-Util + +A python library to handle reading and writing `.icns` files. + + +## Usage + + +``` +Usage: + extract: icnsutil.py input.icns [--png-only] + --png-only: Do not extract ARGB, binary, and meta files. + + compose: icnsutil.py 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. +``` + + +### Extract from ICNS + +```sh +cp /Applications/Safari.app/Contents/Resources/AppIcon.icns ./TestIcon.icns +python3 icnsutil.py TestIcon.icns +``` + + +### Compose new ICNS + +```sh +python3 icnsutil.py TestIcon_new.icns --no-toc ./*.png -f +``` + +Or call the script directly, if it has execution permissions. + + +### Use in python script + +```python +import icnsutil +icnsutil.compose(icns_file, list_of_png_files, toc=True) +icnsutil.extract(icns_file, png_only=False) +``` diff --git a/icnsutil.py b/icnsutil.py new file mode 100755 index 0000000..5978cee --- /dev/null +++ b/icnsutil.py @@ -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()