diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..063b19b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,3 @@ +Release type: patch + +Auto-rotate images based on EXIF Orientation tag before applying user transforms, and clear the orientation tag in the output to prevent double-rotation in EXIF-aware viewers. diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index 644fc5e..c88da7a 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -22,7 +22,7 @@ from urllib.request import pathname2url, url2pathname from bs4 import BeautifulSoup -from PIL import Image, ImageFilter, UnidentifiedImageError +from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError from pelican import __version__ as pelican_version, signals @@ -102,6 +102,7 @@ def _copy_tags(self, src, dst): b"-TagsFromFile", src.encode(self.encoding, ExifTool.errors), dst.encode(self.encoding, ExifTool.errors), + b"-Orientation=1", ) self._send_command(params) params = ( @@ -752,6 +753,8 @@ def process_image(image, settings): except (UnidentifiedImageError, FileNotFoundError): return None + i = ImageOps.exif_transpose(i) + for step in image[2]: if callable(step): i = step(i) diff --git a/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.jpg b/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.jpg new file mode 100644 index 0000000..f5b4561 Binary files /dev/null and b/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.jpg differ diff --git a/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.png b/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.png new file mode 100644 index 0000000..a80c78a Binary files /dev/null and b/pelican/plugins/image_process/test_data/exif/pelican-bird-rot90.png differ diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index cfc2868..4cbd85b 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -6,7 +6,7 @@ import warnings from bs4 import BeautifulSoup -from PIL import Image, UnidentifiedImageError +from PIL import Image, ImageChops, ImageStat, UnidentifiedImageError import pytest from pelican.plugins.image_process import ( @@ -47,6 +47,11 @@ TEST_DATA.joinpath("noexif", f"pelican-bird.{ext}").resolve() for ext in SUPPORTED_EXIF_IMAGE_FORMATS ] +EXIF_ORIENTATION_TEST_IMAGES = [ + TEST_DATA.joinpath("exif", f"pelican-bird-rot90.{ext}").resolve() + for ext in SUPPORTED_EXIF_IMAGE_FORMATS +] +PIXEL_DIFF_MEAN_THRESHOLD = 2 TRANSFORM_RESULTS = TEST_DATA.joinpath("results").resolve() # Register all supported transforms. @@ -826,6 +831,38 @@ def test_copy_exif_tags_does_not_add_exif_dims_tags(tmp_path, image_path): assert "ExifImageHeight" not in actual_tags +@pytest.mark.parametrize("image_path", EXIF_ORIENTATION_TEST_IMAGES) +@pytest.mark.parametrize("copy_tags", [True, False]) +def test_exif_orientation(tmp_path, image_path, copy_tags): + original_path = TEST_DATA.joinpath("pelican-bird.png") + original = Image.open(original_path) + + settings = get_settings(IMAGE_PROCESS_COPY_EXIF_TAGS=copy_tags) + + transform_params = [] + image_name = image_path.name + destination_path = tmp_path / image_name + + process_image( + (str(image_path), str(destination_path), transform_params), + settings, + ) + + result = Image.open(destination_path) + + # The output should be the same size as the original (not the rotated + # source), because ImageOps.exif_transpose() should correct the + # orientation before saving. + assert result.size == original.size + + # Mean per-channel pixel diff must stay under 2. JPEG artifacts + # produce values around 1.5, while a wrong orientation (no transpose + # applied) would produce values between 8 and ~30 per channel. + diff = ImageChops.difference(result.convert("RGB"), original.convert("RGB")) + stat = ImageStat.Stat(diff) + assert all(m < PIXEL_DIFF_MEAN_THRESHOLD for m in stat.mean) + + def test_try_open_image(): for test_image in FILE_FORMAT_TEST_IMAGES: assert try_open_image(test_image)