Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Thumbnail Plugin #169

Closed
wants to merge 13 commits into from

4 participants

@rephorm

Update of @gfuchedzhy's pull request to add tests, fix an off-by-one error and add a larger/smaller options. The latter allow you to specify, e.g. the larger dimension and have the smaller one chosen to keep aspect.

@lakshmivyas
Owner

@rephorm - Thanks so much for so much fantastic work. Will merge as soon as I get a chance.

@lakshmivyas
Owner

Merged.

@lakshmivyas lakshmivyas referenced this pull request
Closed

Thumbnails #81

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Mar 26, 2012
  1. @gfuchedzhy @bmattern

    added thumbnails plugin

    gfuchedzhy authored bmattern committed
  2. @gfuchedzhy @bmattern

    fixed function description

    gfuchedzhy authored bmattern committed
  3. @gfuchedzhy @bmattern
  4. @gfuchedzhy @bmattern

    thumbnails plugin: added 3 crop types

    gfuchedzhy authored bmattern committed
  5. @gfuchedzhy @bmattern

    thumbnails plugin: make resources under content directory instead of …

    gfuchedzhy authored bmattern committed
    …generating them in deploy directly, make all thumnails in single place(content/.thumbnails) for simple maintenance but keep original deploy path to preserve naming logic in generated site
  6. @bmattern

    tests for thumbnailer plugin + larger/smaller options

    bmattern authored
    remove debug statements
    
    rename thumb_size -> thumb_scale_size
Commits on Mar 27, 2012
  1. @bmattern

    simplify preserve_orientation code

    bmattern authored
    remove unneeded parameter
  2. @bmattern
  3. @bmattern

    clean up docs

    bmattern authored
  4. @bmattern
  5. @bmattern

    use os.path.join

    bmattern authored
  6. @bmattern

    better includes matching. don't limit extension

    bmattern authored
    This *should* support anything PIL supports now (but, not yet tested)
  7. @bmattern

    remove unused imports

    bmattern authored
This page is out of date. Refresh to see the latest.
View
184 hyde/ext/plugins/images.py
@@ -6,9 +6,12 @@
"""
from hyde.plugin import Plugin
+from hyde.fs import File, Folder
import re
import Image
+import glob
+import os
class ImageSizerPlugin(Plugin):
"""
@@ -145,3 +148,184 @@ def text_resource_complete(self, resource, text):
continue
return text
+
+def scale_aspect(a, b1, b2):
+ from math import ceil
+ """
+ Scales a by b2/b1 rounding up to nearest integer
+ """
+ return int(ceil(a * b2 / float(b1)))
+
+
+def thumb_scale_size(orig_width, orig_height, width, height):
+ """
+ Determine size to scale to scale for thumbnailst Params
+
+ Params:
+ orig_width, orig_height: original image dimensions
+ width, height: thumbnail dimensions
+ """
+ if width is None:
+ width = scale_aspect(orig_width, orig_height, height)
+ elif height is None:
+ height = scale_aspect(orig_height, orig_width, width)
+ elif orig_width*height >= orig_height*width:
+ width = scale_aspect(orig_width, orig_height, height)
+ else:
+ height = scale_aspect(orig_height, orig_width, width)
+
+ return width, height
+
+
+class ImageThumbnailsPlugin(Plugin):
+ """
+ Provide a function to get thumbnail for any image resource.
+
+ Example of usage:
+ Setting optional defaults in site.yaml:
+ thumbnails:
+ width: 100
+ height: 120
+ prefix: thumbnail_
+
+ Setting thumbnails options in nodemeta.yaml:
+ thumbnails:
+ - width: 50
+ prefix: thumbs1_
+ include:
+ - '*.png'
+ - '*.jpg'
+ - height: 100
+ prefix: thumbs2_
+ include:
+ - '*.png'
+ - '*.jpg'
+ - larger: 100
+ prefix: thumbs3_
+ include:
+ - '*.jpg'
+ - smaller: 50
+ prefix: thumbs4_
+ include:
+ - '*.jpg'
+ which means - make four thumbnails from every picture with different prefixes
+ and sizes
+
+ It is only valid to specify either width/height or larger/smaller, but not to
+ mix the two types.
+
+ If larger/smaller are specified, then the orientation (i.e., landscape or
+ portrait) is preserved while thumbnailing.
+
+ If both width and height (or both larger and smaller) are defined, the
+ image is cropped. You can define crop_type as one of these values:
+ "topleft", "center" and "bottomright". "topleft" is default.
+ """
+
+ def __init__(self, site):
+ super(ImageThumbnailsPlugin, self).__init__(site)
+
+ def thumb(self, resource, width, height, prefix, crop_type, preserve_orientation=False):
+ """
+ Generate a thumbnail for the given image
+ """
+ name = os.path.basename(resource.get_relative_deploy_path())
+ # don't make thumbnails for thumbnails
+ if name.startswith(prefix):
+ return
+ # Prepare path, make all thumnails in single place(content/.thumbnails)
+ # for simple maintenance but keep original deploy path to preserve
+ # naming logic in generated site
+ path = os.path.join(".thumbnails",
+ os.path.dirname(resource.get_relative_deploy_path()),
+ "%s%s" % (prefix, name))
+ target = File(Folder(resource.site.config.content_root_path).child(path))
+ res = self.site.content.add_resource(target)
+ res.set_relative_deploy_path(res.get_relative_deploy_path().replace('.thumbnails/', '', 1))
+
+ target.parent.make()
+ if os.path.exists(target.path) and os.path.getmtime(resource.path) <= os.path.getmtime(target.path):
+ return
+ self.logger.debug("Making thumbnail for [%s]" % resource)
+
+ im = Image.open(resource.path)
+ if im.mode != 'RGBA':
+ im = im.convert('RGBA')
+ format = im.format
+
+ if preserve_orientation and im.size[1] > im.size[0]:
+ width, height = height, width
+
+ resize_width, resize_height = thumb_scale_size(im.size[0], im.size[1], width, height)
+
+ self.logger.debug("Resize to: %d,%d" % (resize_width, resize_height))
+ im = im.resize((resize_width, resize_height), Image.ANTIALIAS)
+ if width is not None and height is not None:
+ shiftx = shifty = 0
+ if crop_type == "center":
+ shiftx = (im.size[0] - width)/2
+ shifty = (im.size[1] - height)/2
+ elif crop_type == "bottomright":
+ shiftx = (im.size[0] - width)
+ shifty = (im.size[1] - height)
+ im = im.crop((shiftx, shifty, width + shiftx, height + shifty))
+ im.load()
+
+ options = dict(optimize=True)
+ if format == "JPEG":
+ options['quality'] = 75
+
+ im.save(target.path, **options)
+
+ def begin_site(self):
+ """
+ Find any image resource that should be thumbnailed and call thumb on it.
+ """
+ # Grab default values from config
+ config = self.site.config
+ defaults = { "width": None,
+ "height": None,
+ "larger": None,
+ "smaller": None,
+ "crop_type": "topleft",
+ "prefix": 'thumb_'}
+ if hasattr(config, 'thumbnails'):
+ defaults.update(config.thumbnails)
+
+ for node in self.site.content.walk():
+ if hasattr(node, 'meta') and hasattr(node.meta, 'thumbnails'):
+ for th in node.meta.thumbnails:
+ if not hasattr(th, 'include'):
+ self.logger.error("Include is not set for node [%s]" % node)
+ continue
+ include = th.include
+ prefix = th.prefix if hasattr(th, 'prefix') else defaults['prefix']
+ height = th.height if hasattr(th, 'height') else defaults['height']
+ width = th.width if hasattr(th, 'width') else defaults['width']
+ larger = th.larger if hasattr(th, 'larger') else defaults['larger']
+ smaller = th.smaller if hasattr(th, 'smaller') else defaults['smaller']
+ crop_type = th.crop_type if hasattr(th, 'crop_type') else defaults['crop_type']
+ if crop_type not in ["topleft", "center", "bottomright"]:
+ self.logger.error("Unknown crop_type defined for node [%s]" % node)
+ continue
+ if width is None and height is None and larger is None and smaller is None:
+ self.logger.error("At least one of width, height, larger, or smaller must be set for node [%s]" % node)
+ continue
+
+ if ((larger is not None or smaller is not None) and
+ (width is not None or height is not None)):
+ self.logger.error("It is not valid to specify both one of width/height and one of larger/smaller for node [%s]" % node)
+ continue
+
+ if larger is None and smaller is None:
+ preserve_orientation = False
+ dim1, dim2 = width, height
+ else:
+ preserve_orientation = True
+ dim1, dim2 = larger, smaller
+
+ match_includes = lambda s: any([glob.fnmatch.fnmatch(s, inc) for inc in include])
+
+ for resource in node.resources:
+ if match_includes(resource.path):
+ self.thumb(resource, dim1, dim2, prefix, crop_type, preserve_orientation)
View
BIN  hyde/tests/ext/images/landscape.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
BIN  hyde/tests/ext/images/portrait.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
211 hyde/tests/ext/test_images.py
@@ -6,16 +6,22 @@
Requires PIL
"""
-from hyde.fs import File, Folder
+from hyde.fs import File
from hyde.generator import Generator
from hyde.site import Site
-
-from pyquery import PyQuery
+from hyde.ext.plugins.images import thumb_scale_size
+import yaml
TEST_SITE = File(__file__).parent.parent.child_folder('_test')
-IMAGE_SOURCE = File(__file__).parent.child_folder('optipng')
-IMAGE_NAME = "hyde-lt-b.png"
-IMAGE_SIZE = (538, 132)
+IMAGE_SOURCE = File(__file__).parent.child_folder('images')
+
+PORTRAIT_IMAGE = "portrait.jpg"
+PORTRAIT_SIZE = (90, 120)
+LANDSCAPE_IMAGE = "landscape.jpg"
+LANDSCAPE_SIZE = (120, 90)
+
+IMAGES = [PORTRAIT_IMAGE, LANDSCAPE_IMAGE]
+SIZES = [PORTRAIT_SIZE, LANDSCAPE_SIZE]
# PIL requirement
import Image
@@ -50,34 +56,34 @@ def _generic_test_image(self, text):
def test_size_image(self):
text = u"""
<img src="/media/img/%s">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
def test_size_image_relative(self):
text = u"""
<img src="media/img/%s">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
def test_size_image_no_resize(self):
text = u"""
<img src="/media/img/%s" width="2000" height="150">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] not in html
- assert ' height="%d"' % IMAGE_SIZE[1] not in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] not in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] not in html
def test_size_image_size_proportional(self):
text = u"""
<img src="/media/img/%s" width="%d">
-""" % (IMAGE_NAME, IMAGE_SIZE[0]*2)
+""" % (PORTRAIT_IMAGE, PORTRAIT_SIZE[0]*2)
html = self._generic_test_image(text)
- assert ' width="%d"' % (IMAGE_SIZE[0]*2) in html
- assert ' height="%d"' % (IMAGE_SIZE[1]*2) in html
+ assert ' width="%d"' % (PORTRAIT_SIZE[0]*2) in html
+ assert ' height="%d"' % (PORTRAIT_SIZE[1]*2) in html
def test_size_image_not_exists(self):
text = u"""
@@ -89,20 +95,20 @@ def test_size_image_multiline(self):
text = u"""
<img
src="/media/img/%s">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
def test_size_multiple_images(self):
text = u"""
<img src="/media/img/%s">
<img src="/media/img/%s">Hello <img src="/media/img/%s">
<img src="/media/img/%s">Bye
-""" % ((IMAGE_NAME,)*4)
+""" % ((PORTRAIT_IMAGE,)*4)
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
assert 'Hello ' in html
assert 'Bye' in html
assert len([f for f in html.split("<img")
@@ -113,24 +119,163 @@ def test_size_multiple_images(self):
def test_size_malformed1(self):
text = u"""
<img src="/media/img/%s>
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
def test_size_malformed2(self):
text = u"""
<img src="/media/img/%s alt="hello">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
def test_outside_media_url(self):
self.site.config.media_url = "http://media.example.com/"
text = u"""
<img src="http://media.example.com/img/%s" alt="hello">
-""" % IMAGE_NAME
+""" % PORTRAIT_IMAGE
html = self._generic_test_image(text)
- assert ' width="%d"' % IMAGE_SIZE[0] in html
- assert ' height="%d"' % IMAGE_SIZE[1] in html
+ assert ' width="%d"' % PORTRAIT_SIZE[0] in html
+ assert ' height="%d"' % PORTRAIT_SIZE[1] in html
+
+class TestImageThumbSize(object):
+
+ def test_width_only(self):
+ ow, oh = 100, 200
+ nw, nh = thumb_scale_size(ow, oh, 50, None)
+ assert nw == 50
+ assert nh == 100
+
+ def test_width_only_nonintegral(self):
+ ow, oh = 100, 205
+ nw, nh = thumb_scale_size(ow, oh, 50, None)
+ assert nw == 50
+ assert nh == 103
+
+ def test_height_only(self):
+ ow, oh = 100, 200
+ nw, nh = thumb_scale_size(ow, oh, None, 100)
+ assert nw == 50
+ assert nh == 100
+
+ def test_height_only_nonintegral(self):
+ ow, oh = 105, 200
+ nw, nh = thumb_scale_size(ow, oh, None, 100)
+ assert nw == 53
+ assert nh == 100
+
+ def test_height_and_width_portrait(self):
+ ow, oh = 100, 200
+ nw, nh = thumb_scale_size(ow, oh, 50, 50)
+ assert nw == 50
+ assert nh == 100
+
+ def test_height_and_width_landscape(self):
+ ow, oh = 200, 100
+ nw, nh = thumb_scale_size(ow, oh, 50, 50)
+ assert nw == 100
+ assert nh == 50
+
+class TestImageThumbnails(object):
+ # TODO: add tests for cropping? (not easy currently)
+
+ def setUp(self):
+ TEST_SITE.make()
+ TEST_SITE.parent.child_folder(
+ 'sites/test_jinja').copy_contents_to(TEST_SITE)
+ IMAGES = TEST_SITE.child_folder('content/media/img')
+ IMAGES.make()
+ IMAGE_SOURCE.copy_contents_to(IMAGES)
+ self.image_folder = IMAGES
+ self.site = Site(TEST_SITE)
+
+ def tearDown(self):
+ TEST_SITE.delete()
+
+ def _generate_site_with_meta(self, meta):
+ self.site.config.mode = "production"
+ self.site.config.plugins = ['hyde.ext.plugins.meta.MetaPlugin', 'hyde.ext.plugins.images.ImageThumbnailsPlugin']
+
+ mlink = File(self.image_folder.child('meta.yaml'))
+ meta_text = yaml.dump(meta, default_flow_style=False)
+ mlink.write(meta_text)
+ gen = Generator(self.site)
+ gen.generate_all()
+
+ def _test_generic_thumbnails(self, meta):
+ self._generate_site_with_meta(meta)
+ thumb_meta = meta.get('thumbnails', [])
+ for th in thumb_meta:
+ prefix = th.get('prefix')
+ if prefix is None:
+ continue
+
+ for fn in [PORTRAIT_IMAGE, LANDSCAPE_IMAGE]:
+ f = File(self._deployed_image(prefix, fn))
+ assert f.exists
+
+ def _deployed_image(self, prefix, filename):
+ return self.site.config.deploy_root_path.child('media/img/%s%s'%(prefix,filename))
+
+ def test_width(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(width=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+ for fn in IMAGES:
+ im = Image.open(self._deployed_image(prefix, fn))
+ assert im.size[0] == 50
+
+ def test_height(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(height=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+ for fn in IMAGES:
+ im = Image.open(self._deployed_image(prefix, fn))
+ assert im.size[1] == 50
+
+ def test_width_and_height(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(width=50, height=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+ for fn in IMAGES:
+ im = Image.open(self._deployed_image(prefix, fn))
+ assert im.size[0] == 50
+ assert im.size[1] == 50
+
+ def test_larger(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(larger=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+
+ im = Image.open(self._deployed_image(prefix, PORTRAIT_IMAGE))
+ assert im.size[1] == 50
+
+ im = Image.open(self._deployed_image(prefix, LANDSCAPE_IMAGE))
+ assert im.size[0] == 50
+
+ def test_smaller(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(smaller=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+
+ im = Image.open(self._deployed_image(prefix, PORTRAIT_IMAGE))
+ assert im.size[0] == 50
+
+ im = Image.open(self._deployed_image(prefix, LANDSCAPE_IMAGE))
+ assert im.size[1] == 50
+
+ def test_larger_and_smaller(self):
+ prefix='thumb_'
+ meta = dict(thumbnails=[dict(larger=100, smaller=50, prefix=prefix, include=['*.jpg'])])
+ self._test_generic_thumbnails(meta)
+
+ im = Image.open(self._deployed_image(prefix, PORTRAIT_IMAGE))
+ assert im.size[0] == 50
+ assert im.size[1] == 100
+
+ im = Image.open(self._deployed_image(prefix, LANDSCAPE_IMAGE))
+ assert im.size[0] == 100
+ assert im.size[1] == 50
Something went wrong with that request. Please try again.