Skip to content

Commit

Permalink
Merge pull request #2957 from mchapman87501/preserve-icc-profiles
Browse files Browse the repository at this point in the history
Preserve image ICC profiles
  • Loading branch information
Kwpolska committed Feb 2, 2018
2 parents 2ef90d5 + 99513d0 commit e091954
Show file tree
Hide file tree
Showing 10 changed files with 147 additions and 8 deletions.
1 change: 1 addition & 0 deletions AUTHORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
* `Michael McNeil Forbes <https://github.com/mforbes>`_
* `Michal Petrucha <https://github.com/koniiiik>`_
* `Miguel Ángel García <https://github.com/magmax>`_
* `Mitch Chapman <https://github.com/mchapman87501>`_
* `mrabbitt <https://github.com/mrabbitt>`_
* `Neil MartinsenBurrell <https://github.com/neilmb>`_
* `Niels Böhm <https://github.com/blubberdiblub>`_
Expand Down
2 changes: 1 addition & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ New in master

Features
--------

* New PRESERVE_ICC_PROFILES option to control whether ICC profiles are preserved when copying images.
* Use baguetteJS in bootstrap theme (part of Issue #2777)
* New default-config command to generate a clean configuration.
* New ``thumbnail`` shortcode similar to the reStructuredText
Expand Down
16 changes: 16 additions & 0 deletions docs/manual.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,22 @@ listed there.
There is a huge number of EXIF tags, described in `the standard <http://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf>`__


Handling ICC Profiles
---------------------

Your images may contain `ICC profiles. <https://en.wikipedia.org/wiki/ICC_profile>`__ These describe the color space in which the images were created or captured.

Most desktop web browsers can use embedded ICC profiles to display images accurately. As of early 2018 few mobile browsers consider ICC profiles when displaying images. A notable exception is Safari on iOS.

By default Nikola strips out ICC profiles when preparing images for your posts and galleries. If you want Nikola to preserve ICC profiles, add this in your ``conf.py``:

.. code:: python

PRESERVE_ICC_PROFILES = True

You may wish to do this if, for example, your site contains JPEG images that use a wide-gamut profile such as "Display P3".


Post Processing Filters
-----------------------

Expand Down
4 changes: 4 additions & 0 deletions nikola/conf.py.in
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,10 @@ GITHUB_COMMIT_SOURCE = True
# Embedded thumbnail information:
# EXIF_WHITELIST['1st'] = ["*"]

# If set to True, any ICC profile will be copied when an image is thumbnailed or
# resized.
# PRESERVE_ICC_PROFILES = False

# Folders containing images to be used in normal posts or pages.
# IMAGE_FOLDERS is a dictionary of the form {"source": "destination"},
# where "source" is the folder containing the images to be published, and
Expand Down
7 changes: 4 additions & 3 deletions nikola/image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def filter_exif(self, exif, whitelist):

return exif or None

def resize_image(self, src, dst, max_size, bigger_panoramas=True, preserve_exif_data=False, exif_whitelist={}):
def resize_image(self, src, dst, max_size, bigger_panoramas=True, preserve_exif_data=False, exif_whitelist={}, preserve_icc_profiles=False):
"""Make a copy of the image in the requested size."""
if not Image or os.path.splitext(src)[1] in ['.svg', '.svgz']:
self.resize_svg(src, dst, max_size, bigger_panoramas)
Expand Down Expand Up @@ -131,6 +131,7 @@ def resize_image(self, src, dst, max_size, bigger_panoramas=True, preserve_exif_
exif['0th'][piexif.ImageIFD.Orientation] = 1

try:
icc_profile = im.info.get('icc_profile') if preserve_icc_profiles else None
im.thumbnail(size, Image.ANTIALIAS)
if exif is not None and preserve_exif_data:
# Put right size in EXIF data
Expand All @@ -143,9 +144,9 @@ def resize_image(self, src, dst, max_size, bigger_panoramas=True, preserve_exif_
exif["Exif"][piexif.ExifIFD.PixelYDimension] = h
# Filter EXIF data as required
exif = self.filter_exif(exif, exif_whitelist)
im.save(dst, exif=piexif.dump(exif))
im.save(dst, exif=piexif.dump(exif), icc_profile=icc_profile)
else:
im.save(dst)
im.save(dst, icc_profile=icc_profile)
except Exception as e:
self.logger.warn("Can't process {0}, using original "
"image! ({1})".format(src, e))
Expand Down
1 change: 1 addition & 0 deletions nikola/nikola.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ def __init__(self, **config):
'POSTS_SECTION_TRANSLATIONS': [],
'POSTS_SECTION_TRANSLATIONS_ADD_DEFAULTS': False,
'PRESERVE_EXIF_DATA': False,
'PRESERVE_ICC_PROFILES': False,
'PAGES': (("pages/*.txt", "pages", "page.tmpl"),),
'PANDOC_OPTIONS': [],
'PRETTY_URLS': True,
Expand Down
5 changes: 3 additions & 2 deletions nikola/plugins/task/galleries.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def set_site(self, site):
'generate_rss': site.config['GENERATE_RSS'],
'preserve_exif_data': site.config['PRESERVE_EXIF_DATA'],
'exif_whitelist': site.config['EXIF_WHITELIST'],
'preserve_icc_profiles': site.config['PRESERVE_ICC_PROFILES'],
}

# Verify that no folder in GALLERY_FOLDERS appears twice
Expand Down Expand Up @@ -481,7 +482,7 @@ def create_target_images(self, img, input_path):
'actions': [
(self.resize_image,
(img, thumb_path, self.kw['thumbnail_size'], True, self.kw['preserve_exif_data'],
self.kw['exif_whitelist']))
self.kw['exif_whitelist'], self.kw['preserve_icc_profiles']))
],
'clean': True,
'uptodate': [utils.config_changed({
Expand All @@ -497,7 +498,7 @@ def create_target_images(self, img, input_path):
'actions': [
(self.resize_image,
(img, orig_dest_path, self.kw['max_image_size'], True, self.kw['preserve_exif_data'],
self.kw['exif_whitelist']))
self.kw['exif_whitelist'], self.kw['preserve_icc_profiles']))
],
'clean': True,
'uptodate': [utils.config_changed({
Expand Down
5 changes: 3 additions & 2 deletions nikola/plugins/task/scale_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ def process_tree(self, src, dst):

def process_image(self, src, dst, thumb):
"""Resize an image."""
self.resize_image(src, dst, self.kw['max_image_size'], True, preserve_exif_data=self.kw['preserve_exif_data'], exif_whitelist=self.kw['exif_whitelist'])
self.resize_image(src, thumb, self.kw['image_thumbnail_size'], True, preserve_exif_data=self.kw['preserve_exif_data'], exif_whitelist=self.kw['exif_whitelist'])
self.resize_image(src, dst, self.kw['max_image_size'], True, preserve_exif_data=self.kw['preserve_exif_data'], exif_whitelist=self.kw['exif_whitelist'], preserve_icc_profiles=self.kw['preserve_icc_profiles'])
self.resize_image(src, thumb, self.kw['image_thumbnail_size'], True, preserve_exif_data=self.kw['preserve_exif_data'], exif_whitelist=self.kw['exif_whitelist'], preserve_icc_profiles=self.kw['preserve_icc_profiles'])

def gen_tasks(self):
"""Copy static files into the output folder."""
Expand All @@ -85,6 +85,7 @@ def gen_tasks(self):
'filters': self.site.config['FILTERS'],
'preserve_exif_data': self.site.config['PRESERVE_EXIF_DATA'],
'exif_whitelist': self.site.config['EXIF_WHITELIST'],
'preserve_icc_profiles': self.site.config['PRESERVE_ICC_PROFILES'],
}

self.image_ext_list = self.image_ext_list_builtin
Expand Down
1 change: 1 addition & 0 deletions tests/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Tests (in alphabetical order)
* ``test_rst_compiler`` exercises the reStructuredText compiler plugin of
Nikola.
* ``test_scheduling`` performs tests on post scheduling rules.
* ``test_task_scale_images`` performs basic tests on the scale_images task plugin.
* ``test_utils`` test various Nikola utilities.

Requirements to run the tests
Expand Down
113 changes: 113 additions & 0 deletions tests/test_task_scale_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# As prescribed in README.rst:
import os
import unittest
import tempfile
from PIL import Image, ImageDraw

from nikola.plugins.task import scale_images
# Import test - should perhaps be moved to a separate module
import nikola.plugins.task.galleries # NOQA
from .base import FakeSite


class TestCase(unittest.TestCase):
def setUp(self):
# These tests don't require valid profiles. They need only to verify
# that profile data is/isn't saved with images.
# It would be nice to use PIL.ImageCms to create valid profiles, but
# in many Pillow distributions ImageCms is a stub.
# ICC file data format specification:
# http://www.color.org/icc32.pdf

self._profile = b'invalid profile data'

# Make a white image with a red stripe on the diagonal.
w = 64
h = 64
img = Image.new("RGB", (w, h), (255, 255, 255))
draw = ImageDraw.Draw(img)
draw.line((0, 0, w, h), fill=(255, 128, 128))
draw.line((w, 0, 0, h), fill=(128, 128, 255))
self._img = img

self._src_dir = tempfile.TemporaryDirectory()
self._dest_dir = tempfile.TemporaryDirectory()

def tearDown(self):
pass

def _tmp_img_name(self, dirname):
pathname = tempfile.NamedTemporaryFile(
suffix=".jpg", dir=dirname, delete=False)
return pathname.name

def _get_site(self, preserve_icc_profiles):
site = FakeSite()
site.config['IMAGE_FOLDERS'] = {self._src_dir.name: ''}
site.config['OUTPUT_FOLDER'] = self._dest_dir.name
site.config['IMAGE_THUMBNAIL_SIZE'] = 128
site.config['IMAGE_THUMBNAIL_FORMAT'] = '{name}.thumbnail{ext}'
site.config['MAX_IMAGE_SIZE'] = 512
site.config['FILTERS'] = {}
site.config['PRESERVE_EXIF_DATA'] = False
site.config['EXIF_WHITELIST'] = {}
site.config['PRESERVE_ICC_PROFILES'] = preserve_icc_profiles
return site

def _get_task_instance(self, preserve_icc_profiles):
result = scale_images.ScaleImage()
result.set_site(self._get_site(preserve_icc_profiles))
return result

def _create_src_images(self):
img = self._img
# Test two variants: with and without an associated icc_profile
pathname = self._tmp_img_name(self._src_dir.name)
img.save(pathname)
sans_icc_filename = os.path.basename(pathname)

pathname = self._tmp_img_name(self._src_dir.name)
img.save(pathname, icc_profile=self._profile)
with_icc_filename = os.path.basename(pathname)
return [sans_icc_filename, with_icc_filename]

def _run_task(self, preserve_icc_profiles):
task_instance = self._get_task_instance(preserve_icc_profiles)
for task in task_instance.gen_tasks():
for action, args in task.get('actions', []):
action(*args)

def test_scale_preserving_icc_profile(self):
sans_icc_filename, with_icc_filename = self._create_src_images()
self._run_task(True)
cases = [
(sans_icc_filename, None),
(with_icc_filename, self._profile),
]
for (filename, expected_profile) in cases:
pathname = os.path.join(self._dest_dir.name, filename)
self.assertTrue(os.path.exists(pathname), pathname)
img = Image.open(pathname)
actual_profile = img.info.get('icc_profile')
self.assertEqual(actual_profile, expected_profile)

def test_scale_discarding_icc_profile(self):
sans_icc_filename, with_icc_filename = self._create_src_images()
self._run_task(False)
cases = [
(sans_icc_filename, None),
(with_icc_filename, None),
]
for (filename, expected_profile) in cases:
pathname = os.path.join(self._dest_dir.name, filename)
self.assertTrue(os.path.exists(pathname), pathname)
img = Image.open(pathname)
actual_profile = img.info.get('icc_profile')
self.assertEqual(actual_profile, expected_profile)


main = unittest.main

if __name__ == '__main__':
main()

0 comments on commit e091954

Please sign in to comment.