diff --git a/thumbnailer/Readme.md b/thumbnailer/Readme.md new file mode 100644 index 0000000..3c0a6a2 --- /dev/null +++ b/thumbnailer/Readme.md @@ -0,0 +1,26 @@ +Thumbnail Creation of images +============================ + +This plugin creates thumbnails for all of the images found under a specific directory, in various thumbnail sizes +It requires PIL to function properly since PIL is used to resize the images, and will only rebuild a thumbnail if it +doesn't already exists (to save processing time) + +Installation +------------- + +Setup up like a normal plugin by setting PLUGIN_PATH, and adding "thumbnailer" to the PLUGINS list + +Configuration +------------- + +* IMAGE_PATH is the path to the image directory. It should reside under content, and defaults to "pictures" +* THUMBNAIL_DIR is the path to the output sub directory where the thumbnails are generated +* THUMBNAIL_SIZES is a dictionary mapping name of size to size specifications. + The generated filename will be originalname_thumbnailname.ext + +Sizes can be specified using any of the following formats: + +* wxh will resize to exactly wxh cropping as necessary to get that size +* wx? will resize so that the width is the specified size, and the height will scale to retain aspect ratio +* ?xh same as wx? but will height being a set size +* s is a shorthand for wxh where w=h \ No newline at end of file diff --git a/thumbnailer/__init__.py b/thumbnailer/__init__.py new file mode 100644 index 0000000..20797b1 --- /dev/null +++ b/thumbnailer/__init__.py @@ -0,0 +1 @@ +from .thumbnailer import * \ No newline at end of file diff --git a/thumbnailer/test_data/expected_exact.jpg b/thumbnailer/test_data/expected_exact.jpg new file mode 100644 index 0000000..5819792 Binary files /dev/null and b/thumbnailer/test_data/expected_exact.jpg differ diff --git a/thumbnailer/test_data/expected_height.jpg b/thumbnailer/test_data/expected_height.jpg new file mode 100644 index 0000000..6459410 Binary files /dev/null and b/thumbnailer/test_data/expected_height.jpg differ diff --git a/thumbnailer/test_data/expected_square.jpg b/thumbnailer/test_data/expected_square.jpg new file mode 100644 index 0000000..de99e5b Binary files /dev/null and b/thumbnailer/test_data/expected_square.jpg differ diff --git a/thumbnailer/test_data/expected_width.jpg b/thumbnailer/test_data/expected_width.jpg new file mode 100644 index 0000000..9c2efc6 Binary files /dev/null and b/thumbnailer/test_data/expected_width.jpg differ diff --git a/thumbnailer/test_data/sample_image.jpg b/thumbnailer/test_data/sample_image.jpg new file mode 100644 index 0000000..cc83880 Binary files /dev/null and b/thumbnailer/test_data/sample_image.jpg differ diff --git a/thumbnailer/test_thumbnails.py b/thumbnailer/test_thumbnails.py new file mode 100644 index 0000000..ca086a1 --- /dev/null +++ b/thumbnailer/test_thumbnails.py @@ -0,0 +1,36 @@ +from thumbnailer import _resizer +from unittest import TestCase, main +import os.path as path +from PIL import Image, ImageChops + +class ThumbnailerTests(TestCase): + + def path(self, filename): + return path.join(self.img_path, filename) + + def setUp(self): + self.img_path = path.join(path.dirname(__file__), "test_data") + self.img = Image.open(self.path("sample_image.jpg")) + + def testSquare(self): + r = _resizer('square', '100') + output = r.resize(self.img) + self.assertEqual((100, 100), output.size) + + def testExact(self): + r = _resizer('exact', '250x100') + output = r.resize(self.img) + self.assertEqual((250, 100), output.size) + + def testWidth(self): + r = _resizer('aspect', '250x?') + output = r.resize(self.img) + self.assertEqual((250, 166), output.size) + + def testHeight(self): + r = _resizer('aspect', '?x250') + output = r.resize(self.img) + self.assertEqual((375, 250), output.size) + +if __name__=="__main__": + main() \ No newline at end of file diff --git a/thumbnailer/thumbnailer.py b/thumbnailer/thumbnailer.py new file mode 100644 index 0000000..cd1b7af --- /dev/null +++ b/thumbnailer/thumbnailer.py @@ -0,0 +1,137 @@ +import os +import os.path as path +import re +from pelican import signals + +import logging +logger = logging.getLogger(__name__) + +try: + from PIL import Image, ImageOps + enabled = True +except ImportError: + logging.warning("Unable to load PIL, disabling thumbnailer") + enabled = False + +DEFAULT_IMAGE_DIR = "pictures" +DEFAULT_THUMBNAIL_DIR = "thumbnails" +DEFAULT_THUMBNAIL_SIZES = { + 'thumbnail_square': '150', + 'thumbnail_wide': '150x?', + 'thumbnail_tall': '?x150', +} + +class _resizer(object): + """ Resizes based on a text specification, see readme """ + + REGEX = re.compile(r'(\d+|\?)x(\d+|\?)') + + def __init__(self, name, spec): + self._name = name + self._spec = spec + + def _null_resize(self, w, h, image): + return image + + def _exact_resize(self, w, h, image): + retval = ImageOps.fit(image, (w,h), Image.BICUBIC) + return retval + + def _aspect_resize(self, w, h, image): + retval = image.copy() + retval.thumbnail((w, h), Image.ANTIALIAS) + + return retval + + def resize(self, image): + resizer = self._null_resize + + # Square resize and crop + if 'x' not in self._spec: + resizer = self._exact_resize + targetw = int(self._spec) + targeth = targetw + else: + matches = self.REGEX.search(self._spec) + tmpw = matches.group(1) + tmph = matches.group(2) + + # Full Size + if tmpw == '?' and tmph == '?': + targetw = image.size[0] + targeth = image.size[1] + resizer = self._null_resize + + # Set Height Size + if tmpw == '?': + targetw = image.size[0] + targeth = int(tmph) + resizer = self._aspect_resize + + # Set Width Size + elif tmph == '?': + targetw = int(tmpw) + targeth = image.size[1] + resizer = self._aspect_resize + + # Scale and Crop + else: + targetw = int(tmpw) + targeth = int(tmph) + resizer = self._exact_resize + + logging.debug("Using resizer {0}".format(resizer.__name__)) + return resizer(targetw, targeth, image) + + def _adjust_filename(self, in_path): + new_filename = path.basename(in_path) + (basename, ext) = path.splitext(new_filename) + basename = "{0}_{1}".format(basename, self._name) + new_filename = "{0}{1}".format(basename, ext) + return new_filename + + def resize_file_to(self, in_path, out_path): + """ Given a filename, resize and save the image per the specification into out_path + + :param in_path: path to image file to save. Must be supposed by PIL + :param out_path: path to the directory root for the outputted thumbnails to be stored + :return: None + """ + filename = path.join(out_path, self._adjust_filename(in_path)) + if not path.exists(out_path): + os.makedirs(out_path) + if not path.exists(filename): + image = Image.open(in_path) + thumbnail = self.resize(image) + thumbnail.save(filename) + logger.info("Generated Thumbnail {0}".format(path.basename(filename))) + + +def resize_thumbnails(pelican): + """ Resize a directory tree full of images into thumbnails + + :param pelican: The pelican instance + :return: None + """ + global enabled + if not enabled: + return + + in_path = path.join(pelican.settings['PATH'], + pelican.settings.get('IMAGE_PATH', DEFAULT_IMAGE_DIR)) + out_path = path.join(pelican.settings['OUTPUT_PATH'], + pelican.settings.get('THUMBNAIL_DIR', DEFAULT_THUMBNAIL_DIR)) + + sizes = pelican.settings.get('THUMBNAIL_SIZES', DEFAULT_THUMBNAIL_SIZES) + resizers = dict((k, _resizer(k, v)) for k,v in sizes.items()) + logger.debug("Thumbnailer Started") + for dirpath, _, filenames in os.walk(in_path): + for filename in filenames: + for name, resizer in resizers.items(): + in_filename = path.join(dirpath, filename) + logger.debug("Processing thumbnail {0}=>{1}".format(filename, name)) + resizer.resize_file_to(in_filename, out_path) + + +def register(): + signals.finalized.connect(resize_thumbnails) \ No newline at end of file