15 Commits
v1.0.1 ... main

Author SHA1 Message Date
relikd
eb7ce460b5 fix: Chrome cli 2025-10-15 18:37:20 +02:00
relikd
f80d7d79f6 feat: deprecation warning 2025-10-15 18:37:03 +02:00
relikd
bc6fe5a2b3 fix: file permissions 2025-10-15 18:36:52 +02:00
Nicholas Bollweg
028df18dbc ensure license is added to distributions (#2) 2024-09-24 15:57:01 +02:00
relikd
d6d3c88ee8 doc: update readme for autosize 2023-03-18 13:17:55 +01:00
relikd
6a82adcd1f feat: autosize CLI 2023-03-18 12:29:35 +01:00
relikd
e9b5563cb9 feat: compose with .iconset input 2023-03-15 01:34:10 +01:00
relikd
bf4efb42d8 feat: allow ArgbImage to load from Pillow image 2023-03-12 17:33:32 +01:00
relikd
176b675316 refactor: deprecate + rename command print -> info 2023-03-08 00:48:40 +01:00
relikd
824616403e feat: add --quiet option to print command 2023-03-08 00:47:38 +01:00
relikd
3b430740dc chore: type formatting + refactor help text 2023-03-08 00:28:58 +01:00
relikd
4f565b6de1 docs: update readme to include usage examples for IcnsFile verify and description 2022-08-30 08:42:21 +02:00
relikd
7f6c73751f docs: clarify usage of iterator in IcnsFile.verify 2022-08-30 08:40:00 +02:00
온실 속 선인장
3b6cdd5f82 Update README.md
print / verify valid format Result Return Add
2022-08-30 11:40:56 +09:00
relikd
8243534f65 update readme: pip install 2021-10-30 19:10:49 +02:00
21 changed files with 533 additions and 108 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
include LICENSE

View File

@@ -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:

View File

@@ -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?

View File

@@ -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 = []

View File

@@ -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]:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View 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

View 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)

View 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)

View File

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env python3
from .cli import main
main()

112
icnsutil/autosize/cli.py Executable file
View 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()

View 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)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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):