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 [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. - 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 ## Usage
@@ -29,16 +30,16 @@ positional arguments:
```sh ```sh
# extract # extract
# extract icnsutil e ExistingIcon.icns -o ./outdir/
# compose # compose
# compose icnsutil c NewIcon.icns 16x16.png 16x16@2x.png *.jp2
# print # print
# print icnsutil p ExistingIcon.icns
# verify valid format # verify valid format
# verify valid format icnsutil t ExistingIcon.icns
``` ```

View File

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

View File

@@ -2,13 +2,14 @@
import os # path, makedirs, remove import os # path, makedirs, remove
import struct # unpack float in _description() import struct # unpack float in _description()
from sys import stderr from sys import stderr
from typing import Iterator, Iterable, Tuple, Optional, List, Dict, Union
from . import RawData, IcnsType from . import RawData, IcnsType
from .ArgbImage import ArgbImage from .ArgbImage import ArgbImage
class IcnsFile: class IcnsFile:
@staticmethod @staticmethod
def verify(fname): def verify(fname: str) -> Iterator[str]:
''' '''
Yields an error message for each issue. Yields an error message for each issue.
You can check for validity with `is_invalid = any(obj.verify())` You can check for validity with `is_invalid = any(obj.verify())`
@@ -22,7 +23,7 @@ class IcnsFile:
try: try:
iType = IcnsType.get(key) iType = IcnsType.get(key)
except NotImplementedError: except NotImplementedError:
yield 'Unsupported icns type: {}'.format(key) yield 'Unsupported icns type: ' + str(key)
continue continue
ext = RawData.determine_file_ext(data) ext = RawData.determine_file_ext(data)
@@ -32,7 +33,7 @@ class IcnsFile:
# Check whether stored type is an expected file format # Check whether stored type is an expected file format
if not (iType.is_type(ext) if ext else iType.is_binary()): if not (iType.is_type(ext) if ext else iType.is_binary()):
yield 'Unexpected type for key {}: {} != {}'.format( 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']: if ext in ['png', 'jp2', 'icns', 'plist']:
continue continue
@@ -40,16 +41,16 @@ class IcnsFile:
# Check whether uncompressed size is equal to expected maxsize # Check whether uncompressed size is equal to expected maxsize
if key == 'it32' and data[:4] != b'\x00\x00\x00\x00': if key == 'it32' and data[:4] != b'\x00\x00\x00\x00':
# TODO: check whether other it32 headers exist # TODO: check whether other it32 headers exist
yield 'Unexpected it32 data header: {}'.format(data[:4]) yield 'Unexpected it32 data header: ' + str(data[:4])
data = iType.decompress(data, ext) # ignores non-compressable udata = iType.decompress(data, ext) or data
# Check expected uncompressed maxsize # 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( yield 'Invalid data length for {}: {} != {}'.format(
key, len(data), iType.maxsize) str(key), len(udata), iType.maxsize)
# if file is not an icns file # if file is not an icns file
except RawData.ParserError as e: except RawData.ParserError as e:
yield e yield str(e)
return return
# Check total size after enum. Enum may raise exception and break early # Check total size after enum. Enum may raise exception and break early
@@ -76,12 +77,14 @@ class IcnsFile:
x, y) x, y)
@staticmethod @staticmethod
def description(fname, *, verbose=False, indent=0): def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \
str:
return IcnsFile._description( return IcnsFile._description(
RawData.parse_icns_file(fname), verbose=verbose, indent=indent) RawData.parse_icns_file(fname), verbose=verbose, indent=indent)
@staticmethod @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) ''' ''' Expects an enumerator with (key, size, data) '''
txt = '' txt = ''
offset = 8 # already with icns header offset = 8 # already with icns header
@@ -89,7 +92,7 @@ class IcnsFile:
for key, data in enumerator: for key, data in enumerator:
size = len(data) size = len(data)
txt += os.linesep + ' ' * indent txt += os.linesep + ' ' * indent
txt += '{}: {} bytes'.format(key, size) txt += '{}: {} bytes'.format(str(key), size)
if verbose: if verbose:
txt += ', offset: {}'.format(offset) txt += ', offset: {}'.format(offset)
offset += size + 8 offset += size + 8
@@ -112,9 +115,9 @@ class IcnsFile:
except RawData.ParserError as e: except RawData.ParserError as e:
return ' ' * indent + str(e) + os.linesep 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. ''' ''' Read .icns file and load bundled media files into memory. '''
self.media = {} self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
self.infile = file self.infile = file
if not file: # create empty image if not file: # create empty image
return return
@@ -124,29 +127,31 @@ class IcnsFile:
IcnsType.get(key) IcnsType.get(key)
except NotImplementedError: except NotImplementedError:
print('Warning: unknown media type: {}, {} bytes, "{}"'.format( 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. If you provide both, data and file, data takes precedence.
However, the filename is still used for type-guessing. However, the filename is still used for type-guessing.
- Declare retina images with suffix "@2x.png". - Declare retina images with suffix "@2x.png".
- Declare icns file with suffix "-dark", "-template", or "-selected" - 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: if file and not data:
with open(file, 'rb') as fp: with open(file, 'rb') as fp:
data = fp.read() data = fp.read()
if not data:
raise AttributeError('Did you miss file= or data= attribute?')
if not key: # Determine ICNS type if not key: # Determine ICNS type
key = IcnsType.guess(data, file).key key = IcnsType.guess(data, file).key
# Check if type is unique # Check if type is unique
if not force and key in self.media.keys(): if not force and key in self.media.keys():
raise KeyError( raise KeyError('Image with identical key "{}". File: {}'.format(
'Image with identical key "{}". File: {}'.format(key, file)) str(key), file))
self.media[key] = data 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. ''' ''' Create a new ICNS file from stored media. '''
# Rebuild TOC to ensure soundness # Rebuild TOC to ensure soundness
order = self._make_toc(enabled=toc) order = self._make_toc(enabled=toc)
@@ -157,8 +162,11 @@ class IcnsFile:
for key in order: for key in order:
RawData.icns_header_write_data(fp, key, self.media[key]) RawData.icns_header_write_data(fp, key, self.media[key])
def export(self, outdir=None, *, allowed_ext=None, key_suffix=False, def export(self, outdir: Optional[str] = None, *,
convert_png=False, decompress=False, recursive=False): 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. Write all bundled media files to output directory.
@@ -177,52 +185,52 @@ class IcnsFile:
elif not os.path.isdir(outdir): elif not os.path.isdir(outdir):
raise OSError('"{}" is not a directory. Abort.'.format(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()) keys = list(self.media.keys())
# Convert to PNG # Convert to PNG
if convert_png: if convert_png:
# keys = [x for x in keys if x not in []]
for imgk, maskk in IcnsType.enum_png_convertable(keys): for imgk, maskk in IcnsType.enum_png_convertable(keys):
fname = self._export_to_png(outdir, imgk, maskk, key_suffix) fname = self._export_to_png(outdir, imgk, maskk, key_suffix)
if not fname: if not fname:
continue continue
exported_files[imgk] = fname export_files[imgk] = fname
if maskk: if maskk:
exported_files[maskk] = fname export_files[maskk] = fname
if maskk in keys: if maskk in keys:
keys.remove(maskk) keys.remove(maskk)
keys.remove(imgk) keys.remove(imgk)
# prepare filter # prepare filter
if type(allowed_ext) == str: allowed = [] if allowed_ext == '*' else allowed_ext.split(',')
allowed_ext = [allowed_ext]
if recursive: if recursive:
cleanup = allowed_ext and 'icns' not in allowed_ext cleanup = allowed and 'icns' not in allowed
if cleanup: if cleanup:
allowed_ext.append('icns') allowed.append('icns')
# Export remaining # Export remaining
for key in keys: for key in keys:
fname = self._export_single(outdir, key, key_suffix, decompress, fname = self._export_single(outdir, key, key_suffix,
allowed_ext) decompress, allowed)
if fname: if fname:
exported_files[key] = fname export_files[key] = fname
# repeat for all icns # repeat for all icns
if recursive: if recursive:
for key, fname in exported_files.items(): for old_key, old_name in export_files.items():
if key == '_' or not fname.endswith('.icns'): assert(isinstance(old_name, str))
if not old_name.endswith('.icns') or old_key == '_':
continue continue
prev_fname = exported_files[key] export_files[old_key] = IcnsFile(old_name).export(
exported_files[key] = IcnsFile(fname).export(
allowed_ext=allowed_ext, key_suffix=key_suffix, allowed_ext=allowed_ext, key_suffix=key_suffix,
convert_png=convert_png, decompress=decompress, convert_png=convert_png, decompress=decompress,
recursive=True) recursive=True)
if cleanup: if cleanup:
os.remove(prev_fname) os.remove(old_name)
return exported_files return export_files
def _make_toc(self, *, enabled): def _make_toc(self, *, enabled: bool) -> List[IcnsType.Media.KeyT]:
# Rebuild TOC to ensure soundness # Rebuild TOC to ensure soundness
if 'TOC ' in self.media.keys(): if 'TOC ' in self.media.keys():
del(self.media['TOC ']) del(self.media['TOC '])
@@ -238,7 +246,9 @@ class IcnsFile:
return order 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 ''' ''' You must ensure that keys exist in self.media '''
data = self.media[key] data = self.media[key]
ext = RawData.determine_file_ext(data) ext = RawData.determine_file_ext(data)
@@ -249,7 +259,7 @@ class IcnsFile:
iType = IcnsType.get(key) iType = IcnsType.get(key)
fname = iType.filename(key_only=key_suffix) fname = iType.filename(key_only=key_suffix)
if decompress: 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) if not ext: # overwrite ext after (decompress requires None)
ext = 'rgb' if iType.compressable else 'bin' ext = 'rgb' if iType.compressable else 'bin'
except NotImplementedError: # If key unkown, export anyway except NotImplementedError: # If key unkown, export anyway
@@ -257,14 +267,16 @@ class IcnsFile:
if not ext: if not ext:
ext = 'unknown' ext = 'unknown'
if allowed_ext and ext not in allowed_ext: if allowed and ext not in allowed:
return None return None
fname = os.path.join(outdir, fname + '.' + ext) fname = os.path.join(outdir, fname + '.' + ext)
with open(fname, 'wb') as fp: with open(fname, 'wb') as fp:
fp.write(data) fp.write(data)
return fname 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! ''' ''' You must ensure key and mask_key exists! '''
data = self.media[img_key] data = self.media[img_key]
if RawData.determine_file_ext(data) not in ['argb', None]: 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) ArgbImage(data=data, mask=mask_data).write_png(fname)
return fname return fname
def __repr__(self): def __repr__(self) -> str:
lst = ', '.join(str(k) for k in self.media.keys()) lst = ', '.join(str(k) for k in self.media.keys())
return '<{}: file={}, [{}]>'.format( return '<{}: file={}, [{}]>'.format(
type(self).__name__, self.infile, lst) type(self).__name__, self.infile, lst)
def __str__(self): def __str__(self) -> str:
return 'File: ' + (self.infile or '-mem-') + os.linesep \ return 'File: ' + (self.infile or '-mem-') + os.linesep \
+ IcnsFile._description(self.media.items(), indent=2) + 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 @see https://en.wikipedia.org/wiki/Apple_Icon_Image_format
''' '''
import os # path import os # path
# import icnsutil # PackBytes, RawData from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict
from . import PackBytes, RawData from . import PackBytes, RawData
@@ -13,14 +13,17 @@ class CanNotDetermine(Exception):
class Media: class Media:
KeyT = Union[str, bytes]
__slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability', __slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability',
'desc', 'compressable', 'retina', 'maxsize', 'ext_certain'] 'desc', 'compressable', 'retina', 'maxsize', 'ext_certain']
def __init__(self, key, types, size=None, *, def __init__(self, key: KeyT, types: list,
ch=None, bits=None, os=None, desc=''): 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.key = key
self.types = types if type(types) == list else [types] self.types = types
self.size = (size, size) if type(size) == int else size self.size = (size, size) if isinstance(size, int) else size
self.availability = os self.availability = os
self.desc = desc self.desc = desc
# computed properties # computed properties
@@ -34,24 +37,25 @@ class Media:
bits = 8 bits = 8
self.channels = ch self.channels = ch
self.bits = bits self.bits = bits
self.maxsize = None self.maxsize = None # type: Optional[int]
if size and ch and bits: if self.size and ch and bits:
self.maxsize = self.size[0] * self.size[1] * ch * bits // 8 self.maxsize = self.size[0] * self.size[1] * ch * bits // 8
self.ext_certain = all(x in ['png', 'argb', 'plist', 'jp2', 'icns'] self.ext_certain = all(x in ['png', 'argb', 'plist', 'jp2', 'icns']
for x in self.types) for x in self.types)
def is_type(self, typ): def is_type(self, typ: str) -> bool:
return typ in self.types return typ in self.types
def is_binary(self) -> bool: def is_binary(self) -> bool:
return any(x in self.types for x in ['rgb', 'bin']) 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]: if self.channels in [1, 2]:
return self.desc # guaranteed to be icon, mask, or iconmask return self.desc # guaranteed to be icon, mask, or iconmask
return self.types[-1] 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]: if self.channels not in [3, 4]:
raise NotImplementedError('Only RGB and ARGB data supported.') raise NotImplementedError('Only RGB and ARGB data supported.')
if len(uncompressed_data) != self.maxsize: if len(uncompressed_data) != self.maxsize:
@@ -64,26 +68,28 @@ class Media:
for i in range(self.channels): for i in range(self.channels):
yield uncompressed_data[per_channel * i:per_channel * (i + 1)] yield uncompressed_data[per_channel * i:per_channel * (i + 1)]
def decompress(self, data, ext='-?-'): def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[
if not self.compressable: List[int]]:
return data ''' Returns None if media is not decompressable. '''
if ext == '-?-': if self.compressable:
ext = RawData.determine_file_ext(data) if ext == '-?-':
if ext == 'argb': ext = RawData.determine_file_ext(data)
return PackBytes.unpack(data[4:]) # remove ARGB header if ext == 'argb':
if ext is None or ext == 'rgb': # RGB files dont have a magic number return PackBytes.unpack(data[4:]) # remove ARGB header
if self.key == 'it32': if ext is None or ext == 'rgb': # RGB files dont have magic number
data = data[4:] if self.key == 'it32':
return PackBytes.unpack(data) data = data[4:]
return data 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 key_only:
if os.path.exists(__file__.upper()): # check case senstive if os.path.exists(__file__.upper()): # check case senstive
if self.key in ['sb24', 'icsb']: if self.key in ['sb24', 'icsb']:
return self.key + '-a' return self.key + '-a' # type: ignore
elif self.key in ['SB24', 'icsB']: 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 return str(self.key) # dont return directy, may be b''-str
else: else:
if self.is_type('icns'): if self.is_type('icns'):
@@ -106,11 +112,11 @@ class Media:
suffix += '-mask{}b'.format(self.bits) suffix += '-mask{}b'.format(self.bits)
return '{}x{}{}'.format(w, h, suffix) return '{}x{}{}'.format(w, h, suffix)
def __repr__(self): def __repr__(self) -> str:
return '<{}: {}, {}.{}>'.format( 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 = '' T = ''
if self.size: if self.size:
T += '{}x{}, '.format(*self.size) T += '{}x{}, '.format(*self.size)
@@ -120,36 +126,36 @@ class Media:
if self.desc: if self.desc:
T += self.desc + ', ' T += self.desc + ', '
return '{}: {}macOS {}+'.format( return '{}: {}macOS {}+'.format(
self.key, T, self.availability or '?') str(self.key), T, self.availability or '?')
_TYPES = {x.key: x for x in ( _TYPES = {x.key: x for x in (
# Read support for these: # Read support for these:
Media('ICON', 'bin', 32, ch=1, bits=1, os=1.0, desc='icon'), 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('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('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('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('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('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('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('ics8', ['bin'], 16, ch=1, bits=8, os=7.0, desc='icon'),
Media('is32', 'rgb', 16, os=8.5), Media('is32', ['rgb'], 16, os=8.5),
Media('s8mk', 'bin', 16, ch=1, bits=8, os=8.5, desc='mask'), 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('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('icl8', ['bin'], 32, ch=1, bits=8, os=7.0, desc='icon'),
Media('il32', 'rgb', 32, os=8.5), Media('il32', ['rgb'], 32, os=8.5),
Media('l8mk', 'bin', 32, ch=1, bits=8, os=8.5, desc='mask'), 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('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('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('ich8', ['bin'], 48, ch=1, bits=8, os=8.5, desc='icon'),
Media('ih32', 'rgb', 48, os=8.5), Media('ih32', ['rgb'], 48, os=8.5),
Media('h8mk', 'bin', 48, ch=1, bits=8, os=8.5, desc='mask'), Media('h8mk', ['bin'], 48, ch=1, bits=8, os=8.5, desc='mask'),
Media('it32', 'rgb', 128, os=10.0), Media('it32', ['rgb'], 128, os=10.0),
Media('t8mk', 'bin', 128, ch=1, bits=8, os=10.0, desc='mask'), Media('t8mk', ['bin'], 128, ch=1, bits=8, os=10.0, desc='mask'),
# Write support for these: # Write support for these:
Media('icp4', ['png', 'jp2', 'rgb'], 16, os=10.7), Media('icp4', ['png', 'jp2', 'rgb'], 16, os=10.7),
Media('icp5', ['png', 'jp2', 'rgb'], 32, 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('ic07', ['png', 'jp2'], 128, os=10.7),
Media('ic08', ['png', 'jp2'], 256, os=10.5), Media('ic08', ['png', 'jp2'], 256, os=10.5),
Media('ic09', ['png', 'jp2'], 512, 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'], 24),
Media('SB24', ['png', 'jp2'], 48, desc='24x24@2x'), Media('SB24', ['png', 'jp2'], 48, desc='24x24@2x'),
# ICNS media files # ICNS media files
Media('sbtp', 'icns', desc='template'), Media('sbtp', ['icns'], desc='template'),
Media('slct', 'icns', desc='selected'), Media('slct', ['icns'], desc='selected'),
Media(b'\xFD\xD9\x2F\xA8', 'icns', os=10.14, desc='dark'), Media(b'\xFD\xD9\x2F\xA8', ['icns'], os=10.14, desc='dark'),
# Meta types: # Meta types:
Media('TOC ', 'bin', os=10.7, desc='Table of Contents'), Media('TOC ', ['bin'], os=10.7, desc='Table of Contents'),
Media('icnV', 'bin', desc='4-byte Icon Composer.app bundle version'), Media('icnV', ['bin'], desc='4-byte Icon Composer.app bundle version'),
Media('name', 'bin', desc='Unknown'), Media('name', ['bin'], desc='Unknown'),
Media('info', 'plist', desc='Info binary plist'), 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 for mask_k, *imgs in [ # list probably never changes, ARGB FTW
('s8mk', 'is32', 'ics8', 'ics4', 'icp4'), ('s8mk', 'is32', 'ics8', 'ics4', 'icp4'),
('l8mk', 'il32', 'icl8', 'icl4', 'icp5'), ('l8mk', 'il32', 'icl8', 'icl4', 'icp5'),
('h8mk', 'ih32', 'ich8', 'ich4'), ('h8mk', 'ih32', 'ich8', 'ich4'),
('t8mk', 'it32'), ('t8mk', 'it32'),
]: ]:
if mask_k not in available_keys: mk = mask_k if mask_k in available_keys else None
mask_k = None
any_img = False any_img = False
for img_k in imgs: for img_k in imgs:
if img_k in available_keys: if img_k in available_keys:
any_img = True any_img = True
yield img_k, mask_k yield img_k, mk
if mask_k and not any_img: if mk and not any_img:
yield None, mask_k 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) ''' ''' Yield (image-key, mask-key or None) '''
for img in _TYPES.values(): for img in _TYPES.values():
if img.key not in available_keys: if img.key not in available_keys:
@@ -212,22 +219,21 @@ def enum_png_convertable(available_keys):
yield img.key, mask_key yield img.key, mask_key
def get(key): def get(key: Media.KeyT) -> Media:
try: try:
return _TYPES[key] return _TYPES[key]
except KeyError: except KeyError:
pass pass
raise NotImplementedError('Unsupported icns type "{}"'.format(key)) raise NotImplementedError('Unsupported icns type "' + str(key) + '"')
def match_maxsize(maxsize, typ): def match_maxsize(total: int, typ: str) -> Media:
for x in _TYPES.values(): assert(typ == 'argb' or typ == 'rgb')
if x.is_type(typ) and x.maxsize == maxsize: ret = [x for x in _TYPES.values() if x.is_type(typ) and x.maxsize == total]
return x # TODO: handle cases with multiple options? eg: is32 icp4 return ret[0] # TODO: handle cases with multiple options? eg: is32 icp4
return None
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. Guess icns media type by analyzing the raw data + file naming convention.
Use: Use:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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