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