feat: autosize CLI
This commit is contained in:
2
Makefile
2
Makefile
@@ -13,7 +13,7 @@ install:
|
||||
uninstall:
|
||||
python3 -m pip uninstall icnsutil
|
||||
rm -rf ./*.egg-info/
|
||||
-rm -i "$$(which icnsutil)"
|
||||
-rm -i "$$(which icnsutil)" "$$(which icnsutil-autosize)"
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
||||
57
icnsutil/autosize/ImageResizer.py
Executable file
57
icnsutil/autosize/ImageResizer.py
Executable 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
Executable file
52
icnsutil/autosize/PixelResizer.py
Executable 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
Executable file
45
icnsutil/autosize/SVGResizer.py
Executable 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=0',
|
||||
'--window-size={0},{0}'.format(self.preferred_size),
|
||||
'--screenshot="{}"'.format(self.fname), 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
Executable file
35
icnsutil/autosize/helper.py
Executable 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)
|
||||
5
setup.py
5
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={
|
||||
|
||||
Reference in New Issue
Block a user