images.py 7.81 KB
Newer Older
1 2 3
"""
Image file manipulation functions related to profile images.
"""
4
from collections import namedtuple
5
from contextlib import closing
6
from cStringIO import StringIO
7

8
import piexif
9 10
from django.conf import settings
from django.core.files.base import ContentFile
11
from django.utils.translation import ugettext as _
12 13 14 15
from PIL import Image

from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage

16
from .exceptions import ImageValidationError
17

18 19
ImageType = namedtuple('ImageType', ('extensions', 'mimetypes', 'magic'))

20
IMAGE_TYPES = {
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
    'jpeg': ImageType(
        extensions=['.jpeg', '.jpg'],
        mimetypes=['image/jpeg', 'image/pjpeg'],
        magic=['ffd8'],
    ),
    'png': ImageType(
        extensions=[".png"],
        mimetypes=['image/png'],
        magic=["89504e470d0a1a0a"],
    ),
    'gif': ImageType(
        extensions=[".gif"],
        mimetypes=['image/gif'],
        magic=["474946383961", "474946383761"],
    ),
36 37 38
}


39
def create_profile_images(image_file, profile_image_names):
40
    """
41 42
    Generates a set of image files based on image_file and stores them
    according to the sizes and filenames specified in `profile_image_names`.
43 44

    Arguments:
45 46 47 48 49 50 51 52 53 54

        image_file (file):
            The uploaded image file to be cropped and scaled to use as a
            profile image.  The image is cropped to the largest possible square,
            and centered on this image.

        profile_image_names (dict):
            A dictionary that maps image sizes to file names.  The image size
            is an integer representing one side of the equilateral image to be
            created.
55 56

    Returns:
57 58

        None
59
    """
60
    storage = get_profile_image_storage()
61

62 63 64
    original = Image.open(image_file)
    image = _set_color_mode_to_rgb(original)
    image = _crop_image_to_square(image)
65

66 67 68 69 70
    for size, name in profile_image_names.items():
        scaled = _scale_image(image, size)
        exif = _get_corrected_exif(scaled, original)
        with closing(_create_image_file(scaled, exif)) as scaled_image_file:
            storage.save(name, scaled_image_file)
71 72


73
def remove_profile_images(profile_image_names):
74
    """
75
    Physically remove the image files specified in `profile_image_names`
76
    """
77 78 79
    storage = get_profile_image_storage()
    for name in profile_image_names.values():
        storage.delete(name)
80 81 82 83 84 85 86 87


def validate_uploaded_image(uploaded_file):
    """
    Raises ImageValidationError if the server should refuse to use this
    uploaded file as the source image for a user's profile image.  Otherwise,
    returns nothing.
    """
88

89 90 91 92 93
    # validation code by @pmitros,
    # adapted from https://github.com/pmitros/ProfileXBlock
    # see also: http://en.wikipedia.org/wiki/Magic_number_%28programming%29

    if uploaded_file.size > settings.PROFILE_IMAGE_MAX_BYTES:
94 95 96
        file_upload_too_large = _(
            u'The file must be smaller than {image_max_size} in size.'
        ).format(
97
            image_max_size=_user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES)
98 99
        )
        raise ImageValidationError(file_upload_too_large)
100
    elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
101 102 103
        file_upload_too_small = _(
            u'The file must be at least {image_min_size} in size.'
        ).format(
104
            image_min_size=_user_friendly_size(settings.PROFILE_IMAGE_MIN_BYTES)
105 106
        )
        raise ImageValidationError(file_upload_too_small)
107 108 109

    # check the file extension looks acceptable
    filename = unicode(uploaded_file.name).lower()
110
    filetype = [ft for ft in IMAGE_TYPES if any(filename.endswith(ext) for ext in IMAGE_TYPES[ft].extensions)]
111
    if not filetype:
112 113
        file_upload_bad_type = _(
            u'The file must be one of the following types: {valid_file_types}.'
114
        ).format(valid_file_types=_get_valid_file_types())
115
        raise ImageValidationError(file_upload_bad_type)
116 117 118
    filetype = filetype[0]

    # check mimetype matches expected file type
119
    if uploaded_file.content_type not in IMAGE_TYPES[filetype].mimetypes:
120 121 122 123 124
        file_upload_bad_mimetype = _(
            u'The Content-Type header for this file does not match '
            u'the file data. The file may be corrupted.'
        )
        raise ImageValidationError(file_upload_bad_mimetype)
125 126

    # check magic number matches expected file type
127
    headers = IMAGE_TYPES[filetype].magic
128
    if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
129 130 131 132 133
        file_upload_bad_ext = _(
            u'The file name extension for this file does not match '
            u'the file data. The file may be corrupted.'
        )
        raise ImageValidationError(file_upload_bad_ext)
134 135 136 137
    # avoid unexpected errors from subsequent modules expecting the fp to be at 0
    uploaded_file.seek(0)


138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
def _crop_image_to_square(image):
    """
    Given a PIL.Image object, return a copy cropped to a square around the
    center point with each side set to the size of the smaller dimension.
    """
    width, height = image.size
    if width != height:
        side = width if width < height else height
        left = (width - side) // 2
        top = (height - side) // 2
        right = (width + side) // 2
        bottom = (height + side) // 2
        image = image.crop((left, top, right, bottom))
    return image


def _set_color_mode_to_rgb(image):
    """
    Given a PIL.Image object, return a copy with the color mode set to RGB.
157
    """
158
    return image.convert('RGB')
159

160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175

def _scale_image(image, side_length):
    """
    Given a PIL.Image object, get a resized copy with each side being
    `side_length` pixels long.  The scaled image will always be square.
    """
    return image.resize((side_length, side_length), Image.ANTIALIAS)


def _create_image_file(image, exif):
    """
    Given a PIL.Image object, create and return a file-like object containing
    the data saved as a JPEG.

    Note that the file object returned is a django ContentFile which holds data
    in memory (not on disk).
176 177
    """
    string_io = StringIO()
178 179 180 181 182 183 184 185

    # The if/else dance below is required, because PIL raises an exception if
    # you pass None as the value of the exif kwarg.
    if exif is None:
        image.save(string_io, format='JPEG')
    else:
        image.save(string_io, format='JPEG', exif=exif)

186 187 188 189
    image_file = ContentFile(string_io.getvalue())
    return image_file


190
def _get_corrected_exif(image, original):
191
    """
192 193
    If the original image contains exif data, use that data to
    preserve image orientation in the new image.
194
    """
195 196 197 198 199
    if 'exif' in original.info:
        image_exif = image.info.get('exif', piexif.dump({}))
        original_exif = original.info['exif']
        image_exif = _update_exif_orientation(image_exif, _get_exif_orientation(original_exif))
        return image_exif
200 201


202 203 204 205 206 207
def _update_exif_orientation(exif, orientation):
    """
    Given an exif value and an integer value 1-8, reflecting a valid value for
    the exif orientation, return a new exif with the orientation set.
    """
    exif_dict = piexif.load(exif)
208 209
    if orientation:
        exif_dict['0th'][piexif.ImageIFD.Orientation] = orientation
210
    return piexif.dump(exif_dict)
211 212


213
def _get_exif_orientation(exif):
214 215 216 217
    """
    Return the orientation value for the given Image object, or None if the
    value is not set.
    """
218 219 220 221 222
    exif_dict = piexif.load(exif)
    return exif_dict['0th'].get(piexif.ImageIFD.Orientation)


def _get_valid_file_types():
223
    """
224
    Return comma separated string of valid file types.
225
    """
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
    return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])


def _user_friendly_size(size):
    """
    Convert size in bytes to user friendly size.

    Arguments:
        size (int): size in bytes

    Returns:
        user friendly size
    """
    units = [_('bytes'), _('KB'), _('MB')]
    i = 0
    while size >= 1024 and i < len(units):
        size /= 1024
        i += 1
    return u'{} {}'.format(size, units[i])