Procházet zdrojové kódy

Add photos and galleries to articles, resize as needed.

Use Photos to add a photo or a gallery of photos to an article, or to include photos in the body text. Photos are kept separately, as an organized library of high resolution photos, and resized as needed.
Joaquim Baptista před 9 roky
rodič
revize
49d1daea4b

+ 157 - 0
photos/README.md

@@ -0,0 +1,157 @@
+# Photos
+
+Use Photos to add a photo or a gallery of photos to an article, or to include photos in the body text. Photos are kept separately, as an organized library of high resolution photos, and resized as needed.
+
+## How to use
+
+Maintain an organized library of high resolution photos somewhere on disk, using folders to group related images. The default path `~/Pictures` is convenient for Mac OS X users.
+
+* To create a gallery of photos, add the metadata field `gallery: {photo}folder` to an article. To simplify the transition from the plug-in Gallery, the syntax `gallery: {filename}folder` is also accepted, but images are not resized.
+* To use an image in the body of the text, just use the syntax `{photo}folder/image.jpg` instead of the usual `{filename}/images/image.jpg`.
+* To associate an image with an article, add the metadata field `image: {photo}folder/image.jpg` to an article. Use associated images to improve navigation. For compatibility, the syntax `image: {filename}/images/image.jpg` is also accepted, but the image is not resized.
+
+Folders of photos may optionally have two text files, where each line describes one photo. Generating these optional files is left as an exercise for the reader (but consider using Phil Harvey's [exiftool](http://www.sno.phy.queensu.ca/~phil/exiftool/)).
+
+`exif.txt`
+:	Associates compact technical information with photos, typically the camera settings. For example:
+
+		best:jpg: Canon EOS 5D Mark II - 20mm f/8 1/250s ISO 100
+		night.jpg: Canon EOS 5D Mark II - 47mm f/8 5s ISO 100
+
+`captions.txt`
+:	Associates comments with photos. For example:
+
+		best.jpg: My best photo ever! How lucky of me!
+		night.jpg: Twilight over the dam.
+
+Here is an example Markdown article that shows the three use cases:
+
+		title: My Article
+		gallery: {photo}favorite
+		image: {photo}favorite/best.jpg
+
+		Here are my best photos, taken with my favorite camera:
+		![]({photo}mybag/camera.jpg).
+
+## How to install and configure
+
+The plug-in requires PIL, the Python Imaging Library, whose installation is outside the scope of this document.
+
+The plug-in resizes the referred photos, and generates thumbnails for galleries and associated photos, based on the following configuration and default values:
+
+`PHOTO_LIBRARY = "~/Pictures"`
+:	Absolute path to the folder where the original photos are kept, organized in sub-folders.
+
+`PHOTO_GALLERY = (1024, 768, 80)`
+:	For photos in galleries, maximum width and height, plus JPEG quality as a percentage. This would typically be the size of the photo displayed when the reader clicks a thumbnail.
+
+`PHOTO_ARTICLE = ( 760, 506, 80)`
+:	For photos associated with articles, maximum width, height, and quality. The maximum size would typically depend on the needs of the theme. 760px is suitable for the theme `notmyidea`.
+
+`PHOTO_THUMB = (192, 144, 60)`
+:	For thumbnails, maximum width, height, and quality.
+
+The plug-in automatically resizes the photos and publishes them to the following output folder:
+
+    ./output/photos
+
+__WARNING:__ The plug-in can take hours to resize 40,000 photos, therefore, photos and thumbnails are only generated once. Clean the output folders to regenerate the resized photos again.
+
+## How to change the Jinja templates
+
+The plugin provides the following variables to your templates:
+
+`article.photo_gallery`
+:	For articles with a gallery, a list of the photos in the gallery. Each item in the list is a tuple with five elements:
+
+	* The filename of the original photo.
+	* The output path to the generated photo.
+	* The output path to the generated thumbnail.
+	* The EXIF information of the photo, as read from the file `exif.txt`.
+	* The caption of the photo, as read from `captions.txt`.
+
+`article.photo_image`
+:	For articles with an associated photo, a tuple with the following information:
+
+	* The filename of the original photo.
+	* The output path to the generated photo.
+	* The output path to the generated thumbnail.
+
+For example, modify the template `article.html` as shown below to display the associated image before the article content:
+
+		<div class="entry-content">
+			{% if article.photo_image %}<img src="{{ SITEURL }}/{{ article.photo_image[1] }}" />{% endif %}
+			{% include 'article_infos.html' %}
+			{{ article.content }}
+		</div><!-- /.entry-content -->
+
+For example, add the following to the template `article.html` to add the gallery as the end of the article:
+
+		{% if article.photo_gallery %}
+		<div class="gallery">
+			{% for name, photo, thumb, exif, caption in article.photo_gallery %}
+			<a href="{{ SITEURL }}/{{ photo }}" title="{{ name }}" exif="{{ exif }}" caption="{{ caption }}"><img src="{{ SITEURL }}/{{ thumb }}"></a>
+			{% endfor %}
+		</div>
+		{% endif %}
+
+For example, add the following to the template `index.html`, inside the `entry-content`, to display the thumbnail with a link to the article:
+
+		{% if article.photo_image %}<a href="{{ SITEURL }}/{{ article.url }}"><img src="{{ SITEURL }}/{{ article.photo_image[2] }}"
+			style="display: inline; float: right; margin: 2px 0 2ex 4ex;" /></a>
+		{% endif %}
+
+## How to make the gallery lightbox
+
+There are several JavaScript libraries that display a list of images as a lightbox. The example below uses [Magnific Popup](http://dimsemenov.com/plugins/magnific-popup/), which allows the more complex initialization needed to display both the filename, the compact technical information, and the caption. The solution would be simpler if photos did not show any extra information.
+
+Copy the files `magnific-popup.css` and `magnific-popup.js` to the root of your Pelican template.
+
+Add the following to the template `base.html`, inside the HTML `head` tags:
+
+		{% if (article and article.photo_gallery) or (articles_page and articles_page.object_list[0].photo_gallery) %}
+		<link rel="stylesheet" href="{{ SITEURL }}/{{ THEME_STATIC_DIR }}/magnific-popup.css">
+		{% endif %}
+
+Add the following to the template `base.html`, before the closing HTML `</body>` tag:
+
+		{% if (article and article.photo_gallery) or (articles_page and articles_page.object_list[0].photo_gallery) %}
+		<!-- jQuery 1.7.2+ or Zepto.js 1.0+ -->
+		<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
+
+		<!-- Magnific Popup core JS file -->
+		<script src="{{ SITEURL }}/{{ THEME_STATIC_DIR }}/magnific-popup.js"></script>
+		<script>
+		$('.gallery').magnificPopup({
+			delegate: 'a',
+			type: 'image',
+			gallery: {
+				enabled: true,
+				navigateByImgClick: true,
+				preload: [1,2]
+			},
+			image: {
+				titleSrc: function(item) {
+					if (item.el.attr('caption') && item.el.attr('exif')) {
+						return (item.el.attr('caption').replace(/\\n/g, '<br />') +
+							'<small>' + item.el.attr('title') + ' - ' + item.el.attr('exif') + '</small>');
+					}
+				return item.el.attr('title') + '<small>' + item.el.attr('exif') + '</small>';
+			} }
+		});
+		</script>
+		{% endif %}
+
+## Known use cases
+
+<pxquim.pt> uses Photos and the plug-in Sub-parts to publish 600 photo galleries with 40,000 photos. Photos keeps the high-resolution photos separate from the site articles.
+
+<pxquim.com> uses sub-parts to cover conferences, where it makes sense to have a sub-part for each speaker.
+
+## Alternatives
+
+Gallery
+:	Galleries are distinct entities, without the organizational capabilities of articles. Photos must be resized separately, and must be kept with the source of the blog. Gallery was the initial inspiration for Photos.
+
+Image_process
+:	Resize and process images in the article body in a more flexible way (based on the CSS class of the image), but without the ability to create galleries. The source photos must be kept with the source of the blog.

+ 1 - 0
photos/__init__.py

@@ -0,0 +1 @@
+from .photos import *

+ 264 - 0
photos/photos.py

@@ -0,0 +1,264 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+import os
+import re
+import logging
+from pelican import signals
+from pelican.utils import pelican_open
+from PIL import Image, ExifTags
+from itertools import chain
+
+logger = logging.getLogger(__name__)
+queue_resize = dict()
+hrefs = None
+
+
+def initialized(pelican):
+    p = os.path.expanduser('~/Pictures')
+    from pelican.settings import DEFAULT_CONFIG
+    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))
+    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))
+
+
+def read_notes(filename, msg=None):
+    notes = {}
+    try:
+        with pelican_open(filename) as text:
+            for line in text.splitlines():
+                m = line.split(':', 1)
+                if len(m) > 1:
+                    pic = m[0].strip()
+                    note = m[1].strip()
+                    if pic and note:
+                        notes[pic] = note
+    except:
+        if msg:
+            logger.warning(msg, filename)
+    return notes
+
+
+def enqueue_resize(orig, resized, spec=(640, 480, 80)):
+    global queue_resize
+    if resized not in queue_resize:
+        queue_resize[resized] = (orig, spec)
+    elif queue_resize[resized] != (orig, spec):
+        logger.error('photos: resize conflict for {}, {}-{} is not {}-{}',
+                     resized,
+                     queue_resize[resized][0], queue_resize[resized][1],
+                     orig, spec)
+
+
+def resize_photos(generator, writer):
+    print('photos: {} photo resizes to consider.'
+          .format(len(queue_resize.items())))
+    for resized, what in 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)):
+            logger.info('photos: make photo %s -> %s', orig, resized)
+            im = Image.open(orig)
+            try:
+                exif = im._getexif()
+            except Exception:
+                exif = None
+            if exif:
+                for tag, value in exif.items():
+                    decoded = ExifTags.TAGS.get(tag, tag)
+                    if decoded == 'Orientation':
+                        if   value == 3: im = im.rotate(180)
+                        elif value == 6: im = im.rotate(270)
+                        elif value == 8: im = im.rotate(90)
+                        break
+            im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS)
+            try:
+                os.makedirs(os.path.split(resized)[0])
+            except:
+                pass
+            im.save(resized, 'JPEG', quality=spec[2])
+
+
+def detect_content(content):
+
+    def replacer(m):
+        what = m.group('what')
+        value = m.group('value')
+        origin = m.group('path')
+
+        if what == 'photo':
+            if value.startswith('/'):
+                value = value[1:]
+            path = os.path.join(settings['PHOTO_LIBRARY'], value)
+            if not os.path.isfile(path):
+                logger.error('photos: No photo %s', path)
+            else:
+                photo = os.path.splitext(value)[0].lower() + 'a.jpg'
+                origin = os.path.join('/photos', photo)
+                enqueue_resize(
+                    path,
+                    os.path.join('photos', photo),
+                    settings['PHOTO_ARTICLE'])
+
+        return ''.join((m.group('markup'), m.group('quote'), origin,
+                        m.group('quote')))
+
+    global hrefs
+    if hrefs is None:
+        regex = r"""
+            (?P<markup><\s*[^\>]*  # match tag with src and href attr
+                (?:href|src)\s*=)
+
+            (?P<quote>["\'])      # require value to be quoted
+            (?P<path>{0}(?P<value>.*?))  # the url value
+            \2""".format(content.settings['INTRASITE_LINK_REGEX'])
+        hrefs = re.compile(regex, re.X)
+
+    if content._content and '{photo}' in content._content:
+        settings = content.settings
+        content._content = hrefs.sub(replacer, content._content)
+
+
+def process_gallery_photo(generator, article, gallery):
+    if gallery.startswith('/'):
+        gallery = gallery[1:]
+    dir_gallery = os.path.join(generator.settings['PHOTO_LIBRARY'], gallery)
+    if os.path.isdir(dir_gallery):
+        logger.info('photos: Gallery detected: %s', gallery)
+        dir_photo = os.path.join('photos', gallery.lower())
+        dir_thumb = os.path.join('photos', gallery.lower())
+        exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
+                           msg='photos: No EXIF for gallery %s')
+        captions = read_notes(os.path.join(dir_gallery, 'captions.txt'))
+        article.photo_gallery = []
+        for pic in os.listdir(dir_gallery):
+            if pic.startswith('.'): continue
+            if pic.endswith('.txt'): continue
+            photo = os.path.splitext(pic)[0].lower() + '.jpg'
+            thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
+            article.photo_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'])
+
+
+def process_gallery_filename(generator, article, gallery):
+    if gallery.startswith('/'):
+        gallery = gallery[1:]
+    else:
+        gallery = os.path.join(article.relative_dir, gallery)
+    dir_gallery = os.path.join(generator.settings['PHOTO_LIBRARY'], gallery)
+    if os.path.isdir(dir_gallery):
+        logger.info('photos: Gallery detected: %s', gallery)
+        dir_photo = gallery.lower()
+        dir_thumb = os.path.join('photos', gallery.lower())
+        exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
+                           msg='photos: No EXIF for gallery %s')
+        captions = read_notes(os.path.join(dir_gallery, 'captions.txt'))
+        article.photo_gallery = []
+        for pic in os.listdir(dir_gallery):
+            if pic.startswith('.'): continue
+            if pic.endswith('.txt'): continue
+            photo = pic.lower()
+            thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
+            article.photo_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_thumb, thumb),
+                generator.settings['PHOTO_THUMB'])
+
+
+def detect_gallery(generator):
+    for article in chain(generator.articles, generator.drafts):
+        if 'gallery' in article.metadata:
+            gallery = article.metadata.get('gallery')
+            if gallery.startswith('{photo}'):
+                process_gallery_photo(generator, article, gallery[7:])
+            elif gallery.startswith('{filename}'):
+                process_gallery_filename(generator, article, gallery[10:])
+            elif gallery:
+                logger.error('photos: Gallery tag not recognized: %s', gallery)
+
+
+def process_image_photo(generator, article, image):
+    if image.startswith('/'):
+        image = image[1:]
+    path = os.path.join(generator.settings['PHOTO_LIBRARY'], image)
+    if os.path.isfile(path):
+        photo = os.path.splitext(image)[0].lower() + 'a.jpg'
+        thumb = os.path.splitext(image)[0].lower() + 't.jpg'
+        article.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 %s at %s', article.source_path, path)
+
+
+def process_image_filename(generator, article, image):
+    if image.startswith('/'):
+        image = image[1:]
+    else:
+        image = os.path.join(article.relative_dir, image)
+    path = os.path.join(generator.path, image)
+    if os.path.isfile(path):
+        small = os.path.splitext(image)[0].lower() + 't.jpg'
+        article.photo_image = (
+            os.path.basename(image),
+            image.lower(),
+            os.path.join('photos', small))
+        enqueue_resize(
+            path,
+            os.path.join('photos', small),
+            generator.settings['PHOTO_THUMB'])
+    else:
+        logger.error('photos: No photo at %s', path)
+
+
+def detect_image(generator):
+    for article in chain(generator.articles, generator.drafts):
+        image = article.metadata.get('image', None)
+        if image:
+            if image.startswith('{photo}'):
+                process_image_photo(generator, article, image[7:])
+            elif image.startswith('{filename}'):
+                process_image_filename(generator, article, image[10:])
+            else:
+                logger.error('photos: Image tag not recognized: %s', image)
+
+
+def register():
+    signals.initialized.connect(initialized)
+    signals.content_object_init.connect(detect_content)
+    signals.article_generator_finalized.connect(detect_gallery)
+    signals.article_generator_finalized.connect(detect_image)
+    signals.article_writer_finalized.connect(resize_photos)

+ 1 - 0
photos/requirements.txt

@@ -0,0 +1 @@
+PIL

binární
photos/test_data/agallery/best.jpg


+ 1 - 0
photos/test_data/agallery/captions.txt

@@ -0,0 +1 @@
+best.jpg: Caption-best

+ 2 - 0
photos/test_data/agallery/exif.txt

@@ -0,0 +1,2 @@
+best.jpg: EXIF-best
+night.png: EXIF-night

binární
photos/test_data/agallery/night.png


+ 7 - 0
photos/test_data/filename.md

@@ -0,0 +1,7 @@
+title: Test filename
+gallery: {filename}agallery
+image: {filename}agallery/best.jpg
+
+Here is my best photo, again.
+
+![]({filename}agallery/best.jpg).

+ 7 - 0
photos/test_data/photo.md

@@ -0,0 +1,7 @@
+title: Test photo
+gallery: {photo}agallery
+image: {photo}agallery/best.jpg
+
+Here is my best photo, again.
+
+![]({photo}agallery/best.jpg).

+ 121 - 0
photos/test_photos.py

@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import os
+from pelican.generators import ArticlesGenerator
+from pelican.tests.support import unittest, get_settings
+from tempfile import mkdtemp
+from shutil import rmtree
+import photos
+
+CUR_DIR = os.path.dirname(__file__)
+
+
+class TestPhotos(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.temp_path = mkdtemp(prefix='pelicantests.')
+        cls.settings = get_settings(filenames={})
+        cls.settings['PATH'] = os.path.join(CUR_DIR, 'test_data')
+        cls.settings['PHOTO_LIBRARY'] = os.path.join(CUR_DIR, 'test_data')
+        cls.settings['DEFAULT_DATE'] = (1970, 1, 1)
+        cls.settings['FILENAME_METADATA'] = '(?P<slug>[^.]+)'
+        cls.settings['PLUGINS'] = [photos]
+        cls.settings['CACHE_CONTENT'] = False
+        cls.settings['OUTPUT_PATH'] = cls.temp_path
+        photos.initialized(cls)
+        cls.generator = ArticlesGenerator(
+            context=cls.settings.copy(), settings=cls.settings,
+            path=cls.settings['PATH'], theme=cls.settings['THEME'],
+            output_path=cls.settings['OUTPUT_PATH'])
+        photos.register()
+        cls.generator.generate_context()
+        photos.detect_gallery(cls.generator)
+        photos.detect_image(cls.generator)
+
+    @classmethod
+    def tearDownClass(cls):
+        rmtree(cls.temp_path)
+
+    def test_image(self):
+        for a in self.generator.articles:
+            if 'image' in a.metadata:
+                self.assertTrue(
+                    hasattr(a, 'photo_image'),
+                    msg="{} not recognized.".format(a.metadata['image']))
+
+    def test_gallery(self):
+        for a in self.generator.articles:
+            if 'gallety' in a.metadata:
+                self.assertTrue(
+                    hasattr(a, 'photo_gallery'),
+                    msg="{} not recognized.".format(a.metadata['gallery']))
+
+    def get_article(self, slug):
+        for a in self.generator.articles:
+            if slug == a.slug:
+                return a
+        return None
+
+    def test_photo_article_image(self):
+        self.assertEqual(self.get_article('photo').photo_image,
+                         ('best.jpg',
+                          'photos/agallery/besta.jpg',
+                          'photos/agallery/bestt.jpg'))
+
+    def test_photo_article_gallery(self):
+        self.assertEqual(self.get_article('photo').photo_gallery[0],
+                         ('best.jpg',
+                          'photos/agallery/best.jpg',
+                          'photos/agallery/bestt.jpg',
+                          'EXIF-best', 'Caption-best'))
+        self.assertEqual(self.get_article('photo').photo_gallery[1],
+                         ('night.png',
+                          'photos/agallery/night.jpg',
+                          'photos/agallery/nightt.jpg',
+                          'EXIF-night', ''))
+
+    def test_photo_article_body(self):
+        expected = ('<p>Here is my best photo, again.</p>\n'
+                    '<p><img alt="" src="/photos/agallery/besta.jpg" />.</p>')
+        self.assertEqual(expected, self.get_article('photo').content)
+
+    def test_filename_article_image(self):
+        self.assertEqual(
+            ('best.jpg', 'agallery/best.jpg', 'photos/agallery/bestt.jpg'),
+            self.get_article('filename').photo_image)
+
+    def test_filename_article_gallery(self):
+        self.assertEqual(self.get_article('filename').photo_gallery[0],
+                         ('best.jpg',
+                          'agallery/best.jpg',
+                          'photos/agallery/bestt.jpg',
+                          'EXIF-best', 'Caption-best'))
+        self.assertEqual(self.get_article('filename').photo_gallery[1],
+                         ('night.png',
+                          'agallery/night.png',
+                          'photos/agallery/nightt.jpg',
+                          'EXIF-night', ''))
+
+    def test_filename_article_body(self):
+        expected = ('<p>Here is my best photo, again.</p>\n'
+                    '<p><img alt="" src="{filename}agallery/best.jpg" />.</p>')
+        self.assertEqual(expected, self.get_article('filename').content)
+
+    def test_queue_resize(self):
+        expected = [
+            ('photos/agallery/best.jpg',
+                ('test_data/agallery/best.jpg', (1024, 768, 80))),
+            ('photos/agallery/besta.jpg',
+                ('test_data/agallery/best.jpg', (760, 506, 80))),
+            ('photos/agallery/bestt.jpg',
+                ('test_data/agallery/best.jpg', (192, 144, 60))),
+            ('photos/agallery/night.jpg',
+                ('test_data/agallery/night.png', (1024, 768, 80))),
+            ('photos/agallery/nightt.jpg',
+                ('test_data/agallery/night.png', (192, 144, 60)))]
+        self.assertEqual(sorted(expected), sorted(photos.queue_resize.items()))
+
+if __name__ == '__main__':
+    unittest.main()