photos.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals
  3. import itertools
  4. import logging
  5. import os
  6. import pprint
  7. import re
  8. from pelican.generators import ArticlesGenerator
  9. from pelican.generators import PagesGenerator
  10. from pelican.settings import DEFAULT_CONFIG
  11. from pelican import signals
  12. from pelican.utils import pelican_open
  13. logger = logging.getLogger(__name__)
  14. try:
  15. from PIL import ExifTags
  16. from PIL import Image
  17. from PIL import ImageDraw
  18. from PIL import ImageEnhance
  19. from PIL import ImageFont
  20. except ImportError:
  21. raise Exception('PIL/Pillow not found')
  22. def initialized(pelican):
  23. p = os.path.expanduser('~/Pictures')
  24. DEFAULT_CONFIG.setdefault('PHOTO_LIBRARY', p)
  25. DEFAULT_CONFIG.setdefault('PHOTO_GALLERY', (1024, 768, 80))
  26. DEFAULT_CONFIG.setdefault('PHOTO_ARTICLE', (760, 506, 80))
  27. DEFAULT_CONFIG.setdefault('PHOTO_THUMB', (192, 144, 60))
  28. DEFAULT_CONFIG.setdefault('PHOTO_GALLERY_TITLE', '')
  29. DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK', 'False')
  30. DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_TEXT', DEFAULT_CONFIG['SITENAME'])
  31. DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG', '')
  32. DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG_SIZE', (64, 64))
  33. DEFAULT_CONFIG['queue_resize'] = {}
  34. DEFAULT_CONFIG['created_galleries'] = {}
  35. if pelican:
  36. pelican.settings.setdefault('PHOTO_LIBRARY', p)
  37. pelican.settings.setdefault('PHOTO_GALLERY', (1024, 768, 80))
  38. pelican.settings.setdefault('PHOTO_ARTICLE', (760, 506, 80))
  39. pelican.settings.setdefault('PHOTO_THUMB', (192, 144, 60))
  40. pelican.settings.setdefault('PHOTO_GALLERY_TITLE', '')
  41. pelican.settings.setdefault('PHOTO_WATERMARK', 'False')
  42. pelican.settings.setdefault('PHOTO_WATERMARK_TEXT', pelican.settings['SITENAME'])
  43. pelican.settings.setdefault('PHOTO_WATERMARK_IMG', '')
  44. pelican.settings.setdefault('PHOTO_WATERMARK_IMG_SIZE', (64, 64))
  45. def read_notes(filename, msg=None):
  46. notes = {}
  47. try:
  48. with pelican_open(filename) as text:
  49. for line in text.splitlines():
  50. if line.startswith('#'):
  51. continue
  52. m = line.split(':', 1)
  53. if len(m) > 1:
  54. pic = m[0].strip()
  55. note = m[1].strip()
  56. if pic and note:
  57. notes[pic] = note
  58. else:
  59. notes[line] = ''
  60. except Exception as e:
  61. if msg:
  62. logger.warning('{} at file {}'.format(msg, filename))
  63. logger.debug('read_notes issue: {} at file {}. Debug message:{}'.format(msg, filename, e))
  64. return notes
  65. def enqueue_resize(orig, resized, spec=(640, 480, 80)):
  66. if resized not in DEFAULT_CONFIG['queue_resize']:
  67. DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
  68. elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
  69. logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0], DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
  70. def isalpha(img):
  71. return True if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) else False
  72. def ReduceOpacity(im, opacity):
  73. """Reduces Opacity
  74. Returns an image with reduced opacity.
  75. Taken from http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879
  76. """
  77. assert opacity >= 0 and opacity <= 1
  78. if isalpha(im):
  79. im = im.copy()
  80. else:
  81. im = im.convert('RGBA')
  82. alpha = im.split()[3]
  83. alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
  84. im.putalpha(alpha)
  85. return im
  86. def watermark_photo(image, watermark_text, watermark_image, watermark_image_size):
  87. margin = [10, 10]
  88. opacity = 0.6
  89. watermark_layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
  90. draw_watermark = ImageDraw.Draw(watermark_layer)
  91. text_reducer = 32
  92. image_reducer = 8
  93. text_size = [0, 0]
  94. mark_size = [0, 0]
  95. font_height = 0
  96. text_position = [0, 0]
  97. if watermark_text:
  98. font_name = 'SourceCodePro-Bold.otf'
  99. plugin_dir = os.path.dirname(os.path.realpath(__file__))
  100. default_font = os.path.join(plugin_dir, font_name)
  101. font = ImageFont.FreeTypeFont(default_font, watermark_layer.size[0] // text_reducer)
  102. text_size = draw_watermark.textsize(watermark_text, font)
  103. text_position = [image.size[i] - text_size[i] - margin[i] for i in [0, 1]]
  104. draw_watermark.text(text_position, watermark_text, (255, 255, 255), font=font)
  105. if watermark_image:
  106. mark_image = Image.open(watermark_image)
  107. mark_image_size = [ watermark_layer.size[0] // image_reducer for size in mark_size]
  108. mark_image.thumbnail(mark_image_size, Image.ANTIALIAS)
  109. mark_position = [watermark_layer.size[i] - mark_image.size[i] - margin[i] for i in [0, 1]]
  110. mark_position = tuple([mark_position[0] - (text_size[0] // 2) + (mark_image_size[0] // 2), mark_position[1] - text_size[1]])
  111. if not isalpha(mark_image):
  112. mark_image = mark_image.convert('RGBA')
  113. watermark_layer.paste(mark_image, mark_position, mark_image)
  114. watermark_layer = ReduceOpacity(watermark_layer, opacity)
  115. image.paste(watermark_layer, (0, 0), watermark_layer)
  116. return image
  117. def resize_photos(generator, writer):
  118. logger.info('photos: {} photo resizes to consider.'.format(len(DEFAULT_CONFIG['queue_resize'].items())))
  119. for resized, what in DEFAULT_CONFIG['queue_resize'].items():
  120. resized = os.path.join(generator.output_path, resized)
  121. orig, spec = what
  122. if (not os.path.isfile(resized) or
  123. os.path.getmtime(orig) > os.path.getmtime(resized)):
  124. logger.info('photos: make photo {} -> {}'.format(orig, resized))
  125. im = Image.open(orig)
  126. try:
  127. exif = im._getexif()
  128. except Exception:
  129. exif = None
  130. try:
  131. icc_profile = im.info.get("icc_profile")
  132. except Exception:
  133. icc_profile = None
  134. if exif:
  135. for tag, value in exif.items():
  136. decoded = ExifTags.TAGS.get(tag, tag)
  137. if decoded == 'Orientation':
  138. if value == 3:
  139. im = im.rotate(180)
  140. elif value == 6:
  141. im = im.rotate(270)
  142. elif value == 8:
  143. im = im.rotate(90)
  144. break
  145. im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS)
  146. try:
  147. os.makedirs(os.path.split(resized)[0])
  148. except Exception:
  149. pass
  150. if generator.settings['PHOTO_WATERMARK']:
  151. im = watermark_photo(im, generator.settings['PHOTO_WATERMARK_TEXT'], generator.settings['PHOTO_WATERMARK_IMG'], generator.settings['PHOTO_WATERMARK_IMG_SIZE'])
  152. im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile)
  153. def detect_content(content):
  154. hrefs = None
  155. def replacer(m):
  156. what = m.group('what')
  157. value = m.group('value')
  158. origin = m.group('path')
  159. if what == 'photo':
  160. if value.startswith('/'):
  161. value = value[1:]
  162. path = os.path.join(
  163. os.path.expanduser(settings['PHOTO_LIBRARY']),
  164. value)
  165. if not os.path.isfile(path):
  166. logger.error('photos: No photo %s', path)
  167. else:
  168. photo = os.path.splitext(value)[0].lower() + 'a.jpg'
  169. origin = os.path.join(settings['SITEURL'], 'photos', photo)
  170. enqueue_resize(
  171. path,
  172. os.path.join('photos', photo),
  173. settings['PHOTO_ARTICLE'])
  174. return ''.join((m.group('markup'), m.group('quote'), origin,
  175. m.group('quote')))
  176. if hrefs is None:
  177. regex = r"""
  178. (?P<markup><\s*[^\>]* # match tag with src and href attr
  179. (?:href|src)\s*=)
  180. (?P<quote>["\']) # require value to be quoted
  181. (?P<path>{0}(?P<value>.*?)) # the url value
  182. \2""".format(content.settings['INTRASITE_LINK_REGEX'])
  183. hrefs = re.compile(regex, re.X)
  184. if content._content and '{photo}' in content._content:
  185. settings = content.settings
  186. content._content = hrefs.sub(replacer, content._content)
  187. def galleries_string_decompose(gallery_string):
  188. splitter_regex = re.compile(r'[\s,]*?({photo}|{filename})')
  189. title_regex = re.compile(r'{(.+)}')
  190. galleries = map(unicode.strip, filter(None, splitter_regex.split(gallery_string)))
  191. galleries = [gallery[1:] if gallery.startswith('/') else gallery for gallery in galleries]
  192. if len(galleries) % 2 == 0 and u' ' not in galleries:
  193. galleries = zip(zip(['type'] * len(galleries[0::2]), galleries[0::2]), zip(['location'] * len(galleries[0::2]), galleries[1::2]))
  194. galleries = [dict(gallery) for gallery in galleries]
  195. for gallery in galleries:
  196. title = re.search(title_regex, gallery['location'])
  197. if title:
  198. gallery['title'] = title.group(1)
  199. gallery['location'] = re.sub(title_regex, '', gallery['location']).strip()
  200. else:
  201. gallery['title'] = DEFAULT_CONFIG['PHOTO_GALLERY_TITLE']
  202. return galleries
  203. else:
  204. logger.critical('Unexpected gallery location format! \n{}'.format(pprint.pformat(galleries)))
  205. raise Exception
  206. def process_gallery(generator, content, location):
  207. content.photo_gallery = []
  208. galleries = galleries_string_decompose(location)
  209. for gallery in galleries:
  210. if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
  211. content.photo_gallery.append((gallery['location'], DEFAULT_CONFIG['created_galleries'][gallery]))
  212. continue
  213. if gallery['type'] == '{photo}':
  214. dir_gallery = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), gallery['location'])
  215. rel_gallery = gallery['location']
  216. elif gallery['type'] == '{filename}':
  217. base_path = os.path.join(generator.path, content.relative_dir)
  218. dir_gallery = os.path.join(base_path, gallery['location'])
  219. rel_gallery = os.path.join(content.relative_dir, gallery['location'])
  220. if os.path.isdir(dir_gallery):
  221. logger.info('photos: Gallery detected: {}'.format(rel_gallery))
  222. dir_photo = os.path.join('photos', rel_gallery.lower())
  223. dir_thumb = os.path.join('photos', rel_gallery.lower())
  224. exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
  225. msg='photos: No EXIF for gallery')
  226. captions = read_notes(os.path.join(dir_gallery, 'captions.txt'), msg='photos: No captions for gallery')
  227. blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'), msg='photos: No blacklist for gallery')
  228. content_gallery = []
  229. title = gallery['title']
  230. for pic in sorted(os.listdir(dir_gallery)):
  231. if pic.startswith('.'):
  232. continue
  233. if pic.endswith('.txt'):
  234. continue
  235. if pic in blacklist:
  236. continue
  237. photo = os.path.splitext(pic)[0].lower() + '.jpg'
  238. thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
  239. content_gallery.append((
  240. pic,
  241. os.path.join(dir_photo, photo),
  242. os.path.join(dir_thumb, thumb),
  243. exifs.get(pic, ''),
  244. captions.get(pic, '')))
  245. enqueue_resize(
  246. os.path.join(dir_gallery, pic),
  247. os.path.join(dir_photo, photo),
  248. generator.settings['PHOTO_GALLERY'])
  249. enqueue_resize(
  250. os.path.join(dir_gallery, pic),
  251. os.path.join(dir_thumb, thumb),
  252. generator.settings['PHOTO_THUMB'])
  253. content.photo_gallery.append((title, content_gallery))
  254. logger.debug('Gallery Data: '.format(pprint.pformat(content.photo_gallery)))
  255. DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
  256. else:
  257. logger.critical('photos: Gallery does not exist: {} at {}'.format(gallery['location'], dir_gallery))
  258. raise Exception
  259. def detect_gallery(generator, content):
  260. if 'gallery' in content.metadata:
  261. gallery = content.metadata.get('gallery')
  262. if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
  263. process_gallery(generator, content, gallery)
  264. elif gallery:
  265. logger.error('photos: Gallery tag not recognized: {}'.format(gallery))
  266. def process_image(generator, content, image):
  267. image_clipper = lambda x: x[8:] if x[8] == '/' else x[7:]
  268. file_clipper = lambda x: x[11:] if x[10] == '/' else x[10:]
  269. if image.startswith('{photo}'):
  270. path = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), image_clipper(image))
  271. image = image_clipper(image)
  272. elif image.startswith('{filename}'):
  273. path = os.path.join(content.relative_dir, file_clipper(image))
  274. image = file_clipper(image)
  275. if os.path.isfile(path):
  276. photo = os.path.splitext(image)[0].lower() + 'a.jpg'
  277. thumb = os.path.splitext(image)[0].lower() + 't.jpg'
  278. content.photo_image = (
  279. os.path.basename(image).lower(),
  280. os.path.join('photos', photo),
  281. os.path.join('photos', thumb))
  282. enqueue_resize(
  283. path,
  284. os.path.join('photos', photo),
  285. generator.settings['PHOTO_ARTICLE'])
  286. enqueue_resize(
  287. path,
  288. os.path.join('photos', thumb),
  289. generator.settings['PHOTO_THUMB'])
  290. else:
  291. logger.error('photo: No photo for {} at {}'.format(content.source_path, path))
  292. def detect_image(generator, content):
  293. image = content.metadata.get('image', None)
  294. if image:
  295. if image.startswith('{photo}') or image.startswith('{filename}'):
  296. process_image(generator, content, image)
  297. else:
  298. logger.error('photos: Image tag not recognized: {}'.format(image))
  299. def detect_images_and_galleries(generators):
  300. """Runs generator on both pages and articles. """
  301. for generator in generators:
  302. if isinstance(generator, ArticlesGenerator):
  303. for article in itertools.chain(generator.articles, generator.drafts):
  304. detect_image(generator, article)
  305. detect_gallery(generator, article)
  306. elif isinstance(generator, PagesGenerator):
  307. for page in itertools.chain(generator.pages, generator.hidden_pages):
  308. detect_image(generator, page)
  309. detect_gallery(generator, page)
  310. def register():
  311. """Uses the new style of registration based on GitHub Pelican issue #314. """
  312. signals.initialized.connect(initialized)
  313. try:
  314. signals.content_object_init.connect(detect_content)
  315. signals.all_generators_finalized.connect(detect_images_and_galleries)
  316. signals.article_writer_finalized.connect(resize_photos)
  317. except Exception as e:
  318. logger.exception('Plugin failed to execute: {}'.format(pprint.pformat(e)))