/hyde/ext/plugins/images.py

http://github.com/hyde/hyde · Python · 577 lines · 397 code · 77 blank · 103 comment · 100 complexity · d704f6789e4512b7fe3520aedea24971 MD5 · raw file

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