diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..b2fd9381 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish easy-thumbnails + +on: + push: + tags: + - '*' + +jobs: + publish: + name: "Publish release" + runs-on: "ubuntu-latest" + + environment: + name: deploy + + strategy: + matrix: + python-version: ["3.9"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build --user + - name: Build 🐍 Python 📦 Package + run: python -m build --sdist --wheel --outdir dist/ + - name: Publish 🐍 Python 📦 Package to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN_EASY_THUMBNAILS }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..3b6635c5 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,31 @@ +name: Python CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh-actions + + - name: Run tests + run: tox diff --git a/.gitignore b/.gitignore index dd8ca645..9b90512c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docs/_build *.egg *.pyc reports/* +.python-version diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..65b390f2 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,5 @@ +version: 2 + +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c91068d4..00000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: python -python: - - "3.5" - - "3.6" - - "3.7" - - "3.8" -install: pip install tox-travis -script: tox diff --git a/CHANGES.rst b/CHANGES.rst index e1b6f6d0..ac690e6c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,44 @@ Changes ======= +2.8.3 (2022-08-02) +------------------ +* Fix regression in library detection introduced in version 2.8.2. + + +2.8.2 (2022-07-31) +------------------ +* Installation of easy-thumbnails now optionally depends on the reportlab library. + + +2.8.1 (2022-01-20) +------------------ + +* Add support for Django 4. +* New ``THUMBNAIL_IMAGE_SAVE_OPTIONS`` setting. +* Fix #587: Uploading SVG Images to S3 storage. + + +2.8.0 (2021-11-03) +------------------ + +* Add support for thumbnailing SVG images. This is done by adding an emulation layer named VIL, + which aims to be compatible with PIL. All thumbnailing operations, such as scaling and cropping + behave like pixel images. +* Remove configuration directives ``THUMBNAIL_HIGH_RESOLUTION`` and ``THUMBNAIL_HIGHRES_INFIX`` + from easy-thumbnails setting directives. + + +2.7.2 (2021-10-17) +------------------ + +* Add support for Django 3.2 and Python-3.10. +* Fix #563: Do not close image after loading content. +* In management command ``thumbnail_cleanup``, replace ``print``-statements + against ``stdout.write``. +* Use Python format strings whereever possible. + + 2.7.1 (2020-11-23) ------------------ @@ -15,11 +53,13 @@ Changes * Drop support for Django < 1.11 * Drop support for Django 2.0, 2.1 + 2.6.0 (2019-02-03) ------------------ * Added testing for Django 2.2 (no code changes required). + 2.5.0 (2017-10-31) ------------------ diff --git a/README.rst b/README.rst index 70189f86..b0156bc6 100644 --- a/README.rst +++ b/README.rst @@ -5,12 +5,12 @@ Easy Thumbnails .. image:: https://img.shields.io/pypi/v/easy-thumbnails.svg :target: https://pypi.python.org/pypi/easy-thumbnails/ -.. image:: https://secure.travis-ci.org/SmileyChris/easy-thumbnails.svg?branch=master +.. image:: https://github.com/SmileyChris/easy-thumbnails/actions/workflows/python.yml/badge.svg :alt: Build Status - :target: http://travis-ci.org/SmileyChris/easy-thumbnails + :target: https://github.com/SmileyChris/easy-thumbnails/actions/workflows/python.yml -A powerful, yet easy to implement thumbnailing application for Django 1.11+ +A powerful, yet easy to implement thumbnailing application for Django 2.2+ Below is a quick summary of usage. For more comprehensive information, view the `full documentation`__ online or the peruse the project's ``docs`` directory. @@ -18,12 +18,36 @@ Below is a quick summary of usage. For more comprehensive information, view the __ http://easy-thumbnails.readthedocs.org/en/latest/index.html +Breaking News +============= + +Version 2.8.0 adds support for thumbnailing SVG images when installed with the ``[svg]`` extra. + +Of course it doesn't make sense to thumbnail SVG images, because being in vector format they can +scale to any size without quality of loss. However, users of easy-thumbnails may want to upload and +use SVG images just as if they would be PNG, GIF or JPEG. They don't necessarily care about the +format and definitely don't want to convert them to a pixel based format. What they want is to reuse +their templates with the templatetag thumbnail and scale and crop the images to whatever their +`` has been prepared for. + +This is done by adding an emulation layer named VIL, which aims to be compatible with the +`PIL `_ library. All thumbnailing operations, such as scaling and +cropping behave like pixel based images. The final filesize of such thumbnailed SVG images doesn't +of course change, but their width/height and bounding box may be adjusted to reflect the desired +size of the thumbnailed image. + +.. note:: This feature is new and experimental, hence feedback about its proper functioning in + third parts applications is highly appreciated. + + Installation ============ Run ``pip install easy-thumbnails``. -Add ``easy_thumbnails`` to your ``INSTALLED_APPS`` setting:: +Add ``easy_thumbnails`` to your ``INSTALLED_APPS`` setting: + +.. code-block:: python INSTALLED_APPS = ( ... @@ -42,7 +66,9 @@ specified in the template or Python code when run. Using a predefined alias ------------------------ -Given the following setting:: +Given the following setting: + +.. code-block:: python THUMBNAIL_ALIASES = { '': { @@ -50,12 +76,16 @@ Given the following setting:: }, } -Template:: +Template: + +.. code-block:: html+django {% load thumbnail %} -Python:: +Python: + +.. code-block:: python from easy_thumbnails.files import get_thumbnailer thumb_url = get_thumbnailer(profile.photo)['avatar'].url @@ -63,12 +93,16 @@ Python:: Manually specifying size / options ---------------------------------- -Template:: +Template: + +.. code-block:: html+django {% load thumbnail %} -Python:: +Python: + +.. code-block:: python from easy_thumbnails.files import get_thumbnailer options = {'size': (100, 100), 'crop': True} @@ -80,7 +114,9 @@ Using in combination with other thumbnailers Alternatively, you load the templatetags by {% load easy_thumbnails_tags %} instead of traditional {% load thumbnail %}. It's especially useful in projects that do make use of multiple thumbnailer libraries that use the -same name (`thumbnail`) for the templatetag module:: +same name (`thumbnail`) for the templatetag module: + +.. code-block:: html+django {% load easy_thumbnails_tags %} @@ -91,7 +127,9 @@ Fields You can use ``ThumbnailerImageField`` (or ``ThumbnailerField``) for easier access to retrieve or generate thumbnail images. -For example:: +For example: + +.. code-block:: python from easy_thumbnails.fields import ThumbnailerImageField @@ -99,12 +137,16 @@ For example:: user = models.OneToOneField('auth.User') photo = ThumbnailerImageField(upload_to='photos', blank=True) -Accessing the field's predefined alias in a template:: +Accessing the field's predefined alias in a template: + +.. code-block:: html+django {% load thumbnail %} -Accessing the field's predefined alias in Python code:: +Accessing the field's predefined alias in Python code: + +.. code-block:: python thumb_url = profile.photo['avatar'].url diff --git a/TESTING.rst b/TESTING.rst index c63e8d3e..cd7fe99a 100644 --- a/TESTING.rst +++ b/TESTING.rst @@ -4,17 +4,15 @@ Testing 1. Install tox (``pip install tox``) 2. Run ``tox`` -If you want to test against other versions of Python then you might need to -install them, too. Here's a quick log for installing them in Ubuntu (probably -just as relevant for Debian):: +To test against other versions of Python, the recommended way is to ``pip +install tox-pyenv`` and install each version in pyenv that you want to test +against. - sudo add-apt-repository ppa:deadsnakes/ppa - sudo apt-get update - sudo apt-get install pythonX.Y pythonX.Y-dev +Run ``tox local `` to make them available when running tox. - sudo apt-get install build-essential python-dev python3-dev - sudo apt-get install libjpeg8-dev zlib1g-dev +pytest +------ -For Ubuntu >=16.04, you'll also want:: - - sudo apt-get install python3.4 python3.4-dev +Assuming you're just wanting to test the current development version against +your virtualenv setup, you can alternatively just ``pip install pytest-django`` +and run ``pytest``. \ No newline at end of file diff --git a/docs/install.rst b/docs/install.rst index 41ca825b..b38e81b9 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -2,10 +2,6 @@ Installation ============ -Before installing easy-thumbnails, you'll obviously need to have copy of -Django installed. For the |version| release, both Django 1.4 and Django 1.7 or -above is supported. - By default, all of the image manipulation is handled by the `Python Imaging Library`__ (a.k.a. PIL), so you'll probably want that installed too. @@ -24,6 +20,7 @@ Simply type:: pip install easy-thumbnails + Manual installation ------------------- diff --git a/docs/ref/svg.rst b/docs/ref/svg.rst new file mode 100644 index 00000000..239d6761 --- /dev/null +++ b/docs/ref/svg.rst @@ -0,0 +1,26 @@ +=============================== +Scalable Vector Graphic Support +=============================== + +Scalable Vector Graphics (SVG) is an XML-based vector image format for two-dimensional graphics with support for +interactivity and animation. The SVG specification is an open standard developed by the World Wide Web Consortium (W3C). + +Thumbnailing vector graphic images doesn't really make sense, because being in vector format they can scale to any size +without any quality of loss. However, users of **easy-thumbnails** may want to upload and use SVG images just as if +they would be in PNG, GIF or JPEG format. End users don't necessarily care about the format and definitely don't want +to convert them to a pixel based format. What they want is to reuse their templates with the templatetag +``{% thumbnail image ... as thumb %}``, and scale and crop the images to whatever the +element tag ```` has been prepared for. + +This is done by adding an emulation layer named VIL, which aims to be compatible with PIL. All thumbnailing operations, +such as scaling and cropping behave like their pixel based counterparts. The content and final filesize of such +thumbnailed SVG images doesn't of course change, but their width/height and bounding box may be adjusted to reflect the +desired size of the thumbnailed image. Therefore, "thumbnailed" SVG images are stored side by side with their original +images and hence can be used by third-party apps such as +`django-filer`_ without modification. + +Since easy-thumbnails version 2.8, you can therefore use an SVG image, just as you would use any other image. + +This requires easy-thumbnails to have been installed with the ``[svg]`` extra enabled. + +Cropping an SVG image works as expected. Filtering an SVG image will however not work. diff --git a/docs/ref/webp.rst b/docs/ref/webp.rst index 3d17556b..aefadb5a 100644 --- a/docs/ref/webp.rst +++ b/docs/ref/webp.rst @@ -68,6 +68,6 @@ In the future, Easy Thumbnails might support WebP natively. This however means t be usable as ```` -tag, supported by all browsers, and fully integrated into tools such as django-filer_. -Until that happens, I reccomend to proceed with the workarround described here. +Until that happens, I recommend to proceed with the workarround described here. .. _django-filer: https://django-filer.readthedocs.io/en/latest/ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..93120e66 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +docutils<0.18 diff --git a/easy_thumbnails/VIL/Image.py b/easy_thumbnails/VIL/Image.py new file mode 100644 index 00000000..e9f5f7ba --- /dev/null +++ b/easy_thumbnails/VIL/Image.py @@ -0,0 +1,188 @@ +import builtins +from pathlib import Path + +from django.core.files import File +from django.utils.functional import cached_property + +from reportlab.graphics import renderSVG +from reportlab.lib.colors import Color + +from svglib.svglib import svg2rlg + + +class Image: + """ + Attempting to be compatible with PIL's Image, but suitable for reportlab's SVGCanvas. + """ + def __init__(self, size=(300, 300)): + assert isinstance(size, (list, tuple)) and len(size) == 2 \ + and isinstance(size[0], (int, float)) and isinstance(size[1], (int, float)), \ + "Expected `size` as tuple with two floats or integers" + self.canvas = renderSVG.SVGCanvas(size=size, useClip=True) + self.mode = None + + @property + def size(self): + return self.width, self.height + + @cached_property + def width(self): + try: + return float(self.canvas.svg.getAttribute('width')) + except ValueError: + return self.getbbox()[2] + + @cached_property + def height(self): + try: + return float(self.canvas.svg.getAttribute('height')) + except ValueError: + return self.getbbox()[3] + + def getbbox(self): + """ + Calculates the bounding box of the non-zero regions in the image. + + :returns: The bounding box is returned as a 4-tuple defining the + left, upper, right, and lower pixel coordinate. + """ + return tuple(float(b) for b in self.canvas.svg.getAttribute('viewBox').split()) + + def resize(self, size, **kwargs): + """ + :param size: The requested size in pixels, as a 2-tuple: (width, height). + :returns: The resized :py:class:`easy_thumbnails.VIL.Image.Image` object. + """ + copy = Image() + copy.canvas.svg = self.canvas.svg.cloneNode(True) + copy.canvas.svg.setAttribute('width', '{0}'.format(*size)) + copy.canvas.svg.setAttribute('height', '{1}'.format(*size)) + return copy + + def convert(self, *args): + """ + Does nothing, just for compatibility with PIL. + :returns: An :py:class:`easy_thumbnails.VIL.Image.Image` object. + """ + return self + + def crop(self, box=None): + """ + Returns a rectangular region from this image. The box is a + 4-tuple defining the left, upper, right, and lower pixel + coordinate. + + :param box: The crop rectangle, as a (left, upper, right, lower)-tuple. + :returns: The cropped :py:class:`easy_thumbnails.VIL.Image.Image` object. + """ + copy = Image(size=self.size) + copy.canvas.svg = self.canvas.svg.cloneNode(True) + if box: + bbox = list(self.getbbox()) + current_aspect_ratio = (bbox[2] - bbox[0]) / (bbox[3] - bbox[1]) + wanted_aspect_ratio = (box[2] - box[0]) / (box[3] - box[1]) + if current_aspect_ratio > wanted_aspect_ratio: + new_width = wanted_aspect_ratio * bbox[3] + bbox[0] += (bbox[2] - new_width) / 2 + bbox[2] = new_width + else: + new_height = bbox[2] / wanted_aspect_ratio + bbox[1] += (bbox[3] - new_height) / 2 + bbox[3] = new_height + size = box[2] - box[0], box[3] - box[1] + copy.canvas.svg.setAttribute('viewBox', '{0} {1} {2} {3}'.format(*bbox)) + copy.canvas.svg.setAttribute('width', '{0}'.format(*size)) + copy.canvas.svg.setAttribute('height', '{1}'.format(*size)) + return copy + + def filter(self, *args): + """ + Does nothing, just for compatibility with PIL. + :returns: An :py:class:`easy_thumbnails.VIL.Image.Image` object. + """ + return self + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + pass + + def save(self, fp, format=None, **params): + """ + Saves this image under the given filename. If no format is + specified, the format to use is determined from the filename + extension, if possible. + + You can use a file object instead of a filename. In this case, + you must always specify the format. The file object must + implement the ``seek``, ``tell``, and ``write`` + methods, and be opened in binary mode. + + :param fp: A filename (string), pathlib.Path object or file object. + :param format: Must be None or 'SVG'. + :param params: Unused extra parameters. + :returns: None + :exception ValueError: If the output format could not be determined + from the file name. Use the format option to solve this. + :exception OSError: If the file could not be written. The file + may have been created, and may contain partial data. + """ + + filename = '' + open_fp = False + if isinstance(fp, (bytes, str)): + filename = fp + open_fp = True + elif isinstance(fp, Path): + filename = str(fp) + open_fp = True + + suffix = Path(filename).suffix.lower() + if format != 'SVG' and suffix != '.svg': + raise ValueError("Image format is expected to be 'SVG' and file suffix to be '.svg'") + + if open_fp: + fp = builtins.open(filename, 'w') + self.canvas.svg.writexml(fp) + if open_fp: + fp.flush() + + +def new(self, size, color=None): + im = Image(size) + if color: + im.canvas.setFillColor(Color(*color)) + return im + + +def load(fp, mode='r'): + """ + Opens and identifies the given SVG image file. + + :param fp: A filename (string), pathlib.Path object or a file object. + The file object must implement :py:meth:`~file.read`, + :py:meth:`~file.seek`, and :py:meth:`~file.tell` methods, + and be opened in binary mode. + :param mode: The mode. If given, this argument must be "r". + :returns: An :py:class:`easy_thumbnails.VIL.Image.Image` object. + :exception FileNotFoundError: If the file cannot be found. + :exception ValueError: If the ``mode`` is not "r", or if a ``StringIO`` + instance is used for ``fp``. + """ + + if mode != 'r': + raise ValueError("bad mode {}".format(mode)) + if isinstance(fp, Path): + filename = str(fp.resolve()) + elif isinstance(fp, (File, str)): + filename = fp + else: + raise RuntimeError("Can not open file.") + drawing = svg2rlg(filename) + if drawing is None: + return + # raise ValueError("cannot decode SVG image") + im = Image(size=(drawing.width, drawing.height)) + renderSVG.draw(drawing, im.canvas) + return im diff --git a/easy_thumbnails/VIL/ImageDraw.py b/easy_thumbnails/VIL/ImageDraw.py new file mode 100644 index 00000000..473d90bc --- /dev/null +++ b/easy_thumbnails/VIL/ImageDraw.py @@ -0,0 +1,21 @@ +def Draw(im, mode=None): + """ + Attempting to be compatible with PIL's ImageDraw, but suitable for reportlab's SVGCanvas. + + :param im: The image to draw in. + :param mode: ignored. + """ + return ImageDraw(im) + + +class ImageDraw: + def __init__(self, im): + self.im = im + + def rectangle(self, xy, fill=None, outline=None, width=1): + if fill: + self.im.canvas.setFillColor(fill) + if outline: + self.im.canvas.setStrokeColor(outline) + self.im.canvas.setLineWidth(width) + self.im.canvas.rect(*xy) diff --git a/easy_thumbnails/VIL/__init__.py b/easy_thumbnails/VIL/__init__.py new file mode 100644 index 00000000..f924c82d --- /dev/null +++ b/easy_thumbnails/VIL/__init__.py @@ -0,0 +1,9 @@ +def is_available() -> bool: + """ + Returns True if SVG support should be available. + """ + try: + import easy_thumbnails.VIL.Image + return True + except ImportError: + return False \ No newline at end of file diff --git a/easy_thumbnails/__init__.py b/easy_thumbnails/__init__.py index 48df7056..85eaf22e 100644 --- a/easy_thumbnails/__init__.py +++ b/easy_thumbnails/__init__.py @@ -1,4 +1,4 @@ -VERSION = (2, 7, 1, 'final', 0) +VERSION = (2, 8, 3, 'final', 0) def get_version(*args, **kwargs): diff --git a/easy_thumbnails/apps.py b/easy_thumbnails/apps.py new file mode 100644 index 00000000..eb3232cc --- /dev/null +++ b/easy_thumbnails/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class EasyThumbnailsConfig(AppConfig): + name = 'easy_thumbnails' + + default_auto_field = 'django.db.models.AutoField' diff --git a/easy_thumbnails/conf.py b/easy_thumbnails/conf.py index f92bdc5e..76da3467 100644 --- a/easy_thumbnails/conf.py +++ b/easy_thumbnails/conf.py @@ -1,3 +1,4 @@ +import django from django.conf import settings as django_settings @@ -173,13 +174,14 @@ class Settings(AppSettings): Note that changing the extension will most likely cause the ``THUMBNAIL_QUALITY`` setting to have no effect. """ + THUMBNAIL_PRESERVE_EXTENSIONS = None """ To preserve specific extensions, for instance if you always want to create lossless PNG thumbnails from PNG sources, you can specify these extensions using this setting, for example:: - THUMBNAIL_PRESERVE_EXTENSIONS = ('png',) + THUMBNAIL_PRESERVE_EXTENSIONS = ['png'] All extensions should be lowercase. @@ -191,6 +193,7 @@ class Settings(AppSettings): The type of image to save thumbnails with a transparency layer (e.g. GIFs or transparent PNGs). """ + THUMBNAIL_NAMER = 'easy_thumbnails.namers.default' """ The function used to generate the filename for thumbnail images. @@ -245,8 +248,10 @@ class Settings(AppSettings): The order of the processors is the order in which they are sequentially called to process the image. """ + THUMBNAIL_SOURCE_GENERATORS = ( 'easy_thumbnails.source_generators.pil_image', + 'easy_thumbnails.source_generators.vil_image', ) """ The :doc:`source_generators` through which the base image is created from @@ -281,39 +286,6 @@ class Settings(AppSettings): THUMBNAIL_DEFAULT_OPTIONS = {'bw': True} """ - THUMBNAIL_HIGH_RESOLUTION = False - """ - Enables thumbnails for retina displays. - - Creates a version of the thumbnails in high resolution that can be used by - a javascript layer to display higher quality thumbnails for high DPI - displays. - - This can be overridden at a per-thumbnail level with the - ``HIGH_RESOLUTION`` thumbnail option:: - - opts = {'size': (100, 100), 'crop': True, HIGH_RESOLUTION: False} - only_basic = get_thumbnailer(obj.image).get_thumbnail(opts) - - In a template tag, use a value of ``0`` to force the disabling of a high - resolution version or just the option name to enable it:: - - {% thumbnail obj.image 50x50 crop HIGH_RESOLUTION=0 %} {# no hires #} - {% thumbnail obj.image 50x50 crop HIGH_RESOLUTION %} {# force hires #} - """ - - THUMBNAIL_HIGHRES_INFIX = '@2x' - """ - Sets the infix used to distinguish thumbnail images for retina displays. - - Thumbnails generated for retina displays are distinguished from the - standard resolution counterparts, by adding an infix to the filename just - before the dot followed by the extension. - - Apple Inc., formerly suggested to use ``@2x`` as infix, but later changed - their mind and now suggests to use ``_2x``, since this is more portable. - """ - THUMBNAIL_CACHE_DIMENSIONS = False """ Save thumbnail dimensions to the database. @@ -330,4 +302,17 @@ class Settings(AppSettings): :class:`easy_thumbnails.widgets.ImageClearableFileInput` widget. """ + THUMBNAIL_IMAGE_SAVE_OPTIONS = { + 'JPEG': { + 'quality': 85, + }, + 'WEBP': { + 'quality': 85, + }, + } + """ + Allows customising Image.save parameters based on format, for example: + `{'WEBP': {'method': 6}}` + """ + settings = Settings() diff --git a/easy_thumbnails/engine.py b/easy_thumbnails/engine.py index c6edcaff..9113b8af 100644 --- a/easy_thumbnails/engine.py +++ b/easy_thumbnails/engine.py @@ -1,10 +1,11 @@ import os -from io import BytesIO +from io import BytesIO, StringIO + +from django.utils.module_loading import import_string from PIL import Image -from easy_thumbnails import utils from easy_thumbnails.conf import settings from easy_thumbnails.options import ThumbnailOptions @@ -28,7 +29,7 @@ def process_image(source, processor_options, processors=None): processor_options = ThumbnailOptions(processor_options) if processors is None: processors = [ - utils.dynamic_import(name) + import_string(name) for name in settings.THUMBNAIL_PROCESSORS] image = source for processor in processors: @@ -36,7 +37,7 @@ def process_image(source, processor_options, processors=None): return image -def save_image(image, destination=None, filename=None, **options): +def save_pil_image(image, destination=None, filename=None, **options): """ Save a PIL image. """ @@ -46,8 +47,9 @@ def save_image(image, destination=None, filename=None, **options): # Ensure plugins are fully loaded so that Image.EXTENSION is populated. Image.init() format = Image.EXTENSION.get(os.path.splitext(filename)[1].lower(), 'JPEG') - if format in ('JPEG', 'WEBP'): - options.setdefault('quality', 85) + if format in settings.THUMBNAIL_IMAGE_SAVE_OPTIONS: + for key, value in settings.THUMBNAIL_IMAGE_SAVE_OPTIONS[format].items(): + options.setdefault(key, value) saved = False if format == 'JPEG': if image.mode.endswith('A'): @@ -93,7 +95,7 @@ def generate_source_image(source_file, processor_options, generators=None, was_closed = getattr(source_file, 'closed', False) if generators is None: generators = [ - utils.dynamic_import(name) + import_string(name) for name in settings.THUMBNAIL_SOURCE_GENERATORS] exceptions = [] try: @@ -129,3 +131,17 @@ def generate_source_image(source_file, processor_options, generators=None, pass if exceptions and not fail_silently: raise NoSourceGenerator(*exceptions) + + +def save_svg_image(image, destination=None, filename=None, **options): + """ + Save a SVG image. + """ + from easy_thumbnails.VIL import Image + + if destination is None: + destination = StringIO() + image.save(destination, format='SVG', **options) + if hasattr(destination, 'seek'): + destination.seek(0) + return destination diff --git a/easy_thumbnails/files.py b/easy_thumbnails/files.py index d38651f4..24d14092 100644 --- a/easy_thumbnails/files.py +++ b/easy_thumbnails/files.py @@ -1,14 +1,14 @@ import os from django.core.files.base import File, ContentFile -from django.core.files.storage import ( - default_storage, Storage) -from django.db.models.fields.files import ImageFieldFile, FieldFile +from django.core.files.storage import default_storage, Storage from django.core.files.images import get_image_dimensions +from django.db.models.fields.files import ImageFieldFile, FieldFile +from django.utils import timezone from django.utils.safestring import mark_safe from django.utils.html import escape -from django.utils import timezone +from django.utils.module_loading import import_string from easy_thumbnails import engine, exceptions, models, utils, signals, storage from easy_thumbnails.alias import aliases @@ -116,7 +116,11 @@ def database_get_image_dimensions(file, close=False, dimensions=None): dimensions_cache = None if dimensions_cache: return dimensions_cache.width, dimensions_cache.height - dimensions = get_image_dimensions(file, close=close) + if os.path.splitext(file.file.name)[1] == '.svg': + from easy_thumbnails.VIL.Image import load + dimensions = load(file.path).size + else: + dimensions = get_image_dimensions(file, close=close) if settings.THUMBNAIL_CACHE_DIMENSIONS and thumbnail: # Using get_or_create in case dimensions were created # while running get_image_dimensions. @@ -323,8 +327,7 @@ def __init__(self, file=None, name=None, source_storage=None, for default in ( 'basedir', 'subdir', 'prefix', 'quality', 'extension', 'preserve_extensions', 'transparency_extension', - 'check_cache_miss', 'high_resolution', 'highres_infix', - 'namer'): + 'check_cache_miss', 'namer'): attr_name = 'thumbnail_%s' % default if getattr(self, attr_name, None) is None: value = getattr(settings, attr_name.upper()) @@ -356,8 +359,7 @@ def get_options(self, thumbnail_options, **kwargs): opts['quality'] = self.thumbnail_quality return opts - def generate_thumbnail(self, thumbnail_options, high_resolution=False, - silent_template_exception=False): + def generate_thumbnail(self, thumbnail_options, silent_template_exception=False): """ Return an unsaved ``ThumbnailFile`` containing a thumbnail image. @@ -370,40 +372,41 @@ def generate_thumbnail(self, thumbnail_options, high_resolution=False, min_dim, max_dim = 0, 0 for dim in orig_size: try: - dim = int(dim) + dim = float(dim) except (TypeError, ValueError): continue min_dim, max_dim = min(min_dim, dim), max(max_dim, dim) if max_dim == 0 or min_dim < 0: - raise exceptions.EasyThumbnailsError( - "The source image is an invalid size (%sx%s)" % orig_size) + msg = "The source image has an invalid size ({0}x{1})" + raise exceptions.EasyThumbnailsError(msg.format(*orig_size)) - if high_resolution: - thumbnail_options['size'] = (orig_size[0] * 2, orig_size[1] * 2) image = engine.generate_source_image( self, thumbnail_options, self.source_generators, fail_silently=silent_template_exception) if image is None: - raise exceptions.InvalidImageFormatError( - "The source file does not appear to be an image") + msg = "The source file does not appear to be an image: '{name}'" + raise exceptions.InvalidImageFormatError(msg.format(name=self.name)) thumbnail_image = engine.process_image(image, thumbnail_options, self.thumbnail_processors) - if high_resolution: - thumbnail_options['size'] = orig_size # restore original size - filename = self.get_thumbnail_name( thumbnail_options, - transparent=utils.is_transparent(thumbnail_image), - high_resolution=high_resolution) + transparent=utils.is_transparent(thumbnail_image)) quality = thumbnail_options['quality'] subsampling = thumbnail_options['subsampling'] - img = engine.save_image( - thumbnail_image, filename=filename, quality=quality, - subsampling=subsampling, keep_icc_profile=thumbnail_options.get('keep_icc_profile', False)) + if os.path.splitext(self.name)[1][1:].lower() == 'svg': + img = engine.save_svg_image(thumbnail_image, filename=filename) + else: + img = engine.save_pil_image( + thumbnail_image, filename=filename, quality=quality, + subsampling=subsampling, keep_icc_profile=thumbnail_options.get('keep_icc_profile', False)) data = img.read() + # S3 requires the data as bytes. + if not isinstance(data, bytes): + data = data.encode() + thumbnail = ThumbnailFile( filename, file=ContentFile(data), storage=self.thumbnail_storage, thumbnail_options=thumbnail_options) @@ -412,8 +415,7 @@ def generate_thumbnail(self, thumbnail_options, high_resolution=False, return thumbnail - def get_thumbnail_name(self, thumbnail_options, transparent=False, - high_resolution=False): + def get_thumbnail_name(self, thumbnail_options, transparent=False): """ Return a thumbnail filename for the given ``thumbnail_options`` dictionary and ``source_name`` (which defaults to the File's ``name`` @@ -421,11 +423,10 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False, """ thumbnail_options = self.get_options(thumbnail_options) path, source_filename = os.path.split(self.name) - source_extension = os.path.splitext(source_filename)[1][1:] + source_extension = os.path.splitext(source_filename)[1][1:].lower() preserve_extensions = self.thumbnail_preserve_extensions - if preserve_extensions and ( - preserve_extensions is True or - source_extension.lower() in preserve_extensions): + if preserve_extensions is True or isinstance(preserve_extensions, (list, tuple)) and \ + source_extension in preserve_extensions: extension = source_extension elif transparent: extension = self.thumbnail_transparency_extension @@ -441,7 +442,7 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False, subdir = self.thumbnail_subdir % data if isinstance(self.thumbnail_namer, str): - namer_func = utils.dynamic_import(self.thumbnail_namer) + namer_func = import_string(self.thumbnail_namer) else: namer_func = self.thumbnail_namer filename = namer_func( @@ -451,26 +452,19 @@ def get_thumbnail_name(self, thumbnail_options, transparent=False, thumbnail_options=thumbnail_options, prepared_options=prepared_opts, ) - if high_resolution: - filename = self.thumbnail_highres_infix.join( - os.path.splitext(filename)) - filename = '%s%s' % (self.thumbnail_prefix, filename) + filename = '{}{}'.format(self.thumbnail_prefix, filename) return os.path.join(basedir, path, subdir, filename) - def get_existing_thumbnail(self, thumbnail_options, high_resolution=False): + def get_existing_thumbnail(self, thumbnail_options): """ Return a ``ThumbnailFile`` containing an existing thumbnail for a set of thumbnail options, or ``None`` if not found. """ thumbnail_options = self.get_options(thumbnail_options) - names = [ - self.get_thumbnail_name( - thumbnail_options, transparent=False, - high_resolution=high_resolution)] + names = [self.get_thumbnail_name(thumbnail_options, transparent=False)] transparent_name = self.get_thumbnail_name( - thumbnail_options, transparent=True, - high_resolution=high_resolution) + thumbnail_options, transparent=True) if transparent_name not in names: names.append(transparent_name) @@ -519,27 +513,7 @@ def get_thumbnail(self, thumbnail_options, save=True, generate=None, self.save_thumbnail(thumbnail) else: signals.thumbnail_missed.send( - sender=self, options=thumbnail_options, - high_resolution=False) - - if 'HIGH_RESOLUTION' in thumbnail_options: - generate_high_resolution = thumbnail_options.get('HIGH_RESOLUTION') - else: - generate_high_resolution = self.thumbnail_high_resolution - if generate_high_resolution: - thumbnail.high_resolution = self.get_existing_thumbnail( - thumbnail_options, high_resolution=True) - if not thumbnail.high_resolution: - if generate: - thumbnail.high_resolution = self.generate_thumbnail( - thumbnail_options, high_resolution=True, - silent_template_exception=silent_template_exception) - if save: - self.save_thumbnail(thumbnail.high_resolution) - else: - signals.thumbnail_missed.send( - sender=self, options=thumbnail_options, - high_resolution=False) + sender=self, options=thumbnail_options) return thumbnail diff --git a/easy_thumbnails/get_version.py b/easy_thumbnails/get_version.py index 6650d48b..df9c8a7a 100644 --- a/easy_thumbnails/get_version.py +++ b/easy_thumbnails/get_version.py @@ -30,7 +30,7 @@ def get_version(version=None): sub = '.dev%s' % git_changeset elif version[3] != 'final': - mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'} + mapping = {'alpha': '.a', 'beta': '.b', 'rc': '.rc'} sub = mapping[version[3]] + str(version[4]) return str(main + sub) diff --git a/easy_thumbnails/management/commands/thumbnail_cleanup.py b/easy_thumbnails/management/commands/thumbnail_cleanup.py index 2d1b9ba6..6c70af39 100644 --- a/easy_thumbnails/management/commands/thumbnail_cleanup.py +++ b/easy_thumbnails/management/commands/thumbnail_cleanup.py @@ -1,10 +1,12 @@ import gc import os import time -from datetime import datetime, date, timedelta +from datetime import date from django.core.files.storage import get_storage_class from django.core.management.base import BaseCommand +from django.utils.timezone import datetime, timedelta + from easy_thumbnails.conf import settings from easy_thumbnails.models import Source @@ -19,6 +21,10 @@ class ThumbnailCollectionCleaner: source_refs_deleted = 0 execution_time = 0 + def __init__(self, stdout, stderr): + self.stdout = stdout + self.stderr = stderr + def _get_absolute_path(self, path): return os.path.join(settings.MEDIA_ROOT, path) @@ -29,8 +35,8 @@ def _check_if_exists(self, storage, path): try: return storage.exists(path) except Exception as e: - print("Something went wrong when checking existance of %s:" % path) - print(str(e)) + self.stderr.write("Something went wrong when checking existance of {}:".format(path)) + self.stderr.write(e) def _delete_sources_by_id(self, ids): Source.objects.all().filter(id__in=ids).delete() @@ -43,7 +49,7 @@ def clean_up(self, dry_run=False, verbosity=1, last_n_days=0, database references). """ if dry_run: - print ("Dry run...") + self.stdout.write("Dry run...") if not storage: storage = get_storage_class(settings.THUMBNAIL_DEFAULT_STORAGE)() @@ -65,7 +71,7 @@ def clean_up(self, dry_run=False, verbosity=1, last_n_days=0, if not self._check_if_exists(storage, abs_source_path): if verbosity > 0: - print ("Source not present:", abs_source_path) + self.stdout.write("Source not present: {}".format(abs_source_path)) self.source_refs_deleted += 1 sources_to_delete.append(source.id) @@ -77,7 +83,7 @@ def clean_up(self, dry_run=False, verbosity=1, last_n_days=0, if not dry_run: storage.delete(abs_thumbnail_path) if verbosity > 0: - print ("Deleting thumbnail:", abs_thumbnail_path) + self.stdout.write("Deleting thumbnail: {}".format(abs_thumbnail_path)) if len(sources_to_delete) >= 1000 and not dry_run: self._delete_sources_by_id(sources_to_delete) @@ -91,14 +97,14 @@ def print_stats(self): """ Print statistics about the cleanup performed. """ - print( - "{0:-<48}".format(str(datetime.now().strftime('%Y-%m-%d %H:%M ')))) - print("{0:<40} {1:>7}".format("Sources checked:", self.sources)) - print("{0:<40} {1:>7}".format( + self.stdout.write( + "{0:-<48}".format(datetime.now().strftime('%Y-%m-%d %H:%M '))) + self.stdout.write("{0:<40} {1:>7}".format("Sources checked:", self.sources)) + self.stdout.write("{0:<40} {1:>7}".format( "Source references deleted from DB:", self.source_refs_deleted)) - print("{0:<40} {1:>7}".format("Thumbnails deleted from disk:", + self.stdout.write("{0:<40} {1:>7}".format("Thumbnails deleted from disk:", self.thumbnails_deleted)) - print("(Completed in %s seconds)\n" % self.execution_time) + self.stdout.write("(Completed in {} seconds)\n".format(self.execution_time)) def queryset_iterator(queryset, chunksize=1000): @@ -142,7 +148,7 @@ def add_arguments(self, parser): help='Specify a path to clean up.') def handle(self, *args, **options): - tcc = ThumbnailCollectionCleaner() + tcc = ThumbnailCollectionCleaner(self.stdout, self.stderr) tcc.clean_up( dry_run=options.get('dry_run', False), verbosity=int(options.get('verbosity', 1)), diff --git a/easy_thumbnails/namers.py b/easy_thumbnails/namers.py index 5a7fb47b..b46db5a6 100644 --- a/easy_thumbnails/namers.py +++ b/easy_thumbnails/namers.py @@ -16,7 +16,7 @@ def default(thumbnailer, prepared_options, source_filename, if thumbnail_extension != os.path.splitext(source_filename)[1][1:]: filename_parts.append(thumbnail_extension) else: - filename_parts += ['_'.join(prepared_options), thumbnail_extension] + filename_parts.extend(['_'.join(prepared_options), thumbnail_extension]) return '.'.join(filename_parts) diff --git a/easy_thumbnails/options.py b/easy_thumbnails/options.py index 2dd63c8b..f9ded89c 100644 --- a/easy_thumbnails/options.py +++ b/easy_thumbnails/options.py @@ -13,14 +13,14 @@ def __init__(self, *args, **kwargs): self.setdefault('subsampling', 2) def prepared_options(self): - prepared_opts = ['%sx%s' % tuple(self['size'])] + prepared_opts = ['{size[0]}x{size[1]}'.format(**self)] - subsampling = str(self['subsampling']) - if subsampling == '2': - subsampling_text = '' - else: - subsampling_text = 'ss%s' % subsampling - prepared_opts.append('q%s%s' % (self['quality'], subsampling_text)) + opts_text = '' + if 'quality' in self: + opts_text += 'q{quality}'.format(**self) + if 'subsampling' in self and str(self['subsampling']) != '2': + opts_text += 'ss{subsampling}'.format(**self) + prepared_opts.append(opts_text) for key, value in sorted(self.items()): if key == key.upper(): @@ -28,7 +28,7 @@ def prepared_options(self): # use of prepared options is to generate the filename -- these # options don't alter the filename). continue - if not value or key in ('size', 'quality', 'subsampling'): + if not value or key in ['size', 'quality', 'subsampling']: continue if value is True: prepared_opts.append(key) @@ -38,6 +38,6 @@ def prepared_options(self): value = ','.join([str(item) for item in value]) except TypeError: value = str(value) - prepared_opts.append('%s-%s' % (key, value)) + prepared_opts.append('{0}-{1}'.format(key, value)) return prepared_opts diff --git a/easy_thumbnails/signals.py b/easy_thumbnails/signals.py index b71e3cc8..cb2625d9 100644 --- a/easy_thumbnails/signals.py +++ b/easy_thumbnails/signals.py @@ -23,6 +23,4 @@ * The ``sender`` argument is the ``Thumbnailer`` * The ``options`` are the thumbnail options requested. -* The ``high_resolution`` boolean argument is set to ``True`` if this is the 2x - resolution thumbnail that was missed. """ diff --git a/easy_thumbnails/source_generators.py b/easy_thumbnails/source_generators.py index 290062f8..68d27bdf 100644 --- a/easy_thumbnails/source_generators.py +++ b/easy_thumbnails/source_generators.py @@ -1,6 +1,6 @@ +import warnings from io import BytesIO -from PIL import Image, ImageFile from easy_thumbnails import utils @@ -18,18 +18,40 @@ def pil_image(source, exif_orientation=True, **options): # object, PIL may have problems with it. For example, some image types # require tell and seek methods that are not present on all storage # File objects. + from PIL import Image, ImageFile + if not source: return source = BytesIO(source.read()) - with Image.open(source) as image: - # Fully load the image now to catch any problems with the image contents. - try: - ImageFile.LOAD_TRUNCATED_IMAGES = True - image.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + image = Image.open(source) + # Fully load the image now to catch any problems with the image contents. + try: + ImageFile.LOAD_TRUNCATED_IMAGES = True + image.load() + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False if exif_orientation: image = utils.exif_orientation(image) return image + + +def vil_image(source, **options): + """ + Try to open the source file directly using VIL, ignoring any errors. + """ + try: + from easy_thumbnails.VIL import Image + except ImportError as ie: + warnings.warn(f"Could not import VIL for SVG image support: {ie}.") + return + + if not source: + return + # path method should not be implemented for remote storages. We can pass the file directly. + # filename = source.source_storage.path(source.file.name) + try: + return Image.load(source.file) + except Exception as exc: + raise exc diff --git a/easy_thumbnails/templatetags/thumbnail.py b/easy_thumbnails/templatetags/thumbnail.py index 45b9e902..3c243688 100644 --- a/easy_thumbnails/templatetags/thumbnail.py +++ b/easy_thumbnails/templatetags/thumbnail.py @@ -17,7 +17,6 @@ VALID_OPTIONS = utils.valid_processor_options() VALID_OPTIONS.remove('size') -VALID_OPTIONS.append('HIGH_RESOLUTION') def split_args(args): @@ -296,7 +295,9 @@ def thumbnail_url(source, alias): """ try: thumb = get_thumbnailer(source)[alias] - except Exception: + except Exception as e: + if settings.THUMBNAIL_DEBUG: + raise e return '' return thumb.url diff --git a/easy_thumbnails/tests/settings.py b/easy_thumbnails/tests/settings.py index 2d0e00f0..0d34c3a2 100644 --- a/easy_thumbnails/tests/settings.py +++ b/easy_thumbnails/tests/settings.py @@ -14,6 +14,8 @@ } } +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.sites', diff --git a/easy_thumbnails/tests/test_engine.py b/easy_thumbnails/tests/test_engine.py index 7c5e99a5..24a539e6 100644 --- a/easy_thumbnails/tests/test_engine.py +++ b/easy_thumbnails/tests/test_engine.py @@ -8,13 +8,13 @@ class SaveTest(TestCase): def test_save_jpeg_rgba(self): source = Image.new('RGBA', (100, 100), (255, 255, 255, 0)) - data = engine.save_image(source, filename='test.jpg') + data = engine.save_pil_image(source, filename='test.jpg') with Image.open(data) as img: self.assertEqual(img.mode, 'RGB') def test_save_jpeg_la(self): source = Image.new('LA', (100, 100), (255, 0)) - data = engine.save_image(source, filename='test.jpg') + data = engine.save_pil_image(source, filename='test.jpg') with Image.open(data) as img: self.assertEqual(img.mode, 'L') diff --git a/easy_thumbnails/tests/test_fields.py b/easy_thumbnails/tests/test_fields.py index 25599275..a5295c8a 100644 --- a/easy_thumbnails/tests/test_fields.py +++ b/easy_thumbnails/tests/test_fields.py @@ -6,6 +6,7 @@ from easy_thumbnails.tests import utils, models from easy_thumbnails.tests.test_aliases import BaseTest as AliasBaseTest +from easy_thumbnails.engine import NoSourceGenerator from easy_thumbnails.exceptions import ( InvalidImageFormatError, EasyThumbnailsError) @@ -37,7 +38,7 @@ def test_generate_thumbnail_bad_image(self): instance = models.TestModel(avatar='avatars/invalid.jpg') generate = lambda: instance.avatar.generate_thumbnail( {'size': (300, 300)}) - self.assertRaises(IOError, generate) + self.assertRaises(NoSourceGenerator, generate) def test_generate_thumbnail_alias_bad_image(self): text_file = ContentFile("Lorem ipsum dolor sit amet. Not an image.") diff --git a/easy_thumbnails/tests/test_files.py b/easy_thumbnails/tests/test_files.py index d85342cb..9b8f0381 100644 --- a/easy_thumbnails/tests/test_files.py +++ b/easy_thumbnails/tests/test_files.py @@ -177,15 +177,6 @@ def test_extensions(self): thumb = self.ext_thumbnailer.get_thumbnail({'size': (100, 100)}) self.assertEqual(path.splitext(thumb.name)[1], '.jpg') - def test_high_resolution(self): - self.ext_thumbnailer.thumbnail_high_resolution = True - thumb = self.ext_thumbnailer.get_thumbnail({'size': (100, 100)}) - base, ext = path.splitext(thumb.path) - hires_thumb_file = ''.join([base + '@2x', ext]) - self.assertTrue(path.isfile(hires_thumb_file)) - with Image.open(hires_thumb_file) as thumb: - self.assertEqual(thumb.size, (200, 150)) - def test_subsampling(self): samplings = { 0: (1, 1, 1, 1, 1, 1), @@ -220,33 +211,6 @@ def test_default_subsampling(self): sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] self.assertEqual(sampling, (2, 1, 1, 1, 1, 1)) - def test_high_resolution_force_off(self): - self.ext_thumbnailer.thumbnail_high_resolution = True - thumb = self.ext_thumbnailer.get_thumbnail( - {'size': (100, 100), 'HIGH_RESOLUTION': False}) - base, ext = path.splitext(thumb.path) - hires_thumb_file = ''.join([base + '@2x', ext]) - self.assertFalse(path.exists(hires_thumb_file)) - - def test_high_resolution_force(self): - thumb = self.ext_thumbnailer.get_thumbnail( - {'size': (100, 100), 'HIGH_RESOLUTION': True}) - base, ext = path.splitext(thumb.path) - hires_thumb_file = ''.join([base + '@2x', ext]) - self.assertTrue(path.isfile(hires_thumb_file)) - with Image.open(hires_thumb_file) as thumb: - self.assertEqual(thumb.size, (200, 150)) - - def test_highres_infix(self): - self.ext_thumbnailer.thumbnail_high_resolution = True - self.ext_thumbnailer.thumbnail_highres_infix = '_2x' - thumb = self.ext_thumbnailer.get_thumbnail({'size': (100, 100)}) - base, ext = path.splitext(thumb.path) - hires_thumb_file = ''.join([base + '_2x', ext]) - self.assertTrue(path.isfile(hires_thumb_file)) - with Image.open(hires_thumb_file) as thumb: - self.assertEqual(thumb.size, (200, 150)) - @unittest.skipIf( 'easy_thumbnails.optimize' not in settings.INSTALLED_APPS, 'optimize app not installed') @@ -322,7 +286,7 @@ def test_cached_dimensions_of_cached_image(self): opts = {'size': (50, 50)} thumb = self.thumbnailer.get_thumbnail(opts) self.assertEqual((thumb.width, thumb.height), (50, 38)) - # Now the thumb has been created, check that dimesions are in the + # Now the thumb has been created, check that dimensions are in the # database. dimensions = models.ThumbnailDimensions.objects.all()[0] self.assertEqual( diff --git a/easy_thumbnails/tests/test_processors.py b/easy_thumbnails/tests/test_pixel_processors.py similarity index 100% rename from easy_thumbnails/tests/test_processors.py rename to easy_thumbnails/tests/test_pixel_processors.py diff --git a/easy_thumbnails/tests/test_svg_processors.py b/easy_thumbnails/tests/test_svg_processors.py new file mode 100644 index 00000000..bfafc663 --- /dev/null +++ b/easy_thumbnails/tests/test_svg_processors.py @@ -0,0 +1,51 @@ +import unittest +from easy_thumbnails import processors, VIL + + +def create_image(mode='RGB', size=(800, 600)): + from easy_thumbnails.VIL import Image, ImageDraw + from reportlab.lib.colors import Color + + image = Image.new(mode, size, (255, 255, 255)) + draw = ImageDraw.Draw(image) + x_bit, y_bit = size[0] // 10, size[1] // 10 + draw.rectangle((x_bit, y_bit * 2, x_bit * 7, y_bit * 3), Color(red=255)) + draw.rectangle((x_bit * 2, y_bit, x_bit * 3, y_bit * 8), Color(red=255)) + return image + + +@unittest.skipUnless(VIL.is_available(), "SVG support not available") +class ScaleAndCropTest(unittest.TestCase): + def test_scale(self): + image = create_image() + + scaled = processors.scale_and_crop(image, (100, 100)) + self.assertEqual(scaled.size, (100, 75)) + self.assertEqual(scaled.getbbox(), (0, 0, 800, 600)) + + not_scaled = processors.scale_and_crop(image, (1000, 1000)) + self.assertEqual(not_scaled.size, (800, 600)) + self.assertEqual(not_scaled.getbbox(), (0, 0, 800, 600)) + + upscaled = processors.scale_and_crop(image, (1000, 1000), upscale=True) + self.assertEqual(upscaled.size, (1000, 750)) + self.assertEqual(upscaled.getbbox(), (0, 0, 800, 600)) + + def test_crop(self): + image = create_image() + + x_cropped = processors.scale_and_crop(image, (100, 100), crop=True) + self.assertEqual(x_cropped.size, (100, 100)) + self.assertEqual(x_cropped.getbbox(), (100, 0, 600, 600)) + + not_cropped = processors.scale_and_crop(image, (1000, 1000), crop=True) + self.assertEqual(not_cropped.size, (800, 600)) + self.assertEqual(not_cropped.getbbox(), (0, 0, 800, 600)) + + y_cropped = processors.scale_and_crop(image, (400, 200), crop=True) + self.assertEqual(y_cropped.size, (400, 200)) + self.assertEqual(y_cropped.getbbox(), (0, 100, 800, 400)) + + upscaled = processors.scale_and_crop(image, (1000, 1000), crop=True, upscale=True) + self.assertEqual(upscaled.size, (1000, 1000)) + self.assertEqual(upscaled.getbbox(), (100, 0, 600, 600)) diff --git a/easy_thumbnails/tests/test_templatetags.py b/easy_thumbnails/tests/test_templatetags.py index ddeb7861..2f375bf3 100644 --- a/easy_thumbnails/tests/test_templatetags.py +++ b/easy_thumbnails/tests/test_templatetags.py @@ -1,13 +1,16 @@ -from os import path +import tempfile +import unittest +from pathlib import Path from django.template import Template, Context, TemplateSyntaxError -from PIL import Image from django.core.files import storage as django_storage +from django.utils.module_loading import import_string from easy_thumbnails import alias, storage from easy_thumbnails.conf import settings from easy_thumbnails.files import get_thumbnailer from easy_thumbnails.tests import utils as test +from easy_thumbnails import VIL class Base(test.BaseTest): @@ -43,6 +46,8 @@ def render_template(self, source, template_tag_library='thumbnail'): def verify_thumbnail(self, expected_size, options, source_filename=None, transparent=False): + from PIL import Image + if source_filename is None: source_filename = self.filename self.assertTrue(isinstance(options, dict)) @@ -145,6 +150,9 @@ def testTagInvalid(self): settings.THUMBNAIL_DEBUG = True self.assertRaises(TemplateSyntaxError, self.render_template, src) + src = '{% thumbnail source 240x240 HIGH_RESOLUTION %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + def testTag(self): # Set THUMBNAIL_DEBUG = True to make it easier to trace any failures settings.THUMBNAIL_DEBUG = True @@ -216,17 +224,6 @@ def test_mirror_templatetag_library(self): expected_url = ''.join((settings.MEDIA_URL, expected)) self.assertEqual(output, 'src="%s"' % expected_url) - def test_high_resolution(self): - output = self.render_template( - 'src="{% thumbnail source 80x80 HIGH_RESOLUTION %}"') - expected = self.verify_thumbnail((80, 60), {'size': (80, 80)}) - expected_url = ''.join((settings.MEDIA_URL, expected)) - self.assertEqual(output, 'src="%s"' % expected_url) - base, ext = path.splitext(expected) - hires_thumb_file = ''.join([base + '@2x', ext]) - self.assertTrue( - self.storage.exists(hires_thumb_file), hires_thumb_file) - class ThumbnailerBase(Base): restore_settings = ['THUMBNAIL_ALIASES', 'THUMBNAIL_MEDIA_ROOT'] @@ -371,3 +368,129 @@ def test_data_uri(self): output = self.render_template(src)[:64] startswith = 'data:application/octet-stream;base64,/9j/4AAQSkZJRgABAQAAAQABAAD' self.assertEqual(output, startswith) + + +@unittest.skipUnless(VIL.is_available(), "SVG support not available") +class ThumbnailSVGImage(test.BaseTest): + + def setUp(self): + super().setUp() + self.storage = test.TemporaryStorage() + # Save a test image. + self.filename = self.create_image(self.storage, 'test.svg', image_format='SVG') + + # Required so that IOError's get wrapped as TemplateSyntaxError + settings.TEMPLATE_DEBUG = True + + def tearDown(self): + self.storage.delete_temporary_storage() + super().tearDown() + + def render_template(self, source, template_tag_library='thumbnail'): + source_image = get_thumbnailer(self.storage, self.filename) + source_image.thumbnail_storage = self.storage + source_image.thumbnail_preserve_extensions = True + context = Context({ + 'source': source_image, + 'storage': self.storage, + 'filename': self.filename, + 'invalid_filename': 'not%s' % self.filename, + 'size': (90, 100), + 'invalid_size': (90, 'fish'), + 'strsize': '80x90', + 'invalid_strsize': ('1notasize2'), + 'invalid_q': 'notanumber'}) + source = '{% load ' + template_tag_library + ' %}' + source + return Template(source).render(context) + + def testTag(self): + # Set THUMBNAIL_DEBUG = True to make it easier to trace any failures + settings.THUMBNAIL_DEBUG = True + + # Basic + output = self.render_template('src="{% thumbnail source 240x240 %}"') + expected = self.verify_thumbnail((240, 180), {'size': (240, 240)}) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="{}"'.format(expected_url)) + + # Size from context variable + # as a tuple: + output = self.render_template( + 'src="{% thumbnail source size %}"') + expected = self.verify_thumbnail((90, 68), {'size': (90, 100)}) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="{}"'.format(expected_url)) + # as a string: + output = self.render_template( + 'src="{% thumbnail source strsize %}"') + expected = self.verify_thumbnail((80, 60), {'size': (80, 90)}) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="{}"'.format(expected_url)) + + # On context + output = self.render_template( + 'height:{% thumbnail source 240x240 as thumb %}{{ thumb.height }}') + self.assertEqual(output, 'height:180.0') + + # With options and quality + output = self.render_template( + 'src="{% thumbnail source 240x240 sharpen crop quality=95 %}"') + # Note that the opts are sorted to ensure a consistent filename. + expected = self.verify_thumbnail( + (240, 240), + {'size': (240, 240), 'crop': True, 'sharpen': True, 'quality': 95}) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="{}"'.format(expected_url)) + + # With option and quality on context (also using its unicode method to + # display the url) + output = self.render_template( + '{% thumbnail source 240x240 sharpen crop quality=95 as thumb %}' + 'width:{{ thumb.width }}, url:{{ thumb.url }}') + self.assertEqual(output, 'width:240.0, url:{}'.format(expected_url)) + + # One dimensional resize + output = self.render_template('src="{% thumbnail source 100x0 %}"') + expected = self.verify_thumbnail((100, 75), {'size': (100, 0)}) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="{}"'.format(expected_url)) + + def verify_thumbnail(self, expected_size, options, source_filename=None, + transparent=False): + from easy_thumbnails.VIL import Image + + if source_filename is None: + source_filename = self.filename + self.assertTrue(isinstance(options, dict)) + # Verify that the thumbnail file exists + thumbnailer = get_thumbnailer(self.storage, source_filename) + thumbnailer.thumbnail_preserve_extensions = True + expected_filename = thumbnailer.get_thumbnail_name( + options, transparent=transparent) + + self.assertTrue( + self.storage.exists(expected_filename), + "Thumbnail file %r not found" % expected_filename) + + # Verify the thumbnail has the expected dimensions + with self.storage.open(expected_filename) as expected_file: + with Image.load(expected_file.name) as image: + self.assertEqual(image.size, expected_size) + + return expected_filename + + def test_named_file(self): + Image = import_string('easy_thumbnails.VIL.Image') + expected = '......' + with Image.new('rgb', (30, 30)) as img: + with tempfile.NamedTemporaryFile() as namedtmpfile: + img.save(namedtmpfile.name, 'SVG') + namedtmpfile.seek(0) + xml = namedtmpfile.read().decode() + self.assertHTMLEqual(xml, expected) + with tempfile.NamedTemporaryFile() as namedtmpfile: + path = Path(namedtmpfile.name) + img.save(path, 'SVG') + namedtmpfile.seek(0) + xml = path.read_text() + self.assertHTMLEqual(xml, expected) diff --git a/easy_thumbnails/tests/utils.py b/easy_thumbnails/tests/utils.py index 214c2b03..1cf5d821 100644 --- a/easy_thumbnails/tests/utils.py +++ b/easy_thumbnails/tests/utils.py @@ -1,12 +1,13 @@ import shutil import tempfile -from io import BytesIO +from io import BytesIO, StringIO from django.core.files.base import ContentFile from django.core.files.storage import FileSystemStorage from django.test import TestCase from django.utils.deconstruct import deconstructible -from PIL import Image +from django.utils.module_loading import import_string + from easy_thumbnails.conf import settings @@ -117,7 +118,12 @@ def create_image(self, storage, filename, size=(800, 600), If ``storage`` is ``None``, the BytesIO containing the image data will be passed instead. """ - data = BytesIO() + if image_format == 'SVG': + from easy_thumbnails.VIL import Image + data = StringIO() + else: + from PIL import Image + data = BytesIO() with Image.new(image_mode, size) as img: img.save(data, image_format) data.seek(0) diff --git a/easy_thumbnails/utils.py b/easy_thumbnails/utils.py index 20cb417f..e34b8a75 100644 --- a/easy_thumbnails/utils.py +++ b/easy_thumbnails/utils.py @@ -2,8 +2,10 @@ import inspect import math -from django.utils.functional import LazyObject from django.utils import timezone +from django.utils.functional import LazyObject +from django.utils.module_loading import import_string + from PIL import Image from easy_thumbnails.conf import settings @@ -21,19 +23,6 @@ def image_entropy(im): return -sum([p * math.log(p, 2) for p in hist if p != 0]) -def dynamic_import(import_string): - """ - Dynamically import a module or object. - """ - # Use rfind rather than rsplit for Python 2.3 compatibility. - lastdot = import_string.rfind('.') - if lastdot == -1: - return __import__(import_string, {}, {}, []) - module_name, attr = import_string[:lastdot], import_string[lastdot + 1:] - parent_module = __import__(module_name, {}, {}, [attr]) - return getattr(parent_module, attr) - - def valid_processor_options(processors=None): """ Return a list of unique valid options for a list of image processors @@ -41,7 +30,7 @@ def valid_processor_options(processors=None): """ if processors is None: processors = [ - dynamic_import(p) for p in + import_string(p) for p in tuple(settings.THUMBNAIL_PROCESSORS) + tuple(settings.THUMBNAIL_SOURCE_GENERATORS)] valid_options = set(['size', 'quality', 'subsampling']) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..b647c37a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[tool:pytest] +DJANGO_SETTINGS_MODULE = easy_thumbnails.tests.settings diff --git a/setup.py b/setup.py index 63104c5c..b6450d8f 100644 --- a/setup.py +++ b/setup.py @@ -1,25 +1,9 @@ #!/usr/bin/env python import codecs -import os -from setuptools import setup, find_packages -from setuptools.command.test import test as TestCommand -import easy_thumbnails - - -class DjangoTests(TestCommand): +from setuptools import find_packages, setup - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self): - from django.core import management - DSM = 'DJANGO_SETTINGS_MODULE' - if DSM not in os.environ: - os.environ[DSM] = 'easy_thumbnails.tests.settings' - management.execute_from_command_line() +import easy_thumbnails def read_files(*filenames): @@ -28,51 +12,58 @@ def read_files(*filenames): """ output = [] for filename in filenames: - f = codecs.open(filename, encoding='utf-8') + f = codecs.open(filename, encoding="utf-8") try: output.append(f.read()) finally: f.close() - return '\n\n'.join(output) + return "\n\n".join(output) setup( - name='easy-thumbnails', + name="easy-thumbnails", version=easy_thumbnails.get_version(), - url='http://github.com/SmileyChris/easy-thumbnails', - description='Easy thumbnails for Django', - long_description=read_files('README.rst', 'CHANGES.rst'), - author='Chris Beaven', - author_email='smileychris@gmail.com', - platforms=['any'], + url="http://github.com/SmileyChris/easy-thumbnails", + description="Easy thumbnails for Django", + long_description=read_files("README.rst", "CHANGES.rst"), + author="Chris Beaven", + author_email="smileychris@gmail.com", + platforms=["any"], packages=find_packages(), include_package_data=True, install_requires=[ - 'django>=1.11,<4.0', - 'pillow', + "django>=2.2", + "pillow", ], - python_requires='>=3.5', - cmdclass={'test': DjangoTests}, + extras_require={ + "svg": [ + "svglib", + "reportlab", + ], + }, + python_requires=">=3.6", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.0', - 'Framework :: Django :: 3.1', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Topic :: Software Development :: Libraries :: Application Frameworks', - 'Topic :: Software Development :: Libraries :: Python Modules', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", ], zip_safe=False, ) diff --git a/tox.ini b/tox.ini index 47a542c1..7dcdfab2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,32 @@ [tox] distribute = False envlist = - py{35,36,37}-django{11,22} - py{36,37,38}-django{30,31} - docs + py{36,37,38,39}-django{22,30,31,32}{-svg,} + py310-django{32,40}{-svg,} skip_missing_interpreters = True -[travis] +[gh-actions] python = - 3.5: py35 - 3.6: py36, docs + 3.6: py36 3.7: py37 3.8: py38 + 3.9: py39 + 3.10: py310 [testenv] -setenv = +setenv = DJANGO_SETTINGS_MODULE=easy_thumbnails.tests.settings usedevelop = True +extras = + svg: svg deps = - django11: Django~=1.11.17 - django22: Django~=2.2.0 - django30: Django~=3.0.0 - django31: Django~=3.1.0 + django22: Django<2.3 + django30: Django<3.1 + django31: Django<3.2 + django32: Django<3.3 + django40: Django<4.1 testfixtures commands = python -Wd {envbindir}/django-admin test {posargs} - -[testenv:docs] -basepython = python3.6 -deps = - Sphinx - Django -commands = - {envbindir}/sphinx-build -a -n -q -W -d docs/_build/doctrees docs docs/_build/html - {envbindir}/rst2html.py --exit-status 2 README.rst /dev/null - {envbindir}/rst2html.py --exit-status 2 CHANGES.rst /dev/null - {envbindir}/rst2html.py --exit-status 2 TESTING.rst /dev/null +ignore_outcome = + djangomain: true