123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- # -*- coding: utf-8 -*-
- from __future__ import unicode_literals
- import datetime
- import itertools
- import json
- import logging
- import multiprocessing
- import os
- import pprint
- import re
- import sys
- from pelican.generators import ArticlesGenerator
- from pelican.generators import PagesGenerator
- from pelican.settings import DEFAULT_CONFIG
- from pelican import signals
- from pelican.utils import pelican_open
- logger = logging.getLogger(__name__)
- try:
- from PIL import Image
- from PIL import ImageDraw
- from PIL import ImageEnhance
- from PIL import ImageFont
- except ImportError:
- logger.error('PIL/Pillow not found')
- try:
- import piexif
- except ImportError:
- ispiexif = False
- logger.warning('piexif not found! Cannot use exif manipulation features')
- else:
- ispiexif = True
- logger.debug('piexif found.')
- def initialized(pelican):
- p = os.path.expanduser('~/Pictures')
- DEFAULT_CONFIG.setdefault('PHOTO_LIBRARY', p)
- DEFAULT_CONFIG.setdefault('PHOTO_GALLERY', (1024, 768, 80))
- DEFAULT_CONFIG.setdefault('PHOTO_ARTICLE', (760, 506, 80))
- DEFAULT_CONFIG.setdefault('PHOTO_THUMB', (192, 144, 60))
- DEFAULT_CONFIG.setdefault('PHOTO_GALLERY_TITLE', '')
- DEFAULT_CONFIG.setdefault('PHOTO_ALPHA_BACKGROUND_COLOR', (255, 255, 255))
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK', False)
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_THUMB', False)
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_TEXT', DEFAULT_CONFIG['SITENAME'])
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_TEXT_COLOR', (255, 255, 255))
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG', '')
- DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG_SIZE', False)
- DEFAULT_CONFIG.setdefault('PHOTO_RESIZE_JOBS', 1)
- DEFAULT_CONFIG.setdefault('PHOTO_EXIF_KEEP', False)
- DEFAULT_CONFIG.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
- DEFAULT_CONFIG.setdefault('PHOTO_EXIF_AUTOROTATE', True)
- DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT', False)
- DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', DEFAULT_CONFIG['SITENAME'])
- DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
- DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
- DEFAULT_CONFIG['queue_resize'] = {}
- DEFAULT_CONFIG['created_galleries'] = {}
- DEFAULT_CONFIG['plugin_dir'] = os.path.dirname(os.path.realpath(__file__))
- if pelican:
- pelican.settings.setdefault('PHOTO_LIBRARY', p)
- pelican.settings.setdefault('PHOTO_GALLERY', (1024, 768, 80))
- pelican.settings.setdefault('PHOTO_ARTICLE', (760, 506, 80))
- pelican.settings.setdefault('PHOTO_THUMB', (192, 144, 60))
- pelican.settings.setdefault('PHOTO_GALLERY_TITLE', '')
- pelican.settings.setdefault('PHOTO_ALPHA_BACKGROUND_COLOR', (255, 255, 255))
- pelican.settings.setdefault('PHOTO_WATERMARK', False)
- pelican.settings.setdefault('PHOTO_WATERMARK_THUMB', False)
- pelican.settings.setdefault('PHOTO_WATERMARK_TEXT', pelican.settings['SITENAME'])
- pelican.settings.setdefault('PHOTO_WATERMARK_TEXT_COLOR', (255, 255, 255))
- pelican.settings.setdefault('PHOTO_WATERMARK_IMG', '')
- pelican.settings.setdefault('PHOTO_WATERMARK_IMG_SIZE', False)
- pelican.settings.setdefault('PHOTO_RESIZE_JOBS', 1)
- pelican.settings.setdefault('PHOTO_EXIF_KEEP', False)
- pelican.settings.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
- pelican.settings.setdefault('PHOTO_EXIF_AUTOROTATE', True)
- pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT', False)
- pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', pelican.settings['AUTHOR'])
- pelican.settings.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
- pelican.settings.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
- def read_notes(filename, msg=None):
- notes = {}
- try:
- with pelican_open(filename) as text:
- for line in text.splitlines():
- if line.startswith('#'):
- continue
- m = line.split(':', 1)
- if len(m) > 1:
- pic = m[0].strip()
- note = m[1].strip()
- if pic and note:
- notes[pic] = note
- else:
- notes[line] = ''
- except Exception as e:
- if msg:
- logger.warning('{} at file {}'.format(msg, filename))
- logger.debug('read_notes issue: {} at file {}. Debug message:{}'.format(msg, filename, e))
- return notes
- def enqueue_resize(orig, resized, spec=(640, 480, 80)):
- if resized not in DEFAULT_CONFIG['queue_resize']:
- DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
- elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
- logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0], DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
- def isalpha(img):
- return True if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) else False
- def remove_alpha(img, bg_color):
- background = Image.new("RGB", img.size, bg_color)
- background.paste(img, mask=img.split()[3]) # 3 is the alpha channel
- return background
- def ReduceOpacity(im, opacity):
- """Reduces Opacity.
- Returns an image with reduced opacity.
- Taken from http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879
- """
- assert opacity >= 0 and opacity <= 1
- if isalpha(im):
- im = im.copy()
- else:
- im = im.convert('RGBA')
- alpha = im.split()[3]
- alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
- im.putalpha(alpha)
- return im
- def watermark_photo(image, settings):
- margin = [10, 10]
- opacity = 0.6
- watermark_layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
- draw_watermark = ImageDraw.Draw(watermark_layer)
- text_reducer = 32
- image_reducer = 8
- text_size = [0, 0]
- mark_size = [0, 0]
- text_position = [0, 0]
- if settings['PHOTO_WATERMARK_TEXT']:
- font_name = 'SourceCodePro-Bold.otf'
- default_font = os.path.join(DEFAULT_CONFIG['plugin_dir'], font_name)
- font = ImageFont.FreeTypeFont(default_font, watermark_layer.size[0] // text_reducer)
- text_size = draw_watermark.textsize(settings['PHOTO_WATERMARK_TEXT'], font)
- text_position = [image.size[i] - text_size[i] - margin[i] for i in [0, 1]]
- draw_watermark.text(text_position, settings['PHOTO_WATERMARK_TEXT'], settings['PHOTO_WATERMARK_TEXT_COLOR'], font=font)
- if settings['PHOTO_WATERMARK_IMG']:
- mark_image = Image.open(settings['PHOTO_WATERMARK_IMG'])
- mark_image_size = [watermark_layer.size[0] // image_reducer for size in mark_size]
- mark_image_size = settings['PHOTO_WATERMARK_IMG_SIZE'] if settings['PHOTO_WATERMARK_IMG_SIZE'] else mark_image_size
- mark_image.thumbnail(mark_image_size, Image.ANTIALIAS)
- mark_position = [watermark_layer.size[i] - mark_image.size[i] - margin[i] for i in [0, 1]]
- mark_position = tuple([mark_position[0] - (text_size[0] // 2) + (mark_image_size[0] // 2), mark_position[1] - text_size[1]])
- if not isalpha(mark_image):
- mark_image = mark_image.convert('RGBA')
- watermark_layer.paste(mark_image, mark_position, mark_image)
- watermark_layer = ReduceOpacity(watermark_layer, opacity)
- image.paste(watermark_layer, (0, 0), watermark_layer)
- return image
- def rotate_image(img, exif_dict):
- if "exif" in img.info and piexif.ImageIFD.Orientation in exif_dict["0th"]:
- orientation = exif_dict["0th"].pop(piexif.ImageIFD.Orientation)
- if orientation == 2:
- img = img.transpose(Image.FLIP_LEFT_RIGHT)
- elif orientation == 3:
- img = img.rotate(180)
- elif orientation == 4:
- img = img.rotate(180).transpose(Image.FLIP_LEFT_RIGHT)
- elif orientation == 5:
- img = img.rotate(-90).transpose(Image.FLIP_LEFT_RIGHT)
- elif orientation == 6:
- img = img.rotate(-90)
- elif orientation == 7:
- img = img.rotate(90).transpose(Image.FLIP_LEFT_RIGHT)
- elif orientation == 8:
- img = img.rotate(90)
- return (img, exif_dict)
- def build_license(license, author):
- year = datetime.datetime.now().year
- license_file = os.path.join(DEFAULT_CONFIG['plugin_dir'], 'licenses.json')
- with open(license_file) as data_file:
- licenses = json.load(data_file)
- if any(license in k for k in licenses):
- return licenses[license]['Text'].format(Author=author, Year=year, URL=licenses[license]['URL'])
- else:
- return 'Copyright {Year} {Author}, All Rights Reserved'.format(Author=author, Year=year)
- def manipulate_exif(img, settings):
- try:
- exif = piexif.load(img.info['exif'])
- except Exception:
- logger.debug('EXIF information not found')
- exif = {}
- if settings['PHOTO_EXIF_AUTOROTATE']:
- img, exif = rotate_image(img, exif)
- if settings['PHOTO_EXIF_REMOVE_GPS']:
- exif.pop('GPS')
- if settings['PHOTO_EXIF_COPYRIGHT']:
- # We want to be minimally destructive to any preset exif author or copyright information.
- # If there is copyright or author information prefer that over everything else.
- if not exif['0th'].get(piexif.ImageIFD.Artist):
- exif['0th'][piexif.ImageIFD.Artist] = settings['PHOTO_EXIF_COPYRIGHT_AUTHOR']
- author = settings['PHOTO_EXIF_COPYRIGHT_AUTHOR']
- if not exif['0th'].get(piexif.ImageIFD.Copyright):
- license = build_license(settings['PHOTO_EXIF_COPYRIGHT'], author)
- exif['0th'][piexif.ImageIFD.Copyright] = license
- return (img, piexif.dump(exif))
- def resize_worker(orig, resized, spec, settings):
- logger.info('photos: make photo {} -> {}'.format(orig, resized))
- im = Image.open(orig)
- if ispiexif and settings['PHOTO_EXIF_KEEP'] and im.format == 'JPEG': # Only works with JPEG exif for sure.
- im, exif_copy = manipulate_exif(im, settings)
- else:
- exif_copy = b''
- icc_profile = im.info.get("icc_profile", None)
- im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS)
- directory = os.path.split(resized)[0]
- if isalpha(im):
- im = remove_alpha(im, settings['PHOTO_ALPHA_BACKGROUND_COLOR'])
- if not os.path.exists(directory):
- try:
- os.makedirs(directory)
- except Exception:
- logger.exception('Could not create {}'.format(directory))
- else:
- logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
- if settings['PHOTO_WATERMARK']:
- isthumb = True if spec == settings['PHOTO_THUMB'] else False
- if not isthumb or (isthumb and settings['PHOTO_WATERMARK_THUMB']):
- im = watermark_photo(im, settings)
- im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy)
- def resize_photos(generator, writer):
- if generator.settings['PHOTO_RESIZE_JOBS'] == -1:
- debug = True
- generator.settings['PHOTO_RESIZE_JOBS'] = 1
- else:
- debug = False
- pool = multiprocessing.Pool(generator.settings['PHOTO_RESIZE_JOBS'])
- logger.debug('Debug Status: {}'.format(debug))
- for resized, what in DEFAULT_CONFIG['queue_resize'].items():
- resized = os.path.join(generator.output_path, resized)
- orig, spec = what
- if (not os.path.isfile(resized) or os.path.getmtime(orig) > os.path.getmtime(resized)):
- if debug:
- resize_worker(orig, resized, spec, generator.settings)
- else:
- pool.apply_async(resize_worker, (orig, resized, spec, generator.settings))
- pool.close()
- pool.join()
- def detect_content(content):
- hrefs = None
- def replacer(m):
- what = m.group('what')
- value = m.group('value')
- tag = m.group('tag')
- output = m.group(0)
- if what in ('photo', 'lightbox'):
- if value.startswith('/'):
- value = value[1:]
- path = os.path.join(
- os.path.expanduser(settings['PHOTO_LIBRARY']),
- value
- )
- if os.path.isfile(path):
- photo_prefix = os.path.splitext(value)[0].lower()
- if what == 'photo':
- photo_article = photo_prefix + 'a.jpg'
- enqueue_resize(
- path,
- os.path.join('photos', photo_article),
- settings['PHOTO_ARTICLE']
- )
- output = ''.join((
- '<',
- m.group('tag'),
- m.group('attrs_before'),
- m.group('src'),
- '=',
- m.group('quote'),
- os.path.join(settings['SITEURL'], 'photos', photo_article),
- m.group('quote'),
- m.group('attrs_after'),
- ))
- elif what == 'lightbox' and tag == 'img':
- photo_gallery = photo_prefix + '.jpg'
- enqueue_resize(
- path,
- os.path.join('photos', photo_gallery),
- settings['PHOTO_GALLERY']
- )
- photo_thumb = photo_prefix + 't.jpg'
- enqueue_resize(
- path,
- os.path.join('photos', photo_thumb),
- settings['PHOTO_THUMB']
- )
- lightbox_attr_list = ['']
- gallery_name = value.split('/')[0]
- lightbox_attr_list.append('{}="{}"'.format(
- settings['PHOTO_LIGHTBOX_GALLERY_ATTR'],
- gallery_name
- ))
- captions = read_notes(
- os.path.join(os.path.dirname(path), 'captions.txt'),
- msg = 'photos: No captions for gallery'
- )
- caption = captions.get(os.path.basename(path)) if captions else None
- if caption:
- lightbox_attr_list.append('{}="{}"'.format(
- settings['PHOTO_LIGHTBOX_CAPTION_ATTR'],
- caption
- ))
- lightbox_attrs = ' '.join(lightbox_attr_list)
- output = ''.join((
- '<a href=',
- m.group('quote'),
- os.path.join(settings['SITEURL'], 'photos', photo_gallery),
- m.group('quote'),
- lightbox_attrs,
- '><img',
- m.group('attrs_before'),
- 'src=',
- m.group('quote'),
- os.path.join(settings['SITEURL'], 'photos', photo_thumb),
- m.group('quote'),
- m.group('attrs_after'),
- '</a>'
- ))
- else:
- logger.error('photos: No photo %s', path)
- return output
- if hrefs is None:
- regex = r"""
- <\s*
- (?P<tag>[^\s\>]+) # detect the tag
- (?P<attrs_before>[^\>]*)
- (?P<src>href|src) # match tag with src and href attr
- \s*=
- (?P<quote>["\']) # require value to be quoted
- (?P<path>{0}(?P<value>.*?)) # the url value
- (?P=quote)
- (?P<attrs_after>[^\>]*>)
- """.format(
- content.settings['INTRASITE_LINK_REGEX']
- )
- hrefs = re.compile(regex, re.X)
- if content._content and ('{photo}' in content._content or '{lightbox}' in content._content):
- settings = content.settings
- content._content = hrefs.sub(replacer, content._content)
- def galleries_string_decompose(gallery_string):
- splitter_regex = re.compile(r'[\s,]*?({photo}|{filename})')
- title_regex = re.compile(r'{(.+)}')
- galleries = map(unicode.strip if sys.version_info.major == 2 else str.strip, filter(None, splitter_regex.split(gallery_string)))
- galleries = [gallery[1:] if gallery.startswith('/') else gallery for gallery in galleries]
- if len(galleries) % 2 == 0 and ' ' not in galleries:
- galleries = zip(zip(['type'] * len(galleries[0::2]), galleries[0::2]), zip(['location'] * len(galleries[0::2]), galleries[1::2]))
- galleries = [dict(gallery) for gallery in galleries]
- for gallery in galleries:
- title = re.search(title_regex, gallery['location'])
- if title:
- gallery['title'] = title.group(1)
- gallery['location'] = re.sub(title_regex, '', gallery['location']).strip()
- else:
- gallery['title'] = DEFAULT_CONFIG['PHOTO_GALLERY_TITLE']
- return galleries
- else:
- logger.error('Unexpected gallery location format! \n{}'.format(pprint.pformat(galleries)))
- def process_gallery(generator, content, location):
- content.photo_gallery = []
- galleries = galleries_string_decompose(location)
- for gallery in galleries:
- if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
- content.photo_gallery.append((gallery['location'], DEFAULT_CONFIG['created_galleries'][gallery]))
- continue
- if gallery['type'] == '{photo}':
- dir_gallery = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), gallery['location'])
- rel_gallery = gallery['location']
- elif gallery['type'] == '{filename}':
- base_path = os.path.join(generator.path, content.relative_dir)
- dir_gallery = os.path.join(base_path, gallery['location'])
- rel_gallery = os.path.join(content.relative_dir, gallery['location'])
- if os.path.isdir(dir_gallery):
- logger.info('photos: Gallery detected: {}'.format(rel_gallery))
- dir_photo = os.path.join('photos', rel_gallery.lower())
- dir_thumb = os.path.join('photos', rel_gallery.lower())
- exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
- msg='photos: No EXIF for gallery')
- captions = read_notes(os.path.join(dir_gallery, 'captions.txt'), msg='photos: No captions for gallery')
- blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'), msg='photos: No blacklist for gallery')
- content_gallery = []
- title = gallery['title']
- for pic in sorted(os.listdir(dir_gallery)):
- if pic.startswith('.'):
- continue
- if pic.endswith('.txt'):
- continue
- if pic in blacklist:
- continue
- photo = os.path.splitext(pic)[0].lower() + '.jpg'
- thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
- content_gallery.append((
- pic,
- os.path.join(dir_photo, photo),
- os.path.join(dir_thumb, thumb),
- exifs.get(pic, ''),
- captions.get(pic, '')))
- enqueue_resize(
- os.path.join(dir_gallery, pic),
- os.path.join(dir_photo, photo),
- generator.settings['PHOTO_GALLERY'])
- enqueue_resize(
- os.path.join(dir_gallery, pic),
- os.path.join(dir_thumb, thumb),
- generator.settings['PHOTO_THUMB'])
- content.photo_gallery.append((title, content_gallery))
- logger.debug('Gallery Data: '.format(pprint.pformat(content.photo_gallery)))
- DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
- else:
- logger.error('photos: Gallery does not exist: {} at {}'.format(gallery['location'], dir_gallery))
- def detect_gallery(generator, content):
- if 'gallery' in content.metadata:
- gallery = content.metadata.get('gallery')
- if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
- process_gallery(generator, content, gallery)
- elif gallery:
- logger.error('photos: Gallery tag not recognized: {}'.format(gallery))
- def image_clipper(x):
- return x[8:] if x[8] == '/' else x[7:]
- def file_clipper(x):
- return x[11:] if x[10] == '/' else x[10:]
- def process_image(generator, content, image):
- if image.startswith('{photo}'):
- path = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), image_clipper(image))
- image = image_clipper(image)
- elif image.startswith('{filename}'):
- path = os.path.join(content.relative_dir, file_clipper(image))
- image = file_clipper(image)
- if os.path.isfile(path):
- photo = os.path.splitext(image)[0].lower() + 'a.jpg'
- thumb = os.path.splitext(image)[0].lower() + 't.jpg'
- content.photo_image = (
- os.path.basename(image).lower(),
- os.path.join('photos', photo),
- os.path.join('photos', thumb))
- enqueue_resize(
- path,
- os.path.join('photos', photo),
- generator.settings['PHOTO_ARTICLE'])
- enqueue_resize(
- path,
- os.path.join('photos', thumb),
- generator.settings['PHOTO_THUMB'])
- else:
- logger.error('photo: No photo for {} at {}'.format(content.source_path, path))
- def detect_image(generator, content):
- image = content.metadata.get('image', None)
- if image:
- if image.startswith('{photo}') or image.startswith('{filename}'):
- process_image(generator, content, image)
- else:
- logger.error('photos: Image tag not recognized: {}'.format(image))
- def detect_images_and_galleries(generators):
- """Runs generator on both pages and articles."""
- for generator in generators:
- if isinstance(generator, ArticlesGenerator):
- for article in itertools.chain(generator.articles, generator.translations, generator.drafts):
- detect_image(generator, article)
- detect_gallery(generator, article)
- elif isinstance(generator, PagesGenerator):
- for page in itertools.chain(generator.pages, generator.translations, generator.hidden_pages):
- detect_image(generator, page)
- detect_gallery(generator, page)
- def register():
- """Uses the new style of registration based on GitHub Pelican issue #314."""
- signals.initialized.connect(initialized)
- try:
- signals.content_object_init.connect(detect_content)
- signals.all_generators_finalized.connect(detect_images_and_galleries)
- signals.article_writer_finalized.connect(resize_photos)
- except Exception as e:
- logger.exception('Plugin failed to execute: {}'.format(pprint.pformat(e)))
|