diff options
Diffstat (limited to 'synapse/media/thumbnailer.py')
-rw-r--r-- | synapse/media/thumbnailer.py | 221 |
1 files changed, 221 insertions, 0 deletions
diff --git a/synapse/media/thumbnailer.py b/synapse/media/thumbnailer.py new file mode 100644 index 0000000000..f909a4fb9a --- /dev/null +++ b/synapse/media/thumbnailer.py @@ -0,0 +1,221 @@ +# Copyright 2014-2016 OpenMarket Ltd +# Copyright 2020-2021 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging +from io import BytesIO +from types import TracebackType +from typing import Optional, Tuple, Type + +from PIL import Image + +logger = logging.getLogger(__name__) + +EXIF_ORIENTATION_TAG = 0x0112 +EXIF_TRANSPOSE_MAPPINGS = { + 2: Image.FLIP_LEFT_RIGHT, + 3: Image.ROTATE_180, + 4: Image.FLIP_TOP_BOTTOM, + 5: Image.TRANSPOSE, + 6: Image.ROTATE_270, + 7: Image.TRANSVERSE, + 8: Image.ROTATE_90, +} + + +class ThumbnailError(Exception): + """An error occurred generating a thumbnail.""" + + +class Thumbnailer: + FORMATS = {"image/jpeg": "JPEG", "image/png": "PNG"} + + @staticmethod + def set_limits(max_image_pixels: int) -> None: + Image.MAX_IMAGE_PIXELS = max_image_pixels + + def __init__(self, input_path: str): + # Have we closed the image? + self._closed = False + + try: + self.image = Image.open(input_path) + except OSError as e: + # If an error occurs opening the image, a thumbnail won't be able to + # be generated. + raise ThumbnailError from e + except Image.DecompressionBombError as e: + # If an image decompression bomb error occurs opening the image, + # then the image exceeds the pixel limit and a thumbnail won't + # be able to be generated. + raise ThumbnailError from e + + self.width, self.height = self.image.size + self.transpose_method = None + try: + # We don't use ImageOps.exif_transpose since it crashes with big EXIF + # + # Ignore safety: Pillow seems to acknowledge that this method is + # "private, experimental, but generally widely used". Pillow 6 + # includes a public getexif() method (no underscore) that we might + # consider using instead when we can bump that dependency. + # + # At the time of writing, Debian buster (currently oldstable) + # provides version 5.4.1. It's expected to EOL in mid-2022, see + # https://wiki.debian.org/DebianReleases#Production_Releases + image_exif = self.image._getexif() # type: ignore + if image_exif is not None: + image_orientation = image_exif.get(EXIF_ORIENTATION_TAG) + assert type(image_orientation) is int + self.transpose_method = EXIF_TRANSPOSE_MAPPINGS.get(image_orientation) + except Exception as e: + # A lot of parsing errors can happen when parsing EXIF + logger.info("Error parsing image EXIF information: %s", e) + + def transpose(self) -> Tuple[int, int]: + """Transpose the image using its EXIF Orientation tag + + Returns: + A tuple containing the new image size in pixels as (width, height). + """ + if self.transpose_method is not None: + # Safety: `transpose` takes an int rather than e.g. an IntEnum. + # self.transpose_method is set above to be a value in + # EXIF_TRANSPOSE_MAPPINGS, and that only contains correct values. + with self.image: + self.image = self.image.transpose(self.transpose_method) # type: ignore[arg-type] + self.width, self.height = self.image.size + self.transpose_method = None + # We don't need EXIF any more + self.image.info["exif"] = None + return self.image.size + + def aspect(self, max_width: int, max_height: int) -> Tuple[int, int]: + """Calculate the largest size that preserves aspect ratio which + fits within the given rectangle:: + + (w_in / h_in) = (w_out / h_out) + w_out = max(min(w_max, h_max * (w_in / h_in)), 1) + h_out = max(min(h_max, w_max * (h_in / w_in)), 1) + + Args: + max_width: The largest possible width. + max_height: The largest possible height. + """ + + if max_width * self.height < max_height * self.width: + return max_width, max((max_width * self.height) // self.width, 1) + else: + return max((max_height * self.width) // self.height, 1), max_height + + def _resize(self, width: int, height: int) -> Image.Image: + # 1-bit or 8-bit color palette images need converting to RGB + # otherwise they will be scaled using nearest neighbour which + # looks awful. + # + # If the image has transparency, use RGBA instead. + if self.image.mode in ["1", "L", "P"]: + if self.image.info.get("transparency", None) is not None: + with self.image: + self.image = self.image.convert("RGBA") + else: + with self.image: + self.image = self.image.convert("RGB") + return self.image.resize((width, height), Image.ANTIALIAS) + + def scale(self, width: int, height: int, output_type: str) -> BytesIO: + """Rescales the image to the given dimensions. + + Returns: + The bytes of the encoded image ready to be written to disk + """ + with self._resize(width, height) as scaled: + return self._encode_image(scaled, output_type) + + def crop(self, width: int, height: int, output_type: str) -> BytesIO: + """Rescales and crops the image to the given dimensions preserving + aspect:: + (w_in / h_in) = (w_scaled / h_scaled) + w_scaled = max(w_out, h_out * (w_in / h_in)) + h_scaled = max(h_out, w_out * (h_in / w_in)) + + Args: + max_width: The largest possible width. + max_height: The largest possible height. + + Returns: + The bytes of the encoded image ready to be written to disk + """ + if width * self.height > height * self.width: + scaled_width = width + scaled_height = (width * self.height) // self.width + crop_top = (scaled_height - height) // 2 + crop_bottom = height + crop_top + crop = (0, crop_top, width, crop_bottom) + else: + scaled_width = (height * self.width) // self.height + scaled_height = height + crop_left = (scaled_width - width) // 2 + crop_right = width + crop_left + crop = (crop_left, 0, crop_right, height) + + with self._resize(scaled_width, scaled_height) as scaled_image: + with scaled_image.crop(crop) as cropped: + return self._encode_image(cropped, output_type) + + def _encode_image(self, output_image: Image.Image, output_type: str) -> BytesIO: + output_bytes_io = BytesIO() + fmt = self.FORMATS[output_type] + if fmt == "JPEG": + output_image = output_image.convert("RGB") + output_image.save(output_bytes_io, fmt, quality=80) + return output_bytes_io + + def close(self) -> None: + """Closes the underlying image file. + + Once closed no other functions can be called. + + Can be called multiple times. + """ + + if self._closed: + return + + self._closed = True + + # Since we run this on the finalizer then we need to handle `__init__` + # raising an exception before it can define `self.image`. + image = getattr(self, "image", None) + if image is None: + return + + image.close() + + def __enter__(self) -> "Thumbnailer": + """Make `Thumbnailer` a context manager that calls `close` on + `__exit__`. + """ + return self + + def __exit__( + self, + type: Optional[Type[BaseException]], + value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self.close() + + def __del__(self) -> None: + # Make sure we actually do close the image, rather than leak data. + self.close() |