PageRenderTime 78ms CodeModel.GetById 24ms app.highlight 48ms RepoModel.GetById 1ms app.codeStats 0ms

/hyde/ext/plugins/images.py

http://github.com/hyde/hyde
Python | 577 lines | 544 code | 16 blank | 17 comment | 17 complexity | d704f6789e4512b7fe3520aedea24971 MD5 | raw file
  1# -*- coding: utf-8 -*-
  2"""
  3Contains classes to handle images related things
  4
  5# Requires PIL or pillow
  6"""
  7
  8from hyde.plugin import CLTransformer, Plugin
  9
 10
 11import glob
 12import os
 13import re
 14
 15from fswrap import File
 16
 17from hyde._compat import str
 18from hyde.exceptions import HydeException
 19
 20
 21class PILPlugin(Plugin):
 22
 23    def __init__(self, site):
 24        super(PILPlugin, self).__init__(site)
 25        try:
 26            from PIL import Image
 27        except ImportError:
 28            # No pillow
 29            try:
 30                import Image
 31            except ImportError as e:
 32                raise HydeException('Unable to load PIL: ' + e.message)
 33
 34        self.Image = Image
 35
 36
 37#
 38# Image sizer
 39#
 40
 41
 42class ImageSizerPlugin(PILPlugin):
 43
 44    """
 45    Each HTML page is modified to add width and height for images if
 46    they are not already specified.
 47
 48    # Requires PIL
 49    """
 50
 51    def __init__(self, site):
 52        super(ImageSizerPlugin, self).__init__(site)
 53        self.cache = {}
 54
 55    def _handle_img(self, resource, src, width, height):
 56        """Determine what should be added to an img tag"""
 57        if height is not None and width is not None:
 58            return ""           # Nothing
 59        if src is None:
 60            self.logger.warn(
 61                "[%s] has an img tag without src attribute" % resource)
 62            return ""           # Nothing
 63        if src not in self.cache:
 64            if src.startswith(self.site.config.media_url):
 65                path = src[len(self.site.config.media_url):].lstrip("/")
 66                path = self.site.config.media_root_path.child(path)
 67                image = self.site.content.resource_from_relative_deploy_path(
 68                    path)
 69            elif re.match(r'([a-z]+://|//).*', src):
 70                # Not a local link
 71                return ""       # Nothing
 72            elif src.startswith("/"):
 73                # Absolute resource
 74                path = src.lstrip("/")
 75                image = self.site.content.resource_from_relative_deploy_path(
 76                    path)
 77            else:
 78                # Relative resource
 79                path = resource.node.source_folder.child(src)
 80                image = self.site.content.resource_from_path(path)
 81            if image is None:
 82                self.logger.warn(
 83                    "[%s] has an unknown image" % resource)
 84                return ""       # Nothing
 85            if image.source_file.kind not in ['png', 'jpg', 'jpeg', 'gif']:
 86                self.logger.warn(
 87                    "[%s] has an img tag not linking to an image" % resource)
 88                return ""       # Nothing
 89            # Now, get the size of the image
 90            try:
 91                self.cache[src] = self.Image.open(image.path).size
 92            except IOError:
 93                self.logger.warn(
 94                    "Unable to process image [%s]" % image)
 95                self.cache[src] = (None, None)
 96                return ""       # Nothing
 97            self.logger.debug("Image [%s] is %s" % (src,
 98                                                    self.cache[src]))
 99        new_width, new_height = self.cache[src]
100        if new_width is None or new_height is None:
101            return ""           # Nothing
102        if width is not None:
103            return 'height="%d" ' % (int(width) * new_height / new_width)
104        elif height is not None:
105            return 'width="%d" ' % (int(height) * new_width / new_height)
106        return 'height="%d" width="%d" ' % (new_height, new_width)
107
108    def text_resource_complete(self, resource, text):
109        """
110        When the resource is generated, search for img tag and specify
111        their sizes.
112
113        Some img tags may be missed, this is not a perfect parser.
114        """
115        try:
116            mode = self.site.config.mode
117        except AttributeError:
118            mode = "production"
119
120        if not resource.source_file.kind == 'html':
121            return
122
123        if mode.startswith('dev'):
124            self.logger.debug("Skipping sizer in development mode.")
125            return
126
127        pos = 0                 # Position in text
128        img = None              # Position of current img tag
129        state = "find-img"
130        while pos < len(text):
131            if state == "find-img":
132                img = text.find("<img", pos)
133                if img == -1:
134                    break           # No more img tag
135                pos = img + len("<img")
136                if not text[pos].isspace():
137                    continue        # Not an img tag
138                pos = pos + 1
139                tags = {"src": "",
140                        "width": "",
141                        "height": ""}
142                state = "find-attr"
143                continue
144            if state == "find-attr":
145                if text[pos] == ">":
146                    # We get our img tag
147                    insert = self._handle_img(resource,
148                                              tags["src"] or None,
149                                              tags["width"] or None,
150                                              tags["height"] or None)
151                    img = img + len("<img ")
152                    text = "".join([text[:img], insert, text[img:]])
153                    state = "find-img"
154                    pos = pos + 1
155                    continue
156                attr = None
157                for tag in tags:
158                    if text[pos:(pos + len(tag) + 1)] == ("%s=" % tag):
159                        attr = tag
160                        pos = pos + len(tag) + 1
161                        break
162                if not attr:
163                    pos = pos + 1
164                    continue
165                if text[pos] in ["'", '"']:
166                    pos = pos + 1
167                state = "get-value"
168                continue
169            if state == "get-value":
170                if text[pos] == ">":
171                    state = "find-attr"
172                    continue
173                if text[pos] in ["'", '"'] or text[pos].isspace():
174                    # We got our value
175                    pos = pos + 1
176                    state = "find-attr"
177                    continue
178                tags[attr] = tags[attr] + text[pos]
179                pos = pos + 1
180                continue
181
182        return text
183
184
185def scale_aspect(a, b1, b2):
186    from math import ceil
187    """
188  Scales a by b2/b1 rounding up to nearest integer
189  """
190    return int(ceil(a * b2 / float(b1)))
191
192
193def thumb_scale_size(orig_width, orig_height, width, height):
194    """
195    Determine size to scale to scale for thumbnailst Params
196
197    Params:
198      orig_width, orig_height: original image dimensions
199      width, height: thumbnail dimensions
200    """
201    if width is None:
202        width = scale_aspect(orig_width, orig_height, height)
203    elif height is None:
204        height = scale_aspect(orig_height, orig_width, width)
205    elif orig_width * height >= orig_height * width:
206        width = scale_aspect(orig_width, orig_height, height)
207    else:
208        height = scale_aspect(orig_height, orig_width, width)
209
210    return width, height
211
212#
213# Image Thumbnails
214#
215
216
217class ImageThumbnailsPlugin(PILPlugin):
218
219    """
220    Provide a function to get thumbnail for any image resource.
221
222    Example of usage:
223    Setting optional defaults in site.yaml:
224        thumbnails:
225          width: 100
226          height: 120
227          prefix: thumbnail_
228
229    Setting thumbnails options in nodemeta.yaml:
230        thumbnails:
231          - width: 50
232            prefix: thumbs1_
233            include:
234            - '*.png'
235            - '*.jpg'
236          - height: 100
237            prefix: thumbs2_
238            include:
239            - '*.png'
240            - '*.jpg'
241          - larger: 100
242            prefix: thumbs3_
243            include:
244            - '*.jpg'
245          - smaller: 50
246            prefix: thumbs4_
247            include:
248            - '*.jpg'
249    which means - make four thumbnails from every picture with different
250    prefixes and sizes
251
252    It is only valid to specify either width/height or larger/smaller, but
253    not to mix the two types.
254
255    If larger/smaller are specified, then the orientation (i.e., landscape or
256    portrait) is preserved while thumbnailing.
257
258    If both width and height (or both larger and smaller) are defined, the
259    image is cropped. You can define crop_type as one of these values:
260    "topleft", "center" and "bottomright".  "topleft" is default.
261    """
262
263    def __init__(self, site):
264        super(ImageThumbnailsPlugin, self).__init__(site)
265
266    def thumb(self, resource, width, height, prefix, crop_type,
267              preserve_orientation=False):
268        """
269        Generate a thumbnail for the given image
270        """
271        name = os.path.basename(resource.get_relative_deploy_path())
272        # don't make thumbnails for thumbnails
273        if name.startswith(prefix):
274            return
275        # Prepare path, make all thumnails in single place(content/.thumbnails)
276        # for simple maintenance but keep original deploy path to preserve
277        # naming logic in generated site
278        path = os.path.join(".thumbnails",
279                            os.path.dirname(
280                                resource.get_relative_deploy_path()),
281                            "%s%s" % (prefix, name))
282        target = resource.site.config.content_root_path.child_file(path)
283        res = self.site.content.add_resource(target)
284        res.set_relative_deploy_path(
285            res.get_relative_deploy_path().replace('.thumbnails/', '', 1))
286
287        target.parent.make()
288        if (os.path.exists(target.path) and os.path.getmtime(resource.path) <=
289                os.path.getmtime(target.path)):
290            return
291        self.logger.debug("Making thumbnail for [%s]" % resource)
292
293        im = self.Image.open(resource.path)
294        if im.mode != 'RGBA':
295            im = im.convert('RGBA')
296        format = im.format
297
298        if preserve_orientation and im.size[1] > im.size[0]:
299            width, height = height, width
300
301        resize_width, resize_height = thumb_scale_size(
302            im.size[0], im.size[1], width, height)
303
304        self.logger.debug("Resize to: %d,%d" % (resize_width, resize_height))
305        im = im.resize((resize_width, resize_height), self.Image.ANTIALIAS)
306        if width is not None and height is not None:
307            shiftx = shifty = 0
308            if crop_type == "center":
309                shiftx = (im.size[0] - width) / 2
310                shifty = (im.size[1] - height) / 2
311            elif crop_type == "bottomright":
312                shiftx = (im.size[0] - width)
313                shifty = (im.size[1] - height)
314            im = im.crop((shiftx, shifty, width + shiftx, height + shifty))
315            im.load()
316
317        options = dict(optimize=True)
318        if format == "JPEG":
319            options['quality'] = 75
320
321        im.save(target.path, **options)
322
323    def begin_site(self):
324        """
325        Find any image resource that should be thumbnailed and call thumb
326        on it.
327        """
328        # Grab default values from config
329        config = self.site.config
330        defaults = {"width": None,
331                    "height": None,
332                    "larger": None,
333                    "smaller": None,
334                    "crop_type": "topleft",
335                    "prefix": 'thumb_'}
336        if hasattr(config, 'thumbnails'):
337            defaults.update(config.thumbnails)
338
339        for node in self.site.content.walk():
340            if hasattr(node, 'meta') and hasattr(node.meta, 'thumbnails'):
341                for th in node.meta.thumbnails:
342                    if not hasattr(th, 'include'):
343                        self.logger.error(
344                            "Include is not set for node [%s]" % node)
345                        continue
346                    include = th.include
347                    prefix = th.prefix if hasattr(
348                        th, 'prefix') else defaults['prefix']
349                    height = th.height if hasattr(
350                        th, 'height') else defaults['height']
351                    width = th.width if hasattr(
352                        th, 'width') else defaults['width']
353                    larger = th.larger if hasattr(
354                        th, 'larger') else defaults['larger']
355                    smaller = th.smaller if hasattr(
356                        th, 'smaller') else defaults['smaller']
357                    crop_type = th.crop_type if hasattr(
358                        th, 'crop_type') else defaults['crop_type']
359                    if crop_type not in ["topleft", "center", "bottomright"]:
360                        self.logger.error(
361                            "Unknown crop_type defined for node [%s]" % node)
362                        continue
363                    if (width is None and height is None and larger is None and
364                            smaller is None):
365                        self.logger.error(
366                            "At least one of width, height, larger, or smaller"
367                            "must be set for node [%s]" % node)
368                        continue
369
370                    if ((larger is not None or smaller is not None) and
371                            (width is not None or height is not None)):
372                        self.logger.error(
373                            "It is not valid to specify both one of"
374                            "width/height and one of larger/smaller"
375                            "for node [%s]" % node)
376                        continue
377
378                    if larger is None and smaller is None:
379                        preserve_orientation = False
380                        dim1, dim2 = width, height
381                    else:
382                        preserve_orientation = True
383                        dim1, dim2 = larger, smaller
384
385                    def match_includes(s):
386                        return any([glob.fnmatch.fnmatch(s, inc)
387                                    for inc in include])
388
389                    for resource in node.resources:
390                        if match_includes(resource.path):
391                            self.thumb(
392                                resource, dim1, dim2, prefix, crop_type,
393                                preserve_orientation)
394
395#
396# JPEG Optimization
397#
398
399
400class JPEGOptimPlugin(CLTransformer):
401
402    """
403    The plugin class for JPEGOptim
404    """
405
406    def __init__(self, site):
407        super(JPEGOptimPlugin, self).__init__(site)
408
409    @property
410    def plugin_name(self):
411        """
412        The name of the plugin.
413        """
414        return "jpegoptim"
415
416    def binary_resource_complete(self, resource):
417        """
418        If the site is in development mode, just return.
419        Otherwise, run jpegoptim to compress the jpg file.
420        """
421
422        try:
423            mode = self.site.config.mode
424        except AttributeError:
425            mode = "production"
426
427        if not resource.source_file.kind == 'jpg':
428            return
429
430        if mode.startswith('dev'):
431            self.logger.debug("Skipping jpegoptim in development mode.")
432            return
433
434        supported = [
435            "force",
436            "max=",
437            "strip-all",
438            "strip-com",
439            "strip-exif",
440            "strip-iptc",
441            "strip-icc",
442        ]
443        target = File(self.site.config.deploy_root_path.child(
444            resource.relative_deploy_path))
445        jpegoptim = self.app
446        args = [str(jpegoptim)]
447        args.extend(self.process_args(supported))
448        args.extend(["-q", str(target)])
449        self.call_app(args)
450
451
452class JPEGTranPlugin(CLTransformer):
453
454    """
455    Almost like jpegoptim except it uses jpegtran. jpegtran allows to make
456    progressive JPEG. Unfortunately, it only does lossless compression. If
457    you want both, you need to combine this plugin with jpegoptim one.
458    """
459
460    def __init__(self, site):
461        super(JPEGTranPlugin, self).__init__(site)
462
463    @property
464    def plugin_name(self):
465        """
466        The name of the plugin.
467        """
468        return "jpegtran"
469
470    def option_prefix(self, option):
471        return "-"
472
473    def binary_resource_complete(self, resource):
474        """
475        If the site is in development mode, just return.
476        Otherwise, run jpegtran to compress the jpg file.
477        """
478
479        try:
480            mode = self.site.config.mode
481        except AttributeError:
482            mode = "production"
483
484        if not resource.source_file.kind == 'jpg':
485            return
486
487        if mode.startswith('dev'):
488            self.logger.debug("Skipping jpegtran in development mode.")
489            return
490
491        supported = [
492            "optimize",
493            "progressive",
494            "restart",
495            "arithmetic",
496            "perfect",
497            "copy",
498        ]
499        source = File(self.site.config.deploy_root_path.child(
500            resource.relative_deploy_path))
501        target = File.make_temp('')
502        jpegtran = self.app
503        args = [str(jpegtran)]
504        args.extend(self.process_args(supported))
505        args.extend(["-outfile", str(target), str(source)])
506        self.call_app(args)
507        target.copy_to(source)
508        target.delete()
509
510
511#
512# PNG Optimization
513#
514
515class OptiPNGPlugin(CLTransformer):
516
517    """
518    The plugin class for OptiPNG
519    """
520
521    def __init__(self, site):
522        super(OptiPNGPlugin, self).__init__(site)
523
524    @property
525    def plugin_name(self):
526        """
527        The name of the plugin.
528        """
529        return "optipng"
530
531    def option_prefix(self, option):
532        return "-"
533
534    def binary_resource_complete(self, resource):
535        """
536        If the site is in development mode, just return.
537        Otherwise, run optipng to compress the png file.
538        """
539
540        try:
541            mode = self.site.config.mode
542        except AttributeError:
543            mode = "production"
544
545        if not resource.source_file.kind == 'png':
546            return
547
548        if mode.startswith('dev'):
549            self.logger.debug("Skipping optipng in development mode.")
550            return
551
552        supported = [
553            "o",
554            "fix",
555            "force",
556            "preserve",
557            "quiet",
558            "log",
559            "f",
560            "i",
561            "zc",
562            "zm",
563            "zs",
564            "zw",
565            "full",
566            "nb",
567            "nc",
568            "np",
569            "nz"
570        ]
571        target = File(self.site.config.deploy_root_path.child(
572            resource.relative_deploy_path))
573        optipng = self.app
574        args = [str(optipng)]
575        args.extend(self.process_args(supported))
576        args.extend([str(target)])
577        self.call_app(args)