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"],
),
],
),
(
'
'
"'
"
' 'A nice pelican

A nice pelican
', 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)