diff options
Diffstat (limited to 'nikola/image_processing.py')
| -rw-r--r-- | nikola/image_processing.py | 162 |
1 files changed, 131 insertions, 31 deletions
diff --git a/nikola/image_processing.py b/nikola/image_processing.py index 0ba139f..e0096b2 100644 --- a/nikola/image_processing.py +++ b/nikola/image_processing.py @@ -29,34 +29,76 @@ from __future__ import unicode_literals import datetime import os +import lxml +import re +import gzip + +import piexif from nikola import utils Image = None try: - from PIL import Image, ExifTags # NOQA + from PIL import ExifTags, Image # NOQA except ImportError: try: - import Image as _Image import ExifTags + import Image as _Image Image = _Image except ImportError: pass +EXIF_TAG_NAMES = {} -class ImageProcessor(object): +class ImageProcessor(object): """Apply image operations.""" - image_ext_list_builtin = ['.jpg', '.png', '.jpeg', '.gif', '.svg', '.bmp', '.tiff'] + image_ext_list_builtin = ['.jpg', '.png', '.jpeg', '.gif', '.svg', '.svgz', '.bmp', '.tiff'] + + def _fill_exif_tag_names(self): + """Connect EXIF tag names to numeric values.""" + if not EXIF_TAG_NAMES: + for ifd in piexif.TAGS: + for tag, data in piexif.TAGS[ifd].items(): + EXIF_TAG_NAMES[tag] = data['name'] - def resize_image(self, src, dst, max_size, bigger_panoramas=True): + def filter_exif(self, exif, whitelist): + """Filter EXIF data as described in the documentation.""" + # Scenario 1: keep everything + if whitelist == {'*': '*'}: + return exif + + # Scenario 2: keep nothing + if whitelist == {}: + return None + + # Scenario 3: keep some + self._fill_exif_tag_names() + exif = exif.copy() # Don't modify in-place, it's rude + for k in list(exif.keys()): + if type(exif[k]) != dict: + pass # At least thumbnails have no fields + elif k not in whitelist: + exif.pop(k) # Not whitelisted, remove + elif k in whitelist and whitelist[k] == '*': + # Fully whitelisted, keep all + pass + else: + # Partially whitelisted + for tag in list(exif[k].keys()): + if EXIF_TAG_NAMES[tag] not in whitelist[k]: + exif[k].pop(tag) + + return exif or None + + def resize_image(self, src, dst, max_size, bigger_panoramas=True, preserve_exif_data=False, exif_whitelist={}): """Make a copy of the image in the requested size.""" - if not Image: - utils.copy_file(src, dst) + if not Image or os.path.splitext(src)[1] in ['.svg', '.svgz']: + self.resize_svg(src, dst, max_size, bigger_panoramas) return im = Image.open(src) - w, h = im.size + size = w, h = im.size if w > max_size or h > max_size: size = max_size, max_size @@ -64,30 +106,86 @@ class ImageProcessor(object): if bigger_panoramas and w > 2 * h: size = min(w, max_size * 4), min(w, max_size * 4) - try: - exif = im._getexif() - except Exception: - exif = None - if exif is not None: - for tag, value in list(exif.items()): - decoded = ExifTags.TAGS.get(tag, tag) + try: + exif = piexif.load(im.info["exif"]) + except KeyError: + exif = None + # Inside this if, we can manipulate exif as much as + # we want/need and it will be preserved if required + if exif is not None: + # Rotate according to EXIF + value = exif['0th'].get(piexif.ImageIFD.Orientation, 1) + if value in (3, 4): + im = im.transpose(Image.ROTATE_180) + elif value in (5, 6): + im = im.transpose(Image.ROTATE_270) + elif value in (7, 8): + im = im.transpose(Image.ROTATE_90) + if value in (2, 4, 5, 7): + im = im.transpose(Image.FLIP_LEFT_RIGHT) + exif['0th'][piexif.ImageIFD.Orientation] = 1 - if decoded == 'Orientation': - if value == 3: - im = im.rotate(180) - elif value == 6: - im = im.rotate(270) - elif value == 8: - im = im.rotate(90) - break - try: - im.thumbnail(size, Image.ANTIALIAS) + try: + im.thumbnail(size, Image.ANTIALIAS) + if exif is not None and preserve_exif_data: + # Put right size in EXIF data + w, h = im.size + if '0th' in exif: + exif["0th"][piexif.ImageIFD.ImageWidth] = w + exif["0th"][piexif.ImageIFD.ImageLength] = h + if 'Exif' in exif: + exif["Exif"][piexif.ExifIFD.PixelXDimension] = w + exif["Exif"][piexif.ExifIFD.PixelYDimension] = h + # Filter EXIF data as required + exif = self.filter_exif(exif, exif_whitelist) + im.save(dst, exif=piexif.dump(exif)) + else: im.save(dst) - except Exception as e: - self.logger.warn("Can't thumbnail {0}, using original " - "image as thumbnail ({1})".format(src, e)) - utils.copy_file(src, dst) - else: # Image is small + except Exception as e: + self.logger.warn("Can't process {0}, using original " + "image! ({1})".format(src, e)) + utils.copy_file(src, dst) + + def resize_svg(self, src, dst, max_size, bigger_panoramas): + """Make a copy of an svg at the requested size.""" + try: + # Resize svg based on viewport hacking. + # note that this can also lead to enlarged svgs + if src.endswith('.svgz'): + with gzip.GzipFile(src, 'rb') as op: + xml = op.read() + else: + with open(src, 'rb') as op: + xml = op.read() + tree = lxml.etree.XML(xml) + width = tree.attrib['width'] + height = tree.attrib['height'] + w = int(re.search("[0-9]+", width).group(0)) + h = int(re.search("[0-9]+", height).group(0)) + # calculate new size preserving aspect ratio. + ratio = float(w) / h + # Panoramas get larger thumbnails because they look *awful* + if bigger_panoramas and w > 2 * h: + max_size = max_size * 4 + if w > h: + w = max_size + h = max_size / ratio + else: + w = max_size * ratio + h = max_size + w = int(w) + h = int(h) + tree.attrib.pop("width") + tree.attrib.pop("height") + tree.attrib['viewport'] = "0 0 %ipx %ipx" % (w, h) + if dst.endswith('.svgz'): + op = gzip.GzipFile(dst, 'wb') + else: + op = open(dst, 'wb') + op.write(lxml.etree.tostring(tree)) + op.close() + except (KeyError, AttributeError) as e: + self.logger.warn("No width/height in %s. Original exception: %s" % (src, e)) utils.copy_file(src, dst) def image_date(self, src): @@ -103,8 +201,10 @@ class ImageProcessor(object): decoded = ExifTags.TAGS.get(tag, tag) if decoded in ('DateTimeOriginal', 'DateTimeDigitized'): try: + if isinstance(value, tuple): + value = value[0] self.dates[src] = datetime.datetime.strptime( - value, r'%Y:%m:%d %H:%M:%S') + value, '%Y:%m:%d %H:%M:%S') break except ValueError: # Invalid EXIF date. pass |
