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()