Skip to content

Commit

Permalink
Merge pull request #560 from SmileyChris/svg-support
Browse files Browse the repository at this point in the history
Add support for thumbnailing SVG images
  • Loading branch information
jrief committed Nov 3, 2021
2 parents bf50cc4 + 264be4b commit 5d7a9cd
Show file tree
Hide file tree
Showing 27 changed files with 561 additions and 215 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10']
python-version: ['3.6', '3.7', '3.8', '3.9', '3.10']

steps:
- uses: actions/checkout@v2
Expand Down
10 changes: 10 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changes
=======

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.


2.7.2 (2021-10-17)
------------------

Expand All @@ -24,11 +32,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)
------------------

Expand Down
22 changes: 22 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ 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.rc0 adds support for thumbnailing SVG images.

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
`<img src="..." width="..." height="...">` has been prepared for.

This is done by adding an emulation layer named VIL, which aims to be compatible with the
`PIL <https://python-pillow.org/>`_ 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
============

Expand Down
5 changes: 1 addition & 4 deletions docs/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,6 +20,7 @@ Simply type::

pip install easy-thumbnails


Manual installation
-------------------

Expand Down
24 changes: 24 additions & 0 deletions docs/ref/svg.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
===============================
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 ``<img src="{{ thumb.url }}" width="..." height="...">`` 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<https://django-filer.readthedocs.io/en/latest/>`_ without modification.

Since easy-thumbnails version 2.8, you can therefore use an SVG image, just as you would use any other image.

Cropping an SVG image works as expected. Filtering an SVG image will however not work.
188 changes: 188 additions & 0 deletions easy_thumbnails/VIL/Image.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions easy_thumbnails/VIL/ImageDraw.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file added easy_thumbnails/VIL/__init__.py
Empty file.
2 changes: 1 addition & 1 deletion easy_thumbnails/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = (2, 7, 2, 'final', 0)
VERSION = (2, 8, 0, 'final', 0)


def get_version(*args, **kwargs):
Expand Down

0 comments on commit 5d7a9cd

Please sign in to comment.