photos.py 16 KB

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