feat: autosize CLI

This commit is contained in:
relikd
2023-03-18 12:29:35 +01:00
parent e9b5563cb9
commit 6a82adcd1f
9 changed files with 308 additions and 3 deletions

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

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

45
icnsutil/autosize/SVGResizer.py Executable file
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=0',
'--window-size={0},{0}'.format(self.preferred_size),
'--screenshot="{}"'.format(self.fname), 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()

35
icnsutil/autosize/helper.py Executable file
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

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