photos.py 15 KB

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