Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7ce460b5 | ||
|
|
f80d7d79f6 | ||
|
|
bc6fe5a2b3 | ||
|
|
028df18dbc | ||
|
|
d6d3c88ee8 | ||
|
|
6a82adcd1f | ||
|
|
e9b5563cb9 | ||
|
|
bf4efb42d8 | ||
|
|
176b675316 | ||
|
|
824616403e | ||
|
|
3b430740dc | ||
|
|
4f565b6de1 | ||
|
|
7f6c73751f | ||
|
|
3b6cdd5f82 | ||
|
|
8243534f65 |
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright 2021 Oleg Geier
|
||||
Copyright 2021-2023 relikd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
1
MANIFEST.in
Normal file
1
MANIFEST.in
Normal file
@@ -0,0 +1 @@
|
||||
include LICENSE
|
||||
2
Makefile
2
Makefile
@@ -13,7 +13,7 @@ install:
|
||||
uninstall:
|
||||
python3 -m pip uninstall icnsutil
|
||||
rm -rf ./*.egg-info/
|
||||
-rm -i "$$(which icnsutil)"
|
||||
-rm -i "$$(which icnsutil)" "$$(which icnsutil-autosize)"
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
||||
74
README.md
74
README.md
@@ -2,27 +2,40 @@
|
||||
|
||||
A fully-featured python library to handle reading and writing `.icns` files.
|
||||
|
||||
## Install
|
||||
|
||||
## HTML icon viewer
|
||||
The easy way is to use the PyPi.org index:
|
||||
|
||||
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.
|
||||
```sh
|
||||
pip3 install icnsutil
|
||||
```
|
||||
|
||||
- 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.
|
||||
Or you can install it **manually** by creating a symlink to `cli.py`:
|
||||
|
||||
[inspector]: https://relikd.github.io/icnsutil/html/inspector.html
|
||||
[viewer]: https://relikd.github.io/icnsutil/html/viewer.html
|
||||
```sh
|
||||
ln -s '/absolute/path/to/icnsutil/icnsutil/cli.py' /usr/local/bin/icnsutil
|
||||
ln -s '/absolute/path/to/icnsutil/icnsutil/autosize/cli.py' /usr/local/bin/icnsutil-autosize
|
||||
```
|
||||
|
||||
Or call the python module (if the module is in the search path):
|
||||
|
||||
```sh
|
||||
python3 -m icnsutil
|
||||
python3 -m icnsutil.autosize
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
See [#tools](#tools) for further options on icns processing (e.g., autosize).
|
||||
|
||||
```
|
||||
positional arguments:
|
||||
command
|
||||
extract (e) Read and extract contents of icns file(s).
|
||||
compose (c) Create new icns file from provided image files.
|
||||
update (u) Update existing icns file by inserting or removing media entries.
|
||||
print (p) Print contents of icns file(s).
|
||||
info (i) Print contents of icns file(s).
|
||||
test (t) Test if icns file is valid.
|
||||
convert (img) Convert images between PNG, ARGB, or RGB + alpha mask.
|
||||
```
|
||||
@@ -43,7 +56,7 @@ icnsutil u Existing.icns -set is32=16.rgb dark="dark icon.icns"
|
||||
icnsutil u Existing.icns -rm dark -set ic04=16.argb -o Updated.icns
|
||||
|
||||
# print
|
||||
icnsutil p Existing.icns
|
||||
icnsutil i Existing.icns
|
||||
|
||||
# verify valid format
|
||||
icnsutil t Existing.icns
|
||||
@@ -81,10 +94,16 @@ if img.remove_media('TOC '):
|
||||
img.write('Existing.icns', toc=True)
|
||||
|
||||
# print
|
||||
icnsutil.IcnsFile.description(fname, indent=2)
|
||||
# return type str
|
||||
desc = icnsutil.IcnsFile.description(fname, indent=2)
|
||||
print(desc)
|
||||
|
||||
# verify valid format
|
||||
icnsutil.IcnsFile.verify(fname)
|
||||
# return type Iterator[str]
|
||||
itr = icnsutil.IcnsFile.verify(fname)
|
||||
print(list(itr))
|
||||
# If you just want to check if a file is faulty, you can use `any(itr)` instead.
|
||||
# This way it will not test all checks but break early after the first hit.
|
||||
```
|
||||
|
||||
|
||||
@@ -114,6 +133,41 @@ with open('32x32.mask', 'wb') as fp:
|
||||
Note: the CLI `export` command will fail if you run `--convert` without Pillow.
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### Autosize
|
||||
|
||||
`icnsutil.autosize` is a tool to automatically generate smaller icon sizes from a larger one.
|
||||
Currently, autosize has support for “normal” raster images (via sips or Pillow) and SVG images (via [resvg] or Chrome Headless).
|
||||
|
||||
```sh
|
||||
icnsutil-autosize icon.svg -32 intermediate.png -16 small.svg
|
||||
# or
|
||||
python3 -m icnsutil.autosize icon.svg -32 intermediate.png -16 small.svg
|
||||
```
|
||||
|
||||
Additionally, `autosize` will also try to convert 32px and 16px PNG images to ARGB.
|
||||
If Pillow is not installed, this step will be skipped (without negative side effects).
|
||||
The output is an iconset folder with all necessary images.
|
||||
|
||||
You may ask why this tool does not create the icns file immediatelly?
|
||||
This way you can modify the generated images before packing them into an icns file.
|
||||
For example, you can run [ImageOptim] to compress the images and reduce the overall icns filesize.
|
||||
|
||||
[resvg]: https://github.com/RazrFalcon/resvg/
|
||||
[ImageOptim]: https://github.com/ImageOptim/ImageOptim
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
## Help needed
|
||||
|
||||
1. Do you have an old macOS version running somewhere?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from typing import Union, Iterator, Optional
|
||||
from typing import Union, Optional
|
||||
from math import sqrt
|
||||
from . import IcnsType, PackBytes, RawData
|
||||
try:
|
||||
@@ -16,6 +16,8 @@ class ArgbImage:
|
||||
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.size)
|
||||
assert(iType.channels)
|
||||
img = []
|
||||
for byte in data:
|
||||
for i in range(7, -1, -1):
|
||||
@@ -31,9 +33,14 @@ class ArgbImage:
|
||||
self.r, self.g, self.b = img, img, img
|
||||
return self
|
||||
|
||||
def __init__(self, *, data: Optional[bytes] = None,
|
||||
file: Optional[str] = None,
|
||||
mask: Union[bytes, str, None] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
data: Optional[bytes] = None,
|
||||
file: Optional[str] = None,
|
||||
image: Optional['Image.Image'] = 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
|
||||
@@ -44,6 +51,8 @@ class ArgbImage:
|
||||
self.load_file(file)
|
||||
elif data:
|
||||
self.load_data(data)
|
||||
elif image:
|
||||
self._load_pillow_image(image)
|
||||
else:
|
||||
raise AttributeError('Neither data nor file provided.')
|
||||
if mask:
|
||||
@@ -91,8 +100,9 @@ class ArgbImage:
|
||||
self.g = uncompressed_data[(i + 1) * per_channel:(i + 2) * per_channel]
|
||||
self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel]
|
||||
|
||||
def load_mask(self, *, file: Optional[str] = None,
|
||||
data: Optional[bytes] = None) -> 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:
|
||||
@@ -121,7 +131,10 @@ class ArgbImage:
|
||||
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').convert('RGBA')
|
||||
self._load_pillow_image(Image.open(fname, mode='r'))
|
||||
|
||||
def _load_pillow_image(self, image: 'Image.Image') -> None:
|
||||
img = image.convert('RGBA')
|
||||
self.size = img.size
|
||||
self.channels = 4
|
||||
self.a = []
|
||||
|
||||
@@ -79,14 +79,18 @@ class IcnsFile:
|
||||
x, y)
|
||||
|
||||
@staticmethod
|
||||
def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \
|
||||
str:
|
||||
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: Iterable[Tuple[IcnsType.Media.KeyT, bytes]],
|
||||
*, verbose: bool = False, indent: int = 0) -> str:
|
||||
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
|
||||
@@ -112,12 +116,12 @@ class IcnsFile:
|
||||
txt += ', ' + ext + ': ' + iType.filename(size_only=True)
|
||||
except NotImplementedError:
|
||||
txt += ': UNKNOWN TYPE: ' + str(ext or data[:6])
|
||||
return txt[len(os.linesep):] + os.linesep
|
||||
return txt[len(os.linesep):]
|
||||
# if file is not an icns file
|
||||
except RawData.ParserError as e:
|
||||
return ' ' * indent + str(e) + os.linesep
|
||||
return ' ' * indent + str(e)
|
||||
|
||||
def __init__(self, file: str = None) -> None:
|
||||
def __init__(self, file: Optional[str] = None) -> None:
|
||||
''' Read .icns file and load bundled media files into memory. '''
|
||||
self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
|
||||
self.infile = file
|
||||
@@ -134,9 +138,14 @@ class IcnsFile:
|
||||
def has_toc(self) -> bool:
|
||||
return 'TOC ' in self.media.keys()
|
||||
|
||||
def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *,
|
||||
file: Optional[str] = None, data: Optional[bytes] = None,
|
||||
force: bool = False) -> None:
|
||||
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.
|
||||
@@ -163,6 +172,10 @@ class IcnsFile:
|
||||
# Nested icns files must omit the icns header
|
||||
if is_icns and data[:4] == b'icns':
|
||||
data = data[8:]
|
||||
if key in ('icp4', 'icp5'):
|
||||
iType = IcnsType.get(key)
|
||||
print('Warning: deprecated "{}"({}) use argb instead'.format(
|
||||
str(key), iType.filename(size_only=True)), file=stderr)
|
||||
self.media[key] = data
|
||||
|
||||
def remove_media(self, key: IcnsType.Media.KeyT) -> bool:
|
||||
@@ -182,11 +195,16 @@ class IcnsFile:
|
||||
for key in order:
|
||||
RawData.icns_header_write_data(fp, key, self.media[key])
|
||||
|
||||
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]]:
|
||||
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.
|
||||
|
||||
@@ -266,9 +284,14 @@ class IcnsFile:
|
||||
|
||||
return order
|
||||
|
||||
def _export_single(self, outdir: str, key: IcnsType.Media.KeyT,
|
||||
key_suffix: bool, decompress: bool,
|
||||
allowed: List[str]) -> Optional[str]:
|
||||
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)
|
||||
@@ -294,9 +317,13 @@ class IcnsFile:
|
||||
fp.write(data)
|
||||
return fname
|
||||
|
||||
def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT,
|
||||
mask_key: Optional[IcnsType.Media.KeyT],
|
||||
key_suffix: bool) -> Optional[str]:
|
||||
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]:
|
||||
|
||||
@@ -4,7 +4,7 @@ Namespace for the ICNS format.
|
||||
@see https://en.wikipedia.org/wiki/Apple_Icon_Image_format
|
||||
'''
|
||||
import os # path
|
||||
from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict
|
||||
from typing import Union, Optional, Tuple, Iterator, List, Iterable, Set
|
||||
from . import PackBytes, RawData
|
||||
|
||||
|
||||
@@ -17,10 +17,17 @@ class Media:
|
||||
__slots__ = ['key', 'types', 'size', 'channels', 'bits', 'availability',
|
||||
'desc', 'compressable', 'retina', 'maxsize', 'ext_certain']
|
||||
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
key: KeyT,
|
||||
types: List[str],
|
||||
size: Union[int, Tuple[int, int], None] = None,
|
||||
*,
|
||||
ch: Optional[int] = None,
|
||||
bits: Optional[int] = None,
|
||||
os: Optional[float] = None,
|
||||
desc: str = '',
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.types = types
|
||||
self.size = (size, size) if isinstance(size, int) else size
|
||||
@@ -54,8 +61,8 @@ class Media:
|
||||
return self.desc # guaranteed to be icon, mask, or iconmask
|
||||
return self.types[-1]
|
||||
|
||||
def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[
|
||||
List[int]]:
|
||||
def decompress(self, data: bytes, ext: Optional[str] = '-?-') \
|
||||
-> Optional[List[int]]:
|
||||
''' Returns None if media is not decompressable. '''
|
||||
if self.compressable:
|
||||
if ext == '-?-':
|
||||
@@ -165,11 +172,11 @@ _TYPES = {x.key: x for x in (
|
||||
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: Iterable[Media.KeyT]) -> Iterator[
|
||||
Tuple[Optional[str], Optional[str]]]:
|
||||
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'),
|
||||
@@ -186,8 +193,8 @@ def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
||||
yield None, mk
|
||||
|
||||
|
||||
def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
||||
Tuple[Media.KeyT, Optional[Media.KeyT]]]:
|
||||
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:
|
||||
@@ -205,6 +212,10 @@ def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
||||
yield img.key, mask_key
|
||||
|
||||
|
||||
def supported_extensions() -> Set[str]:
|
||||
return set(y for x in _TYPES.values() for y in x.types)
|
||||
|
||||
|
||||
def get(key: Media.KeyT) -> Media:
|
||||
try:
|
||||
return _TYPES[key]
|
||||
@@ -219,8 +230,8 @@ def key_from_readable(key: str) -> Media.KeyT:
|
||||
'selected': 'slct',
|
||||
'template': 'sbtp',
|
||||
'toc': 'TOC ',
|
||||
} # type: Dict[str, Media.KeyT]
|
||||
return key_mapping.get(key.lower(), key)
|
||||
}
|
||||
return key_mapping.get(key.lower(), key) # type: ignore[return-value]
|
||||
|
||||
|
||||
def match_maxsize(total: int, typ: str) -> Media:
|
||||
|
||||
@@ -7,7 +7,7 @@ def pack(data: List[int]) -> bytes:
|
||||
buf = [] # type: List[int]
|
||||
i = 0
|
||||
|
||||
def flush_buf():
|
||||
def flush_buf() -> None:
|
||||
# write out non-repeating bytes
|
||||
if len(buf) > 0:
|
||||
ret.append(len(buf) - 1)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import struct # pack, unpack
|
||||
from typing import Union, Optional, Tuple, Iterator, BinaryIO
|
||||
from typing import Optional, Tuple, Iterator, BinaryIO
|
||||
from . import IcnsType, PackBytes
|
||||
|
||||
|
||||
@@ -85,8 +85,9 @@ def icns_header_read(data: bytes) -> Tuple[IcnsType.Media.KeyT, int]:
|
||||
return data[:4], length # Fallback to bytes-string key
|
||||
|
||||
|
||||
def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT,
|
||||
data: bytes) -> None:
|
||||
def icns_header_write_data(
|
||||
fp: BinaryIO, key: IcnsType.Media.KeyT, data: bytes,
|
||||
) -> None:
|
||||
''' Calculates length from data. '''
|
||||
fp.write(key.encode('utf8') if isinstance(key, str) else key)
|
||||
fp.write(struct.pack('>I', len(data) + 8))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
'''
|
||||
A fully-featured python library to handle reading and writing icns files.
|
||||
'''
|
||||
__version__ = '1.0.1'
|
||||
__version__ = '1.1.0'
|
||||
|
||||
from .IcnsFile import IcnsFile
|
||||
from .ArgbImage import ArgbImage, PIL_ENABLED
|
||||
|
||||
57
icnsutil/autosize/ImageResizer.py
Normal file
57
icnsutil/autosize/ImageResizer.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
from typing import Tuple, Optional, List, Type, TypeVar
|
||||
|
||||
ResizerT = TypeVar('ResizerT', bound='Type[ImageResizer]')
|
||||
|
||||
|
||||
def firstSupportedResizer(choices: List['ResizerT']) -> 'ResizerT':
|
||||
for x in choices:
|
||||
if x.isSupported():
|
||||
return x
|
||||
for x in choices:
|
||||
print(' NOT SUPPORTED:', (x.__doc__ or '').strip())
|
||||
raise RuntimeError('No supported image resizer found.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image resizer (base class)
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
class ImageResizer:
|
||||
_exe = None # type: str # executable to be used for resize()
|
||||
|
||||
@staticmethod
|
||||
def isSupported() -> bool:
|
||||
assert 0, 'Missing implementation for isSupported() method'
|
||||
|
||||
def __init__(self, fname: str, preferred_size: int):
|
||||
self.fname = fname
|
||||
self.preferred_size = preferred_size
|
||||
self.actual_size = -42 # postpone calculation until needed
|
||||
|
||||
@property
|
||||
def exe(self) -> str:
|
||||
return self._exe # guaranteed by isSupported()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
if self.actual_size == -42:
|
||||
w, h = self.calculateSize()
|
||||
assert w == h, 'Image dimensions must be square'
|
||||
self.actual_size = w // 2 # retina half size
|
||||
return min(self.preferred_size, self.actual_size)
|
||||
|
||||
def calculateSize(self) -> Tuple[int, int]:
|
||||
assert 0, 'Missing implementation for calculateSize() method'
|
||||
|
||||
def resize(self, size: int, fname_out: str) -> None:
|
||||
assert 0, 'Missing implementation for resize() method'
|
||||
|
||||
|
||||
class SVGResizer(ImageResizer):
|
||||
def calculateSize(self) -> Tuple[int, int]:
|
||||
return 999999, 999999
|
||||
|
||||
|
||||
class PixelResizer(ImageResizer):
|
||||
pass
|
||||
52
icnsutil/autosize/PixelResizer.py
Normal file
52
icnsutil/autosize/PixelResizer.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from shutil import which
|
||||
from subprocess import run, PIPE, DEVNULL
|
||||
from typing import Tuple
|
||||
from .ImageResizer import PixelResizer
|
||||
try:
|
||||
from PIL import Image
|
||||
PILLOW_ENABLED = True
|
||||
except ImportError:
|
||||
PILLOW_ENABLED = False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Raster image resizer
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
class Sips(PixelResizer):
|
||||
''' sips (pre-installed on macOS) '''
|
||||
|
||||
@staticmethod
|
||||
def isSupported() -> bool:
|
||||
Sips._exe = which('sips')
|
||||
return True if Sips._exe else False
|
||||
|
||||
_regex = re.compile(
|
||||
rb'.*pixelWidth:([\s0-9]+).*pixelHeight:([\s0-9]+)', re.DOTALL)
|
||||
|
||||
def calculateSize(self) -> Tuple[int, int]:
|
||||
res = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight',
|
||||
self.fname], stdout=PIPE)
|
||||
match = Sips._regex.match(res.stdout)
|
||||
w, h = map(int, match.groups()) if match else (0, 0)
|
||||
return w, h
|
||||
|
||||
def resize(self, size: int, fname_out: str) -> None:
|
||||
run(['sips', '-Z', str(size), self.fname, '-o', fname_out],
|
||||
stdout=DEVNULL)
|
||||
|
||||
|
||||
class Pillow(PixelResizer):
|
||||
''' PIL (pip3 install Pillow) '''
|
||||
|
||||
@staticmethod
|
||||
def isSupported() -> bool:
|
||||
return PILLOW_ENABLED
|
||||
|
||||
def calculateSize(self) -> Tuple[int, int]:
|
||||
return Image.open(self.fname, mode='r').size # type: ignore
|
||||
|
||||
def resize(self, size: int, fname_out: str) -> None:
|
||||
Image.open(self.fname, mode='r').resize((size, size)).save(fname_out)
|
||||
45
icnsutil/autosize/SVGResizer.py
Normal file
45
icnsutil/autosize/SVGResizer.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import platform
|
||||
from shutil import which
|
||||
from subprocess import run, PIPE, DEVNULL
|
||||
from .ImageResizer import SVGResizer
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Vector based image resizer
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
class ReSVG(SVGResizer):
|
||||
''' resvg (https://github.com/RazrFalcon/resvg/) '''
|
||||
|
||||
@staticmethod
|
||||
def isSupported() -> bool:
|
||||
ReSVG._exe = which('resvg')
|
||||
return True if ReSVG._exe else False
|
||||
|
||||
def resize(self, size: int, fname_out: str) -> None:
|
||||
run([self.exe, '-w', str(size), self.fname, fname_out])
|
||||
|
||||
|
||||
class ChromeSVG(SVGResizer):
|
||||
''' Google Chrome (macOS only) '''
|
||||
|
||||
@staticmethod
|
||||
def isSupported() -> bool:
|
||||
if platform.system() == 'Darwin':
|
||||
ret = run(['defaults', 'read', 'com.google.Chrome',
|
||||
'LastRunAppBundlePath'], stdout=PIPE).stdout.strip()
|
||||
app_path = ret.decode('utf8') or '/Applications/Google Chrome.app'
|
||||
app_path += '/Contents/MacOS/Google Chrome'
|
||||
if os.path.isfile(app_path):
|
||||
ChromeSVG._exe = app_path
|
||||
return True
|
||||
return False
|
||||
|
||||
def resize(self, size: int, fname_out: str) -> None:
|
||||
run([self.exe, '--headless', '--disable-gpu', '--hide-scrollbars',
|
||||
'--force-device-scale-factor=1', '--default-background-color=000000',
|
||||
'--window-size={0},{0}'.format(size),
|
||||
'--screenshot={}'.format(fname_out), self.fname],
|
||||
stderr=DEVNULL)
|
||||
0
icnsutil/autosize/__init__.py
Normal file
0
icnsutil/autosize/__init__.py
Normal file
3
icnsutil/autosize/__main__.py
Normal file
3
icnsutil/autosize/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
from .cli import main
|
||||
main()
|
||||
112
icnsutil/autosize/cli.py
Executable file
112
icnsutil/autosize/cli.py
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
'''
|
||||
Auto-downscale images to generate an iconset (`.iconset`) for `.icns` files.
|
||||
'''
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, List, Tuple, Optional
|
||||
if __name__ == '__main__':
|
||||
sys.path[0] = os.path.dirname(os.path.dirname(sys.path[0]))
|
||||
from icnsutil.autosize.helper import bestImageResizer
|
||||
if TYPE_CHECKING:
|
||||
from icnsutil.autosize.ImageResizer import ImageResizer
|
||||
try:
|
||||
from icnsutil import ArgbImage
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
images, err = parse_input_args()
|
||||
if images:
|
||||
# TODO: should iconset be created at image source or CWD?
|
||||
iconset_out = images[0].fname + '.iconset'
|
||||
os.makedirs(iconset_out, exist_ok=True)
|
||||
downscale_images(images, iconset_out)
|
||||
icns_file = images[0].fname + '.icns'
|
||||
convert_icnsutil(iconset_out, icns_file)
|
||||
else:
|
||||
print(__doc__.strip())
|
||||
print()
|
||||
print('Usage: icnsutil-autosize icon.svg -16 small.svg')
|
||||
print(' icnsutil-autosize 1024.png img32px.png')
|
||||
if err:
|
||||
print()
|
||||
print(err, file=sys.stderr)
|
||||
else:
|
||||
print(parse_input_args.__doc__)
|
||||
exit(1 if err else 0)
|
||||
|
||||
|
||||
def parse_input_args() -> Tuple[Optional[List['ImageResizer']], Optional[str]]:
|
||||
'''
|
||||
List of image files sorted by resolution in descending order.
|
||||
Manually overwrite resolution by prepending `-X` before image-name,
|
||||
where `X` is one of: [16, 32, 128, 256, 512].
|
||||
`X` applies for both, normal and retina size (`img_X.png`, `img_X@2x.png`)
|
||||
'''
|
||||
if len(sys.argv) == 1 or '-h' in sys.argv or '--help' in sys.argv:
|
||||
return None, None # just print help
|
||||
size = 512 # assume first icon is 1024x1024 (512@2x)
|
||||
ret = []
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith('-'): # size indicator (-<int>)
|
||||
new_size = int(arg[1:])
|
||||
if new_size >= size:
|
||||
return None, 'Icons must be sorted by size, largest first.'
|
||||
size = new_size
|
||||
elif os.path.isfile(arg): # icon file
|
||||
ret.append(bestImageResizer(arg, size))
|
||||
else:
|
||||
return None, 'File "{}" does not exist.'.format(arg)
|
||||
return ret, None
|
||||
|
||||
|
||||
def downscale_images(images: List['ImageResizer'], outdir: str) -> None:
|
||||
''' Go through all files and apply resizer one by one. '''
|
||||
all_sizes = [x.size for x in images[1:]] + [0]
|
||||
for img, nextsize in zip(images, all_sizes):
|
||||
maxsize = img.size
|
||||
if nextsize >= maxsize:
|
||||
print('SKIP: "{}" (next image is larger, {}px <= {}px)'.format(
|
||||
img.fname, maxsize, nextsize), file=sys.stderr)
|
||||
continue
|
||||
|
||||
print('downscaling from {}@2x ({}): '.format(
|
||||
maxsize, type(img).__name__), end='')
|
||||
for s in (16, 32, 128, 256, 512):
|
||||
if nextsize < s <= maxsize:
|
||||
base = os.path.join(outdir, 'icon_{0}x{0}'.format(s))
|
||||
print('.', end='')
|
||||
img.resize(s, base + '.png')
|
||||
print('.', end='')
|
||||
img.resize(s * 2, base + '@2x.png')
|
||||
print(' done.') # finishes "...." line
|
||||
|
||||
|
||||
def convert_icnsutil(iconset_dir: str, icns_file: str) -> None:
|
||||
''' After downscaling, try to convert PNG to ARGB. '''
|
||||
for x in [16, 32]:
|
||||
src = os.path.join(iconset_dir, 'icon_{0}x{0}.png'.format(x))
|
||||
dst = src[:-4] + '.argb'
|
||||
if not os.path.isfile(src):
|
||||
continue
|
||||
print('converting {0}x{0}.argb (icnsutil): ... '.format(x), end='')
|
||||
try:
|
||||
argb_image = ArgbImage(file=src)
|
||||
with open(dst, 'wb') as fp:
|
||||
fp.write(argb_image.argb_data())
|
||||
print('done.') # finishes "..." line
|
||||
os.remove(src)
|
||||
except Exception as e:
|
||||
print('error.') # finishes "..." line
|
||||
print(' E:', e, file=sys.stderr)
|
||||
print(' E: Proceeding without ARGB images ...', file=sys.stderr)
|
||||
break
|
||||
print('''
|
||||
Finished. After your adjustments (e.g. compress with ImageOptim), run:
|
||||
$> icnsutil compose "{}" "{}"'''.format(icns_file, iconset_dir))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
35
icnsutil/autosize/helper.py
Normal file
35
icnsutil/autosize/helper.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
from .ImageResizer import firstSupportedResizer
|
||||
from .PixelResizer import Sips, Pillow
|
||||
from .SVGResizer import ReSVG, ChromeSVG
|
||||
from typing import TYPE_CHECKING, List, Optional, Type
|
||||
if TYPE_CHECKING:
|
||||
from .ImageResizer import ImageResizer, SVGResizer, PixelResizer
|
||||
|
||||
# order matters! First supported resizer is returned. Prefer faster ones.
|
||||
|
||||
SVG_RESIZERS = [
|
||||
ReSVG,
|
||||
ChromeSVG,
|
||||
] # type: List[Type[SVGResizer]]
|
||||
PX_RESIZERS = [
|
||||
Sips,
|
||||
Pillow,
|
||||
] # type: List[Type[PixelResizer]]
|
||||
|
||||
BEST_SVG = None # type: Optional[Type[SVGResizer]]
|
||||
BEST_PX = None # type: Optional[Type[PixelResizer]]
|
||||
|
||||
|
||||
def bestImageResizer(fname: str, preferred_size: int) -> 'ImageResizer':
|
||||
global BEST_SVG, BEST_PX
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if ext == '.svg':
|
||||
BEST_SVG = BEST_SVG or firstSupportedResizer(SVG_RESIZERS)
|
||||
assert BEST_SVG, 'No supported image resizer found for ' + ext
|
||||
return BEST_SVG(fname, preferred_size)
|
||||
else:
|
||||
BEST_PX = BEST_PX or firstSupportedResizer(PX_RESIZERS)
|
||||
assert BEST_PX, 'No supported image resizer found for ' + ext
|
||||
return BEST_PX(fname, preferred_size)
|
||||
@@ -4,9 +4,8 @@ Export existing icns files or compose new ones.
|
||||
'''
|
||||
import os # path, makedirs
|
||||
import sys # path, stderr
|
||||
from typing import Iterator, Optional, Callable
|
||||
from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter
|
||||
from argparse import Namespace as ArgParams
|
||||
from typing import Iterator, Optional, Callable, List
|
||||
from argparse import ArgumentParser, ArgumentTypeError, Namespace as ArgParams
|
||||
if __name__ == '__main__':
|
||||
sys.path[0] = os.path.dirname(sys.path[0])
|
||||
from icnsutil import __version__, IcnsFile, IcnsType, ArgbImage
|
||||
@@ -53,16 +52,15 @@ def cli_update(args: ArgParams) -> None:
|
||||
has_changes |= icns.remove_media(IcnsType.key_from_readable(x))
|
||||
# add media
|
||||
for key_val in args.set or []:
|
||||
def fail():
|
||||
raise ArgumentTypeError(
|
||||
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
|
||||
if key_val.lower() == 'toc':
|
||||
key_val = 'toc=1'
|
||||
if '=' not in key_val:
|
||||
fail()
|
||||
key, val = key_val.split('=')
|
||||
raise ArgumentTypeError(
|
||||
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
|
||||
key, val = key_val.split('=', 1)
|
||||
if not val:
|
||||
fail()
|
||||
raise ArgumentTypeError(
|
||||
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
|
||||
|
||||
has_changes = True
|
||||
if key.lower() == 'toc':
|
||||
@@ -80,9 +78,13 @@ def cli_update(args: ArgParams) -> None:
|
||||
|
||||
def cli_print(args: ArgParams) -> None:
|
||||
''' Print contents of icns file(s). '''
|
||||
indent = 0 if args.quiet else 2
|
||||
for fname in enum_with_stdin(args.file):
|
||||
print('File:', fname)
|
||||
print(IcnsFile.description(fname, verbose=args.verbose, indent=2))
|
||||
if not args.quiet:
|
||||
print('File:', fname)
|
||||
print(IcnsFile.description(fname, verbose=args.verbose, indent=indent))
|
||||
if not args.quiet:
|
||||
print()
|
||||
|
||||
|
||||
def cli_verify(args: ArgParams) -> None:
|
||||
@@ -130,19 +132,24 @@ def cli_convert(args: ArgParams) -> None:
|
||||
exit(1)
|
||||
|
||||
|
||||
def enum_with_stdin(file_arg: list) -> Iterator[str]:
|
||||
def enum_with_stdin(file_arg: List[str]) -> Iterator[str]:
|
||||
for x in file_arg:
|
||||
if x == '-':
|
||||
for line in sys.stdin.readlines():
|
||||
yield line.strip()
|
||||
elif x.lower().endswith('.iconset'): # enum directory content
|
||||
allowed_ext = IcnsType.supported_extensions()
|
||||
for fname in os.listdir(x):
|
||||
if os.path.splitext(fname)[1][1:].lower() in allowed_ext:
|
||||
yield os.path.join(x, fname)
|
||||
else:
|
||||
yield x
|
||||
|
||||
|
||||
def main() -> None:
|
||||
class PathExist:
|
||||
def __init__(self, kind: Optional[str] = None, stdin: bool = False):
|
||||
self.kind = kind
|
||||
def __init__(self, kind: str, *, stdin: bool = False):
|
||||
self.kind, *self.allowed_ext = kind.split('|')
|
||||
self.stdin = stdin
|
||||
|
||||
def __call__(self, path: str) -> str:
|
||||
@@ -151,28 +158,30 @@ def main() -> None:
|
||||
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):
|
||||
if os.path.splitext(path)[1].lower() in self.allowed_ext:
|
||||
return path
|
||||
raise ArgumentTypeError('Does not exist "{}"'.format(path))
|
||||
return path
|
||||
|
||||
# Args Parser
|
||||
parser = ArgumentParser(description=__doc__,
|
||||
formatter_class=RawTextHelpFormatter)
|
||||
parser = ArgumentParser(description=__doc__)
|
||||
parser.set_defaults(func=lambda _: parser.print_help(sys.stdout))
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='version', version='icnsutil ' + __version__)
|
||||
sub_parser = parser.add_subparsers(metavar='command')
|
||||
sub_parser = parser.add_subparsers(metavar='command', dest='command')
|
||||
|
||||
# helper method
|
||||
def add_command(name: str, alias: str, fn: Callable[[ArgParams], None]):
|
||||
def add_command(
|
||||
name: str, aliases: List[str], fn: Callable[[ArgParams], None]
|
||||
) -> ArgumentParser:
|
||||
desc = fn.__doc__ or ''
|
||||
cmd = sub_parser.add_parser(name, aliases=[alias],
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
help=desc, description=desc.strip())
|
||||
cmd = sub_parser.add_parser(name, aliases=aliases, help=desc,
|
||||
description=desc.strip())
|
||||
cmd.set_defaults(func=fn)
|
||||
return cmd
|
||||
|
||||
# Extract
|
||||
cmd = add_command('extract', 'e', cli_extract)
|
||||
cmd = add_command('extract', ['e'], cli_extract)
|
||||
cmd.add_argument('-r', '--recursive', action='store_true',
|
||||
help='extract nested icns files as well')
|
||||
cmd.add_argument('-o', '--export-dir', type=PathExist('d'),
|
||||
@@ -187,27 +196,25 @@ def main() -> None:
|
||||
metavar='FILE', help='One or more .icns files')
|
||||
|
||||
# Compose
|
||||
cmd = add_command('compose', 'c', cli_compose)
|
||||
cmd = add_command('compose', ['c'], cli_compose)
|
||||
cmd.add_argument('-f', '--force', action='store_true',
|
||||
help='force overwrite output file')
|
||||
cmd.add_argument('--toc', action='store_true',
|
||||
help='write table of contents to file')
|
||||
help='Force overwrite output file')
|
||||
cmd.add_argument('--toc', action='store_true', help='''
|
||||
Write table of contents to file.
|
||||
TOC is optional and uses just a few bytes (8b per media entry).''')
|
||||
cmd.add_argument('target', type=str, metavar='destination',
|
||||
help='Output file for newly created icns file.')
|
||||
cmd.add_argument('source', type=PathExist('f', stdin=True), nargs='+',
|
||||
metavar='src',
|
||||
help='One or more media files: png, argb, rgb, jp2, icns')
|
||||
cmd.epilog = '''
|
||||
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:
|
||||
template, selected, dark
|
||||
'''
|
||||
cmd.add_argument('source', type=PathExist('f|.iconset', stdin=True),
|
||||
nargs='+', metavar='src', help='''
|
||||
One or more media files: png, argb, rgb, jp2, icns.
|
||||
--
|
||||
Icon dimensions are read directly from file.
|
||||
Filename suffixes "@2x.png" and "@2x.jp2" will set the retina flag.
|
||||
If the suffix ends on one of these (template, selected, dark),
|
||||
the file is automatically assigned to an icns file field.''')
|
||||
|
||||
# Update
|
||||
cmd = add_command('update', 'u', cli_update)
|
||||
cmd = add_command('update', ['u'], cli_update)
|
||||
cmd.add_argument('file', type=PathExist('f', stdin=True),
|
||||
metavar='FILE', help='The icns file to be updated.')
|
||||
cmd.add_argument('-o', '--output', type=str, metavar='OUT_FILE',
|
||||
@@ -220,21 +227,23 @@ Notes:
|
||||
cmd.epilog = 'KEY supports names like "dark", "selected", and "template"'
|
||||
|
||||
# Print
|
||||
cmd = add_command('print', 'p', cli_print)
|
||||
cmd = add_command('info', ['i', 'p', 'print'], cli_print)
|
||||
cmd.add_argument('-v', '--verbose', action='store_true',
|
||||
help='print all keys with offsets and sizes')
|
||||
cmd.add_argument('-q', '--quiet', action='store_true',
|
||||
help='do not print filename and indentation')
|
||||
cmd.add_argument('file', type=PathExist('f', stdin=True), nargs='+',
|
||||
metavar='FILE', help='One or more .icns files.')
|
||||
|
||||
# Verify
|
||||
cmd = add_command('test', 't', cli_verify)
|
||||
cmd = add_command('test', ['t'], cli_verify)
|
||||
cmd.add_argument('-q', '--quiet', action='store_true',
|
||||
help='do not print OK results')
|
||||
cmd.add_argument('file', type=PathExist('f', stdin=True), nargs='+',
|
||||
metavar='FILE', help='One or more .icns files.')
|
||||
|
||||
# Convert
|
||||
cmd = add_command('convert', 'img', cli_convert)
|
||||
cmd = add_command('convert', ['img'], cli_convert)
|
||||
cmd.add_argument('--raw', action='store_true',
|
||||
help='no post-processing. Do not prepend it32 header.')
|
||||
cmd.add_argument('target', type=str, metavar='destination',
|
||||
@@ -245,6 +254,9 @@ Notes:
|
||||
help='Alpha mask. If set, assume src is RGB image.')
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command in ['p', 'print']:
|
||||
print('{1}WARNING: command "{0}" is deprecated, use info instead.{1}'
|
||||
.format(args.command, os.linesep), file=sys.stderr)
|
||||
args.func(args)
|
||||
|
||||
|
||||
|
||||
8
setup.py
8
setup.py
@@ -2,7 +2,7 @@
|
||||
from setuptools import setup
|
||||
from icnsutil import __doc__, __version__
|
||||
|
||||
with open('README.md') as fp:
|
||||
with open('README.md', encoding='utf-8') as fp:
|
||||
longdesc = fp.read()
|
||||
|
||||
setup(
|
||||
@@ -12,10 +12,11 @@ setup(
|
||||
author='relikd',
|
||||
url='https://github.com/relikd/icnsutil',
|
||||
license='MIT',
|
||||
packages=['icnsutil'],
|
||||
packages=['icnsutil', 'icnsutil.autosize'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'icnsutil=icnsutil.cli:main'
|
||||
'icnsutil = icnsutil.cli:main',
|
||||
'icnsutil-autosize = icnsutil.autosize.cli:main',
|
||||
]
|
||||
},
|
||||
extras_require={
|
||||
@@ -51,4 +52,5 @@ setup(
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: Utilities',
|
||||
],
|
||||
include_package_data=True
|
||||
)
|
||||
|
||||
@@ -200,7 +200,7 @@ class TestCLI_update(unittest.TestCase):
|
||||
|
||||
class TestCLI_print(unittest.TestCase):
|
||||
def test_single(self):
|
||||
ret = run_cli(['p', 'rgb.icns']).stdout
|
||||
ret = run_cli(['i', 'rgb.icns']).stdout
|
||||
for x in [b'rgb.icns', b'ICN#', b'il32', b'l8mk', b'ics#', b'is32',
|
||||
b's8mk', b'it32', b't8mk', b'16x16', b'32x32', b'128x128']:
|
||||
self.assertTrue(x in ret)
|
||||
@@ -209,11 +209,11 @@ class TestCLI_print(unittest.TestCase):
|
||||
self.assertFalse(b'offset' in ret)
|
||||
|
||||
def test_verbose(self):
|
||||
ret = run_cli(['p', '-v', 'rgb.icns']).stdout
|
||||
ret = run_cli(['i', '-v', 'rgb.icns']).stdout
|
||||
self.assertTrue(b'offset' in ret)
|
||||
|
||||
def test_multiple(self):
|
||||
ret = run_cli(['p', 'rgb.icns', 'icp4rgb.icns']).stdout
|
||||
ret = run_cli(['i', 'rgb.icns', 'icp4rgb.icns']).stdout
|
||||
for x in [b'rgb.icns', b'icp4rgb.icns', b'icp4', b'icp5']:
|
||||
self.assertTrue(x in ret)
|
||||
self.assertFalse(b'offset' in ret)
|
||||
|
||||
@@ -246,7 +246,7 @@ is32: 705 bytes, rgb: 16x16
|
||||
s8mk: 256 bytes, mask: 16x16
|
||||
it32: 14005 bytes, rgb: 128x128
|
||||
t8mk: 16384 bytes, mask: 128x128
|
||||
'''.lstrip().replace('\n', os.linesep))
|
||||
'''.strip().replace('\n', os.linesep))
|
||||
str = IcnsFile.description('selected.icns', verbose=True, indent=0)
|
||||
self.assertEqual(str, '''
|
||||
info: 314 bytes, offset: 8, plist: info
|
||||
@@ -259,7 +259,7 @@ ic05: 690 bytes, offset: 5148, argb: 32x32
|
||||
icsB: 1001 bytes, offset: 5846, png: 18x18@2x
|
||||
ic11: 1056 bytes, offset: 6855, png: 16x16@2x
|
||||
slct: 7660 bytes, offset: 7919, icns: selected
|
||||
'''.lstrip().replace('\n', os.linesep))
|
||||
'''.strip().replace('\n', os.linesep))
|
||||
|
||||
|
||||
class TestIcnsType(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user