diff --git a/Makefile b/Makefile index b583053..be7ad78 100644 --- a/Makefile +++ b/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: diff --git a/icnsutil/autosize/ImageResizer.py b/icnsutil/autosize/ImageResizer.py new file mode 100755 index 0000000..d15c923 --- /dev/null +++ b/icnsutil/autosize/ImageResizer.py @@ -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 diff --git a/icnsutil/autosize/PixelResizer.py b/icnsutil/autosize/PixelResizer.py new file mode 100755 index 0000000..710313e --- /dev/null +++ b/icnsutil/autosize/PixelResizer.py @@ -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) diff --git a/icnsutil/autosize/SVGResizer.py b/icnsutil/autosize/SVGResizer.py new file mode 100755 index 0000000..6de1587 --- /dev/null +++ b/icnsutil/autosize/SVGResizer.py @@ -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=0', + '--window-size={0},{0}'.format(self.preferred_size), + '--screenshot="{}"'.format(self.fname), self.fname], + stderr=DEVNULL) diff --git a/icnsutil/autosize/__init__.py b/icnsutil/autosize/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/icnsutil/autosize/__main__.py b/icnsutil/autosize/__main__.py new file mode 100644 index 0000000..97d8b1f --- /dev/null +++ b/icnsutil/autosize/__main__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from .cli import main +main() diff --git a/icnsutil/autosize/cli.py b/icnsutil/autosize/cli.py new file mode 100755 index 0000000..26d2766 --- /dev/null +++ b/icnsutil/autosize/cli.py @@ -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 (-) + 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() diff --git a/icnsutil/autosize/helper.py b/icnsutil/autosize/helper.py new file mode 100755 index 0000000..d39cd5b --- /dev/null +++ b/icnsutil/autosize/helper.py @@ -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) diff --git a/setup.py b/setup.py index a6e913b..6343c3d 100644 --- a/setup.py +++ b/setup.py @@ -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={