pre-release

This commit is contained in:
relikd
2021-09-26 20:47:38 +02:00
parent fc1e8749f2
commit 6c8e832cc3
27 changed files with 2218 additions and 296 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
/*.txt
/tests/format-support-*/
/tests/fixtures/tmp_*

26
Makefile Executable file
View File

@@ -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

111
README.md
View File

@@ -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)
cp /Applications/Safari.app/Contents/Resources/AppIcon.icns ./TestIcon.icns
python3 icnsutil.py TestIcon.icns
```sh
# 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
icnsutil.compose(icns_file, list_of_png_files, toc=True)
icnsutil.extract(icns_file, png_only=False)
import icnsutil
# 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.

167
cli.py Executable file
View File

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

14
html/inspector.html Executable file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html ondrop="dropfile(event, 'input')" ondragover="event.preventDefault();">
<head>
<meta charset="utf-8">
<title>Icns Inspector</title>
<link rel="stylesheet" href="style.css">
<script type="text/javascript" src="script.js"></script>
</head>
<body>
<h1>Icns Inspector</h1>
<textarea id="input" name="data" rows="10" style="width: 98%;" placeholder="41524742 81000901 ... or drag-n-drop file" onkeyup="inspect_into(this, 'inspector')" autocomplete="off"></textarea>
<code id="inspector"></code>
</body>
</html>

257
html/script.js Normal file
View File

@@ -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 `<span class="${cls}"${ttp}>${tmp.substring(1)}</span> `;
}
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 += '<div>';
if (head) {
txt += '<h3 id="' + head + '">' + head + '</h3>';
txt += fn('head', src, i, 4, head);
txt += fn('len', src, i + 8, 4, 'len:&nbsp;' + len);
} else {
txt += '<h3>raw data</h3>';
}
if (!ext) { txt += '</div>'; 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 += `<span class="data">... ${abbreviate}, ${len - 8} bytes ...</span>`;
txt += '</div>';
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 += '<p>Image size: ' + w + 'x' + w + '</p>';
txt += '</div>';
}
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 = `<h3>${head || ''}</h3><p>${w}x${w}</p>`
container.appendChild(img);
output.appendChild(container);
}
}

31
html/style.css Normal file
View File

@@ -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;}

14
html/viewer.html Executable file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html ondrop="dropfile(event, 'input')" ondragover="event.preventDefault();">
<head>
<meta charset="utf-8">
<title>Icns Image Viewer</title>
<link rel="stylesheet" href="style.css">
<script type="text/javascript" src="script.js"></script>
</head>
<body>
<h1>Icns Image Viewer</h1>
<textarea id="input" name="data" rows="10" style="width: 98%;" placeholder="41524742 81000901 ... or drag-n-drop file" onkeyup="put_images_into(this, 'images')" autocomplete="off"></textarea>
<div id="images"></div>
</body>
</html>

View File

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

143
icnsutil/ArgbImage.py Executable file
View File

@@ -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}>'

285
icnsutil/IcnsFile.py Executable file
View File

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

266
icnsutil/IcnsType.py Executable file
View File

@@ -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}))

85
icnsutil/PackBytes.py Executable file
View File

@@ -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

109
icnsutil/RawData.py Executable file
View File

@@ -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

14
icnsutil/__init__.py Executable file
View File

@@ -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

BIN
tests/fixtures/18x18.j2k vendored Executable file

Binary file not shown.

BIN
tests/fixtures/256x256.jp2 vendored Executable file

Binary file not shown.

BIN
tests/fixtures/icp4rgb.icns vendored Executable file

Binary file not shown.

BIN
tests/fixtures/rgb.icns vendored Executable file

Binary file not shown.

BIN
tests/fixtures/rgb.icns.argb vendored Executable file

Binary file not shown.

BIN
tests/fixtures/rgb.icns.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

BIN
tests/fixtures/rgb.icns.rgb vendored Executable file

Binary file not shown.

BIN
tests/fixtures/selected.icns vendored Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

109
tests/format-support.py Executable file
View File

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

611
tests/test_icnsutil.py Executable file
View File

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