pre-release
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
/*.txt
|
||||
/tests/format-support-*/
|
||||
/tests/fixtures/tmp_*
|
||||
26
Makefile
Executable file
26
Makefile
Executable 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
111
README.md
@@ -1,46 +1,101 @@
|
||||
# ICNS-Util
|
||||
# icnsutil
|
||||
|
||||
A python library to handle reading and writing `.icns` files.
|
||||
A fully-featured python library to handle reading and writing `.icns` files.
|
||||
|
||||
|
||||
## HTML icon viewer
|
||||
|
||||
Here are two tools to open icns files directly in your browser. Both tools can be used either with an icns file or a rgb / argb image file.
|
||||
|
||||
- The [inspector] shows the structure of an icns file (useful to understand byte-unpacking in ARGB and 24-bit RGB files).
|
||||
- The [viewer] displays icons in ARGB or 24-bit RGB file format.
|
||||
|
||||
[inspector]: https://relikd.github.io/icnsutil/html/inspector.html
|
||||
[viewer]: https://relikd.github.io/icnsutil/html/viewer.html
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
Usage:
|
||||
extract: icnsutil.py input.icns [--png-only]
|
||||
--png-only: Do not extract ARGB, binary, and meta files.
|
||||
|
||||
compose: icnsutil.py output.icns [-f] [--no-toc] 16.png 16@2x.png ...
|
||||
-f: Force overwrite output file.
|
||||
--no-toc: Do not write TOC to file.
|
||||
|
||||
Note: Icon dimensions are read directly from file.
|
||||
However, the suffix "@2x" will set the retina flag accordingly.
|
||||
```
|
||||
positional arguments:
|
||||
command
|
||||
extract (e) Read and extract contents of icns file(s).
|
||||
compose (c) Create new icns file from provided image files.
|
||||
print (p) Print contents of icns file(s).
|
||||
test (t) Test if icns file is valid.
|
||||
```
|
||||
|
||||
### Extract from ICNS
|
||||
|
||||
### Use command line interface (CLI)
|
||||
|
||||
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
167
cli.py
Executable 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
14
html/inspector.html
Executable 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
257
html/script.js
Normal 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: ' + 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
31
html/style.css
Normal 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
14
html/viewer.html
Executable 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>
|
||||
268
icnsutil.py
268
icnsutil.py
@@ -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
143
icnsutil/ArgbImage.py
Executable 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
285
icnsutil/IcnsFile.py
Executable 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
266
icnsutil/IcnsType.py
Executable 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
85
icnsutil/PackBytes.py
Executable 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
109
icnsutil/RawData.py
Executable 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
14
icnsutil/__init__.py
Executable 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
BIN
tests/fixtures/18x18.j2k
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/256x256.jp2
vendored
Executable file
BIN
tests/fixtures/256x256.jp2
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/icp4rgb.icns
vendored
Executable file
BIN
tests/fixtures/icp4rgb.icns
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/rgb.icns
vendored
Executable file
BIN
tests/fixtures/rgb.icns
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/rgb.icns.argb
vendored
Executable file
BIN
tests/fixtures/rgb.icns.argb
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/rgb.icns.png
vendored
Executable file
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
BIN
tests/fixtures/rgb.icns.rgb
vendored
Executable file
Binary file not shown.
BIN
tests/fixtures/selected.icns
vendored
Executable file
BIN
tests/fixtures/selected.icns
vendored
Executable file
Binary file not shown.
BIN
tests/format-support-icns.zip
Normal file
BIN
tests/format-support-icns.zip
Normal file
Binary file not shown.
BIN
tests/format-support-raw.zip
Normal file
BIN
tests/format-support-raw.zip
Normal file
Binary file not shown.
109
tests/format-support.py
Executable file
109
tests/format-support.py
Executable 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
611
tests/test_icnsutil.py
Executable 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()
|
||||
Reference in New Issue
Block a user