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:
|
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:
|
uninstall:
|
||||||
python3 -m pip uninstall icnsutil
|
python3 -m pip uninstall icnsutil
|
||||||
rm -rf ./*.egg-info/
|
rm -rf ./*.egg-info/
|
||||||
-rm -i "$$(which icnsutil)"
|
-rm -i "$$(which icnsutil)" "$$(which icnsutil-autosize)"
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -2,27 +2,40 @@
|
|||||||
|
|
||||||
A fully-featured python library to handle reading and writing `.icns` files.
|
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).
|
Or you can install it **manually** by creating a symlink to `cli.py`:
|
||||||
- The [viewer] displays icons in ARGB or 24-bit RGB file format.
|
|
||||||
|
|
||||||
[inspector]: https://relikd.github.io/icnsutil/html/inspector.html
|
```sh
|
||||||
[viewer]: https://relikd.github.io/icnsutil/html/viewer.html
|
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
|
## Usage
|
||||||
|
|
||||||
|
See [#tools](#tools) for further options on icns processing (e.g., autosize).
|
||||||
|
|
||||||
```
|
```
|
||||||
positional arguments:
|
positional arguments:
|
||||||
command
|
command
|
||||||
extract (e) Read and extract contents of icns file(s).
|
extract (e) Read and extract contents of icns file(s).
|
||||||
compose (c) Create new icns file from provided image files.
|
compose (c) Create new icns file from provided image files.
|
||||||
update (u) Update existing icns file by inserting or removing media entries.
|
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.
|
test (t) Test if icns file is valid.
|
||||||
convert (img) Convert images between PNG, ARGB, or RGB + alpha mask.
|
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
|
icnsutil u Existing.icns -rm dark -set ic04=16.argb -o Updated.icns
|
||||||
|
|
||||||
# print
|
# print
|
||||||
icnsutil p Existing.icns
|
icnsutil i Existing.icns
|
||||||
|
|
||||||
# verify valid format
|
# verify valid format
|
||||||
icnsutil t Existing.icns
|
icnsutil t Existing.icns
|
||||||
@@ -81,10 +94,16 @@ if img.remove_media('TOC '):
|
|||||||
img.write('Existing.icns', toc=True)
|
img.write('Existing.icns', toc=True)
|
||||||
|
|
||||||
# print
|
# print
|
||||||
icnsutil.IcnsFile.description(fname, indent=2)
|
# return type str
|
||||||
|
desc = icnsutil.IcnsFile.description(fname, indent=2)
|
||||||
|
print(desc)
|
||||||
|
|
||||||
# verify valid format
|
# 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.
|
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
|
## Help needed
|
||||||
|
|
||||||
1. Do you have an old macOS version running somewhere?
|
1. Do you have an old macOS version running somewhere?
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from typing import Union, Iterator, Optional
|
from typing import Union, Optional
|
||||||
from math import sqrt
|
from math import sqrt
|
||||||
from . import IcnsType, PackBytes, RawData
|
from . import IcnsType, PackBytes, RawData
|
||||||
try:
|
try:
|
||||||
@@ -16,6 +16,8 @@ class ArgbImage:
|
|||||||
def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage':
|
def from_mono(cls, data: bytes, iType: IcnsType.Media) -> 'ArgbImage':
|
||||||
''' Load monochrome 1-bit image with or without mask. '''
|
''' Load monochrome 1-bit image with or without mask. '''
|
||||||
assert(iType.bits == 1)
|
assert(iType.bits == 1)
|
||||||
|
assert(iType.size)
|
||||||
|
assert(iType.channels)
|
||||||
img = []
|
img = []
|
||||||
for byte in data:
|
for byte in data:
|
||||||
for i in range(7, -1, -1):
|
for i in range(7, -1, -1):
|
||||||
@@ -31,9 +33,14 @@ 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: Optional[bytes] = None,
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
data: Optional[bytes] = None,
|
||||||
file: Optional[str] = None,
|
file: Optional[str] = None,
|
||||||
mask: Union[bytes, str, None] = None) -> None:
|
image: Optional['Image.Image'] = 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
|
||||||
@@ -44,6 +51,8 @@ class ArgbImage:
|
|||||||
self.load_file(file)
|
self.load_file(file)
|
||||||
elif data:
|
elif data:
|
||||||
self.load_data(data)
|
self.load_data(data)
|
||||||
|
elif image:
|
||||||
|
self._load_pillow_image(image)
|
||||||
else:
|
else:
|
||||||
raise AttributeError('Neither data nor file provided.')
|
raise AttributeError('Neither data nor file provided.')
|
||||||
if mask:
|
if mask:
|
||||||
@@ -91,8 +100,9 @@ class ArgbImage:
|
|||||||
self.g = uncompressed_data[(i + 1) * per_channel:(i + 2) * per_channel]
|
self.g = uncompressed_data[(i + 1) * per_channel:(i + 2) * per_channel]
|
||||||
self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel]
|
self.b = uncompressed_data[(i + 2) * per_channel:(i + 3) * per_channel]
|
||||||
|
|
||||||
def load_mask(self, *, file: Optional[str] = None,
|
def load_mask(
|
||||||
data: Optional[bytes] = None) -> None:
|
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:
|
||||||
@@ -121,7 +131,10 @@ class ArgbImage:
|
|||||||
def _load_png(self, fname: str) -> None:
|
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').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.size = img.size
|
||||||
self.channels = 4
|
self.channels = 4
|
||||||
self.a = []
|
self.a = []
|
||||||
|
|||||||
@@ -79,14 +79,18 @@ class IcnsFile:
|
|||||||
x, y)
|
x, y)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def description(fname: str, *, verbose: bool = False, indent: int = 0) -> \
|
def description(fname: str, *, verbose: bool = False, indent: int = 0) \
|
||||||
str:
|
-> 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: Iterable[Tuple[IcnsType.Media.KeyT, bytes]],
|
def _description(
|
||||||
*, verbose: bool = False, indent: int = 0) -> str:
|
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
|
||||||
@@ -112,12 +116,12 @@ class IcnsFile:
|
|||||||
txt += ', ' + ext + ': ' + iType.filename(size_only=True)
|
txt += ', ' + ext + ': ' + iType.filename(size_only=True)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
txt += ': UNKNOWN TYPE: ' + str(ext or data[:6])
|
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
|
# if file is not an icns file
|
||||||
except RawData.ParserError as e:
|
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. '''
|
''' Read .icns file and load bundled media files into memory. '''
|
||||||
self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
|
self.media = {} # type: Dict[IcnsType.Media.KeyT, bytes]
|
||||||
self.infile = file
|
self.infile = file
|
||||||
@@ -134,9 +138,14 @@ class IcnsFile:
|
|||||||
def has_toc(self) -> bool:
|
def has_toc(self) -> bool:
|
||||||
return 'TOC ' in self.media.keys()
|
return 'TOC ' in self.media.keys()
|
||||||
|
|
||||||
def add_media(self, key: Optional[IcnsType.Media.KeyT] = None, *,
|
def add_media(
|
||||||
file: Optional[str] = None, data: Optional[bytes] = None,
|
self,
|
||||||
force: bool = False) -> None:
|
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.
|
||||||
@@ -163,6 +172,10 @@ class IcnsFile:
|
|||||||
# Nested icns files must omit the icns header
|
# Nested icns files must omit the icns header
|
||||||
if is_icns and data[:4] == b'icns':
|
if is_icns and data[:4] == b'icns':
|
||||||
data = data[8:]
|
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
|
self.media[key] = data
|
||||||
|
|
||||||
def remove_media(self, key: IcnsType.Media.KeyT) -> bool:
|
def remove_media(self, key: IcnsType.Media.KeyT) -> bool:
|
||||||
@@ -182,11 +195,16 @@ 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: Optional[str] = None, *,
|
def export(
|
||||||
allowed_ext: str = '*', key_suffix: bool = False,
|
self,
|
||||||
convert_png: bool = False, decompress: bool = False,
|
outdir: Optional[str] = None,
|
||||||
recursive: bool = False) -> Dict[IcnsType.Media.KeyT,
|
*,
|
||||||
Union[str, Dict]]:
|
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.
|
||||||
|
|
||||||
@@ -266,9 +284,14 @@ class IcnsFile:
|
|||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
def _export_single(self, outdir: str, key: IcnsType.Media.KeyT,
|
def _export_single(
|
||||||
key_suffix: bool, decompress: bool,
|
self,
|
||||||
allowed: List[str]) -> Optional[str]:
|
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)
|
||||||
@@ -294,9 +317,13 @@ class IcnsFile:
|
|||||||
fp.write(data)
|
fp.write(data)
|
||||||
return fname
|
return fname
|
||||||
|
|
||||||
def _export_to_png(self, outdir: str, img_key: IcnsType.Media.KeyT,
|
def _export_to_png(
|
||||||
|
self,
|
||||||
|
outdir: str,
|
||||||
|
img_key: IcnsType.Media.KeyT,
|
||||||
mask_key: Optional[IcnsType.Media.KeyT],
|
mask_key: Optional[IcnsType.Media.KeyT],
|
||||||
key_suffix: bool) -> Optional[str]:
|
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]:
|
||||||
|
|||||||
@@ -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
|
||||||
from typing import Union, Optional, Tuple, Iterator, List, Iterable, Dict
|
from typing import Union, Optional, Tuple, Iterator, List, Iterable, Set
|
||||||
from . import PackBytes, RawData
|
from . import PackBytes, RawData
|
||||||
|
|
||||||
|
|
||||||
@@ -17,10 +17,17 @@ class Media:
|
|||||||
__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: KeyT, types: list,
|
def __init__(
|
||||||
size: Optional[Union[int, Tuple[int, int]]] = None,
|
self,
|
||||||
*, ch: Optional[int] = None, bits: Optional[int] = None,
|
key: KeyT,
|
||||||
os: Optional[float] = None, desc: str = '') -> None:
|
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.key = key
|
||||||
self.types = types
|
self.types = types
|
||||||
self.size = (size, size) if isinstance(size, int) else size
|
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.desc # guaranteed to be icon, mask, or iconmask
|
||||||
return self.types[-1]
|
return self.types[-1]
|
||||||
|
|
||||||
def decompress(self, data: bytes, ext: Optional[str] = '-?-') -> Optional[
|
def decompress(self, data: bytes, ext: Optional[str] = '-?-') \
|
||||||
List[int]]:
|
-> Optional[List[int]]:
|
||||||
''' Returns None if media is not decompressable. '''
|
''' Returns None if media is not decompressable. '''
|
||||||
if self.compressable:
|
if self.compressable:
|
||||||
if ext == '-?-':
|
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('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: Iterable[Media.KeyT]) -> Iterator[
|
def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) \
|
||||||
Tuple[Optional[str], Optional[str]]]:
|
-> 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'),
|
||||||
@@ -186,8 +193,8 @@ def enum_img_mask_pairs(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
|||||||
yield None, mk
|
yield None, mk
|
||||||
|
|
||||||
|
|
||||||
def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
def enum_png_convertable(available_keys: Iterable[Media.KeyT]) \
|
||||||
Tuple[Media.KeyT, Optional[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:
|
||||||
@@ -205,6 +212,10 @@ def enum_png_convertable(available_keys: Iterable[Media.KeyT]) -> Iterator[
|
|||||||
yield img.key, mask_key
|
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:
|
def get(key: Media.KeyT) -> Media:
|
||||||
try:
|
try:
|
||||||
return _TYPES[key]
|
return _TYPES[key]
|
||||||
@@ -219,8 +230,8 @@ def key_from_readable(key: str) -> Media.KeyT:
|
|||||||
'selected': 'slct',
|
'selected': 'slct',
|
||||||
'template': 'sbtp',
|
'template': 'sbtp',
|
||||||
'toc': 'TOC ',
|
'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:
|
def match_maxsize(total: int, typ: str) -> Media:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ def pack(data: List[int]) -> bytes:
|
|||||||
buf = [] # type: List[int]
|
buf = [] # type: List[int]
|
||||||
i = 0
|
i = 0
|
||||||
|
|
||||||
def flush_buf():
|
def flush_buf() -> None:
|
||||||
# write out non-repeating bytes
|
# write out non-repeating bytes
|
||||||
if len(buf) > 0:
|
if len(buf) > 0:
|
||||||
ret.append(len(buf) - 1)
|
ret.append(len(buf) - 1)
|
||||||
|
|||||||
@@ -1,6 +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 typing import Optional, Tuple, Iterator, BinaryIO
|
||||||
from . import IcnsType, PackBytes
|
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
|
return data[:4], length # Fallback to bytes-string key
|
||||||
|
|
||||||
|
|
||||||
def icns_header_write_data(fp: BinaryIO, key: IcnsType.Media.KeyT,
|
def icns_header_write_data(
|
||||||
data: bytes) -> None:
|
fp: BinaryIO, key: IcnsType.Media.KeyT, data: bytes,
|
||||||
|
) -> None:
|
||||||
''' Calculates length from data. '''
|
''' Calculates length from data. '''
|
||||||
fp.write(key.encode('utf8') if isinstance(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))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
'''
|
'''
|
||||||
A fully-featured python library to handle reading and writing icns files.
|
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 .IcnsFile import IcnsFile
|
||||||
from .ArgbImage import ArgbImage, PIL_ENABLED
|
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 os # path, makedirs
|
||||||
import sys # path, stderr
|
import sys # path, stderr
|
||||||
from typing import Iterator, Optional, Callable
|
from typing import Iterator, Optional, Callable, List
|
||||||
from argparse import ArgumentParser, ArgumentTypeError, RawTextHelpFormatter
|
from argparse import ArgumentParser, ArgumentTypeError, Namespace as ArgParams
|
||||||
from argparse import Namespace as ArgParams
|
|
||||||
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, IcnsType, ArgbImage
|
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))
|
has_changes |= icns.remove_media(IcnsType.key_from_readable(x))
|
||||||
# add media
|
# add media
|
||||||
for key_val in args.set or []:
|
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':
|
if key_val.lower() == 'toc':
|
||||||
key_val = 'toc=1'
|
key_val = 'toc=1'
|
||||||
if '=' not in key_val:
|
if '=' not in key_val:
|
||||||
fail()
|
raise ArgumentTypeError(
|
||||||
key, val = key_val.split('=')
|
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
|
||||||
|
key, val = key_val.split('=', 1)
|
||||||
if not val:
|
if not val:
|
||||||
fail()
|
raise ArgumentTypeError(
|
||||||
|
'Expected arg format KEY=FILE - got "{}"'.format(key_val))
|
||||||
|
|
||||||
has_changes = True
|
has_changes = True
|
||||||
if key.lower() == 'toc':
|
if key.lower() == 'toc':
|
||||||
@@ -80,9 +78,13 @@ def cli_update(args: ArgParams) -> None:
|
|||||||
|
|
||||||
def cli_print(args: ArgParams) -> None:
|
def cli_print(args: ArgParams) -> None:
|
||||||
''' Print contents of icns file(s). '''
|
''' Print contents of icns file(s). '''
|
||||||
|
indent = 0 if args.quiet else 2
|
||||||
for fname in enum_with_stdin(args.file):
|
for fname in enum_with_stdin(args.file):
|
||||||
|
if not args.quiet:
|
||||||
print('File:', fname)
|
print('File:', fname)
|
||||||
print(IcnsFile.description(fname, verbose=args.verbose, indent=2))
|
print(IcnsFile.description(fname, verbose=args.verbose, indent=indent))
|
||||||
|
if not args.quiet:
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def cli_verify(args: ArgParams) -> None:
|
def cli_verify(args: ArgParams) -> None:
|
||||||
@@ -130,19 +132,24 @@ def cli_convert(args: ArgParams) -> None:
|
|||||||
exit(1)
|
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:
|
for x in file_arg:
|
||||||
if x == '-':
|
if x == '-':
|
||||||
for line in sys.stdin.readlines():
|
for line in sys.stdin.readlines():
|
||||||
yield line.strip()
|
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:
|
else:
|
||||||
yield x
|
yield x
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
class PathExist:
|
class PathExist:
|
||||||
def __init__(self, kind: Optional[str] = None, stdin: bool = False):
|
def __init__(self, kind: str, *, stdin: bool = False):
|
||||||
self.kind = kind
|
self.kind, *self.allowed_ext = kind.split('|')
|
||||||
self.stdin = stdin
|
self.stdin = stdin
|
||||||
|
|
||||||
def __call__(self, path: str) -> str:
|
def __call__(self, path: str) -> str:
|
||||||
@@ -151,28 +158,30 @@ def main() -> None:
|
|||||||
if not os.path.exists(path) or \
|
if not os.path.exists(path) or \
|
||||||
self.kind == 'f' and not os.path.isfile(path) or \
|
self.kind == 'f' and not os.path.isfile(path) or \
|
||||||
self.kind == 'd' and not os.path.isdir(path):
|
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))
|
raise ArgumentTypeError('Does not exist "{}"'.format(path))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
# Args Parser
|
# Args Parser
|
||||||
parser = ArgumentParser(description=__doc__,
|
parser = ArgumentParser(description=__doc__)
|
||||||
formatter_class=RawTextHelpFormatter)
|
|
||||||
parser.set_defaults(func=lambda _: parser.print_help(sys.stdout))
|
parser.set_defaults(func=lambda _: parser.print_help(sys.stdout))
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-v', '--version', action='version', version='icnsutil ' + __version__)
|
'-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
|
# 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 ''
|
desc = fn.__doc__ or ''
|
||||||
cmd = sub_parser.add_parser(name, aliases=[alias],
|
cmd = sub_parser.add_parser(name, aliases=aliases, help=desc,
|
||||||
formatter_class=RawTextHelpFormatter,
|
description=desc.strip())
|
||||||
help=desc, description=desc.strip())
|
|
||||||
cmd.set_defaults(func=fn)
|
cmd.set_defaults(func=fn)
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
# Extract
|
# Extract
|
||||||
cmd = add_command('extract', 'e', cli_extract)
|
cmd = add_command('extract', ['e'], cli_extract)
|
||||||
cmd.add_argument('-r', '--recursive', action='store_true',
|
cmd.add_argument('-r', '--recursive', action='store_true',
|
||||||
help='extract nested icns files as well')
|
help='extract nested icns files as well')
|
||||||
cmd.add_argument('-o', '--export-dir', type=PathExist('d'),
|
cmd.add_argument('-o', '--export-dir', type=PathExist('d'),
|
||||||
@@ -187,27 +196,25 @@ def main() -> None:
|
|||||||
metavar='FILE', help='One or more .icns files')
|
metavar='FILE', help='One or more .icns files')
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
cmd = add_command('compose', 'c', cli_compose)
|
cmd = add_command('compose', ['c'], cli_compose)
|
||||||
cmd.add_argument('-f', '--force', action='store_true',
|
cmd.add_argument('-f', '--force', action='store_true',
|
||||||
help='force overwrite output file')
|
help='Force overwrite output file')
|
||||||
cmd.add_argument('--toc', action='store_true',
|
cmd.add_argument('--toc', action='store_true', help='''
|
||||||
help='write table of contents to file')
|
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',
|
cmd.add_argument('target', type=str, metavar='destination',
|
||||||
help='Output file for newly created icns file.')
|
help='Output file for newly created icns file.')
|
||||||
cmd.add_argument('source', type=PathExist('f', stdin=True), nargs='+',
|
cmd.add_argument('source', type=PathExist('f|.iconset', stdin=True),
|
||||||
metavar='src',
|
nargs='+', metavar='src', help='''
|
||||||
help='One or more media files: png, argb, rgb, jp2, icns')
|
One or more media files: png, argb, rgb, jp2, icns.
|
||||||
cmd.epilog = '''
|
--
|
||||||
Notes:
|
Icon dimensions are read directly from file.
|
||||||
- TOC is optional but only a few bytes long (8b per media entry).
|
Filename suffixes "@2x.png" and "@2x.jp2" will set the retina flag.
|
||||||
- Icon dimensions are read directly from file.
|
If the suffix ends on one of these (template, selected, dark),
|
||||||
- Filename suffix "@2x.png" or "@2x.jp2" sets the retina flag.
|
the file is automatically assigned to an icns file field.''')
|
||||||
- Use one of these suffixes to automatically assign icns files:
|
|
||||||
template, selected, dark
|
|
||||||
'''
|
|
||||||
|
|
||||||
# Update
|
# Update
|
||||||
cmd = add_command('update', 'u', cli_update)
|
cmd = add_command('update', ['u'], cli_update)
|
||||||
cmd.add_argument('file', type=PathExist('f', stdin=True),
|
cmd.add_argument('file', type=PathExist('f', stdin=True),
|
||||||
metavar='FILE', help='The icns file to be updated.')
|
metavar='FILE', help='The icns file to be updated.')
|
||||||
cmd.add_argument('-o', '--output', type=str, metavar='OUT_FILE',
|
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"'
|
cmd.epilog = 'KEY supports names like "dark", "selected", and "template"'
|
||||||
|
|
||||||
# Print
|
# 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',
|
cmd.add_argument('-v', '--verbose', action='store_true',
|
||||||
help='print all keys with offsets and sizes')
|
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='+',
|
cmd.add_argument('file', type=PathExist('f', stdin=True), nargs='+',
|
||||||
metavar='FILE', help='One or more .icns files.')
|
metavar='FILE', help='One or more .icns files.')
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
cmd = add_command('test', 't', cli_verify)
|
cmd = add_command('test', ['t'], cli_verify)
|
||||||
cmd.add_argument('-q', '--quiet', action='store_true',
|
cmd.add_argument('-q', '--quiet', action='store_true',
|
||||||
help='do not print OK results')
|
help='do not print OK results')
|
||||||
cmd.add_argument('file', type=PathExist('f', stdin=True), nargs='+',
|
cmd.add_argument('file', type=PathExist('f', stdin=True), nargs='+',
|
||||||
metavar='FILE', help='One or more .icns files.')
|
metavar='FILE', help='One or more .icns files.')
|
||||||
|
|
||||||
# Convert
|
# Convert
|
||||||
cmd = add_command('convert', 'img', cli_convert)
|
cmd = add_command('convert', ['img'], cli_convert)
|
||||||
cmd.add_argument('--raw', action='store_true',
|
cmd.add_argument('--raw', action='store_true',
|
||||||
help='no post-processing. Do not prepend it32 header.')
|
help='no post-processing. Do not prepend it32 header.')
|
||||||
cmd.add_argument('target', type=str, metavar='destination',
|
cmd.add_argument('target', type=str, metavar='destination',
|
||||||
@@ -245,6 +254,9 @@ Notes:
|
|||||||
help='Alpha mask. If set, assume src is RGB image.')
|
help='Alpha mask. If set, assume src is RGB image.')
|
||||||
|
|
||||||
args = parser.parse_args()
|
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)
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
setup.py
8
setup.py
@@ -2,7 +2,7 @@
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
from icnsutil import __doc__, __version__
|
from icnsutil import __doc__, __version__
|
||||||
|
|
||||||
with open('README.md') as fp:
|
with open('README.md', encoding='utf-8') as fp:
|
||||||
longdesc = fp.read()
|
longdesc = fp.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
@@ -12,10 +12,11 @@ setup(
|
|||||||
author='relikd',
|
author='relikd',
|
||||||
url='https://github.com/relikd/icnsutil',
|
url='https://github.com/relikd/icnsutil',
|
||||||
license='MIT',
|
license='MIT',
|
||||||
packages=['icnsutil'],
|
packages=['icnsutil', 'icnsutil.autosize'],
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'icnsutil=icnsutil.cli:main'
|
'icnsutil = icnsutil.cli:main',
|
||||||
|
'icnsutil-autosize = icnsutil.autosize.cli:main',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
extras_require={
|
extras_require={
|
||||||
@@ -51,4 +52,5 @@ setup(
|
|||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||||
'Topic :: Utilities',
|
'Topic :: Utilities',
|
||||||
],
|
],
|
||||||
|
include_package_data=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class TestCLI_update(unittest.TestCase):
|
|||||||
|
|
||||||
class TestCLI_print(unittest.TestCase):
|
class TestCLI_print(unittest.TestCase):
|
||||||
def test_single(self):
|
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',
|
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']:
|
b's8mk', b'it32', b't8mk', b'16x16', b'32x32', b'128x128']:
|
||||||
self.assertTrue(x in ret)
|
self.assertTrue(x in ret)
|
||||||
@@ -209,11 +209,11 @@ class TestCLI_print(unittest.TestCase):
|
|||||||
self.assertFalse(b'offset' in ret)
|
self.assertFalse(b'offset' in ret)
|
||||||
|
|
||||||
def test_verbose(self):
|
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)
|
self.assertTrue(b'offset' in ret)
|
||||||
|
|
||||||
def test_multiple(self):
|
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']:
|
for x in [b'rgb.icns', b'icp4rgb.icns', b'icp4', b'icp5']:
|
||||||
self.assertTrue(x in ret)
|
self.assertTrue(x in ret)
|
||||||
self.assertFalse(b'offset' in ret)
|
self.assertFalse(b'offset' in ret)
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ is32: 705 bytes, rgb: 16x16
|
|||||||
s8mk: 256 bytes, mask: 16x16
|
s8mk: 256 bytes, mask: 16x16
|
||||||
it32: 14005 bytes, rgb: 128x128
|
it32: 14005 bytes, rgb: 128x128
|
||||||
t8mk: 16384 bytes, mask: 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)
|
str = IcnsFile.description('selected.icns', verbose=True, indent=0)
|
||||||
self.assertEqual(str, '''
|
self.assertEqual(str, '''
|
||||||
info: 314 bytes, offset: 8, plist: info
|
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
|
icsB: 1001 bytes, offset: 5846, png: 18x18@2x
|
||||||
ic11: 1056 bytes, offset: 6855, png: 16x16@2x
|
ic11: 1056 bytes, offset: 6855, png: 16x16@2x
|
||||||
slct: 7660 bytes, offset: 7919, icns: selected
|
slct: 7660 bytes, offset: 7919, icns: selected
|
||||||
'''.lstrip().replace('\n', os.linesep))
|
'''.strip().replace('\n', os.linesep))
|
||||||
|
|
||||||
|
|
||||||
class TestIcnsType(unittest.TestCase):
|
class TestIcnsType(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user