PageRenderTime 27ms CodeModel.GetById 34ms RepoModel.GetById 0ms app.codeStats 0ms

/plugins/image_process/image_process.py

https://gitlab.com/janninematt/janninematt
Python | 510 lines | 468 code | 16 blank | 26 comment | 10 complexity | 9f2603c5928e9848fc71b18975d506ba MD5 | raw file
  1. # -*- coding: utf-8 -*- #
  2. """
  3. Image Process
  4. =============
  5. This plugin process images according to their class attribute.
  6. """
  7. from __future__ import unicode_literals
  8. import copy
  9. import collections
  10. import functools
  11. import os.path
  12. import re
  13. import six
  14. from PIL import Image, ImageFilter
  15. from bs4 import BeautifulSoup
  16. from pelican import signals
  17. IMAGE_PROCESS_REGEX = re.compile("image-process-[-a-zA-Z0-9_]+")
  18. Path = collections.namedtuple(
  19. 'Path', ['base_url', 'source', 'base_path', 'filename', 'process_dir']
  20. )
  21. def convert_box(image, t, l, r, b):
  22. """Convert box coordinates strings to integer.
  23. t, l, r, b (top, left, right, bottom) must be strings specifying
  24. either a number or a percentage.
  25. """
  26. bbox = image.getbbox()
  27. iw = bbox[2] - bbox[0]
  28. ih = bbox[3] - bbox[1]
  29. if t[-1] == '%':
  30. t = ih * float(t[:-1]) / 100.
  31. else:
  32. t = float(t)
  33. if l[-1] == '%':
  34. l = iw * float(l[:-1]) / 100.
  35. else:
  36. l = float(l)
  37. if r[-1] == '%':
  38. r = iw * float(r[:-1]) / 100.
  39. else:
  40. r = float(r)
  41. if b[-1] == '%':
  42. b = ih * float(b[:-1]) / 100.
  43. else:
  44. b = float(b)
  45. return (t, l, r, b)
  46. def crop(i, t, l, r, b):
  47. """Crop image i to the box (l,t)-(r,b).
  48. t, l, r, b (top, left, right, bottom) must be strings specifying
  49. either a number or a percentage.
  50. """
  51. t, l, r, b = convert_box(i, t, l, r, b)
  52. return i.crop((int(t), int(l), int(r), int(b)))
  53. def resize(i, w, h):
  54. """Resize the image to the dimension specified.
  55. w, h (width, height) must be strings specifying either a number
  56. or a percentage.
  57. """
  58. _, _, w, h = convert_box(i, '0', '0', w, h)
  59. if i.mode == 'P':
  60. i = i.convert('RGBA')
  61. elif i.mode == '1':
  62. i = i.convert('L')
  63. return i.resize((int(w), int(h)), Image.LANCZOS)
  64. def scale(i, w, h, upscale, inside):
  65. """Resize the image to the dimension specified, keeping the aspect
  66. ratio.
  67. w, h (width, height) must be strings specifying either a number
  68. or a percentage, or "None" to ignore this constraint.
  69. If upscale is True, upscaling is allowed.
  70. If inside is True, the resulting image will not be larger than the
  71. dimensions specified, else it will not be smaller.
  72. """
  73. bbox = i.getbbox()
  74. iw = bbox[2] - bbox[0]
  75. ih = bbox[3] - bbox[1]
  76. if w == 'None':
  77. w = 1.
  78. elif w[-1] == '%':
  79. w = float(w[:-1]) / 100.
  80. else:
  81. w = float(w) / iw
  82. if h == 'None':
  83. h = 1.
  84. elif h[-1] == '%':
  85. h = float(h[:-1]) / 100.
  86. else:
  87. h = float(h) / ih
  88. if inside:
  89. scale = min(w, h)
  90. else:
  91. scale = max(w, h)
  92. if upscale in [0, '0', 'False', False]:
  93. scale = min(scale, 1.)
  94. if i.mode == 'P':
  95. i = i.convert('RGBA')
  96. elif i.mode == '1':
  97. i = i.convert('L')
  98. return i.resize((int(scale*iw), int(scale*ih)), Image.LANCZOS)
  99. def rotate(i, degrees):
  100. if i.mode == 'P':
  101. i = i.convert('RGBA')
  102. elif i.mode == '1':
  103. i = i.convert('L')
  104. # rotate does not support the LANCZOS filter (Pillow 2.7.0).
  105. return i.rotate(int(degrees), Image.BICUBIC, True)
  106. def apply_filter(i, f):
  107. if i.mode == 'P':
  108. i = i.convert('RGBA')
  109. elif i.mode == '1':
  110. i = i.convert('L')
  111. return i.filter(f)
  112. basic_ops = {
  113. 'crop': crop,
  114. 'flip_horizontal': lambda i: i.transpose(Image.FLIP_LEFT_RIGHT),
  115. 'flip_vertical': lambda i: i.transpose(Image.FLIP_TOP_BOTTOM),
  116. 'grayscale': lambda i: i.convert('L'),
  117. 'resize': resize,
  118. 'rotate': rotate,
  119. 'scale_in': functools.partial(scale, inside=True),
  120. 'scale_out': functools.partial(scale, inside=False),
  121. 'blur': functools.partial(apply_filter, f=ImageFilter.BLUR),
  122. 'contour': functools.partial(apply_filter, f=ImageFilter.CONTOUR),
  123. 'detail': functools.partial(apply_filter, f=ImageFilter.DETAIL),
  124. 'edge_enhance': functools.partial(apply_filter,
  125. f=ImageFilter.EDGE_ENHANCE),
  126. 'edge_enhance_more': functools.partial(apply_filter,
  127. f=ImageFilter.EDGE_ENHANCE_MORE),
  128. 'emboss': functools.partial(apply_filter, f=ImageFilter.EMBOSS),
  129. 'find_edges': functools.partial(apply_filter, f=ImageFilter.FIND_EDGES),
  130. 'smooth': functools.partial(apply_filter, f=ImageFilter.SMOOTH),
  131. 'smooth_more': functools.partial(apply_filter, f=ImageFilter.SMOOTH_MORE),
  132. 'sharpen': functools.partial(apply_filter, f=ImageFilter.SHARPEN),
  133. }
  134. def harvest_images(path, context):
  135. # Set default value for 'IMAGE_PROCESS_DIR'.
  136. if 'IMAGE_PROCESS_DIR' not in context:
  137. context['IMAGE_PROCESS_DIR'] = 'derivatives'
  138. with open(path, 'r+') as f:
  139. res = harvest_images_in_fragment(f, context)
  140. f.seek(0)
  141. f.truncate()
  142. f.write(res)
  143. def harvest_images_in_fragment(fragment, settings):
  144. parser = settings.get("IMAGE_PROCESS_PARSER", "html.parser")
  145. soup = BeautifulSoup(fragment, parser)
  146. for img in soup.find_all('img', class_=IMAGE_PROCESS_REGEX):
  147. for c in img['class']:
  148. if c.startswith('image-process-'):
  149. derivative = c[14:]
  150. break
  151. else:
  152. continue
  153. try:
  154. d = settings['IMAGE_PROCESS'][derivative]
  155. except KeyError:
  156. raise RuntimeError('Derivative %s undefined.' % derivative)
  157. if isinstance(d, list):
  158. # Single source image specification.
  159. process_img_tag(img, settings, derivative)
  160. elif not isinstance(d, dict):
  161. raise RuntimeError('Derivative %s definition not handled'
  162. '(must be list or dict)' % (derivative))
  163. elif 'type' not in d:
  164. raise RuntimeError('"type" is mandatory for %s.' % derivative)
  165. elif d['type'] == 'image':
  166. # Single source image specification.
  167. process_img_tag(img, settings, derivative)
  168. elif d['type'] == 'responsive-image':
  169. # srcset image specification.
  170. build_srcset(img, settings, derivative)
  171. elif d['type'] == 'picture':
  172. # Multiple source (picture) specification.
  173. group = img.find_parent()
  174. if group.name == 'div':
  175. convert_div_to_picture_tag(soup, img, group, settings,
  176. derivative)
  177. elif group.name == 'picture':
  178. process_picture(soup, img, group, settings, derivative)
  179. return str(soup)
  180. def compute_paths(img, settings, derivative):
  181. process_dir = settings['IMAGE_PROCESS_DIR']
  182. url_path, filename = os.path.split(img['src'])
  183. base_url = os.path.join(url_path, process_dir, derivative)
  184. for f in settings['filenames']:
  185. if os.path.basename(img['src']) in f:
  186. source = settings['filenames'][f].source_path
  187. base_path = os.path.join(settings['OUTPUT_PATH'], os.path.dirname(settings['filenames'][f].save_as), process_dir, derivative)
  188. break
  189. else:
  190. source = os.path.join(settings['PATH'], img['src'][1:])
  191. base_path = os.path.join(settings['OUTPUT_PATH'], base_url[1:])
  192. return Path(base_url, source, base_path, filename, process_dir)
  193. def process_img_tag(img, settings, derivative):
  194. path = compute_paths(img, settings, derivative)
  195. process = settings['IMAGE_PROCESS'][derivative]
  196. img['src'] = os.path.join(path.base_url, path.filename)
  197. destination = os.path.join(path.base_path, path.filename)
  198. if not isinstance(process, list):
  199. process = process['ops']
  200. process_image((path.source, destination, process), settings)
  201. def build_srcset(img, settings, derivative):
  202. path = compute_paths(img, settings, derivative)
  203. process = settings['IMAGE_PROCESS'][derivative]
  204. default = process['default']
  205. if isinstance(default, six.string_types):
  206. default_name = default
  207. elif isinstance(default, list):
  208. default_name = 'default'
  209. destination = os.path.join(path.base_path, default_name, path.filename)
  210. process_image((path.source, destination, default), settings)
  211. img['src'] = os.path.join(path.base_url, default_name, path.filename)
  212. if 'sizes' in process:
  213. img['sizes'] = process['sizes']
  214. srcset = []
  215. for src in process['srcset']:
  216. file_path = os.path.join(path.base_url, src[0], path.filename)
  217. srcset.append("%s %s" % (file_path, src[0]))
  218. destination = os.path.join(path.base_path, src[0], path.filename)
  219. process_image((path.source, destination, src[1]), settings)
  220. if len(srcset) > 0:
  221. img['srcset'] = ', '.join(srcset)
  222. def convert_div_to_picture_tag(soup, img, group, settings, derivative):
  223. """
  224. Convert a div containing multiple images to a picture.
  225. """
  226. process_dir = settings['IMAGE_PROCESS_DIR']
  227. # Compile sources URL. Special source "default" uses the main
  228. # image URL. Other sources use the img with classes
  229. # [source['name'], 'image-process']. We also remove the img from
  230. # the DOM.
  231. sources = copy.deepcopy(settings['IMAGE_PROCESS'][derivative]['sources'])
  232. for s in sources:
  233. if s['name'] == 'default':
  234. s['url'] = img['src']
  235. else:
  236. candidates = group.find_all('img', class_=s['name'])
  237. for candidate in candidates:
  238. if 'image-process' in candidate['class']:
  239. s['url'] = candidate['src']
  240. candidate.decompose()
  241. break
  242. url_path, s['filename'] = os.path.split(s['url'])
  243. s['base_url'] = os.path.join(url_path, process_dir, derivative)
  244. s['base_path'] = os.path.join(settings['OUTPUT_PATH'],
  245. s['base_url'][1:])
  246. # If default is not None, change default img source to the image
  247. # derivative referenced.
  248. default = settings['IMAGE_PROCESS'][derivative]['default']
  249. if default is not None:
  250. default_source_name = default[0]
  251. default_source = None
  252. for s in sources:
  253. if s['name'] == default_source_name:
  254. default_source = s
  255. break
  256. if default_source is None:
  257. raise RuntimeError(
  258. 'No source matching "%s", referenced in default setting.',
  259. (default_source_name,)
  260. )
  261. if isinstance(default[1], six.string_types):
  262. default_item_name = default[1]
  263. elif isinstance(default[1], list):
  264. default_item_name = 'default'
  265. source = os.path.join(settings['PATH'], default_source['url'][1:])
  266. destination = os.path.join(s['base_path'], default_source_name,
  267. default_item_name,
  268. default_source['filename'])
  269. process_image((source, destination, default[1]), settings)
  270. # Change img src to url of default processed image.
  271. img['src'] = os.path.join(s['base_url'], default_source_name,
  272. default_item_name,
  273. default_source['filename'])
  274. # Create picture tag.
  275. picture_tag = soup.new_tag('picture')
  276. for s in sources:
  277. # Create new <source>
  278. source_attrs = {k: s[k] for k in s if k in ['media', 'sizes']}
  279. source_tag = soup.new_tag('source', **source_attrs)
  280. srcset = []
  281. for src in s['srcset']:
  282. srcset.append("%s %s" % (os.path.join(s['base_url'], s['name'],
  283. src[0], s['filename']), src[0]))
  284. source = os.path.join(settings['PATH'], s['url'][1:])
  285. destination = os.path.join(s['base_path'], s['name'], src[0],
  286. s['filename'])
  287. process_image((source, destination, src[1]), settings)
  288. if len(srcset) > 0:
  289. source_tag['srcset'] = ', '.join(srcset)
  290. picture_tag.append(source_tag)
  291. # Wrap img with <picture>
  292. img.wrap(picture_tag)
  293. def process_picture(soup, img, group, settings, derivative):
  294. """
  295. Convert a simplified picture to a full HTML picture:
  296. <picture>
  297. <source class="source-1" src="image1.jpg"></source>
  298. <source class="source-2" src="image2.jpg"></source>
  299. <img class="image-process-picture" src="image3.jpg"></img>
  300. </picture>
  301. to
  302. <picture>
  303. <source srcset="...image1.jpg..." media="..." sizes="..."></source>
  304. <source srcset="...image2.jpg..."></source>
  305. <source srcset="...image3.jpg..." media="..." sizes="..."></source>
  306. <img src=".../image3.jpg"></img>
  307. </picture>
  308. """
  309. process_dir = settings['IMAGE_PROCESS_DIR']
  310. process = settings['IMAGE_PROCESS'][derivative]
  311. # Compile sources URL. Special source "default" uses the main
  312. # image URL. Other sources use the <source> with classes
  313. # source['name']. We also remove the <source>s from the DOM.
  314. sources = copy.deepcopy(process['sources'])
  315. for s in sources:
  316. if s['name'] == 'default':
  317. s['url'] = img['src']
  318. source_attrs = {k: s[k] for k in s if k in ['media', 'sizes']}
  319. s['element'] = soup.new_tag('source', **source_attrs)
  320. else:
  321. s['element'] = group.find('source', class_=s['name']).extract()
  322. s['url'] = s['element']['src']
  323. del s['element']['src']
  324. del s['element']['class']
  325. url_path, s['filename'] = os.path.split(s['url'])
  326. s['base_url'] = os.path.join(url_path, process_dir, derivative)
  327. s['base_path'] = os.path.join(settings['OUTPUT_PATH'],
  328. s['base_url'][1:])
  329. # If default is not None, change default img source to the image
  330. # derivative referenced.
  331. default = process['default']
  332. if default is not None:
  333. default_source_name = default[0]
  334. default_source = None
  335. for s in sources:
  336. if s['name'] == default_source_name:
  337. default_source = s
  338. break
  339. if default_source is None:
  340. raise RuntimeError(
  341. 'No source matching "%s", referenced in default setting.',
  342. (default_source_name,)
  343. )
  344. if isinstance(default[1], six.string_types):
  345. default_item_name = default[1]
  346. elif isinstance(default[1], list):
  347. default_item_name = 'default'
  348. source = os.path.join(settings['PATH'], default_source['url'][1:])
  349. destination = os.path.join(s['base_path'], default_source_name,
  350. default_item_name,
  351. default_source['filename'])
  352. process_image((source, destination, default[1]), settings)
  353. # Change img src to url of default processed image.
  354. img['src'] = os.path.join(s['base_url'], default_source_name,
  355. default_item_name,
  356. default_source['filename'])
  357. # Generate srcsets and put back <source>s in <picture>.
  358. for s in sources:
  359. srcset = []
  360. for src in s['srcset']:
  361. srcset.append("%s %s" % (os.path.join(s['base_url'], s['name'],
  362. src[0], s['filename']), src[0]))
  363. source = os.path.join(settings['PATH'], s['url'][1:])
  364. destination = os.path.join(s['base_path'], s['name'], src[0],
  365. s['filename'])
  366. process_image((source, destination, src[1]), settings)
  367. if len(srcset) > 0:
  368. # Append source elements to the picture in the same order
  369. # as they are found in
  370. # settings['IMAGE_PROCESS'][derivative]['sources'].
  371. s['element']['srcset'] = ', '.join(srcset)
  372. img.insert_before(s['element'])
  373. def process_image(image, settings):
  374. # Set default value for 'IMAGE_PROCESS_FORCE'.
  375. if 'IMAGE_PROCESS_FORCE' not in settings:
  376. settings['IMAGE_PROCESS_FORCE'] = False
  377. path, _ = os.path.split(image[1])
  378. try:
  379. os.makedirs(path)
  380. except OSError as e:
  381. if e.errno == 17:
  382. # Already exists
  383. pass
  384. # If original image is older than existing derivative, skip
  385. # processing to save time, unless user explicitely forced
  386. # image generation.
  387. if (settings['IMAGE_PROCESS_FORCE'] or
  388. not os.path.exists(image[1]) or
  389. os.path.getmtime(image[0]) > os.path.getmtime(image[1])):
  390. i = Image.open(image[0])
  391. for step in image[2]:
  392. if hasattr(step, '__call__'):
  393. i = step(i)
  394. else:
  395. elems = step.split(' ')
  396. i = basic_ops[elems[0]](i, *(elems[1:]))
  397. i.save(image[1])
  398. def register():
  399. signals.content_written.connect(harvest_images)