diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7933a00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +/*.txt +/tests/format-support-*/ +/tests/fixtures/tmp_* diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..4be726b --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: help test sys-icons-print sys-icons-test + +help: + @echo 'Available commands: test, sys-icons-print, sys-icons-test' + +test: + python3 tests/test_icnsutil.py + +_listofsystemicns.txt: + @echo 'Generate list of system icns files...' + find /Applications -type f -name '*.icns' > _listofsystemicns.txt || echo + find /Users -type f -name '*.icns' >> _listofsystemicns.txt || echo + find /Library -type f -name '*.icns' >> _listofsystemicns.txt || echo + find /System -not \( -path '/System/Volumes' -prune \) \ + -not \( -path '/System/Library/Templates' -prune \) \ + -type f -name '*.icns' >> _listofsystemicns.txt || echo 'Done.' + +sys-icons-print: _listofsystemicns.txt + @while read fname; do \ + ./cli.py print "$${fname}"; \ + done < _listofsystemicns.txt + +sys-icons-test: _listofsystemicns.txt + @while read fname; do \ + ./cli.py test -q "$${fname}"; \ + done < _listofsystemicns.txt diff --git a/README.md b/README.md index bdfa86d..8cdce7e 100755 --- a/README.md +++ b/README.md @@ -1,46 +1,101 @@ -# ICNS-Util +# icnsutil -A python library to handle reading and writing `.icns` files. +A fully-featured python library to handle reading and writing `.icns` files. + + +## HTML icon viewer + +Here are two tools to open icns files directly in your browser. Both tools can be used either with an icns file or a rgb / argb image file. + +- The [inspector] shows the structure of an icns file (useful to understand byte-unpacking in ARGB and 24-bit RGB files). +- The [viewer] displays icons in ARGB or 24-bit RGB file format. + +[inspector]: https://relikd.github.io/icnsutil/html/inspector.html [viewer]: https://relikd.github.io/icnsutil/html/viewer.html ## 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. +positional arguments: + command + extract (e) Read and extract contents of icns file(s). + compose (c) Create new icns file from provided image files. + print (p) Print contents of icns file(s). + test (t) Test if icns file is valid. ``` -### Extract from ICNS +### Use command line interface (CLI) ```sh -cp /Applications/Safari.app/Contents/Resources/AppIcon.icns ./TestIcon.icns -python3 icnsutil.py TestIcon.icns +# extract +./cli.py e ExistingIcon.icns -o ./outdir/ + +# compose +./cli.py c NewIcon.icns 16x16.png 16x16@2x.png *.jp2 + +# print +./cli.py p ExistingIcon.icns + +# verify valid format +./cli.py t ExistingIcon.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 +### Use python library ```python import icnsutil -icnsutil.compose(icns_file, list_of_png_files, toc=True) -icnsutil.extract(icns_file, png_only=False) + +# extract +img = icnsutil.IcnsFile('ExistingIcon.icns') +img.export(out_dir, allowed_ext='png', + recursive=True, convert_png=True) + +# compose +img = icnsutil.IcnsFile() +img.add_media(file='16x16.png') +img.add_media(file='16x16@2x.png') +img.write('./new-icon.icns', toc=False) + +# print +icnsutil.IcnsFile.description(fname, indent=2) + +# verify valid format +icnsutil.IcnsFile.verify(fname) ``` + + +#### Converting between (A)RGB and PNG + +You can use the library without installing PIL. +However, if you want to convert between PNG and ARGB files, Pillow is required. + +```sh +pip install Pillow +``` + +```python +import icnsutil + +# Convert from ARGB to PNG +icnsutil.ArgbImage('16x16.argb').write_png('16x16.png') + +# Convert from PNG to 24-bit RGB +img = icnsutil.ArgbImage('32x32.png') +with open('32x32.rgb', 'wb') as fp: + fp.write(img.rgb_data()) +with open('32x32.mask', 'wb') as fp: + fp.write(img.mask_data()) +``` + +Note: the CLI `export` command will fail if you run `--convert` without Pillow. + + +## Help needed + +1. Do you have an old macOS version running somewhere? +You can help and identify what file formats / icns types were introduced and when. Download the [format-support-icns.zip](./tests/format-support-icns.zip) file and report back which icons are displayed properly and in which macOS version. +See the [Apple Icon Image](https://en.wikipedia.org/wiki/Apple_Icon_Image) wikipedia article. + +2. You can run `make sys-icons-test` and report back whether you find some weird icons that are not handled properly by this library. diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..22b8570 --- /dev/null +++ b/cli.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +''' +Export existing icns files or compose new ones. +''' +import os # path, mkdir +import icnsutil +from sys import stderr +from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter + +__version__ = icnsutil.__version__ + + +def cli_extract(args): + ''' Read and extract contents of icns file(s). ''' + multiple = len(args.file) > 1 + for i, fname in enumerate(args.file): + # PathExist ensures that all files and directories exist + out = args.export_dir + if out and multiple: + out = os.path.join(out, str(i)) + os.makedirs(out, exist_ok=True) + + pred = 'png' if args.png_only else None + icnsutil.IcnsFile(fname).export( + out, allowed_ext=pred, recursive=args.recursive, + convert_png=args.convert, key_suffix=args.keys) + + +def cli_compose(args): + ''' Create new icns file from provided image files. ''' + dest = args.target + if not os.path.splitext(dest)[1]: + dest += '.icns' # for the lazy people + if not args.force and os.path.exists(dest): + print(f'File "{dest}" already exists. Force overwrite with -f.', + file=stderr) + return 1 + img = icnsutil.IcnsFile() + for x in args.source: + img.add_media(file=x) + img.write(dest, toc=not args.no_toc) + + +def cli_print(args): + ''' Print contents of icns file(s). ''' + for fname in args.file: + print('File:', fname) + print(icnsutil.IcnsFile.description( + fname, verbose=args.verbose, indent=2)) + + +def cli_verify(args): + ''' Test if icns file is valid. ''' + for fname in args.file: + is_valid = True + if not args.quiet: + print('File:', fname) + is_valid = None + for issue in icnsutil.IcnsFile.verify(fname): + if is_valid: + print('File:', fname) + is_valid = False + print(' ', issue) + if not args.quiet and is_valid is not False: + print('OK') + + +def main(): + class PathExist: + def __init__(self, kind=None): + self.kind = kind + + def __call__(self, path): + if not os.path.exists(path) or \ + self.kind == 'f' and not os.path.isfile(path) or \ + self.kind == 'd' and not os.path.isdir(path): + raise ArgumentTypeError('Does not exist "{}"'.format(path)) + return path + + # Args Parser + parser = ArgumentParser(description=__doc__, + formatter_class=RawTextHelpFormatter) + parser.set_defaults(func=lambda _: parser.print_help(stderr)) + parser.add_argument( + '-v', '--version', action='version', version='icnsutil ' + __version__) + sub_parser = parser.add_subparsers(metavar='command') + + # Extract + cmd = sub_parser.add_parser( + 'extract', aliases=['e'], formatter_class=RawTextHelpFormatter, + help=cli_extract.__doc__, description=cli_extract.__doc__.strip()) + cmd.add_argument( + '-r', '--recursive', action='store_true', + help='extract nested icns files as well') + cmd.add_argument( + '-o', '--export-dir', type=PathExist('d'), metavar='DIR', + help='set custom export directory') + cmd.add_argument( + '-k', '--keys', action='store_true', + help='use icns key as filenam') + cmd.add_argument( + '-c', '--convert', action='store_true', + help='convert ARGB and RGB images to PNG') + cmd.add_argument( + '--png-only', action='store_true', + help='do not extract ARGB, binary, and meta files') + cmd.add_argument( + 'file', nargs='+', type=PathExist('f'), metavar='FILE', + help='One or more .icns files.') + cmd.set_defaults(func=cli_extract) + + # Compose + cmd = sub_parser.add_parser( + 'compose', aliases=['c'], formatter_class=RawTextHelpFormatter, + help=cli_compose.__doc__, description=cli_compose.__doc__.strip()) + cmd.add_argument( + '-f', '--force', action='store_true', + help='force overwrite output file') + cmd.add_argument( + '--no-toc', action='store_true', + help='do not write table of contents to file') + cmd.add_argument( + 'target', type=str, metavar='destination', + help='Output file for newly created icns file.') + cmd.add_argument( + 'source', nargs='+', type=PathExist('f'), metavar='src', + help='One or more media files: png, argb, plist, icns.') + cmd.set_defaults(func=cli_compose) + cmd.epilog = f''' +Notes: +- TOC is optional but only a few bytes long (8b per media entry). +- Icon dimensions are read directly from file. +- Filename suffix "@2x.png" or "@2x.jp2" sets the retina flag. +- Use one of these suffixes to automatically assign icns files: + {', '.join(f'{x.name.lower()}.icns' for x in icnsutil.IcnsType.Role)} +''' + + # Print + cmd = sub_parser.add_parser( + 'print', aliases=['p'], formatter_class=RawTextHelpFormatter, + help=cli_print.__doc__, description=cli_print.__doc__.strip()) + cmd.add_argument( + '-v', '--verbose', action='store_true', + help='print all keys with offsets and sizes') + cmd.add_argument( + 'file', nargs='+', type=PathExist('f'), metavar='FILE', + help='One or more .icns files.') + cmd.set_defaults(func=cli_print) + + # Verify + cmd = sub_parser.add_parser( + 'test', aliases=['t'], formatter_class=RawTextHelpFormatter, + help=cli_verify.__doc__, description=cli_verify.__doc__.strip()) + cmd.add_argument( + '-q', '--quiet', action='store_true', + help='do not print OK results') + cmd.add_argument( + 'file', nargs='+', type=PathExist('f'), metavar='FILE', + help='One or more .icns files.') + cmd.set_defaults(func=cli_verify) + + args = parser.parse_args() + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/html/inspector.html b/html/inspector.html new file mode 100755 index 0000000..6ebb95d --- /dev/null +++ b/html/inspector.html @@ -0,0 +1,14 @@ + + + + +Icns Inspector + + + + +

Icns Inspector

+ + + + \ No newline at end of file diff --git a/html/script.js b/html/script.js new file mode 100644 index 0000000..14f56f0 --- /dev/null +++ b/html/script.js @@ -0,0 +1,257 @@ +function dropfile(ev, target, fn) { + ev.preventDefault(); + let reader = new FileReader(); + reader.readAsArrayBuffer(event.dataTransfer.files[0]); + reader.onload = function() { + tgt = document.getElementById(target); + tgt.value = [...new Uint8Array(reader.result)] + .map(x => x.toString(16).padStart(2, '0')) + .join(''); + tgt.dispatchEvent(new KeyboardEvent('keyup', {'key':'a'})); + }; +} + + +// General + +function determine_file_ext(str) { + let s8 = str.slice(0,8); + if (s8 == '\x89PNG\x0d\x0a\x1a\x0a') return 'png'; + if (s8 == '\x00\x00\x00\x0CjP ' || s8 == '\xFF\x4F\xFF\x51\x00\x2F\x00\x00') return 'jp2'; + if (str.slice(0,6) == 'bplist') return 'plist'; + let s4 = str.slice(0,4); + if (s4 == 'ARGB') return 'argb'; + if (s4 == 'icns') return 'icns'; + if (str.slice(0,3) == '\xFF\xD8\xFF') return 'jpg'; + return null; +} + +function icns_type(head) { + if (['is32', 'il32', 'ih32', 'it32', 'icp4', 'icp5'].indexOf(head) > -1) return 'rgb'; + if (['s8mk', 'l8mk', 'h8mk', 't8mk'].indexOf(head) > -1) return 'mask'; + if (['ICN#', 'icm#', 'ics#', 'ich#'].indexOf(head) > -1) return 'iconmask'; + if (['icm8', 'ics8', 'icl8', 'ich8'].indexOf(head) > -1) return 'icon8b'; + if (['icm4', 'ics4', 'icl4', 'ich4'].indexOf(head) > -1) return 'icon4b'; + if (['sbtp', 'slct', '\xFD\xD9\x2F\xA8'].indexOf(head) > -1) return 'icns'; + if (head == 'ICON') return 'icon1b'; + if (head == 'TOC ') return 'toc'; + if (head == 'info') return 'plist'; + return 'bin'; +} + +function is_it32(ext, itype, first4b) { + if (ext == 'rgb') return itype == 'it32'; + if (ext == null) return first4b == [0,0,0,0] || first4b == '00000000'; + return false; +} + +function* parse_file(hex_str) { + function get_length(hex, i) { return Number('0x'+hex.substring(i, i + 8)); } + function get_str(hex, i, len=8) { + var str = ''; + for (var u = i; u < i + len; u += 2) + str += String.fromCharCode(parseInt(hex.substr(u, 2), 16)); + return str; + } + function get_media_ext(itype, idx, len) { + let ext = determine_file_ext(get_str(hex_str, idx, 16)); + if (ext || !itype) + return [ext, idx, idx + len]; + return [icns_type(itype), idx, idx + len]; + } + var txt = ''; + var i = 0; + let ext = get_media_ext(null, 0, hex_str.length); + if (ext[0] == 'icns') { + var num = get_length(hex_str, i + 8); + yield ['icns', i, num, null]; + i += 8 * 2; + while (i < hex_str.length) { + let head = get_str(hex_str, i); + num = get_length(hex_str, i + 8); + yield [head, i, num, get_media_ext(head, i + 16, num * 2 - 16)]; + i += num * 2; + } + } else if (ext[0] == 'argb' || ext[0] == null) { + yield [null, 0, hex_str.length, ext]; + } +} + + +// Image viewer + +function num_arr_from_hex(hex) { + var ret = []; + for (var i = 0; i < hex.length; i += 2) + ret.push(parseInt(hex.substr(i, 2), 16)); + return ret; +} + +function msb_stream(source) { + var data = []; + for (var ii = 0; ii < source.length; ii++) { + let chr = source[ii]; + for (var uu = 7; uu >= 0; uu--) { + data.push((chr & (1 << uu)) ? 255 : 0); + } + } + return data; +} + +function expand_rgb(num_arr) { + var i = 0; + var ret = []; + while (i < num_arr.length) { + x = num_arr[i]; + i++; + if (x < 128) { + for (var u = i; u < i + x + 1; u++) { ret.push(num_arr[u]); } + i += x + 1; + } else { + for (var u = x - 128 + 3; u > 0; u--) { ret.push(num_arr[i]); } + i++; + } + } + return ret; +} + +function make_image(data, channels) { + let scale = 2; + let per_channel = data.length / channels; + let orig_width = Math.sqrt(per_channel); + let width = orig_width * scale; + let height = width; + + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + var ctx = canvas.getContext('2d'); + let map; + switch (channels) { + case 1: map = [0, 0, 0, -1]; break; + case 2: map = [0, 0, 0, 1]; break; + case 3: map = [0, 1, 2, -1]; break; + case 4: map = [1, 2, 3, 0]; break; + } + + for (var i = 0; i < per_channel; i++) { + let r = data[map[0] * per_channel + i]; + let g = data[map[1] * per_channel + i]; + let b = data[map[2] * per_channel + i]; + let a = (map[3] == -1) ? 255 : data[map[3] * per_channel + i]; + var imagedata = ctx.createImageData(scale, scale); + for (var idx = scale * scale * 4 - 4; idx >= 0; idx -= 4) { + imagedata.data[idx] = r; + imagedata.data[idx + 1] = g; + imagedata.data[idx + 2] = b; + imagedata.data[idx + 3] = a; + } + let y = Math.floor(i / orig_width); + let x = i - y * orig_width; + ctx.putImageData(imagedata, x * scale, y * scale); + } + return [canvas, orig_width]; +} + + +// Entry point + +function inspect_into(sender, dest) { + function fn(cls, hex, idx, len, tooltip) { + var tmp = ''; + for (var u = idx; u < idx + len * 2; u += 2) + tmp += ' ' + hex[u] + hex[u+1]; + + let ttp = tooltip ? ' title="' + tooltip + '"' : ''; + return `${tmp.substring(1)} `; + } + + let output = document.getElementById(dest); + output.innerHTML = 'loading ...'; + let src = sender.value.replace(/\s/g, ''); + var txt = ''; + for (let [head, i, len, ext] of parse_file(src)) { + txt += '
'; + if (head) { + txt += '

' + head + '

'; + txt += fn('head', src, i, 4, head); + txt += fn('len', src, i + 8, 4, 'len: ' + len); + } else { + txt += '

raw data

'; + } + if (!ext) { txt += '
'; continue; } // top icns-header + + let abbreviate; + if (['argb', 'rgb', null].indexOf(ext[0]) > -1) abbreviate = null; + else if (ext[0] == 'png') abbreviate = 'PNG data'; + else if (ext[0] == 'plist') abbreviate = 'info.plist'; + else if (ext[0] == 'icns') abbreviate = 'icns file'; + else abbreviate = 'raw data'; + if (abbreviate) { + txt += `... ${abbreviate}, ${len - 8} bytes ...`; + txt += ''; + continue; + } + + // parse unpacking + let is_argb = ext[0] == 'argb'; + var u = ext[1]; + if (is_argb || is_it32(ext[0], head, src.substring(u,u+8))) { + let title = (ext[0] == 'argb') ? 'ARGB' : 'it32-header'; + txt += fn('head', src, u, 4, title); + u += 8; + } + var total = 0; + while (u < ext[2]) { + x = Number('0x'+src[u]+src[u+1]); + if (x < 128) { + txt += fn('ctrl', src, u, 1, 'Copy ' + (x + 1) + ' bytes'); + txt += fn('data', src, u + 2, x + 1); + total += x + 1; + u += x * 2 + 4; + } else { + txt += fn('ctrl', src, u, 1, 'Repeat ' + (x - 128 + 3) + ' times'); + txt += fn('data', src, u + 2, 1); + total += x - 128 + 3; + u += 4; + } + } + let w = Math.sqrt(total / (is_argb ? 4 : 3)); + txt += '

Image size: ' + w + 'x' + w + '

'; + txt += ''; + } + output.innerHTML = txt; +} + +function put_images_into(sender, dest) { + let src = sender.value.replace(/\s/g, ''); + let output = document.getElementById(dest); + output.innerHTML = ''; + for (let [head, , , ext] of parse_file(src)) { + if (!ext) continue; + if (['argb', 'rgb', 'mask', 'iconmask', 'icon1b', null].indexOf(ext[0]) == -1) + continue; + + let num_arr = num_arr_from_hex(src.substring(ext[1], ext[2])); + let ch; + let data; + if (ext[0] == 'argb') { + ch = 4; data = expand_rgb(num_arr.slice(4)); + } else if (ext[0] == 'mask') { + ch = 1; data = num_arr; + } else if (ext[0] == 'icon1b') { + ch = 1; data = msb_stream(num_arr); + } else if (ext[0] == 'iconmask') { + ch = 2; data = msb_stream(num_arr); + } else { + let it32 = is_it32(ext[0], head, num_arr.slice(0,4)); + ch = 3; data = expand_rgb(it32 ? num_arr.slice(4) : num_arr); + } + let [img, w] = make_image(data, ch); + let container = document.createElement('div'); + container.innerHTML = `

${head || ''}

${w}x${w}

` + container.appendChild(img); + output.appendChild(container); + } +} diff --git a/html/style.css b/html/style.css new file mode 100644 index 0000000..2e61755 --- /dev/null +++ b/html/style.css @@ -0,0 +1,31 @@ +body {margin: 2em; font-family: sans-serif;} +#inspector {letter-spacing: -1px;} +#inspector h3 {margin: 3em 0 1.5em;} +#inspector span {padding: 0 3px; line-height: 3ex;} +#inspector span.head {background: #9F9;} +#inspector span.ctrl {background: #FAA;} +#inspector span.len {background: #AAF;} +#inspector span.data {background: #EEE;} +#inspector span.head::before, span.len::before { + content: attr(title); + position: relative; + left: 0; + bottom: 1.7em; + display: inline-block; + font-size: 0.8em; + width: 0; + white-space: nowrap; +} +/* image viewer */ +#images {margin-top: 1em;} +#images>div { + width: max-content; + display: inline-block; + padding: 0 10px; + margin-right: 10px; + vertical-align: top; + border: .5px solid gray; +} +#images>div>h3 {margin: 7px 0 0;} +#images>div>p {font-size: 0.8em; margin: 0 0 7px; color: gray;} +#images>div>canvas {border: .5px solid gray;} diff --git a/html/viewer.html b/html/viewer.html new file mode 100755 index 0000000..f733df7 --- /dev/null +++ b/html/viewer.html @@ -0,0 +1,14 @@ + + + + +Icns Image Viewer + + + + +

Icns Image Viewer

+ +
+ + \ No newline at end of file diff --git a/icnsutil.py b/icnsutil.py deleted file mode 100755 index 5978cee..0000000 --- a/icnsutil.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/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() diff --git a/icnsutil/ArgbImage.py b/icnsutil/ArgbImage.py new file mode 100755 index 0000000..a1e8097 --- /dev/null +++ b/icnsutil/ArgbImage.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +import PackBytes # pack, unpack, msb_stream +import IcnsType # match_maxsize +try: + from PIL import Image + PIL_ENABLED = True +except ImportError: + PIL_ENABLED = False + + +class ArgbImage: + __slots__ = ['a', 'r', 'g', 'b', 'size', 'channels'] + + @classmethod + def from_mono(cls, data, iType): + assert(iType.bits == 1) + img = [] + for byte in data: + for i in range(7, -1, -1): + img.append(255 if byte & (1 << i) else 0) + self = object.__new__(cls) + self.size = iType.size + self.channels = iType.channels + if iType.channels == 2: + self.a = img[len(img) // 2:] + img = img[:len(img) // 2] + else: + self.a = [255] * len(img) + self.r, self.g, self.b = img, img, img + return self + + def __init__(self, *, data=None, file=None, mask=None): + ''' + Provide either a filename or raw binary data. + - mask : Optional, may be either binary data or filename + ''' + if file: + self.load_file(file) + elif data: + self.load_data(data) + else: + raise AttributeError('Neither data nor file provided.') + if mask: + if type(mask) == bytes: + self.load_mask(data=mask) + else: + self.load_mask(file=mask) + + def load_file(self, fname): + with open(fname, 'rb') as fp: + if fp.read(4) == b'\x89PNG': + self._load_png(fname) + return + # else + fp.seek(0) + data = fp.read() + try: + self.load_data(data) + return + except Exception as e: + tmp = e # ignore previous exception to create a new one + raise type(tmp)('{} File: "{}"'.format(str(tmp), fname)) + + def load_data(self, data): + ''' Has support for ARGB and RGB-channels files. ''' + is_argb = data[:4] == b'ARGB' + if is_argb or data[:4] == b'\x00\x00\x00\x00': + data = data[4:] # remove ARGB and it32 header + + data = PackBytes.unpack(data) + iType = IcnsType.match_maxsize(len(data), 'argb' if is_argb else 'rgb') + if not iType: + raise ValueError('No (A)RGB image data. Could not determine size.') + + self.size = iType.size + self.channels = iType.channels + self.a, self.r, self.g, self.b = iType.split_channels(data) + + def load_mask(self, *, file=None, data=None): + ''' Data must be uncompressed and same length as a single channel! ''' + if file: + with open(file, 'rb') as fp: + data = fp.read() + if not data: + raise AttributeError('Neither data nor file provided.') + + assert(len(data) == len(self.r)) + self.a = data + + def mask_data(self, bits=8, *, compress=False): + if bits == 8: # default for rgb and argb + return PackBytes.pack(self.a) if compress else bytes(self.a) + return bytes(PackBytes.msb_stream(self.a, bits=bits)) + + def rgb_data(self, *, compress=True): + return b''.join(self._raw_rgb_channels(compress=compress)) + + def argb_data(self, *, compress=True): + return b'ARGB' + self.mask_data(compress=compress) + \ + b''.join(self._raw_rgb_channels(compress=compress)) + + def _raw_rgb_channels(self, *, compress=True): + for x in (self.r, self.g, self.b): + yield PackBytes.pack(x) if compress else bytes(x) + + def _load_png(self, fname): + if not PIL_ENABLED: + raise ImportError('Install Pillow to support PNG conversion.') + img = Image.open(fname, mode='r') + self.size = img.size + self.a = [] + self.r = [] + self.g = [] + self.b = [] + w, h = img.size + for y in range(h): + for x in range(w): + px = img.getpixel((x, y)) + if type(px) == int: + px = (px, px, px) # convert mono to rgb + if len(px) == 3: + px = px + (0xFF,) # convert rgb to rgba + r, g, b, a = px + self.a.append(a) + self.r.append(r) + self.g.append(g) + self.b.append(b) + + def write_png(self, fname): + if not PIL_ENABLED: + raise ImportError('Install Pillow to support PNG conversion.') + img = Image.new(mode='RGBA', size=self.size) + w, h = self.size + for y in range(h): + for x in range(w): + i = y * w + x + img.putpixel( + (x, y), (self.r[i], self.g[i], self.b[i], self.a[i])) + img.save(fname) + + def __repr__(self): + typ = ['', 'Mono', 'Mono with Mask', 'RGB', 'RGBA'][self.channels] + return f'<{type(self).__name__}: {self.size[0]}x{self.size[1]} {typ}>' diff --git a/icnsutil/IcnsFile.py b/icnsutil/IcnsFile.py new file mode 100755 index 0000000..6ed4774 --- /dev/null +++ b/icnsutil/IcnsFile.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +import os # path +import sys # stderr +import RawData +import IcnsType +import struct # unpack float in _description() +from ArgbImage import ArgbImage # in _export_to_png() + + +class IcnsFile: + @staticmethod + def verify(fname): + ''' + Yields an error message for each issue. + You can check for validity with `is_invalid = any(obj.verify())` + ''' + all_keys = set() + bin_keys = set() + try: + for key, data in RawData.parse_icns_file(fname): + all_keys.add(key) + # Check if icns type is known + try: + iType = IcnsType.get(key) + except NotImplementedError: + yield f'Unsupported icns type: {key}' + continue + + ext = RawData.determine_file_ext(data) + if ext is None: + bin_keys.add(key) + + # Check whether stored type is an expected file format + if not (iType.is_type(ext) if ext else iType.is_binary()): + yield 'Unexpected type for key {}: {} != {}'.format( + key, ext or 'binary', list(iType.types)) + + if ext in ['png', 'jp2', 'icns', 'plist']: + continue + + # Check whether uncompressed size is equal to expected maxsize + if key == 'it32' and data[:4] != b'\x00\x00\x00\x00': + # TODO: check whether other it32 headers exist + yield f'Unexpected it32 data header: {data[:4]}' + data = iType.decompress(data, ext) # ignores non-compressable + + # Check expected uncompressed maxsize + if iType.maxsize and len(data) != iType.maxsize: + yield 'Invalid data length for {}: {} != {}'.format( + key, len(data), iType.maxsize) + # if file is not an icns file + except TypeError as e: + yield e + return + + # Check total size after enum. Enum may raise exception and break early + with open(fname, 'rb') as fp: + _, header_size = RawData.icns_header_read(fp.read(8)) + actual_size = os.path.getsize(fname) + if header_size != actual_size: + yield 'header file-size != actual size: {} != {}'.format( + header_size, actual_size) + + # Check key pairings + for img, mask in IcnsType.enum_img_mask_pairs(bin_keys): + if not img or not mask: + if not img: + img, mask = mask, img + yield f'Missing key pair: {mask} found, {img} missing.' + + # Check duplicate image dimensions + for x, y in [('is32', 'icp4'), ('il32', 'icp5'), ('it32', 'ic07'), + ('ic04', 'icp4'), ('ic05', 'icp5')]: + if x in all_keys and y in all_keys: + yield f'Redundant keys: {x} and {y} have identical size.' + + @staticmethod + def description(fname, *, verbose=False, indent=0): + return IcnsFile._description( + RawData.parse_icns_file(fname), verbose=verbose, indent=indent) + + @staticmethod + def _description(enumerator, *, verbose=False, indent=0): + ''' Expects an enumerator with (key, size, data) ''' + txt = '' + offset = 8 # already with icns header + for key, data in enumerator: + # actually, icns length should be -8 (artificially appended header) + size = len(data) + txt += ' ' * indent + txt += f'{key}: {size} bytes' + if verbose: + txt += f', offset: {offset}' + offset += size + 8 + if key == 'name': + txt += f', value: "{data.decode("utf-8")}"\n' + continue + if key == 'icnV': + txt += f', value: {struct.unpack(">f", data)[0]}\n' + continue + ext = RawData.determine_file_ext(data) + try: + iType = IcnsType.get(key) + if not ext: + ext = list(iType.types)[-1] + desc = iType.filename(size_only=True) + txt += f', {ext or "binary"}: {desc}\n' + except NotImplementedError: + txt += f': UNKNOWN TYPE: {ext or data[:6]}\n' + return txt + + def __init__(self, file=None): + ''' Read .icns file and load bundled media files into memory. ''' + self.media = {} + self.infile = file + if not file: # create empty image + return + for key, data in RawData.parse_icns_file(file): + self.media[key] = data + try: + IcnsType.get(key) + except NotImplementedError: + print('Warning: unknown media type: {}, {} bytes, "{}"'.format( + key, len(data), file), file=sys.stderr) + + def add_media(self, key=None, *, file=None, data=None, force=False): + ''' + If you provide both, data and file, data takes precedence. + However, the filename is still used for type-guessing. + - Declare retina images with suffix "@2x.png". + - Declare icns file with suffix "-dark", "-template", or "-selected" + ''' + assert(not key or len(key) == 4) # did you miss file= or data=? + if file and not data: + with open(file, 'rb') as fp: + data = fp.read() + + if not key: # Determine ICNS type + key = IcnsType.guess(data, file).key + # Check if type is unique + if not force and key in self.media.keys(): + raise KeyError(f'Image with identical key "{key}". File: {file}') + self.media[key] = data + + def write(self, fname, *, toc=True): + ''' Create a new ICNS file from stored media. ''' + # Rebuild TOC to ensure soundness + order = self._make_toc(enabled=toc) + # Total file size has always +8 for media header (after _make_toc) + total = sum(len(x) + 8 for x in self.media.values()) + with open(fname, 'wb') as fp: + fp.write(RawData.icns_header_w_len(b'icns', total)) + for key in order: + RawData.icns_header_write_data(fp, key, self.media[key]) + + def export(self, outdir=None, *, allowed_ext=None, key_suffix=False, + convert_png=False, decompress=False, recursive=False): + ''' + Write all bundled media files to output directory. + + - outdir : If none provided, use same directory as source file. + - allowed_ext : Export only data with matching extension(s). + - key_suffix : If True, use icns type instead of image size filename. + - convert_png : If True, convert rgb and argb images to png. + - decompress : Only relevant for ARGB and 24-bit binary images. + - recursive : Repeat export for all attached icns files. + Incompatible with png_only flag. + ''' + if not outdir: # aka, determine by input file + # Determine filename and prepare output directory + outdir = (self.infile or 'in-memory.icns') + '.export' + os.makedirs(outdir, exist_ok=True) + elif not os.path.isdir(outdir): + raise NotADirectoryError(f'"{outdir}" is not a directory. Abort.') + + exported_files = {'_': self.infile} + keys = list(self.media.keys()) + # Convert to PNG + if convert_png: + # keys = [x for x in keys if x not in []] + for imgk, maskk in IcnsType.enum_png_convertable(keys): + fname = self._export_to_png(outdir, imgk, maskk, key_suffix) + if not fname: + continue + exported_files[imgk] = fname + if maskk: + exported_files[maskk] = fname + if maskk in keys: + keys.remove(maskk) + keys.remove(imgk) + + # prepare filter + if type(allowed_ext) == str: + allowed_ext = [allowed_ext] + if recursive: + cleanup = allowed_ext and 'icns' not in allowed_ext + if cleanup: + allowed_ext.append('icns') + + # Export remaining + for key in keys: + fname = self._export_single(outdir, key, key_suffix, decompress, + allowed_ext) + if fname: + exported_files[key] = fname + + # repeat for all icns + if recursive: + for key, fname in exported_files.items(): + if key == '_' or not fname.endswith('.icns'): + continue + prev_fname = exported_files[key] + exported_files[key] = IcnsFile(fname).export( + allowed_ext=allowed_ext, key_suffix=key_suffix, + convert_png=convert_png, decompress=decompress, + recursive=True) + if cleanup: + os.remove(prev_fname) + return exported_files + + def _make_toc(self, *, enabled): + # Rebuild TOC to ensure soundness + if 'TOC ' in self.media.keys(): + del(self.media['TOC ']) + # We loop two times over the keys; so, make sure order is identical. + # By default this will be the same order as read/written. + order = list(self.media.keys()) + if enabled: + self.media['TOC '] = b''.join( + RawData.icns_header_w_len(x, len(self.media[x])) + for x in order) + # Table of contents, if enabled, is always first entry + order.insert(0, 'TOC ') + + return order + + def _export_single(self, outdir, key, key_suffix, decompress, allowed_ext): + ''' You must ensure that keys exist in self.media ''' + data = self.media[key] + ext = RawData.determine_file_ext(data) + if ext == 'icns' and data[:4] != b'icns': + header = RawData.icns_header_w_len(b'icns', len(data)) + data = header + data # Add missing icns header + try: + iType = IcnsType.get(key) + fname = iType.filename(key_only=key_suffix) + if decompress: + data = iType.decompress(data, ext) # ignores non-compressable + if not ext: # overwrite ext after (decompress requires None) + ext = 'rgb' if iType.compressable else 'bin' + except NotImplementedError: # If key unkown, export anyway + fname = str(key) # str() because key may be binary-str + if not ext: + ext = 'unknown' + + if allowed_ext and ext not in allowed_ext: + return None + fname = os.path.join(outdir, f'{fname}.{ext}') + with open(fname, 'wb') as fp: + fp.write(data) + return fname + + def _export_to_png(self, outdir, img_key, mask_key, key_suffix): + ''' You must ensure key and mask_key exists! ''' + data = self.media[img_key] + if RawData.determine_file_ext(data) not in ['argb', None]: + return None # icp4 and icp5 can have png or jp2 data + iType = IcnsType.get(img_key) + fname = iType.filename(key_only=key_suffix, size_only=True) + fname = os.path.join(outdir, fname + '.png') + if iType.bits == 1: + # return None + ArgbImage.from_mono(data, iType).write_png(fname) + else: + mask_data = self.media[mask_key] if mask_key else None + ArgbImage(data=data, mask=mask_data).write_png(fname) + return fname + + def __repr__(self): + lst = ', '.join(str(k) for k in self.media.keys()) + return f'<{type(self).__name__}: file={self.infile}, [{lst}]>' + + def __str__(self): + return f'File: {self.infile or "-mem-"}\n' \ + + IcnsFile._description(self.media.items(), indent=2) diff --git a/icnsutil/IcnsType.py b/icnsutil/IcnsType.py new file mode 100755 index 0000000..e3142ad --- /dev/null +++ b/icnsutil/IcnsType.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +''' +Namespace for the ICNS format. +@see https://en.wikipedia.org/wiki/Apple_Icon_Image_format +''' +import os # path +from enum import Enum # IcnsType.Role +import RawData +import PackBytes + + +class Role(Enum): + DARK = b'\xFD\xD9\x2F\xA8' + TEMPLATE = 'sbtp' + SELECTED = 'slct' + + +class Media: + __slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability', + 'desc', 'compressable', 'retina', 'maxsize'] + + def __init__(self, key, types, size=None, *, + ch=None, bits=None, os=None, desc=''): + self.key = key + self.types = frozenset(types if type(types) == list else [types]) + self.size = (size, size) if type(size) == int else size + self.availability = os + self.desc = desc + # computed properties + self.compressable = self.is_type('argb') or self.is_type('rgb') + self.retina = ('@2x' in self.desc) if self.is_type('png') else None + if self.is_type('rgb'): + ch = 3 + bits = 8 + if self.is_type('argb'): + ch = 4 + bits = 8 + self.channels = ch + self.bits = bits + self.maxsize = None + if size and ch and bits: + self.maxsize = self.size[0] * self.size[1] * ch * bits // 8 + + def is_type(self, typ): + return typ in self.types + + def is_binary(self) -> bool: + return any(x in self.types for x in ['rgb', 'bin']) + + def split_channels(self, uncompressed_data): + if self.channels not in [3, 4]: + raise NotImplementedError('Only RGB and ARGB data supported.') + if len(uncompressed_data) != self.maxsize: + raise ValueError( + 'Data does not match expected uncompressed length. ' + '{} != {}'.format(len(uncompressed_data), self.maxsize)) + per_channel = self.maxsize // self.channels + if self.channels == 3: + yield [255] * per_channel # opaque alpha channel for rgb + for i in range(self.channels): + yield uncompressed_data[per_channel * i:per_channel * (i + 1)] + + def decompress(self, data, ext='-?-'): + if not self.compressable: + return data + if ext == '-?-': + ext = RawData.determine_file_ext(data) + if ext == 'argb': + return PackBytes.unpack(data[4:]) # remove ARGB header + if ext is None or ext == 'rgb': # RGB files dont have a magic number + if self.key == 'it32': + data = data[4:] # TODO: dirty fix for it32 \x00\x00\x00\x00 + return PackBytes.unpack(data) + return data + + def filename(self, *, key_only=False, size_only=False): + if key_only: + if os.path.exists(__file__.upper()): # check case senstive + if self.key in ['sb24', 'icsb']: + return self.key + '-a' + elif self.key in ['SB24', 'icsB']: + return self.key + '-b' + return f'{self.key}' # dont return directy, may be b''-str + else: + if self.is_type('icns'): + return Role(self.key).name.lower() + if not self.size: + return f'{self.key}' # dont return directy, may be b''-str + w, h = self.size + suffix = '' + if self.retina: + w //= 2 + h //= 2 + suffix = '@2x' + if size_only: + if self.bits == 1: + suffix += '-mono' + else: + if self.desc in ['icon', 'iconmask']: + suffix += f'-icon{self.bits}b' + if self.desc in ['mask', 'iconmask']: + suffix += f'-mask{self.bits}b' + return f'{w}x{h}{suffix}' + + def __repr__(self): + return '<{}: {}, {}.{}>'.format(type(self).__name__, self.key, + self.filename(), list(self.types)[0]) + + def __str__(self): + T = '' + if self.size: + T += '{}x{}, '.format(*self.size) + if self.maxsize: + T += f'{self.channels}ch@{self.bits}-bit={self.maxsize}, ' + if self.desc: + T += f'{self.desc}, ' + return f'{self.key}: {T}macOS {self.availability or "?"}+' + + +_TYPES = {x.key: x for x in ( + # Read support for these: + Media('ICON', 'bin', 32, ch=1, bits=1, os=1.0, desc='icon'), + Media('ICN#', 'bin', 32, ch=2, bits=1, os=6.0, desc='iconmask'), + Media('icm#', 'bin', (16, 12), ch=2, bits=1, os=6.0, desc='iconmask'), + Media('icm4', 'bin', (16, 12), ch=1, bits=4, os=7.0, desc='icon'), + Media('icm8', 'bin', (16, 12), ch=1, bits=8, os=7.0, desc='icon'), + Media('ics#', 'bin', 16, ch=2, bits=1, os=6.0, desc='iconmask'), + Media('ics4', 'bin', 16, ch=1, bits=4, os=7.0, desc='icon'), + Media('ics8', 'bin', 16, ch=1, bits=8, os=7.0, desc='icon'), + Media('is32', 'rgb', 16, os=8.5), + Media('s8mk', 'bin', 16, ch=1, bits=8, os=8.5, desc='mask'), + Media('icl4', 'bin', 32, ch=1, bits=4, os=7.0, desc='icon'), + Media('icl8', 'bin', 32, ch=1, bits=8, os=7.0, desc='icon'), + Media('il32', 'rgb', 32, os=8.5), + Media('l8mk', 'bin', 32, ch=1, bits=8, os=8.5, desc='mask'), + Media('ich#', 'bin', 48, ch=2, bits=1, os=8.5, desc='iconmask'), + Media('ich4', 'bin', 48, ch=1, bits=4, os=8.5, desc='icon'), + Media('ich8', 'bin', 48, ch=1, bits=8, os=8.5, desc='icon'), + Media('ih32', 'rgb', 48, os=8.5), + Media('h8mk', 'bin', 48, ch=1, bits=8, os=8.5, desc='mask'), + Media('it32', 'rgb', 128, os=10.0), + Media('t8mk', 'bin', 128, ch=1, bits=8, os=10.0, desc='mask'), + # Write support for these: + Media('icp4', ['png', 'jp2', 'rgb'], 16, os=10.7), + Media('icp5', ['png', 'jp2', 'rgb'], 32, os=10.7), + Media('icp6', 'png', 64, os=10.7), + Media('ic07', ['png', 'jp2'], 128, os=10.7), + Media('ic08', ['png', 'jp2'], 256, os=10.5), + Media('ic09', ['png', 'jp2'], 512, os=10.5), + Media('ic10', ['png', 'jp2'], 1024, os=10.7, desc='or 512x512@2x (10.8)'), + Media('ic11', ['png', 'jp2'], 32, os=10.8, desc='16x16@2x'), + Media('ic12', ['png', 'jp2'], 64, os=10.8, desc='32x32@2x'), + Media('ic13', ['png', 'jp2'], 256, os=10.8, desc='128x128@2x'), + Media('ic14', ['png', 'jp2'], 512, os=10.8, desc='256x256@2x'), + Media('ic04', 'argb', 16, os=11.0), + Media('ic05', 'argb', 32, os=11.0), + Media('icsb', 'argb', 18, os=11.0), + Media('icsB', ['png', 'jp2'], 36, desc='18x18@2x'), + Media('sb24', ['png', 'jp2'], 24), + Media('SB24', ['png', 'jp2'], 48, desc='24x24@2x'), + # ICNS media files + Media(Role.TEMPLATE.value, 'icns', desc='"template" icns'), + Media(Role.SELECTED.value, 'icns', desc='"selected" icns'), + Media(Role.DARK.value, 'icns', os=10.14, desc='"dark" icns'), + # Meta types: + Media('TOC ', 'bin', os=10.7, desc='Table of Contents'), + Media('icnV', 'bin', desc='4-byte Icon Composer.app bundle version'), + Media('name', 'bin', desc='Unknown'), + Media('info', 'plist', desc='Info binary plist'), +)} + + +def enum_img_mask_pairs(available_keys): + for mask_k, *imgs in [ # list probably never changes, ARGB FTW + ('s8mk', 'is32', 'ics8', 'ics4', 'icp4'), + ('l8mk', 'il32', 'icl8', 'icl4', 'icp5'), + ('h8mk', 'ih32', 'ich8', 'ich4'), + ('t8mk', 'it32'), + ]: + if mask_k not in available_keys: + mask_k = None + any_img = False + for img_k in imgs: + if img_k in available_keys: + any_img = True + yield img_k, mask_k + if mask_k and not any_img: + yield None, mask_k + + +def enum_png_convertable(available_keys): + ''' Yield (image-key, mask-key or None) ''' + for img in _TYPES.values(): + if img.key not in available_keys: + continue + if img.is_type('argb') or img.bits == 1: # allow mono icons + yield img.key, None + elif img.is_type('rgb'): + mask_key = None + for mask in _TYPES.values(): + if mask.key not in available_keys: + continue + if mask.desc == 'mask' and mask.size == img.size: + mask_key = mask.key + break + yield img.key, mask_key + + +def get(key): # support for IcnsType[key] + try: + return _TYPES[key] + except KeyError: + pass + raise NotImplementedError(f'Unsupported icns type "{key}"') + + +def match_maxsize(maxsize, typ): + for x in _TYPES.values(): + if x.is_type(typ) and x.maxsize == maxsize: + return x # TODO: handle cases with multiple options? eg: is32 icp4 + return None + + +def guess(data, filename=None): + ''' + Guess icns media type by analyzing the raw data + file naming convention. + Use: + - @2x.png or @2x.jp2 for retina images + - directly name the file with the corresponding icns-key + - or {selected|template|dark}.icns to select the proper icns key. + ''' + # Set type directly via filename + if filename: + bname = os.path.splitext(os.path.basename(filename))[0] + if bname in _TYPES: + return _TYPES[bname] + + ext = RawData.determine_file_ext(data) + # Icns specific names + if ext == 'icns' and filename: + for candidate in Role: + if filename.endswith(f'{candidate.name.lower()}.icns'): + return _TYPES[candidate.value] + # if not found, fallback and output all options + + # Guess by image size and retina flag + size = RawData.determine_image_size(data, ext) # None for non-image types + retina = None + if ext in ['png', 'jp2']: + retina = bname.lower().endswith('@2x') if filename else False + + choices = [] + for x in _TYPES.values(): + if size != x.size: # currently no support for RGB and binary data + continue + if ext and not x.is_type(ext): + continue + if retina is not None and retina != x.retina: + continue + choices.append(x) + + if len(choices) == 1: + return choices[0] + raise ValueError('Could not determine type – one of {} -- {}'.format( + [x.key for x in choices], + {'type': ext, 'size': size, 'retina': retina})) diff --git a/icnsutil/PackBytes.py b/icnsutil/PackBytes.py new file mode 100755 index 0000000..0f70544 --- /dev/null +++ b/icnsutil/PackBytes.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +def pack(data): + ret = [] + buf = [] + i = 0 + + def flush_buf(): + # write out non-repeating bytes + if len(buf) > 0: + ret.append(len(buf) - 1) + ret.extend(buf) + buf.clear() + + end = len(data) + while i < end: + arr = data[i:i + 3] + x = arr[0] + if len(arr) == 3 and x == arr[1] and x == arr[2]: + flush_buf() + # repeating + c = 3 + while (i + c) < len(data) and data[i + c] == x: + c += 1 + i += c + while c > 130: # max number of copies encodable in compression + ret.append(0xFF) + ret.append(x) + c -= 130 + ret.append(c + 0x7D) # 0x80 - 3 + ret.append(x) + else: + buf.append(x) + if len(buf) > 127: + flush_buf() + i += 1 + flush_buf() + return bytes(ret) + + +def unpack(data): + ret = [] + i = 0 + end = len(data) + while i < end: + n = data[i] + if n < 0x80: + ret += data[i + 1:i + n + 2] + i += n + 2 + else: + ret += [data[i + 1]] * (n - 0x7D) + i += 2 + return ret + + +def get_size(data): + count = 0 + i = 0 + end = len(data) + while i < end: + n = data[i] + if n < 0x80: + count += n + 1 + i += n + 2 + else: + count += n - 125 + i += 2 + return count + + +def msb_stream(data, *, bits): + if bits not in [1, 2, 4]: + raise NotImplementedError('Unsupported bit-size.') + c = 0 + byte = 0 + for x in data: # 8-bits in, most significant n-bits out + c += bits + byte <<= bits + byte |= (x >> (8 - bits)) + if c == 8: + yield byte + c = 0 + byte = 0 + if c > 0: # fill up missing bits + byte <<= (8 - c) + yield byte diff --git a/icnsutil/RawData.py b/icnsutil/RawData.py new file mode 100755 index 0000000..50e7240 --- /dev/null +++ b/icnsutil/RawData.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +import struct # pack, unpack +import PackBytes # get_size +import IcnsType # get, match_maxsize + + +def determine_file_ext(data): + ''' + Data should be at least 8 bytes long. + Returns one of: png, argb, plist, jp2, icns, None + ''' + if data[:8] == b'\x89PNG\x0d\x0a\x1a\x0a': + return 'png' + if data[:4] == b'ARGB': + return 'argb' + if data[:6] == b'bplist': + return 'plist' + if data[:8] in [b'\x00\x00\x00\x0CjP ', + b'\xFF\x4F\xFF\x51\x00\x2F\x00\x00']: # JPEG 2000 + return 'jp2' + # if data[:3] == b'\xFF\xD8\xFF': # JPEG (not supported in icns files) + # return 'jpg' + if data[:4] == b'icns' or is_icns_without_header(data): + return 'icns' # a rather heavy calculation, postpone till end + return None + + +def determine_image_size(data, ext=None): + ''' Supports PNG, ARGB, and Jpeg 2000 image data. ''' + if not ext: + ext = determine_file_ext(data) + if ext == 'png': + return struct.unpack('>II', data[16:24]) + elif ext == 'argb': + total = PackBytes.get_size(data[4:]) # without ARGB header + return IcnsType.match_maxsize(total, 'argb').size + elif ext == 'jp2': + if data[:4] == b'\xFF\x4F\xFF\x51': + return struct.unpack('>II', data[8:16]) + len_ftype = struct.unpack('>I', data[12:16])[0] + # file header + type box + header box (super box) + image header box + offset = 12 + len_ftype + 8 + 8 + h, w = struct.unpack('>II', data[offset:offset + 8]) + return w, h + return None # icns does not support other image types except binary + + +def is_icns_without_header(data): + ''' Returns True even if icns header is missing. ''' + offset = 0 + for i in range(2): # test n keys if they exist + key, size = icns_header_read(data[offset:offset + 8]) + try: + IcnsType.get(key) + except NotImplementedError: + return False + offset += size + if offset > len(data) or size == 0: + return False + if offset == len(data): + return True + return True + + +def icns_header_read(data): + ''' Returns icns type name and data length (incl. +8 for header) ''' + assert(type(data) == bytes) + if len(data) != 8: + return None, 0 + try: + name = data[:4].decode('utf8') + except UnicodeDecodeError: + name = data[:4] # Fallback to bytes-string key + return name, struct.unpack('>I', data[4:])[0] + + +def icns_header_write_data(fp, key, data): + ''' Calculates length from data. ''' + fp.write(key.encode('utf8') if type(key) == str else key) + fp.write(struct.pack('>I', len(data) + 8)) + fp.write(data) + + +def icns_header_w_len(key, length): + ''' Adds +8 to length. ''' + name = key.encode('utf8') if type(key) == str else key + return name + struct.pack('>I', length + 8) + + +def parse_icns_file(fname): + ''' + Parse file and yield media entries: (key, data) + :raises: + TypeError: if file is not an icns file ("icns" header missing) + ''' + with open(fname, 'rb') as fp: + # Check whether it is an actual ICNS file + magic_num, _ = icns_header_read(fp.read(8)) # ignore total size + if magic_num != 'icns': + raise TypeError('Not an ICNS file, missing "icns" header.') + # Read media entries as long as there is something to read + while True: + key, size = icns_header_read(fp.read(8)) + if not key: + break # EOF + # TODO: remove test case + if key in ['ICON', 'icm#', 'icm4', 'icm8']: + print('YAAAY', key, fname) + yield key, fp.read(size - 8) # -8 header diff --git a/icnsutil/__init__.py b/icnsutil/__init__.py new file mode 100755 index 0000000..1ad7e1e --- /dev/null +++ b/icnsutil/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +__version__ = '1.0' + +import sys +if __name__ != '__main__': + sys.path.insert(0, __path__[0]) + +# static modules +import IcnsType +import PackBytes +import RawData +# class modules +from ArgbImage import ArgbImage, PIL_ENABLED +from IcnsFile import IcnsFile diff --git a/tests/fixtures/18x18.j2k b/tests/fixtures/18x18.j2k new file mode 100755 index 0000000..5a091c7 Binary files /dev/null and b/tests/fixtures/18x18.j2k differ diff --git a/tests/fixtures/256x256.jp2 b/tests/fixtures/256x256.jp2 new file mode 100755 index 0000000..5fe6b69 Binary files /dev/null and b/tests/fixtures/256x256.jp2 differ diff --git a/tests/fixtures/icp4rgb.icns b/tests/fixtures/icp4rgb.icns new file mode 100755 index 0000000..f051326 Binary files /dev/null and b/tests/fixtures/icp4rgb.icns differ diff --git a/tests/fixtures/rgb.icns b/tests/fixtures/rgb.icns new file mode 100755 index 0000000..58a15fe Binary files /dev/null and b/tests/fixtures/rgb.icns differ diff --git a/tests/fixtures/rgb.icns.argb b/tests/fixtures/rgb.icns.argb new file mode 100755 index 0000000..e7f759f Binary files /dev/null and b/tests/fixtures/rgb.icns.argb differ diff --git a/tests/fixtures/rgb.icns.png b/tests/fixtures/rgb.icns.png new file mode 100755 index 0000000..5303b4a Binary files /dev/null and b/tests/fixtures/rgb.icns.png differ diff --git a/tests/fixtures/rgb.icns.rgb b/tests/fixtures/rgb.icns.rgb new file mode 100755 index 0000000..3b8ae1c Binary files /dev/null and b/tests/fixtures/rgb.icns.rgb differ diff --git a/tests/fixtures/selected.icns b/tests/fixtures/selected.icns new file mode 100755 index 0000000..cbc1d78 Binary files /dev/null and b/tests/fixtures/selected.icns differ diff --git a/tests/format-support-icns.zip b/tests/format-support-icns.zip new file mode 100644 index 0000000..3724087 Binary files /dev/null and b/tests/format-support-icns.zip differ diff --git a/tests/format-support-raw.zip b/tests/format-support-raw.zip new file mode 100644 index 0000000..780b3fb Binary files /dev/null and b/tests/format-support-raw.zip differ diff --git a/tests/format-support.py b/tests/format-support.py new file mode 100755 index 0000000..14c29d0 --- /dev/null +++ b/tests/format-support.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +import os +import sys +import zipfile +from random import randint +if __name__ == '__main__': + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from icnsutil import IcnsFile, PackBytes + + +def main(): + # generate_raw_rgb() + generate_icns() + generate_random_it32_header() + print('Done.') + + +INFO = { + 16: ['is32', 'icp4', 'ic04'], + 18: ['icsb'], + 24: ['sb24'], + 32: ['il32', 'icp5', 'ic11', 'ic05'], + 36: ['icsB'], + 48: ['ih32', 'SB24'], + 64: ['icp6', 'ic12'], + 128: ['it32', 'ic07'], + 256: ['ic08', 'ic13'], + 512: ['ic09', 'ic14'], + 1024: ['ic10'], +} + + +def generate_raw_rgb(): + def testpattern(w, h, *, ch, compress=True): + ARGB = ch == 4 + sz = w * h + if compress: + pattern = [0, 0, 0, 0, 255, 255] * sz + a = PackBytes.pack([255] * sz) if ARGB else b'' + r = PackBytes.pack(pattern[4:sz + 4]) + g = PackBytes.pack(pattern[2:sz + 2]) + b = PackBytes.pack(pattern[:sz]) + else: + pattern = b'\x00\x00\x00\x00\xFF\xFF' * sz + a = b'\xFF' * sz if ARGB else b'' + r = pattern[4:sz + 4] + g = pattern[2:sz + 2] + b = pattern[:sz] + return (b'ARGB' if ARGB else b'') + a + r + g + b + + os.makedirs('format-support-raw', exist_ok=True) + for s in INFO.keys(): + print(f'generate {s}x{s}.argb') + argb_data = testpattern(s, s, ch=4) + with open(f'format-support-raw/{s}x{s}.argb', 'wb') as fp: + fp.write(argb_data) + print(f'generate {s}x{s}.rgb') + rgb_data = testpattern(s, s, ch=3) + with open(f'format-support-raw/{s}x{s}.rgb', 'wb') as fp: + fp.write(rgb_data) + + +def generate_icns(): + os.makedirs('format-support-icns', exist_ok=True) + with zipfile.ZipFile('format-support-raw.zip') as Zip: + for s, keys in INFO.items(): + print(f'generate icns for {s}x{s}') + for key in keys: + # JPEG 2000, PNG, and ARGB + for ext in ['jp2', 'png', 'argb']: + img = IcnsFile() + with Zip.open(f'{s}x{s}.{ext}') as f: + img.add_media(key, data=f.read()) + img.write(f'format-support-icns/{s}-{key}-{ext}.icns', + toc=False) + # RGB + mask + img = IcnsFile() + with Zip.open(f'{s}x{s}.rgb') as f: + data = f.read() + if key == 'it32': + data = b'\x00\x00\x00\x00' + data + img.add_media(key, data=data) + img.add_media('s8mk', data=b'\xFF' * 256) + img.add_media('l8mk', data=b'\xFF' * 1024) + img.add_media('h8mk', data=b'\xFF' * 2304) + img.add_media('t8mk', data=b'\xFF' * 16384) + img.write(f'format-support-icns/{s}-{key}-rgb.icns', toc=False) + + +def generate_random_it32_header(): + print(f'testing random it32 header') + os.makedirs('format-support-it32', exist_ok=True) + with zipfile.ZipFile('format-support-raw.zip') as Zip: + with Zip.open(f'128x128.rgb') as f: + data = f.read() + + def random_header(): + return bytes([randint(0, 255), randint(0, 255), + randint(0, 255), randint(0, 255)]) + + for i in range(100): + img = IcnsFile() + img.add_media('it32', data=random_header() + data) + img.add_media('t8mk', data=b'\xFF' * 16384) + img.write(f'format-support-it32/{i}.icns', toc=False) + + +if __name__ == '__main__': + main() diff --git a/tests/test_icnsutil.py b/tests/test_icnsutil.py new file mode 100755 index 0000000..6e72525 --- /dev/null +++ b/tests/test_icnsutil.py @@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +import unittest +import shutil # rmtree +import os # chdir, listdir, makedirs, path, remove +import sys +if __name__ == '__main__': + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from icnsutil import * + + +def main(): + # ensure working dir is correct + os.chdir(os.path.join(os.path.dirname(__file__), 'fixtures')) + print('Running tests with PIL_ENABLED =', PIL_ENABLED) + unittest.main() + exit() + + +################ +# Unit tests # +################ + +class TestArgbImage(unittest.TestCase): + def test_init_data(self): + w = 16 # size + ch_255 = b'\xFF\xFF\xFB\xFF' + ch_128 = b'\xFF\x80\xFB\x80' + ch_000 = b'\xFF\x00\xFB\x00' + # Test ARGB init + img = ArgbImage(data=b'ARGB' + ch_000 + ch_128 + ch_000 + ch_255) + self.assertEqual(img.size, (w, w)) + self.assertEqual(img.a, [0] * w * w) + self.assertEqual(img.r, [128] * w * w) + self.assertEqual(img.g, [0] * w * w) + self.assertEqual(img.b, [255] * w * w) + # Test RGB init + img = ArgbImage(data=ch_128 + ch_000 + ch_255) + self.assertEqual(img.size, (w, w)) + self.assertEqual(img.a, [255] * w * w) + self.assertEqual(img.r, [128] * w * w) + self.assertEqual(img.g, [0] * w * w) + self.assertEqual(img.b, [255] * w * w) + # Test setting mask manually + img.load_mask(data=[117] * w * w) + self.assertEqual(img.size, (w, w)) + self.assertEqual(img.a, [117] * w * w) + self.assertEqual(img.r, [128] * w * w) + self.assertEqual(img.g, [0] * w * w) + self.assertEqual(img.b, [255] * w * w) + with self.assertRaises(AssertionError): + img.load_mask(data=[117] * 42) + + def test_init_file(self): + # Test ARGB init + img = ArgbImage(file='rgb.icns.argb') + self.assertEqual(img.size, (16, 16)) + self.assertEqual(img.a, [255] * 16 * 16) + # Test RGB init + img = ArgbImage(file='rgb.icns.rgb') + self.assertEqual(img.size, (16, 16)) + self.assertEqual(img.a, [255] * 16 * 16) + # Test PNG init + if not PIL_ENABLED: + with self.assertRaises(ImportError): + ArgbImage(file='rgb.icns.png') + else: + img = ArgbImage(file='rgb.icns.png') + self.assertEqual(img.size, (16, 16)) + self.assertEqual(img.a, [255] * 16 * 16) + + def test_data_getter(self): + img = ArgbImage(file='rgb.icns.argb') + argb = img.argb_data(compress=True) + self.assertEqual(argb[:4], b'ARGB') + self.assertEqual(argb[4:8], b'\xFF\xFF\xFB\xFF') + self.assertEqual(len(argb), 4 + 709) + self.assertEqual(len(img.argb_data(compress=False)), 4 + 16 * 16 * 4) + self.assertEqual(len(img.rgb_data(compress=True)), 705) + self.assertEqual(len(img.rgb_data(compress=False)), 16 * 16 * 3) + self.assertEqual(len(img.mask_data(compress=True)), 4) + self.assertEqual(len(img.mask_data(compress=False)), 16 * 16) + self.assertEqual(img.mask_data(), b'\xFF' * 16 * 16) + if PIL_ENABLED: + img = ArgbImage(file='rgb.icns.png') + self.assertEqual(img.argb_data(), argb) + self.assertEqual(img.mask_data(), b'\xFF' * 16 * 16) + + def test_export(self): + img = ArgbImage(file='rgb.icns.argb') + if not PIL_ENABLED: + with self.assertRaises(ImportError): + img.write_png('any') + else: + img.write_png('tmp_argb_to_png.png') + with open('tmp_argb_to_png.png', 'rb') as fA: + with open('rgb.icns.png', 'rb') as fB: + self.assertEqual(fA.read(1), fB.read(1)) + os.remove('tmp_argb_to_png.png') + + +class TestIcnsFile(unittest.TestCase): + def test_init(self): + img = IcnsFile() + self.assertEqual(img.media, {}) + self.assertEqual(img.infile, None) + img = IcnsFile(file='rgb.icns') + self.assertEqual(img.infile, 'rgb.icns') + self.assertEqual(len(img.media), 8) + self.assertListEqual(list(img.media.keys()), + ['ICN#', 'il32', 'l8mk', 'ics#', + 'is32', 's8mk', 'it32', 't8mk']) + img = IcnsFile(file='selected.icns') + self.assertEqual(len(img.media), 10) + self.assertListEqual(list(img.media.keys()), + ['info', 'ic12', 'icsb', 'sb24', 'ic04', + 'SB24', 'ic05', 'icsB', 'ic11', 'slct']) + # Not an ICNS file + with self.assertRaises(TypeError): + IcnsFile(file='rgb.icns.argb') + with self.assertRaises(TypeError): + IcnsFile(file='rgb.icns.png') + + def test_load_file(self): + img = IcnsFile() + fname = 'rgb.icns.argb' + with open(fname, 'rb') as fp: + img.add_media(data=fp.read(), file='lol.argb') + self.assertListEqual(list(img.media.keys()), ['ic04']) + # test overwrite + with self.assertRaises(KeyError): + img.add_media(file=fname) + img.add_media(file=fname, force=True) + self.assertListEqual(list(img.media.keys()), ['ic04']) + # test manual key assignment + img.add_media('ic05', file=fname) + self.assertListEqual(list(img.media.keys()), ['ic04', 'ic05']) + + def test_add_named_media(self): + img = IcnsFile('selected.icns') + data = img.media['ic11'] + newimg = IcnsFile() + newimg.add_media(data=data) + self.assertEqual(list(newimg.media.keys()), ['icp5']) + newimg.add_media(data=data, file='@2x.png') + self.assertEqual(list(newimg.media.keys()), ['icp5', 'ic11']) + # Test duplicate key exception + try: + newimg.add_media(data=data, file='dd.png') + except KeyError as e: + self.assertTrue('icp5' in str(e)) + self.assertTrue('ic11' not in str(e)) + try: + newimg.add_media(data=data, file='dd@2x.png') + except KeyError as e: + self.assertTrue('icp5' not in str(e)) + self.assertTrue('ic11' in str(e)) + # Test Jpeg 2000 + newimg.add_media(file='256x256.jp2') + self.assertEqual(list(newimg.media.keys()), ['icp5', 'ic11', 'ic08']) + # Test jp2 with retina flag + with open('256x256.jp2', 'rb') as fp: + newimg.add_media(data=fp.read(), file='256x256@2x.jp2') + self.assertEqual( + list(newimg.media.keys()), ['icp5', 'ic11', 'ic08', 'ic13']) + + def test_toc(self): + img = IcnsFile() + fname_out = 'tmp-out.icns' + img.add_media(file='rgb.icns.argb', key='ic04') + # without TOC + img.write(fname_out, toc=False) + with open(fname_out, 'rb') as fp: + self.assertEqual(fp.read(4), b'icns') + self.assertEqual(fp.read(4), b'\x00\x00\x02\xD9') + self.assertEqual(fp.read(4), b'ic04') + self.assertEqual(fp.read(4), b'\x00\x00\x02\xD1') + self.assertEqual(fp.read(4), b'ARGB') + # with TOC + img.write(fname_out, toc=True) + with open(fname_out, 'rb') as fp: + self.assertEqual(fp.read(4), b'icns') + self.assertEqual(fp.read(4), b'\x00\x00\x02\xE9') + self.assertEqual(fp.read(4), b'TOC ') + self.assertEqual(fp.read(4), b'\x00\x00\x00\x10') + self.assertEqual(fp.read(4), b'ic04') + self.assertEqual(fp.read(4), b'\x00\x00\x02\xD1') + self.assertEqual(fp.read(4), b'ic04') + self.assertEqual(fp.read(4), b'\x00\x00\x02\xD1') + self.assertEqual(fp.read(4), b'ARGB') + os.remove(fname_out) + + def test_verify(self): + is_invalid = any(IcnsFile.verify('rgb.icns')) + self.assertEqual(is_invalid, False) + is_invalid = any(IcnsFile.verify('selected.icns')) + self.assertEqual(is_invalid, False) + + +class TestIcnsType(unittest.TestCase): + def test_sizes(self): + for key, ext, desc, size, total in [ + ('ics4', 'bin', 'icon', (16, 16), 128), # 4-bit icon + ('ich#', 'bin', 'iconmask', (48, 48), 576), # 2x1-bit + ('it32', 'rgb', '', (128, 128), 49152), # 3x8-bit + ('t8mk', 'bin', 'mask', (128, 128), 16384), # 8-bit mask + ('ic05', 'argb', '', (32, 32), 4096), # 4x8-bit + ('icp6', 'png', '', (64, 64), None), + ('ic14', 'png', '@2x', (512, 512), None), + ('info', 'plist', '', None, None), + ] + [(x.value, 'icns', '', None, None) + for x in IcnsType.Role]: + m = IcnsType.get(key) + self.assertEqual(m.size, size) + self.assertTrue(m.is_type(ext)) + self.assertTrue(desc in m.desc) + self.assertEqual(m.maxsize, total) + + def test_guess(self): + with open('rgb.icns.png', 'rb') as fp: + x = IcnsType.guess(fp.read(32), 'rgb.icns.png') + self.assertTrue(x.is_type('png')) + self.assertEqual(x.size, (16, 16)) + self.assertEqual(x.retina, False) + self.assertEqual(x.channels, 3) # because icp4 supports RGB + self.assertEqual(x.compressable, True) + with open('rgb.icns.argb', 'rb') as fp: + x = IcnsType.guess(fp.read(), 'rgb.icns.argb') + self.assertTrue(x.is_type('argb')) + self.assertEqual(x.size, (16, 16)) + self.assertEqual(x.retina, None) + self.assertEqual(x.channels, 4) + self.assertEqual(x.compressable, True) + with open('256x256.jp2', 'rb') as fp: + x = IcnsType.guess(fp.read(), '256x256.jp2') + self.assertTrue(x.is_type('jp2')) + self.assertEqual(x.size, (256, 256)) + self.assertEqual(x.compressable, False) + self.assertEqual(x.availability, 10.5) + + def test_img_mask_pairs(self): + for x, y in IcnsType.enum_img_mask_pairs(['t8mk']): + self.assertEqual(x, None) + self.assertEqual(y, 't8mk') + for x, y in IcnsType.enum_img_mask_pairs(['it32']): + self.assertEqual(x, 'it32') + self.assertEqual(y, None) + for x, y in IcnsType.enum_img_mask_pairs(['it32', 't8mk', 'ic04']): + self.assertEqual(x, 'it32') + self.assertEqual(y, 't8mk') + with self.assertRaises(StopIteration): + next(IcnsType.enum_img_mask_pairs(['info', 'icm#', 'ICN#'])) + + def test_enum_png_convertable(self): + gen = IcnsType.enum_png_convertable([ + 'ICON', 'ICN#', 'icm#', # test 1-bit mono icons + 'icm4', 'icl4', 'ic07', # test keys that should not be exported + 'ic04', 'ic05', # test if argb are exported without mask + 'icp5', 'l8mk', # test if png+mask is exported (YES if icp4 icp5) + 'ih32', 'h8mk', # test if 24-bit + mask is exported (YES) + 'is32', # test if image only is exported (YES) + 't8mk', # test if mask only is exported (NO) + 'icp4', # test if png is exported (user must validate file type!) + ]) + self.assertEqual(next(gen), ('ICON', None)) + self.assertEqual(next(gen), ('ICN#', None)) + self.assertEqual(next(gen), ('icm#', None)) + self.assertEqual(next(gen), ('is32', None)) + self.assertEqual(next(gen), ('ih32', 'h8mk')) + self.assertEqual(next(gen), ('icp4', None)) # icp4 & icp5 can be RGB + self.assertEqual(next(gen), ('icp5', 'l8mk')) + self.assertEqual(next(gen), ('ic04', None)) + self.assertEqual(next(gen), ('ic05', None)) + with self.assertRaises(StopIteration): + print(next(gen)) + + def test_match_maxsize(self): + for typ, size, key in [ + ('bin', 512, 'icl4'), + ('bin', 192, 'icm8'), + ('png', 768, 'icp4'), + ('rgb', 768, 'is32'), + ('rgb', 3072, 'il32'), + ('rgb', 6912, 'ih32'), + ('rgb', 49152, 'it32'), + ('argb', 1024, 'ic04'), + ('argb', 4096, 'ic05'), + ('argb', 1296, 'icsb'), + ]: + iType = IcnsType.match_maxsize(size, typ) + self.assertEqual(iType.key, key, msg=f'{typ} ({size}) != {key}') + + def test_decompress(self): + # Test ARGB deflate + with open('rgb.icns.argb', 'rb') as fp: + data = fp.read() + data = IcnsType.get('ic04').decompress(data) + self.assertEqual(len(data), 16 * 16 * 4) + # Test RGB deflate + with open('rgb.icns.rgb', 'rb') as fp: + data = fp.read() + d = IcnsType.get('is32').decompress(data) + self.assertEqual(len(d), 16 * 16 * 3) + d = IcnsType.get('it32').decompress(data) + self.assertEqual(len(d), 1966) # decompress removes 4-byte it32-header + d = IcnsType.get('ic04').decompress(data, ext='png') + self.assertEqual(len(d), 705) # if png, dont decompress + + def test_exceptions(self): + with self.assertRaises(NotImplementedError): + IcnsType.get('wrong key') + with self.assertRaises(ValueError): + IcnsType.guess(b'\x00') + with self.assertRaises(ValueError): # could be any icns + with open('rgb.icns', 'rb') as fp: + IcnsType.guess(fp.read(6)) + + +class TestPackBytes(unittest.TestCase): + def test_pack(self): + d = PackBytes.pack(b'\x00' * 514) + self.assertEqual(d, b'\xff\x00\xff\x00\xff\x00\xf9\x00') + d = PackBytes.pack(b'\x01\x02' * 5) + self.assertEqual(d, b'\t\x01\x02\x01\x02\x01\x02\x01\x02\x01\x02') + d = PackBytes.pack(b'\x01\x02' + b'\x03' * 134 + b'\x04\x05') + self.assertEqual(d, b'\x01\x01\x02\xff\x03\x81\x03\x01\x04\x05') + d = PackBytes.pack(b'\x00' * 223 + b'\x01' * 153) + self.assertEqual(d, b'\xff\x00\xda\x00\xff\x01\x94\x01') + + def test_unpack(self): + d = PackBytes.unpack(b'\xff\x00\xff\x00\xff\x00\xf9\x00') + self.assertListEqual(d, [0] * 514) + d = PackBytes.unpack(b'\t\x01\x02\x01\x02\x01\x02\x01\x02\x01\x02') + self.assertListEqual(d, [1, 2] * 5) + d = PackBytes.unpack(b'\x01\x01\x02\xff\x03\x81\x03\x01\x04\x05') + self.assertListEqual(d, [1, 2] + [3] * 134 + [4, 5]) + d = PackBytes.unpack(b'\xff\x00\xda\x00\xff\x01\x94\x01') + self.assertListEqual(d, [0] * 223 + [1] * 153) + + def test_get_size(self): + for d in [b'\xff\x00\xff\x00\xff\x00\xf9\x00', + b'\t\x01\x02\x01\x02\x01\x02\x01\x02\x01\x02', + b'\x01\x01\x02\xff\x03\x81\x03\x01\x04\x05', + b'\xff\x00\xda\x00\xff\x01\x94\x01']: + self.assertEqual(PackBytes.get_size(d), len(PackBytes.unpack(d))) + + +class TestRawData(unittest.TestCase): + def test_img_size(self): + def fn(fname): + with open(fname, 'rb') as fp: + return RawData.determine_image_size(fp.read()) + + self.assertEqual(fn('rgb.icns'), None) + self.assertEqual(fn('rgb.icns.png'), (16, 16)) + self.assertEqual(fn('rgb.icns.argb'), (16, 16)) + self.assertEqual(fn('256x256.jp2'), (256, 256)) + self.assertEqual(fn('18x18.j2k'), (18, 18)) + + def test_ext(self): + for data, ext in ( + (b'\x89PNG\x0d\x0a\x1a\x0a#', 'png'), + (b'ARGB\x00\x00', 'argb'), + (b'icns\x00\x00', 'icns'), + (b'bplist\x00\x00', 'plist'), + (b'\xff\xd8\xff\x00\x00', None), # JPEG + (b'\x00\x00\x00\x0CjP \x00\x00', 'jp2'), # JPEG2000 + ): + self.assertEqual(RawData.determine_file_ext(data), ext) + + +####################### +# Integration tests # +####################### + +class TestExport(unittest.TestCase): + INFILE = None + OUTDIR = None # set in setUpClass + CLEANUP = True # for debugging purposes + ARGS = {} + + @classmethod + def setUpClass(cls): + cls.OUTDIR = 'tmp_' + cls.__name__ + if os.path.isdir(cls.OUTDIR): + shutil.rmtree(cls.OUTDIR) + os.makedirs(cls.OUTDIR, exist_ok=True) + cls.img = IcnsFile(file=cls.INFILE) + cls.outfiles = cls.img.export(cls.OUTDIR, **cls.ARGS) + + @classmethod + def tearDownClass(cls): + if cls.CLEANUP: + shutil.rmtree(cls.OUTDIR) + + def assertEqualFiles(self, fname_a, fname_b): + with open(fname_a, 'rb') as fA: + with open(fname_b, 'rb') as fB: + self.assertEqual(fA.read(1), fB.read(1)) + + def assertExportCount(self, filecount, subpath=None): + self.assertEqual(len(os.listdir(subpath or self.OUTDIR)), filecount) + + +class TestRGB(TestExport): + INFILE = 'rgb.icns' + + def test_export_count(self): + self.assertExportCount(8) + + def test_file_extension(self): + for x in ['ICN#', 'ics#', 'l8mk', 's8mk', 't8mk']: + self.assertTrue(self.outfiles[x].endswith('.bin')) + for x in ['il32', 'is32', 'it32']: + self.assertTrue(self.outfiles[x].endswith('.rgb')) + + def test_rgb_size(self): + for key, s in [('is32', 705), ('il32', 2224), ('it32', 14005)]: + self.assertEqual(os.path.getsize(self.outfiles[key]), s) + img = ArgbImage(file=self.outfiles[key]) + media = IcnsType.get(key) + self.assertEqual(img.size, media.size) + self.assertEqual(len(img.a), len(img.r)) + self.assertEqual(len(img.r), len(img.g)) + self.assertEqual(len(img.g), len(img.b)) + self.assertEqual(media.maxsize, len(img.rgb_data(compress=False))) + + def test_rgb_to_png(self): + fname = self.outfiles['is32'] + img = ArgbImage(file=fname) + fname = fname + '.png' + if not PIL_ENABLED: + with self.assertRaises(ImportError): + img.write_png(fname) + else: + img.write_png(fname) + self.assertEqualFiles(fname, self.INFILE + '.png') + os.remove(fname) + + +class TestARGB(TestExport): + INFILE = 'selected.icns' + + def test_export_count(self): + self.assertExportCount(10) + + def test_file_extension(self): + for x in ['ic11', 'ic12', 'icsB', 'sb24', 'SB24']: + self.assertTrue(self.outfiles[x].endswith('.png')) + for x in ['ic04', 'ic05', 'icsb']: + self.assertTrue(self.outfiles[x].endswith('.argb')) + self.assertTrue(self.outfiles['info'].endswith('.plist')) + self.assertTrue(self.outfiles['slct'].endswith('.icns')) + + def test_argb_size(self): + f_argb = self.outfiles['ic05'] + self.assertEqual(os.path.getsize(f_argb), 690) # compressed + img = ArgbImage(file=f_argb) + self.assertEqual(img.size, (32, 32)) + self.assertEqual(len(img.a), len(img.r)) + self.assertEqual(len(img.r), len(img.g)) + self.assertEqual(len(img.g), len(img.b)) + + len_argb = len(img.argb_data(compress=False)) - 4 # -header + self.assertEqual(len_argb, IcnsType.get('ic05').maxsize) + len_rgb = len(img.rgb_data(compress=False)) + self.assertEqual(len_rgb, len_argb // 4 * 3) + len_mask = len(img.mask_data(compress=False)) + self.assertEqual(len_mask, len_argb // 4) + + def test_argb_to_png(self): + f_argb = self.outfiles['ic05'] + img = ArgbImage(file=f_argb) + fname = f_argb + '.png' + if not PIL_ENABLED: + with self.assertRaises(ImportError): + img.write_png(fname) + else: + img.write_png(fname) + self.assertEqualFiles(fname, self.outfiles['ic11']) + os.remove(fname) + + def test_png_to_argb(self): + f_png = self.outfiles['ic11'] + if not PIL_ENABLED: + with self.assertRaises(ImportError): + ArgbImage(file=f_png) + else: + img = ArgbImage(file=f_png) + fname = f_png + '.argb' + with open(fname, 'wb') as fp: + fp.write(img.argb_data()) + self.assertEqualFiles(fname, self.outfiles['ic05']) + os.remove(fname) + + def test_argb_compression(self): + fname = self.outfiles['ic05'] + img = ArgbImage(file=fname) + # test decompress + self.assertEqual(img.rgb_data(compress=False), b'\x00' * 32 * 32 * 3) + with open(fname + '.tmp', 'wb') as fp: + fp.write(img.argb_data(compress=True)) + # test compress + self.assertEqualFiles(fname, fname + '.tmp') + os.remove(fname + '.tmp') + + +class TestNested(TestExport): + INFILE = 'selected.icns' + ARGS = {'recursive': True} + + def test_export_count(self): + self.assertExportCount(10 + 1) + self.assertExportCount(9, self.outfiles['slct']['_'] + '.export') + + def test_icns_readable(self): + img = IcnsFile(file=self.outfiles['slct']['_']) + self.assertEqual(len(img.media), 9) + argb = ArgbImage(data=img.media['ic04']) + self.assertEqual(argb.rgb_data(compress=False), b'\x00' * 16 * 16 * 3) + + +class TestPngOnly(TestExport): + INFILE = 'selected.icns' + ARGS = {'allowed_ext': 'png'} + + def test_export_count(self): + self.assertExportCount(5) + + +class TestPngOnlyNested(TestExport): + INFILE = 'selected.icns' + ARGS = {'allowed_ext': 'png', 'recursive': True} + + def test_export_count(self): + self.assertExportCount(5 + 1) + self.assertExportCount(5, self.outfiles['slct']['_'] + '.export') + + +class TestIcp4RGB(TestExport): + INFILE = 'icp4rgb.icns' + ARGS = {'key_suffix': True} + + def test_export_count(self): + self.assertExportCount(4) + self.assertListEqual(list(self.outfiles.keys()), + ['_', 'icp4', 's8mk', 'icp5', 'l8mk']) + + def test_filenames(self): + for fname in ['s8mk.bin', 'icp4.rgb', 'icp5.rgb', 'l8mk.bin']: + self.assertTrue(os.path.exists(os.path.join( + self.OUTDIR, fname)), msg='File does not exist: ' + fname) + + +if PIL_ENABLED: + class TestRGB_toPNG(TestExport): + INFILE = 'rgb.icns' + ARGS = {'convert_png': True} + + def test_export_count(self): + self.assertExportCount(5) + + def test_conversion(self): + img = ArgbImage(file=self.outfiles['il32']) + self.assertEqual(self.img.media['il32'], img.rgb_data()) + self.assertEqual(self.img.media['l8mk'], img.mask_data()) + self.assertTrue(self.outfiles['il32'].endswith('.png')) + + class TestARGB_toPNG(TestExport): + INFILE = 'selected.icns' + ARGS = {'convert_png': True} + + def test_export_count(self): + self.assertExportCount(10) + + def test_conversion(self): + img = ArgbImage(file=self.outfiles['ic05']) + self.assertEqual(self.img.media['ic05'], img.argb_data()) + self.assertTrue(self.outfiles['ic05'].endswith('.png')) + img = ArgbImage(file=self.outfiles['ic04']) # is a PNG + self.assertEqual(self.img.media['ic04'], img.argb_data()) + self.assertTrue(self.outfiles['ic04'].endswith('.png')) + + class TestNested_toPNG(TestExport): + INFILE = 'selected.icns' + ARGS = {'convert_png': True, 'recursive': True} + + def test_export_count(self): + self.assertExportCount(10 + 1) + + def test_conversion(self): + fname = self.outfiles['slct']['ic05'] + self.assertTrue(fname.endswith('.png')) + + class TestPngOnlyNested_toPNG(TestExport): + INFILE = 'selected.icns' + ARGS = {'allowed_ext': 'png', 'convert_png': True, 'recursive': True} + + def test_export_count(self): + self.assertExportCount(8 + 1) + self.assertExportCount(8, self.outfiles['slct']['_'] + '.export') + + class TestIcp4RGB_toPNG(TestExport): + INFILE = 'icp4rgb.icns' + ARGS = {'convert_png': True} + + def test_export_count(self): + self.assertExportCount(2) + + +if __name__ == '__main__': + main()