This commit is contained in:
relikd
2021-09-30 01:31:27 +02:00
parent 823ed3aaa9
commit 63d2fc4f42
10 changed files with 240 additions and 198 deletions

View File

@@ -10,7 +10,8 @@ Here are two tools to open icns files directly in your browser. Both tools can b
- 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
[inspector]: https://relikd.github.io/icnsutil/html/inspector.html
[viewer]: https://relikd.github.io/icnsutil/html/viewer.html
## Usage
@@ -29,16 +30,16 @@ positional arguments:
```sh
# extract
# extract
icnsutil e ExistingIcon.icns -o ./outdir/
# compose
# compose
icnsutil c NewIcon.icns 16x16.png 16x16@2x.png *.jp2
# print
# print
icnsutil p ExistingIcon.icns
# verify valid format
# verify valid format
icnsutil t ExistingIcon.icns
```

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
from typing import Union, Iterator, Optional
from . import IcnsType, PackBytes
try:
from PIL import Image
@@ -11,7 +12,8 @@ class ArgbImage:
__slots__ = ['a', 'r', 'g', 'b', 'size', 'channels']
@classmethod
def from_mono(cls, data, iType):
def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage':
''' Load monochrome 1-bit image with or without mask. '''
assert(iType.bits == 1)
img = []
for byte in data:
@@ -28,11 +30,15 @@ class ArgbImage:
self.r, self.g, self.b = img, img, img
return self
def __init__(self, *, data=None, file=None, mask=None):
def __init__(self, *, data: Optional[bytes] = None,
file: Optional[str] = None,
mask: Union[bytes, str, None] = None) -> None:
'''
Provide either a filename or raw binary data.
- mask : Optional, may be either binary data or filename
'''
self.size = (0, 0)
self.channels = 0
if file:
self.load_file(file)
elif data:
@@ -40,12 +46,12 @@ class ArgbImage:
else:
raise AttributeError('Neither data nor file provided.')
if mask:
if type(mask) == bytes:
if isinstance(mask, bytes):
self.load_mask(data=mask)
else:
self.load_mask(file=mask)
def load_file(self, fname):
def load_file(self, fname: str) -> None:
with open(fname, 'rb') as fp:
if fp.read(4) == b'\x89PNG':
self._load_png(fname)
@@ -60,49 +66,50 @@ class ArgbImage:
tmp = e # ignore previous exception to create a new one
raise type(tmp)('{} File: "{}"'.format(str(tmp), fname))
def load_data(self, data):
def load_data(self, data: bytes) -> None:
''' 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.')
idat = PackBytes.unpack(data)
iType = IcnsType.match_maxsize(len(idat), 'argb' if is_argb else 'rgb')
self.size = iType.size
self.channels = iType.channels
self.a, self.r, self.g, self.b = iType.split_channels(data)
self.channels = iType.channels or 0
self.a, self.r, self.g, self.b = iType.split_channels(idat)
def load_mask(self, *, file=None, data=None):
def load_mask(self, *, file: Optional[str] = None,
data: Optional[bytes] = None) -> None:
''' Data must be uncompressed and same length as a single channel! '''
if file:
with open(file, 'rb') as fp:
data = fp.read()
else:
assert(isinstance(data, bytes))
if not data:
raise AttributeError('Neither data nor file provided.')
assert(len(data) == len(self.r))
self.a = data
self.a = list(data)
def mask_data(self, bits=8, *, compress=False):
def mask_data(self, bits: int = 8, *, compress: bool = False) -> bytes:
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):
def rgb_data(self, *, compress: bool = True) -> bytes:
return b''.join(self._raw_rgb_channels(compress=compress))
def argb_data(self, *, compress=True):
def argb_data(self, *, compress: bool = True) -> bytes:
return b'ARGB' + self.mask_data(compress=compress) + \
b''.join(self._raw_rgb_channels(compress=compress))
def _raw_rgb_channels(self, *, compress=True):
def _raw_rgb_channels(self, *, compress: bool = True) -> Iterator[bytes]:
for x in (self.r, self.g, self.b):
yield PackBytes.pack(x) if compress else bytes(x)
yield (PackBytes.pack(x) if compress else bytes(x))
def _load_png(self, fname):
def _load_png(self, fname: str) -> None:
if not PIL_ENABLED:
raise ImportError('Install Pillow to support PNG conversion.')
img = Image.open(fname, mode='r')
@@ -126,7 +133,7 @@ class ArgbImage:
self.g.append(g)
self.b.append(b)
def write_png(self, fname):
def write_png(self, fname: str) -> None:
if not PIL_ENABLED:
raise ImportError('Install Pillow to support PNG conversion.')
img = Image.new(mode='RGBA', size=self.size)
@@ -138,7 +145,7 @@ class ArgbImage:
(x, y), (self.r[i], self.g[i], self.b[i], self.a[i]))
img.save(fname)
def __repr__(self):
def __repr__(self) -> str:
typ = ['', 'Mono', 'Mono with Mask', 'RGB', 'RGBA'][self.channels]
return '<{}: {}x{} {}>'.format(
type(self).__name__, self.size[0], self.size[1], typ)

View File

@@ -2,13 +2,14 @@
import os # path, makedirs, remove
import struct # unpack float in _description()
from sys import stderr
from typing import Iterator, Iterable, Tuple, Optional, List, Dict, Union
from . import RawData, IcnsType
from .ArgbImage import ArgbImage
class IcnsFile:
@staticmethod
def verify(fname):
def verify(fname: str) -> Iterator[str]:
'''
Yields an error message for each issue.
You can check for validity with `is_invalid = any(obj.verify())`
@@ -22,7 +23,7 @@ class IcnsFile:
try:
iType = IcnsType.get(key)
except NotImplementedError:
yield 'Unsupported icns type: {}'.format(key)
yield 'Unsupported icns type: ' + str(key)
continue
ext = RawData.determine_file_ext(data)
@@ -32,7 +33,7 @@ class IcnsFile:
# 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', iType.types)
str(key), ext or 'binary', iType.types)
if ext in ['png', 'jp2', 'icns', 'plist']:
continue
@@ -40,16 +41,16 @@ class IcnsFile:
# 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 'Unexpected it32 data header: {}'.format(data[:4])
data = iType.decompress(data, ext) # ignores non-compressable
yield 'Unexpected it32 data header: ' + str(data[:4])
udata = iType.decompress(data, ext) or data
# Check expected uncompressed maxsize
if iType.maxsize and len(data) != iType.maxsize:
if iType.maxsize and len(udata) != iType.maxsize:
yield 'Invalid data length for {}: {} != {}'.format(
key, len(data), iType.maxsize)
str(key), len(udata), iType.maxsize)
# if file is not an icns file
except RawData.ParserError as e:
yield e
yield str(e)
return
# Check total size after enum. Enum may raise exception and break early
@@ -76,12 +77,14 @@ class IcnsFile:
x, y)
@staticmethod
def description(fname, *, verbose=False, indent=0):
def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \
str:
return IcnsFile._description(
RawData.parse_icns_file(fname), verbose=verbose, indent=indent)
@staticmethod
def _description(enumerator, *, verbose=False, indent=0):
def _description(enumerator: Iterable[Tuple[IcnsType.Media.KeyT, bytes]],
*, verbose: bool = False, indent: int = 0) -> str:
''' Expects an enumerator with (key, size, data) '''
txt = ''
offset = 8 # already with icns header
@@ -89,7 +92,7 @@ class IcnsFile:
for key, data in enumerator:
size = len(data)
txt += os.linesep + ' ' * indent
txt += '{}: {} bytes'.format(key, size)
txt += '{}: {} bytes'.format(str(key), size)
if verbose:
txt += ', offset: {}'.format(offset)
offset += size + 8
@@ -112,9 +115,9 @@ class IcnsFile:
except RawData.ParserError as e:
return ' ' * indent + str(e) + os.linesep
def __init__(self, file=None):
def __init__(self, file: str = None) -> None:
''' Read .icns file and load bundled media files into memory. '''
self.media = {}
self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
self.infile = file
if not file: # create empty image
return
@@ -124,29 +127,31 @@ class IcnsFile:
IcnsType.get(key)
except NotImplementedError:
print('Warning: unknown media type: {}, {} bytes, "{}"'.format(
key, len(data), file), file=stderr)
str(key), len(data), file), file=stderr)
def add_media(self, key=None, *, file=None, data=None, force=False):
def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *,
file: Optional[str] = None, data: Optional[bytes] = None,
force: bool = False) -> None:
'''
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 data:
raise AttributeError('Did you miss file= or data= attribute?')
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(
'Image with identical key "{}". File: {}'.format(key, file))
raise KeyError('Image with identical key "{}". File: {}'.format(
str(key), file))
self.media[key] = data
def write(self, fname, *, toc=True):
def write(self, fname: str, *, toc: bool = True) -> None:
''' Create a new ICNS file from stored media. '''
# Rebuild TOC to ensure soundness
order = self._make_toc(enabled=toc)
@@ -157,8 +162,11 @@ class IcnsFile:
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):
def export(self, outdir: Optional[str] = None, *,
allowed_ext: str = '*', key_suffix: bool = False,
convert_png: bool = False, decompress: bool = False,
recursive: bool = False) -> Dict[IcnsType.Media.KeyT,
Union[str, Dict]]:
'''
Write all bundled media files to output directory.
@@ -177,52 +185,52 @@ class IcnsFile:
elif not os.path.isdir(outdir):
raise OSError('"{}" is not a directory. Abort.'.format(outdir))
exported_files = {'_': self.infile}
export_files = {} # type: Dict[IcnsType.Media.KeyT, Union[str, Dict]]
if self.infile:
export_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
export_files[imgk] = fname
if maskk:
exported_files[maskk] = fname
export_files[maskk] = fname
if maskk in keys:
keys.remove(maskk)
keys.remove(imgk)
# prepare filter
if type(allowed_ext) == str:
allowed_ext = [allowed_ext]
allowed = [] if allowed_ext == '*' else allowed_ext.split(',')
if recursive:
cleanup = allowed_ext and 'icns' not in allowed_ext
cleanup = allowed and 'icns' not in allowed
if cleanup:
allowed_ext.append('icns')
allowed.append('icns')
# Export remaining
for key in keys:
fname = self._export_single(outdir, key, key_suffix, decompress,
allowed_ext)
fname = self._export_single(outdir, key, key_suffix,
decompress, allowed)
if fname:
exported_files[key] = fname
export_files[key] = fname
# repeat for all icns
if recursive:
for key, fname in exported_files.items():
if key == '_' or not fname.endswith('.icns'):
for old_key, old_name in export_files.items():
assert(isinstance(old_name, str))
if not old_name.endswith('.icns') or old_key == '_':
continue
prev_fname = exported_files[key]
exported_files[key] = IcnsFile(fname).export(
export_files[old_key] = IcnsFile(old_name).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
os.remove(old_name)
return export_files
def _make_toc(self, *, enabled):
def _make_toc(self, *, enabled: bool) -> List[IcnsType.Media.KeyT]:
# Rebuild TOC to ensure soundness
if 'TOC ' in self.media.keys():
del(self.media['TOC '])
@@ -238,7 +246,9 @@ class IcnsFile:
return order
def _export_single(self, outdir, key, key_suffix, decompress, allowed_ext):
def _export_single(self, outdir: str, key: IcnsType.Media.KeyT,
key_suffix: bool, decompress: bool,
allowed: List[str]) -> Optional[str]:
''' You must ensure that keys exist in self.media '''
data = self.media[key]
ext = RawData.determine_file_ext(data)
@@ -249,7 +259,7 @@ class IcnsFile:
iType = IcnsType.get(key)
fname = iType.filename(key_only=key_suffix)
if decompress:
data = iType.decompress(data, ext) # ignores non-compressable
data = iType.decompress(data, ext) or data # type: ignore
if not ext: # overwrite ext after (decompress requires None)
ext = 'rgb' if iType.compressable else 'bin'
except NotImplementedError: # If key unkown, export anyway
@@ -257,14 +267,16 @@ class IcnsFile:
if not ext:
ext = 'unknown'
if allowed_ext and ext not in allowed_ext:
if allowed and ext not in allowed:
return None
fname = os.path.join(outdir, 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):
def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT,
mask_key: Optional[IcnsType.Media.KeyT],
key_suffix: bool) -> Optional[str]:
''' You must ensure key and mask_key exists! '''
data = self.media[img_key]
if RawData.determine_file_ext(data) not in ['argb', None]:
@@ -279,11 +291,11 @@ class IcnsFile:
ArgbImage(data=data, mask=mask_data).write_png(fname)
return fname
def __repr__(self):
def __repr__(self) -> str:
lst = ', '.join(str(k) for k in self.media.keys())
return '<{}: file={}, [{}]>'.format(
type(self).__name__, self.infile, lst)
def __str__(self):
def __str__(self) -> str:
return 'File: ' + (self.infile or '-mem-') + os.linesep \
+ IcnsFile._description(self.media.items(), indent=2)

View File

@@ -4,7 +4,7 @@ Namespace for the ICNS format.
@see https://en.wikipedia.org/wiki/Apple_Icon_Image_format
'''
import os # path
# import icnsutil # PackBytes, RawData
from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict
from . import PackBytes, RawData
@@ -13,14 +13,17 @@ class CanNotDetermine(Exception):
class Media:
KeyT = Union[str, bytes]
__slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability',
'desc', 'compressable', 'retina', 'maxsize', 'ext_certain']
def __init__(self, key, types, size=None, *,
ch=None, bits=None, os=None, desc=''):
def __init__(self, key: KeyT, types: list,
size: Optional[Union[int, Tuple[int, int]]] = None,
*, ch: Optional[int] = None, bits: Optional[int] = None,
os: Optional[float] = None, desc: str = '') -> None:
self.key = key
self.types = types if type(types) == list else [types]
self.size = (size, size) if type(size) == int else size
self.types = types
self.size = (size, size) if isinstance(size, int) else size
self.availability = os
self.desc = desc
# computed properties
@@ -34,24 +37,25 @@ class Media:
bits = 8
self.channels = ch
self.bits = bits
self.maxsize = None
if size and ch and bits:
self.maxsize = None # type: Optional[int]
if self.size and ch and bits:
self.maxsize = self.size[0] * self.size[1] * ch * bits // 8
self.ext_certain = all(x in ['png', 'argb', 'plist', 'jp2', 'icns']
for x in self.types)
def is_type(self, typ):
def is_type(self, typ: str) -> bool:
return typ in self.types
def is_binary(self) -> bool:
return any(x in self.types for x in ['rgb', 'bin'])
def fallback_ext(self):
def fallback_ext(self) -> str:
if self.channels in [1, 2]:
return self.desc # guaranteed to be icon, mask, or iconmask
return self.types[-1]
def split_channels(self, uncompressed_data):
def split_channels(self, uncompressed_data: List[int]) -> Iterator[
List[int]]:
if self.channels not in [3, 4]:
raise NotImplementedError('Only RGB and ARGB data supported.')
if len(uncompressed_data) != self.maxsize:
@@ -64,26 +68,28 @@ class Media:
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:]
return PackBytes.unpack(data)
return data
def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[
List[int]]:
''' Returns None if media is not decompressable. '''
if self.compressable:
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 magic number
if self.key == 'it32':
data = data[4:]
return PackBytes.unpack(data)
return None
def filename(self, *, key_only=False, size_only=False):
def filename(self, *, key_only: bool = False, size_only: bool = False) \
-> str:
if key_only:
if os.path.exists(__file__.upper()): # check case senstive
if self.key in ['sb24', 'icsb']:
return self.key + '-a'
return self.key + '-a' # type: ignore
elif self.key in ['SB24', 'icsB']:
return self.key + '-b'
return self.key + '-b' # type: ignore
return str(self.key) # dont return directy, may be b''-str
else:
if self.is_type('icns'):
@@ -106,11 +112,11 @@ class Media:
suffix += '-mask{}b'.format(self.bits)
return '{}x{}{}'.format(w, h, suffix)
def __repr__(self):
def __repr__(self) -> str:
return '<{}: {}, {}.{}>'.format(
type(self).__name__, self.key, self.filename(), self.types[0])
type(self).__name__, str(self.key), self.filename(), self.types[0])
def __str__(self):
def __str__(self) -> str:
T = ''
if self.size:
T += '{}x{}, '.format(*self.size)
@@ -120,36 +126,36 @@ class Media:
if self.desc:
T += self.desc + ', '
return '{}: {}macOS {}+'.format(
self.key, T, self.availability or '?')
str(self.key), T, 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'),
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('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),
@@ -165,36 +171,37 @@ _TYPES = {x.key: x for x in (
Media('sb24', ['png', 'jp2'], 24),
Media('SB24', ['png', 'jp2'], 48, desc='24x24@2x'),
# ICNS media files
Media('sbtp', 'icns', desc='template'),
Media('slct', 'icns', desc='selected'),
Media(b'\xFD\xD9\x2F\xA8', 'icns', os=10.14, desc='dark'),
Media('sbtp', ['icns'], desc='template'),
Media('slct', ['icns'], desc='selected'),
Media(b'\xFD\xD9\x2F\xA8', ['icns'], os=10.14, desc='dark'),
# 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'),
)}
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'),
)} # type: Dict[Media.KeyT, Media]
def enum_img_mask_pairs(available_keys):
def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[
Tuple[Optional[str], Optional[str]]]:
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
mk = mask_k if mask_k in available_keys else 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
yield img_k, mk
if mk and not any_img:
yield None, mk
def enum_png_convertable(available_keys):
def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[
Tuple[Media.KeyT, Optional[Media.KeyT]]]:
''' Yield (image-key, mask-key or None) '''
for img in _TYPES.values():
if img.key not in available_keys:
@@ -212,22 +219,21 @@ def enum_png_convertable(available_keys):
yield img.key, mask_key
def get(key):
def get(key: Media.KeyT) -> Media:
try:
return _TYPES[key]
except KeyError:
pass
raise NotImplementedError('Unsupported icns type "{}"'.format(key))
raise NotImplementedError('Unsupported icns type "' + str(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 match_maxsize(total: int, typ: str) -> Media:
assert(typ == 'argb' or typ == 'rgb')
ret = [x for x in _TYPES.values() if x.is_type(typ) and x.maxsize == total]
return ret[0] # TODO: handle cases with multiple options? eg: is32 icp4
def guess(data, filename=None):
def guess(data: bytes, filename: Optional[str] = None) -> Media:
'''
Guess icns media type by analyzing the raw data + file naming convention.
Use:

View File

@@ -1,7 +1,10 @@
#!/usr/bin/env python3
def pack(data):
ret = []
buf = []
from typing import List, Iterator, Union
def pack(data: List[int]) -> bytes:
ret = [] # type: List[int]
buf = [] # type: List[int]
i = 0
def flush_buf():
@@ -37,8 +40,8 @@ def pack(data):
return bytes(ret)
def unpack(data):
ret = []
def unpack(data: bytes) -> List[int]:
ret = [] # type: List[int]
i = 0
end = len(data)
while i < end:
@@ -52,7 +55,7 @@ def unpack(data):
return ret
def get_size(data):
def get_size(data: bytes) -> int:
count = 0
i = 0
end = len(data)
@@ -67,7 +70,7 @@ def get_size(data):
return count
def msb_stream(data, *, bits):
def msb_stream(data: Union[bytes, List[int]], *, bits: int) -> Iterator[int]:
if bits not in [1, 2, 4]:
raise NotImplementedError('Unsupported bit-size.')
c = 0

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import struct # pack, unpack
from typing import Union, Optional, Tuple, Iterator, BinaryIO
from . import IcnsType, PackBytes
@@ -7,7 +8,7 @@ class ParserError(Exception):
pass
def determine_file_ext(data):
def determine_file_ext(data: bytes) -> Optional[str]:
'''
Data should be at least 8 bytes long.
Returns one of: png, argb, plist, jp2, icns, None
@@ -28,12 +29,14 @@ def determine_file_ext(data):
return None
def determine_image_size(data, ext=None):
def determine_image_size(data: bytes, ext: Optional[str] = None) \
-> Optional[Tuple[int, int]]:
''' 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])
w, h = struct.unpack('>II', data[16:24])
return w, h
elif ext == 'argb':
total = PackBytes.get_size(data[4:]) # without ARGB header
return IcnsType.match_maxsize(total, 'argb').size
@@ -43,7 +46,8 @@ def determine_image_size(data, ext=None):
return IcnsType.match_maxsize(PackBytes.get_size(data), 'rgb').size
elif ext == 'jp2':
if data[:4] == b'\xFF\x4F\xFF\x51':
return struct.unpack('>II', data[8:16])
w, h = struct.unpack('>II', data[8:16])
return w, h
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
@@ -52,7 +56,7 @@ def determine_image_size(data, ext=None):
return None # icns does not support other image types except binary
def is_icns_without_header(data):
def is_icns_without_header(data: bytes) -> bool:
''' Returns True even if icns header is missing. '''
offset = 0
for i in range(2): # test n keys if they exist
@@ -69,32 +73,33 @@ def is_icns_without_header(data):
return True
def icns_header_read(data):
def icns_header_read(data: bytes) -> Tuple[IcnsType.Media.KeyT, int]:
''' Returns icns type name and data length (incl. +8 for header) '''
assert(type(data) == bytes)
assert(isinstance(data, bytes))
if len(data) != 8:
return None, 0
return '', 0
length = struct.unpack('>I', data[4:])[0]
try:
name = data[:4].decode('utf8')
return data[:4].decode('utf8'), length
except UnicodeDecodeError:
name = data[:4] # Fallback to bytes-string key
return name, struct.unpack('>I', data[4:])[0]
return data[:4], length # Fallback to bytes-string key
def icns_header_write_data(fp, key, data):
def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT,
data: bytes) -> None:
''' Calculates length from data. '''
fp.write(key.encode('utf8') if type(key) == str else key)
fp.write(key.encode('utf8') if isinstance(key, str) else key)
fp.write(struct.pack('>I', len(data) + 8))
fp.write(data)
def icns_header_w_len(key, length):
def icns_header_w_len(key: IcnsType.Media.KeyT, length: int) -> bytes:
''' Adds +8 to length. '''
name = key.encode('utf8') if type(key) == str else key
name = key.encode('utf8') if isinstance(key, str) else key
return name + struct.pack('>I', length + 8)
def parse_icns_file(fname):
def parse_icns_file(fname: str) -> Iterator[Tuple[IcnsType.Media.KeyT, bytes]]:
'''
Parse file and yield media entries: (key, data)
:raises:

View File

@@ -4,13 +4,14 @@ Export existing icns files or compose new ones.
'''
import os # path, makedirs
import sys # path, stderr
from typing import Iterator, Optional
from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter
if __name__ == '__main__':
sys.path[0] = os.path.dirname(sys.path[0])
from icnsutil import __version__, IcnsFile
def cli_extract(args):
def cli_extract(args) -> None:
''' Read and extract contents of icns file(s). '''
multiple = len(args.file) > 1 or '-' in args.file
for i, fname in enumerate(enum_with_stdin(args.file)):
@@ -20,13 +21,13 @@ def cli_extract(args):
out = os.path.join(out, str(i))
os.makedirs(out, exist_ok=True)
pred = 'png' if args.png_only else None
IcnsFile(fname).export(
out, allowed_ext=pred, recursive=args.recursive,
convert_png=args.convert, key_suffix=args.keys)
out, allowed_ext='png' if args.png_only else '*',
recursive=args.recursive, convert_png=args.convert,
key_suffix=args.keys)
def cli_compose(args):
def cli_compose(args) -> None:
''' Create new icns file from provided image files. '''
dest = args.target
if not os.path.splitext(dest)[1]:
@@ -35,24 +36,24 @@ def cli_compose(args):
print(
'File "{}" already exists. Force overwrite with -f.'.format(dest),
file=sys.stderr)
return 1
return
img = IcnsFile()
for x in enum_with_stdin(args.source):
img.add_media(file=x)
img.write(dest, toc=not args.no_toc)
def cli_print(args):
def cli_print(args) -> None:
''' Print contents of icns file(s). '''
for fname in enum_with_stdin(args.file):
print('File:', fname)
print(IcnsFile.description(fname, verbose=args.verbose, indent=2))
def cli_verify(args):
def cli_verify(args) -> None:
''' Test if icns file is valid. '''
for fname in enum_with_stdin(args.file):
is_valid = True
is_valid = True # type: Optional[bool]
if not args.quiet:
print('File:', fname)
is_valid = None
@@ -65,7 +66,7 @@ def cli_verify(args):
print('OK')
def enum_with_stdin(file_arg):
def enum_with_stdin(file_arg: list) -> Iterator[str]:
for x in file_arg:
if x == '-':
for line in sys.stdin.readlines():
@@ -74,13 +75,13 @@ def enum_with_stdin(file_arg):
yield x
def main():
def main() -> None:
class PathExist:
def __init__(self, kind=None, stdin=False):
def __init__(self, kind: Optional[str] = None, stdin: bool = False):
self.kind = kind
self.stdin = stdin
def __call__(self, path):
def __call__(self, path: str) -> str:
if self.stdin and path == '-':
return '-'
if not os.path.exists(path) or \

View File

@@ -23,7 +23,7 @@ setup(
},
long_description_content_type="text/markdown",
long_description=longdesc,
python_requires='>=3.2',
python_requires='>=3.5',
keywords=[
'icns',
'icon',

View File

@@ -8,7 +8,7 @@ if __name__ == '__main__':
from icnsutil import IcnsFile, PackBytes
def main():
def main() -> None:
# generate_raw_rgb()
generate_icns()
generate_random_it32_header()
@@ -30,8 +30,8 @@ INFO = {
}
def generate_raw_rgb():
def testpattern(w, h, *, ch, compress=True):
def generate_raw_rgb() -> None:
def testpattern(w: int, h: int, ch: int, compress: bool = True) -> bytes:
ARGB = ch == 4
sz = w * h
if compress:
@@ -60,7 +60,7 @@ def generate_raw_rgb():
fp.write(rgb_data)
def generate_icns():
def generate_icns() -> None:
os.makedirs('format-support-icns', exist_ok=True)
with ZipFile('format-support-raw.zip') as Zip:
for s, keys in INFO.items():
@@ -88,14 +88,14 @@ def generate_icns():
toc=False)
def generate_random_it32_header():
def generate_random_it32_header() -> None:
print('testing random it32 header')
os.makedirs('format-support-it32', exist_ok=True)
with ZipFile('format-support-raw.zip') as Zip:
with Zip.open('128x128.rgb') as f:
data = f.read()
def random_header():
def random_header() -> bytes:
return bytes([randint(0, 255), randint(0, 255),
randint(0, 255), randint(0, 255)])

View File

@@ -2,6 +2,7 @@
import unittest
import shutil # rmtree
import os # chdir, listdir, makedirs, path, remove
from typing import Optional, Dict, Any
if __name__ == '__main__':
import sys
sys.path[0] = os.path.dirname(sys.path[0])
@@ -41,7 +42,7 @@ class TestArgbImage(unittest.TestCase):
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)
img.load_mask(data=b'\x75' * w * w)
self.assertEqual(img.size, (w, w))
self.assertEqual(img.a, [117] * w * w)
self.assertEqual(img.r, [128] * w * w)
@@ -70,8 +71,10 @@ class TestArgbImage(unittest.TestCase):
@unittest.skipUnless(PIL_ENABLED, 'PIL_ENABLED == False')
def test_attributes(self):
img = ArgbImage(file='rgb.icns.png')
self.assertTrue(img.channels != 0)
# will raise AttributeError if _load_png didnt init all attrributes
str(ArgbImage(file='rgb.icns.png'))
str(img)
def test_data_getter(self):
img = ArgbImage(file='rgb.icns.argb')
@@ -319,9 +322,6 @@ class TestIcnsType(unittest.TestCase):
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'),
@@ -332,6 +332,13 @@ class TestIcnsType(unittest.TestCase):
]:
iType = IcnsType.match_maxsize(size, typ)
self.assertEqual(iType.key, key, msg=f'{typ} ({size}) != {key}')
for typ, size, key in [
('bin', 512, 'icl4'),
('bin', 192, 'icm8'),
('png', 768, 'icp4'),
]:
with self.assertRaises(AssertionError):
IcnsType.match_maxsize(size, typ)
def test_decompress(self):
# Test ARGB deflate
@@ -347,7 +354,7 @@ class TestIcnsType(unittest.TestCase):
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
self.assertEqual(d, None) # if png, dont decompress
def test_exceptions(self):
with self.assertRaises(NotImplementedError):
@@ -417,10 +424,10 @@ class TestRawData(unittest.TestCase):
#######################
class TestExport(unittest.TestCase):
INFILE = None
OUTDIR = None # set in setUpClass
INFILE = None # type: Optional[str]
OUTDIR = None # type: Optional[str] # set in setUpClass
CLEANUP = True # for debugging purposes
ARGS = {}
ARGS = {} # type: Dict[str, Any]
@classmethod
def setUpClass(cls):