From ad2e77ebd0796e33ed621d6f9de01eb750730d6d Mon Sep 17 00:00:00 2001 From: Anton Sarukhanov <code@ant.sr> Date: Sun, 19 Apr 2020 22:00:25 -0400 Subject: [PATCH] WIP: Photo Gallery --- content/Photos/ukraine-2019.md | 5 + my_plugins/photos/photos.py | 585 +++++++++++++++++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 content/Photos/ukraine-2019.md create mode 100644 my_plugins/photos/photos.py diff --git a/content/Photos/ukraine-2019.md b/content/Photos/ukraine-2019.md new file mode 100644 index 0000000..5842185 --- /dev/null +++ b/content/Photos/ukraine-2019.md @@ -0,0 +1,5 @@ +Title: Ukraine +Description: Kyiv, Turbiv, Vinnitsa +gallery: {photo}/ukraine-2019 + +test diff --git a/my_plugins/photos/photos.py b/my_plugins/photos/photos.py new file mode 100644 index 0000000..5692d3c --- /dev/null +++ b/my_plugins/photos/photos.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import itertools +import logging +import multiprocessing +import os +import pprint +import re +import shutil + +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 ImageOps +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): + """Initialize the plugin. + + This should be triggered on Pelican's `initialized` signal.""" + + 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_SQUARE_THUMB', False) + DEFAULT_CONFIG.setdefault('PHOTO_GALLERY_TITLE', '') + DEFAULT_CONFIG.setdefault('PHOTO_RESIZE_JOBS', 1) + DEFAULT_CONFIG.setdefault('VIDEO_COPY_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['queue_copy'] = {} + 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_SQUARE_THUMB', False) + pelican.settings.setdefault('PHOTO_GALLERY_TITLE', '') + pelican.settings.setdefault('PHOTO_RESIZE_JOBS', 1) + pelican.settings.setdefault('VIDEO_COPY_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: + item = m[0].strip() + note = m[1].strip() + if item and note: + notes[item] = note + else: + notes[line] = '' + except Exception as e: + if msg: + logger.info('{} 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)): + logger.debug('photos: enqueue_resize({}, {}, {}' + .format(orig, resized, spec)) + 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 enqueue_copy(orig, copied): + logger.debug('photos: enqueue_copy({}, {}'.format(orig, copied)) + if copied not in DEFAULT_CONFIG['queue_copy']: + DEFAULT_CONFIG['queue_copy'][copied] = orig + elif DEFAULT_CONFIG['queue_copy'][copied] != orig: + logger.error('photos: copy conflict for {}, {} is not {}' + .format(copied, DEFAULT_CONFIG['queue_copy'][copied], + orig)) + + +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, expand=True) + elif orientation == 7: + img = img.rotate(90).transpose(Image.FLIP_LEFT_RIGHT) + elif orientation == 8: + img = img.rotate(90) + + return (img, exif_dict) + + +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') + + 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. + try: + im, exif_copy = manipulate_exif(im, settings) + except Exception: + logger.info('photos: no EXIF or EXIF error in {}'.format(orig)) + exif_copy = b'' + else: + exif_copy = b'' + + icc_profile = im.info.get("icc_profile", None) + + if settings['PHOTO_SQUARE_THUMB'] and spec == settings['PHOTO_THUMB']: + im = ImageOps.fit(im, (spec[0], spec[1]), Image.ANTIALIAS) + + im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS) + directory = os.path.split(resized)[0] + + 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])) + + im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy) + + +def copy_worker(orig, resized, settings): + logger.info('photos: copy video {} -> {}'.format(orig, resized)) + directory = os.path.split(resized)[0] + + 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])) + + shutil.copyfile(orig, resized) + + +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 copy_videos(generator, writer): + if generator.settings['VIDEO_COPY_JOBS'] == -1: + debug = True + generator.settings['VIDEO_COPY_JOBS'] = 1 + else: + debug = False + pool = multiprocessing.Pool(generator.settings['VIDEO_COPY_JOBS']) + logger.debug('Debug Status: {}'.format(debug)) + for copied, orig in DEFAULT_CONFIG['queue_copy'].items(): + copied = os.path.join(generator.output_path, copied) + if (not os.path.isfile(copied) or os.path.getmtime(orig) > os.path.getmtime(copied)): + if debug: + copy_worker(orig, copied, generator.settings) + else: + pool.apply_async(resize_worker, (orig, copied, generator.settings)) + + pool.close() + pool.join() + + +def detect_content(content): + """ + + + Triggered by Pelican's `content_object_init` signal.""" + + 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(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 item in sorted(os.listdir(dir_gallery)): + if item.startswith('.'): + continue + if item.endswith('.txt'): + continue + if item in blacklist: + continue + if item.endswith('.mp4.jpg'): + if os.path.isfile( + os.path.join(dir_gallery, os.path.splitext(item)[0])): + continue + thumb = os.path.splitext(item)[0] + 't.jpg' + if item.endswith('.jpg'): + enqueue_resize( + os.path.join(dir_gallery, item), + os.path.join(dir_photo, item), + generator.settings['PHOTO_GALLERY']) + enqueue_resize( + os.path.join(dir_gallery, item), + os.path.join(dir_thumb, thumb), + generator.settings['PHOTO_THUMB']) + elif item.endswith('.mp4'): + enqueue_copy( + os.path.join(dir_gallery, item), + os.path.join(generator.output_path, dir_photo, item), + ) + still = item + '.jpg' + if os.path.isfile(os.path.join(dir_gallery, still)): + still = os.path.join(dir_gallery, still) + else: + here = os.path.dirname(__file__) + still = os.path.join(here, 'images/video-icon.png') + enqueue_resize( + still, + os.path.join(dir_thumb, thumb), + generator.settings['PHOTO_THUMB']) + content_gallery.append(( + item, + os.path.join(dir_photo, item), + os.path.join(dir_thumb, thumb), + exifs.get(item, ''), + captions.get(item, ''))) + + # DEBUG TODO + # for c in content_gallery: + # raise(Exception(c)) + + 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(): + """Map signals to plugin logic. + + Required by Pelican. + 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) + signals.article_writer_finalized.connect(copy_videos) + except Exception as e: + logger.exception('Plugin failed to execute: {}' + .format(pprint.pformat(e))) -- GitLab