# HG changeset patch # User Goffi # Date 1587308024 -7200 # Node ID f300d78f08f31d22087d03aecf4fdb99b1ff57a3 # Parent 7aa01e262e058b9f68d76138e2d074767c9d0188 core: image convertion + SVG support: /!\ new optional dependency: CairoSVG (with installed `[SVG]` extra) - new `convert` method in `tools.image` to save an image in an other format, with support for SVG (when CairoSVG is available) - new `imageConvert` method is available for frontends diff -r 7aa01e262e05 -r f300d78f08f3 sat/bridge/bridge_constructor/bridge_template.ini --- a/sat/bridge/bridge_constructor/bridge_template.ini Sun Apr 19 16:40:34 2020 +0200 +++ b/sat/bridge/bridge_constructor/bridge_template.ini Sun Apr 19 16:53:44 2020 +0200 @@ -1009,3 +1009,18 @@ doc_param_0=image_path: path of the original image doc_param_1=%(doc_profile_key)s doc_return=path to the preview in cache + +[imageConvert] +async= +type=method +category=core +sig_in=ssss +sig_out=s +doc=Convert an image to an other format +doc_param_0=source: path of the image to convert +doc_param_1=dest: path to the location where the new image must be stored. + Empty string to generate a file in cache, unique to the source +doc_param_3=extra: serialised extra +doc_param_4=profile_key: either profile_key or empty string to use common cache + this parameter is used only when dest is empty +doc_return=path to the new converted image diff -r 7aa01e262e05 -r f300d78f08f3 sat/bridge/dbus_bridge.py --- a/sat/bridge/dbus_bridge.py Sun Apr 19 16:40:34 2020 +0200 +++ b/sat/bridge/dbus_bridge.py Sun Apr 19 16:53:44 2020 +0200 @@ -409,6 +409,12 @@ return self._callback("imageCheck", str(arg_0)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssss', out_signature='s', + async_callbacks=('callback', 'errback')) + def imageConvert(self, source, dest, arg_2, extra, callback=None, errback=None): + return self._callback("imageConvert", str(source), str(dest), str(arg_2), str(extra), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='ss', out_signature='s', async_callbacks=('callback', 'errback')) def imageGeneratePreview(self, image_path, profile_key, callback=None, errback=None): diff -r 7aa01e262e05 -r f300d78f08f3 sat/core/sat_main.py --- a/sat/core/sat_main.py Sun Apr 19 16:40:34 2020 +0200 +++ b/sat/core/sat_main.py Sun Apr 19 16:53:44 2020 +0200 @@ -174,6 +174,7 @@ self.bridge.register_method("imageCheck", self._imageCheck) self.bridge.register_method("imageResize", self._imageResize) self.bridge.register_method("imageGeneratePreview", self._imageGeneratePreview) + self.bridge.register_method("imageConvert", self._imageConvert) self.memory.initialized.addCallback(lambda __: defer.ensureDeferred(self._postMemoryInit())) @@ -796,6 +797,63 @@ return preview_path + def _imageConvert(self, source, dest, extra, profile_key): + client = self.getClient(profile_key) if profile_key else None + source = Path(source) + dest = None if not dest else Path(dest) + extra = data_format.deserialise(extra) + d = defer.ensureDeferred(self.imageConvert(client, source, dest, extra)) + d.addCallback(lambda dest_path: str(dest_path)) + return d + + async def imageConvert(self, client, source, dest=None, extra=None): + """Helper method to convert an image from one format to an other + + @param client(SatClient, None): client to use for caching + this parameter is only used if dest is None + if client is None, common cache will be used insted of profile cache + @param source(Path): path to the image to convert + @param dest(None, Path, file): where to save the converted file + - None: use a cache file (uid generated from hash of source) + file will be converted to PNG + - Path: path to the file to create/overwrite + - file: a file object which must be opened for writing in binary mode + @param extra(dict, None): conversion options + see [image.convert] for details + @return (Path): path to the converted image + @raise ValueError: an issue happened with source of dest + """ + if not source.is_file: + raise ValueError(f"Source file {source} doesn't exist!") + if dest is None: + # we use hash as id, to re-use potentially existing conversion + path_hash = hashlib.sha256(str(source).encode()).hexdigest() + uid = f"{source.stem}_{path_hash}_convert_png" + filename = f"{uid}.png" + if client is None: + cache = self.common_cache + else: + cache = client.cache + metadata = cache.getMetadata(uid=uid) + if metadata is not None: + # there is already a conversion for this image in cache + return metadata['path'] + else: + with cache.cacheData( + source='HOST_IMAGE_CONVERT', + uid=uid, + filename=filename) as cache_f: + + converted_path = await image.convert( + source, + dest=cache_f, + extra=extra + ) + return converted_path + else: + return await image.convert(source, dest, extra) + + # local dirs def getLocalPath(self, client, dir_name, *extra_path, **kwargs): diff -r 7aa01e262e05 -r f300d78f08f3 sat/tools/image.py --- a/sat/tools/image.py Sun Apr 19 16:40:34 2020 +0200 +++ b/sat/tools/image.py Sun Apr 19 16:53:44 2020 +0200 @@ -23,6 +23,18 @@ from PIL import Image from pathlib import Path from twisted.internet import threads +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.log import getLogger + +log = getLogger(__name__) + +try: + import cairosvg +except Exception as e: + log.warning(_("SVG support not available, please install cairosvg: {e}").format( + e=e)) + cairosvg = None def check(host, path, max_size=None): @@ -57,7 +69,7 @@ return report -def _resizeBlocking(image_path, new_size, dest=None): +def _resizeBlocking(image_path, new_size, dest): im_path = Path(image_path) im = Image.open(im_path) resized = im.resize(new_size, Image.LANCZOS) @@ -80,6 +92,7 @@ @param new_size(tuple[int, int]): size to use for new image @param dest(None, Path, file): where the resized image must be stored, can be: - None: use a temporary file + file will be converted to PNG - Path: path to the file to create/overwrite - file: a file object which must be opened for writing in binary mode @return (Path): path of the resized file. @@ -88,6 +101,81 @@ return threads.deferToThread(_resizeBlocking, image_path, new_size, dest) +def _convertBlocking(image_path, dest, extra): + media_type = mimetypes.guess_type(str(image_path), strict=False)[0] + + if dest is None: + dest = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + filepath = Path(dest.name) + elif isinstance(dest, Path): + filepath = dest + else: + # we should have a file-like object + try: + name = dest.name + except AttributeError: + name = None + if name: + try: + filepath = Path(name) + except TypeError: + filepath = Path('noname.png') + else: + filepath = Path('noname.png') + + if media_type == "image/svg+xml": + if cairosvg is None: + raise exceptions.MissingModule( + f"Can't convert SVG image at {image_path} due to missing CairoSVG module") + width, height = extra.get('width'), extra.get('height') + cairosvg.svg2png( + url=str(image_path), write_to=dest, + output_width=width, output_height=height + ) + else: + suffix = filepath.suffix + if not suffix: + raise ValueError( + "A suffix is missing for destination, it is needed to determine file " + "format") + if not suffix in Image.EXTENSION: + Image.init() + try: + im_format = Image.EXTENSION[suffix] + except KeyError: + raise ValueError( + "Dest image format can't be determined, {suffix!r} suffix is unknown" + ) + im = Image.open(image_path) + im.save(dest, format=im_format) + + log.debug(f"image {image_path} has been converted to {filepath}") + return filepath + + +def convert(image_path, dest=None, extra=None): + """Convert an image to a new file, and return its path + + @param image_path(str, Path): path of the image to convert + @param dest(None, Path, file): where the converted image must be stored, can be: + - None: use a temporary file + - Path: path to the file to create/overwrite + - file: a file object which must be opened for writing in binary mode + @param extra(None, dict): conversion options + if image_path link to a SVG file, following options can be used: + - width: destination width + - height: destination height + @return (Path): path of the converted file. + a generic name is used if dest is an unnamed file like object + """ + image_path = Path(image_path) + if not image_path.is_file(): + raise ValueError(f"There is no file at {image_path}!") + if extra is None: + extra = {} + return threads.deferToThread(_convertBlocking, image_path, dest, extra) + + def guess_type(source): """Guess image media type @@ -109,5 +197,3 @@ return Image.MIME[img.format] except KeyError: return None - - diff -r 7aa01e262e05 -r f300d78f08f3 sat_frontends/bridge/dbus_bridge.py --- a/sat_frontends/bridge/dbus_bridge.py Sun Apr 19 16:40:34 2020 +0200 +++ b/sat_frontends/bridge/dbus_bridge.py Sun Apr 19 16:53:44 2020 +0200 @@ -507,6 +507,15 @@ kwargs['error_handler'] = error_handler return str(self.db_core_iface.imageCheck(arg_0, **kwargs)) + def imageConvert(self, source, dest, arg_2, extra, callback=None, errback=None): + if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err:errback(dbus_to_bridge_exception(err)) + return str(self.db_core_iface.imageConvert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)) + def imageGeneratePreview(self, image_path, profile_key, callback=None, errback=None): if callback is None: error_handler = None @@ -1197,6 +1206,14 @@ self.db_core_iface.imageCheck(arg_0, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler) return fut + def imageConvert(self, source, dest, arg_2, extra): + loop = asyncio.get_running_loop() + fut = loop.create_future() + reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret) + error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err)) + self.db_core_iface.imageConvert(source, dest, arg_2, extra, timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler) + return fut + def imageGeneratePreview(self, image_path, profile_key): loop = asyncio.get_running_loop() fut = loop.create_future() diff -r 7aa01e262e05 -r f300d78f08f3 sat_frontends/bridge/pb.py --- a/sat_frontends/bridge/pb.py Sun Apr 19 16:40:34 2020 +0200 +++ b/sat_frontends/bridge/pb.py Sun Apr 19 16:53:44 2020 +0200 @@ -439,6 +439,15 @@ else: d.addErrback(self._errback, ori_errback=errback) + def imageConvert(self, source, dest, arg_2, extra, callback=None, errback=None): + d = self.root.callRemote("imageConvert", source, dest, arg_2, extra) + if callback is not None: + d.addCallback(callback) + if errback is None: + d.addErrback(self._generic_errback) + else: + d.addErrback(self._errback, ori_errback=errback) + def imageGeneratePreview(self, image_path, profile_key, callback=None, errback=None): d = self.root.callRemote("imageGeneratePreview", image_path, profile_key) if callback is not None: @@ -926,6 +935,11 @@ d.addErrback(self._errback) return d.asFuture(asyncio.get_event_loop()) + def imageConvert(self, source, dest, arg_2, extra): + d = self.root.callRemote("imageConvert", source, dest, arg_2, extra) + d.addErrback(self._errback) + return d.asFuture(asyncio.get_event_loop()) + def imageGeneratePreview(self, image_path, profile_key): d = self.root.callRemote("imageGeneratePreview", image_path, profile_key) d.addErrback(self._errback) diff -r 7aa01e262e05 -r f300d78f08f3 setup.py --- a/setup.py Sun Apr 19 16:40:34 2020 +0200 +++ b/setup.py Sun Apr 19 16:53:44 2020 +0200 @@ -53,6 +53,10 @@ 'omemo-backend-signal', ] +extras_require = { + "SVG": ["CairoSVG"], +} + DBUS_DIR = 'dbus-1/services' DBUS_FILE = 'misc/org.salutatoi.SAT.service' with open(os.path.join(NAME, 'VERSION')) as f: