summaryrefslogtreecommitdiffstats
path: root/nikola/image_processing.py
diff options
context:
space:
mode:
authorLibravatarUnit 193 <unit193@unit193.net>2021-02-03 19:17:00 -0500
committerLibravatarUnit 193 <unit193@unit193.net>2021-02-03 19:17:00 -0500
commit3a0d66f07b112b6d2bdc2b57bbf717a89a351ce6 (patch)
treea7cf56282e54f05785243bc1e903d6594f2c06ba /nikola/image_processing.py
parent787b97a4cb24330b36f11297c6d3a7a473a907d0 (diff)
New upstream version 8.1.2.upstream/8.1.2
Diffstat (limited to 'nikola/image_processing.py')
-rw-r--r--nikola/image_processing.py215
1 files changed, 170 insertions, 45 deletions
diff --git a/nikola/image_processing.py b/nikola/image_processing.py
index 0ba139f..04d4e64 100644
--- a/nikola/image_processing.py
+++ b/nikola/image_processing.py
@@ -26,69 +26,191 @@
"""Process images."""
-from __future__ import unicode_literals
import datetime
+import gzip
import os
+import re
+
+import lxml
+import piexif
+from PIL import ExifTags, Image
from nikola import utils
-Image = None
-try:
- from PIL import Image, ExifTags # NOQA
-except ImportError:
- try:
- import Image as _Image
- import ExifTags
- Image = _Image
- except ImportError:
- pass
+EXIF_TAG_NAMES = {}
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', '.webp']
+
+ 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 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=None, max_size=None, bigger_panoramas=True, preserve_exif_data=False, exif_whitelist={}, preserve_icc_profiles=False, dst_paths=None, max_sizes=None):
+ """Make a copy of the image in the requested size(s).
- def resize_image(self, src, dst, max_size, bigger_panoramas=True):
- """Make a copy of the image in the requested size."""
- if not Image:
- utils.copy_file(src, dst)
+ max_sizes should be a list of sizes, and the image would be resized to fit in a
+ square of each size (preserving aspect ratio).
+
+ dst_paths is a list of the destination paths, and should be the same length as max_sizes.
+
+ Backwards compatibility:
+
+ * If max_sizes is None, it's set to [max_size]
+ * If dst_paths is None, it's set to [dst]
+ * Either max_size or max_sizes should be set
+ * Either dst or dst_paths should be set
+ """
+ if dst_paths is None:
+ dst_paths = [dst]
+ if max_sizes is None:
+ max_sizes = [max_size]
+ if len(max_sizes) != len(dst_paths):
+ raise ValueError('resize_image called with incompatible arguments: {} / {}'.format(dst_paths, max_sizes))
+ extension = os.path.splitext(src)[1].lower()
+ if extension in {'.svg', '.svgz'}:
+ self.resize_svg(src, dst_paths, max_sizes, bigger_panoramas)
return
- im = Image.open(src)
- w, h = im.size
- if w > max_size or h > max_size:
- size = max_size, max_size
- # Panoramas get larger thumbnails because they look *awful*
- if bigger_panoramas and w > 2 * h:
- size = min(w, max_size * 4), min(w, max_size * 4)
+ _im = Image.open(src)
- 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)
+ # The jpg exclusion is Issue #3332
+ is_animated = hasattr(_im, 'n_frames') and _im.n_frames > 1 and extension not in {'.jpg', '.jpeg'}
+
+ exif = None
+ if "exif" in _im.info:
+ exif = piexif.load(_im.info["exif"])
+ # Rotate according to EXIF
+ if "0th" in 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
+ exif = self.filter_exif(exif, exif_whitelist)
+
+ icc_profile = _im.info.get('icc_profile') if preserve_icc_profiles else None
+
+ for dst, max_size in zip(dst_paths, max_sizes):
+ if is_animated: # Animated gif, leave as-is
+ utils.copy_file(src, dst)
+ continue
- 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
+ im = _im.copy()
+
+ size = w, h = im.size
+ if w > max_size or h > max_size:
+ size = max_size, max_size
+ # Panoramas get larger thumbnails because they look *awful*
+ if bigger_panoramas and w > 2 * h:
+ size = min(w, max_size * 4), min(w, max_size * 4)
try:
im.thumbnail(size, Image.ANTIALIAS)
- im.save(dst)
+ save_args = {}
+ if icc_profile:
+ save_args['icc_profile'] = icc_profile
+
+ 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
+ save_args['exif'] = piexif.dump(exif)
+
+ im.save(dst, **save_args)
except Exception as e:
- self.logger.warn("Can't thumbnail {0}, using original "
- "image as thumbnail ({1})".format(src, e))
+ self.logger.warning("Can't process {0}, using original "
+ "image! ({1})".format(src, e))
+ utils.copy_file(src, dst)
+
+ def resize_svg(self, src, dst_paths, max_sizes, bigger_panoramas):
+ """Make a copy of an svg at the requested sizes."""
+ # 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()
+
+ for dst, max_size in zip(dst_paths, max_sizes):
+ try:
+ 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.warning("No width/height in %s. Original exception: %s" % (src, e))
utils.copy_file(src, dst)
- else: # Image is small
- utils.copy_file(src, dst)
def image_date(self, src):
"""Try to figure out the date of the image."""
@@ -96,6 +218,7 @@ class ImageProcessor(object):
try:
im = Image.open(src)
exif = im._getexif()
+ im.close()
except Exception:
exif = None
if exif is not None:
@@ -103,8 +226,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