changeset 3259:f300d78f08f3

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
author Goffi <goffi@goffi.org>
date Sun, 19 Apr 2020 16:53:44 +0200
parents 7aa01e262e05
children e9ecd133773b
files sat/bridge/bridge_constructor/bridge_template.ini sat/bridge/dbus_bridge.py sat/core/sat_main.py sat/tools/image.py sat_frontends/bridge/dbus_bridge.py sat_frontends/bridge/pb.py setup.py
diffstat 7 files changed, 203 insertions(+), 3 deletions(-) [+]
line wrap: on
line diff
--- 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
--- 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):
--- 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):
--- 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
-
-
--- 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()
--- 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)
--- 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: