import json import os from pathlib import Path import shutil import subprocess import warnings from PIL import Image, ImageChops import pytest from pelican.plugins.image_process import ( ExifTool, compute_paths, harvest_images_in_fragment, is_img_identifiable, process_image, ) # Prepare test image constants. HERE = Path(__file__).resolve().parent TEST_DATA = HERE.joinpath("test_data").resolve() TEST_IMAGES = [ TEST_DATA.joinpath(f"pelican-bird.{ext}").resolve() for ext in ["jpg", "png"] ] EXIF_TEST_IMAGES = [ TEST_DATA.joinpath("exif", f"pelican-bird.{ext}").resolve() for ext in ["jpg", "png"] ] TRANSFORM_RESULTS = TEST_DATA.joinpath("results").resolve() # Register all supported transforms. SINGLE_TRANSFORMS = { "crop": ["crop 10 20 100 200"], "flip_horizontal": ["flip_horizontal"], "flip_vertical": ["flip_vertical"], "grayscale": ["grayscale"], "resize": ["resize 200 250"], "rotate": ["rotate 20"], "scale_in": ["scale_in 200 250 False"], "scale_out": ["scale_out 200 250 False"], "blur": ["blur"], "contour": ["contour"], "detail": ["detail"], "edge_enhance": ["edge_enhance"], "edge_enhance_more": ["edge_enhance_more"], "emboss": ["emboss"], "find_edges": ["find_edges"], "smooth": ["smooth"], "smooth_more": ["smooth_more"], "sharpen": ["sharpen"], } def get_settings(**kwargs): """Provide tweaked setting dictionaries for testing Set keyword arguments to override specific settings. """ DEFAULT_CONFIG = { "PATH": HERE, "OUTPUT_PATH": "output", "static_content": {}, "filenames": {}, "SITEURL": "//", "IMAGE_PROCESS": SINGLE_TRANSFORMS, } settings = DEFAULT_CONFIG.copy() settings.update(kwargs) return settings def test_undefined_transform(): settings = get_settings() tag = "" with pytest.raises(RuntimeError): harvest_images_in_fragment(tag, settings) @pytest.mark.parametrize("transform_id, transform_params", SINGLE_TRANSFORMS.items()) @pytest.mark.parametrize("image_path", TEST_IMAGES) def test_all_transforms(tmp_path, transform_id, transform_params, image_path): """Test the raw transform and their results on images.""" settings = get_settings() image_name = image_path.name destination_path = tmp_path.joinpath(transform_id, image_name) expected_path = TRANSFORM_RESULTS.joinpath(transform_id, image_name) process_image((str(image_path), str(destination_path), transform_params), settings) transformed = Image.open(destination_path) expected = Image.open(expected_path) # Image.getbbox() returns None if there are only black pixels in the image: image_diff = ImageChops.difference(transformed, expected).getbbox() assert image_diff is None @pytest.mark.parametrize( "orig_src, orig_img, new_src, new_img", [ ( "/tmp/test.jpg", "tmp/test.jpg", "/tmp/derivatives/crop/test.jpg", "tmp/derivatives/crop/test.jpg", ), ( "../tmp/test.jpg", "../tmp/test.jpg", "../tmp/derivatives/crop/test.jpg", "../tmp/derivatives/crop/test.jpg", ), ( "http://xxx/tmp/test.jpg", "tmp/test.jpg", "http://xxx/tmp/derivatives/crop/test.jpg", "tmp/derivatives/crop/test.jpg", ), ], ) def test_path_normalization(mocker, orig_src, orig_img, new_src, new_img): # Allow non-existing images to be processed: mocker.patch( "pelican.plugins.image_process.image_process.is_img_identifiable", lambda img_filepath: True, ) # Silence image transforms. process = mocker.patch("pelican.plugins.image_process.image_process.process_image") settings = get_settings(IMAGE_PROCESS_DIR="derivatives") img_tag_orig = ( '' ) img_tag_processed = harvest_images_in_fragment(img_tag_orig, settings) assert img_tag_processed == ( f'' ) process.assert_called_once_with( ( os.path.join(settings["PATH"], orig_img), os.path.join(settings["OUTPUT_PATH"], new_img), SINGLE_TRANSFORMS["crop"], ), settings, ) COMPLEX_TRANSFORMS = { "thumb": ["crop 0 0 50% 50%", "scale_out 150 150", "crop 0 0 150 150"], "article-image": {"type": "image", "ops": ["scale_in 300 300"]}, "crisp": { "type": "responsive-image", "srcset": [ ("1x", ["scale_in 800 600 True"]), ("2x", ["scale_in 1600 1200 True"]), ("4x", ["scale_in 3200 2400 True"]), ], "default": "1x", }, "crisp2": { "type": "responsive-image", "srcset": [ ("1x", ["scale_in 800 600 True"]), ("2x", ["scale_in 1600 1200 True"]), ("4x", ["scale_in 3200 2400 True"]), ], "default": ["scale_in 400 300 True"], }, "large-photo": { "type": "responsive-image", "sizes": ( "(min-width: 1200px) 800px, " "(min-width: 992px) 650px, " "(min-width: 768px) 718px, " "100vw" ), "srcset": [ ("600w", ["scale_in 600 450 True"]), ("800w", ["scale_in 800 600 True"]), ("1600w", ["scale_in 1600 1200 True"]), ], "default": "800w", }, "pict": { "type": "picture", "sources": [ { "name": "default", "media": "(min-width: 640px)", "srcset": [ ("640w", ["scale_in 640 480 True"]), ("1024w", ["scale_in 1024 683 True"]), ("1600w", ["scale_in 1600 1200 True"]), ], "sizes": "100vw", }, { "name": "source-1", "srcset": [ ("1x", ["crop 100 100 200 200"]), ("2x", ["crop 100 100 300 300"]), ], }, ], "default": ("default", "640w"), }, "pict2": { "type": "picture", "sources": [ { "name": "default", "media": "(min-width: 640px)", "srcset": [ ("640w", ["scale_in 640 480 True"]), ("1024w", ["scale_in 1024 683 True"]), ("1600w", ["scale_in 1600 1200 True"]), ], "sizes": "100vw", }, { "name": "source-2", "srcset": [ ("1x", ["crop 100 100 200 200"]), ("2x", ["crop 100 100 300 300"]), ], }, ], "default": ("source-2", ["scale_in 800 600 True"]), }, } @pytest.mark.parametrize( "orig_tag, new_tag, call_args", [ ( '', '', [ ( "tmp/test.jpg", "tmp/derivs/thumb/test.jpg", ["crop 0 0 50% 50%", "scale_out 150 150", "crop 0 0 150 150"], ), ], ), ( '', '', [ ( "tmp/test.jpg", "tmp/derivs/article-image/test.jpg", ["scale_in 300 300"], ), ], ), ( '', '', [ ( "tmp/test.jpg", "tmp/derivs/crisp/1x/test.jpg", ["scale_in 800 600 True"], ), ( "tmp/test.jpg", "tmp/derivs/crisp/2x/test.jpg", ["scale_in 1600 1200 True"], ), ( "tmp/test.jpg", "tmp/derivs/crisp/4x/test.jpg", ["scale_in 3200 2400 True"], ), ], ), ( '', '', # default must be first, because the process execute it first [ ( "tmp/test.jpg", "tmp/derivs/crisp2/default/test.jpg", ["scale_in 400 300 True"], ), ( "tmp/test.jpg", "tmp/derivs/crisp2/1x/test.jpg", ["scale_in 800 600 True"], ), ( "tmp/test.jpg", "tmp/derivs/crisp2/2x/test.jpg", ["scale_in 1600 1200 True"], ), ( "tmp/test.jpg", "tmp/derivs/crisp2/4x/test.jpg", ["scale_in 3200 2400 True"], ), ], ), ( '', '', [ ( "tmp/test.jpg", "tmp/derivs/large-photo/600w/test.jpg", ["scale_in 600 450 True"], ), ( "tmp/test.jpg", "tmp/derivs/large-photo/800w/test.jpg", ["scale_in 800 600 True"], ), ( "tmp/test.jpg", "tmp/derivs/large-photo/1600w/test.jpg", ["scale_in 1600 1200 True"], ), ], ), ( '' "", '' '' "", [ ( "images/pelican.jpg", "images/derivs/pict/default/640w/pelican.jpg", ["scale_in 640 480 True"], ), ( "images/pelican.jpg", "images/derivs/pict/default/1024w/pelican.jpg", ["scale_in 1024 683 True"], ), ( "images/pelican.jpg", "images/derivs/pict/default/1600w/pelican.jpg", ["scale_in 1600 1200 True"], ), ( "images/pelican-closeup.jpg", "images/derivs/pict/source-1/1x/pelican-closeup.jpg", ["crop 100 100 200 200"], ), ( "images/pelican-closeup.jpg", "images/derivs/pict/source-1/2x/pelican-closeup.jpg", ["crop 100 100 300 300"], ), ], ), ( '
Pelican

' 'A nice pelican

Other view of '
            'pelican
', '
' 'Pelican

A nice pelican

', [ # Default calls first. ( "images/pelican-closeup.jpg", "images/derivs/pict2/source-2/default/pelican-closeup.jpg", ["scale_in 800 600 True"], ), ( "images/pelican.jpg", "images/derivs/pict2/default/640w/pelican.jpg", ["scale_in 640 480 True"], ), # Then images in processing order. ( "images/pelican.jpg", "images/derivs/pict2/default/1024w/pelican.jpg", ["scale_in 1024 683 True"], ), ( "images/pelican.jpg", "images/derivs/pict2/default/1600w/pelican.jpg", ["scale_in 1600 1200 True"], ), ( "images/pelican-closeup.jpg", "images/derivs/pict2/source-2/1x/pelican-closeup.jpg", ["crop 100 100 200 200"], ), ( "images/pelican-closeup.jpg", "images/derivs/pict2/source-2/2x/pelican-closeup.jpg", ["crop 100 100 300 300"], ), ], ), ], ) def test_picture_generation(mocker, orig_tag, new_tag, call_args): # Allow non-existing images to be processed: mocker.patch( "pelican.plugins.image_process.image_process.is_img_identifiable", lambda img_filepath: True, ) process = mocker.patch("pelican.plugins.image_process.image_process.process_image") settings = get_settings( IMAGE_PROCESS=COMPLEX_TRANSFORMS, IMAGE_PROCESS_DIR="derivs" ) assert harvest_images_in_fragment(orig_tag, settings) == new_tag for i, (orig_img, new_img, transform_params) in enumerate(call_args): assert process.mock_calls[i] == mocker.call( ( os.path.join(settings["PATH"], orig_img), os.path.join(settings["OUTPUT_PATH"], new_img), transform_params, ), settings, ) def process_image_mock_exif_tool_started(image, settings): assert ExifTool._instance is not None def process_image_mock_exif_tool_not_started(image, settings): assert ExifTool._instance is None @pytest.mark.parametrize("copy_tags", [True, False]) def test_exiftool_process_is_started_only_when_necessary(mocker, copy_tags): if shutil.which("exiftool") is None: warnings.warn( "EXIF tags copying will not be tested because the exiftool program could " "not be found. Please install exiftool and make sure it is in your path." ) return if copy_tags: mocker.patch( "pelican.plugins.image_process.image_process.process_image", process_image_mock_exif_tool_started, ) else: mocker.patch( "pelican.plugins.image_process.image_process.process_image", process_image_mock_exif_tool_not_started, ) settings = get_settings( IMAGE_PROCESS_COPY_EXIF_TAGS=copy_tags, IMAGE_PROCESS=COMPLEX_TRANSFORMS, IMAGE_PROCESS_DIR="derivs", ) harvest_images_in_fragment( '', settings ) @pytest.mark.parametrize("image_path", EXIF_TEST_IMAGES) @pytest.mark.parametrize("copy_tags", [True, False]) def test_copy_exif_tags(tmp_path, image_path, copy_tags): if shutil.which("exiftool") is None: warnings.warn( "EXIF tags copying will not be tested because the exiftool program could " "not be found. Please install exiftool and make sure it is in your path." ) return # A few EXIF tags to test for. exif_tags = [ "Artist", "Creator", "Title", "Description", "Subject", "Rating", "ExifImageWidth", ] settings = get_settings(IMAGE_PROCESS_COPY_EXIF_TAGS=copy_tags) transform_id = "grayscale" transform_params = ["grayscale"] image_name = image_path.name destination_path = tmp_path.joinpath(transform_id, image_name) expected_results = subprocess.run( ["exiftool", "-json", image_path], stdout=subprocess.PIPE ) expected_tags = json.loads(expected_results.stdout)[0] for tag in exif_tags: assert tag in expected_tags.keys() if copy_tags: ExifTool.start_exiftool() process_image((str(image_path), str(destination_path), transform_params), settings) if copy_tags: ExifTool.stop_exiftool() actual_results = subprocess.run( ["exiftool", "-json", destination_path], stdout=subprocess.PIPE ) assert actual_results.returncode == 0 actual_tags = json.loads(actual_results.stdout)[0] for tag in exif_tags: if copy_tags: assert tag in actual_tags.keys() assert expected_tags[tag] == actual_tags[tag] else: assert tag not in actual_tags.keys() def test_is_img_identifiable(): for test_image in TEST_IMAGES: assert is_img_identifiable(test_image) assert not is_img_identifiable("image/that/does/not/exist.png") assert not is_img_identifiable(TEST_DATA.joinpath("folded_puzzle.png")) assert not is_img_identifiable(TEST_DATA.joinpath("minimal.svg")) img = {"src": "https://upload.wikimedia.org/wikipedia/commons/3/34/Exemple.png"} settings = get_settings(IMAGE_PROCESS_DIR="derivatives") path = compute_paths(img, settings, derivative="thumb") assert not is_img_identifiable(path.source)