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 [viewer]: https://relikd.github.io/icnsutil/html/viewer.html
## Usage ## Usage
Usage: ```
extract: icnsutil.py input.icns [--png-only] positional arguments:
--png-only: Do not extract ARGB, binary, and meta files. command
extract (e) Read and extract contents of icns file(s).
compose: icnsutil.py output.icns [-f] [--no-toc] 16.png 16@2x.png ... compose (c) Create new icns file from provided image files.
-f: Force overwrite output file. print (p) Print contents of icns file(s).
--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.
test (t) Test if icns file is valid. test (t) Test if icns file is valid.
``` ```
### Extract from ICNS
### Use command line interface (CLI) ### Use command line interface (CLI)
cp /Applications/Safari.app/Contents/Resources/AppIcon.icns ./TestIcon.icns ```sh
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 ./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 ### Use python library
```python ```python
icnsutil.compose(icns_file, list_of_png_files, toc=True) import icnsutil
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) 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()