You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

574 lines
19 KiB
Python

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 = "<img class='image-process-undefined' src='/tmp/test.jpg' />"
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 class="test image-process image-process-crop test2" '
f'src="{orig_src}"/>'
)
img_tag_processed = harvest_images_in_fragment(img_tag_orig, settings)
assert img_tag_processed == (
f'<img class="test image-process image-process-crop test2" src="{new_src}"/>'
)
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",
[
(
'<img class="image-process-thumb" src="/tmp/test.jpg" />',
'<img class="image-process-thumb" src="/tmp/derivs/thumb/test.jpg"/>',
[
(
"tmp/test.jpg",
"tmp/derivs/thumb/test.jpg",
["crop 0 0 50% 50%", "scale_out 150 150", "crop 0 0 150 150"],
),
],
),
(
'<img class="image-process-article-image" src="/tmp/test.jpg" />',
'<img class="image-process-article-image" '
'src="/tmp/derivs/article-image/test.jpg"/>',
[
(
"tmp/test.jpg",
"tmp/derivs/article-image/test.jpg",
["scale_in 300 300"],
),
],
),
(
'<img class="image-process-crisp" src="/tmp/test.jpg" />',
'<img class="image-process-crisp" src="/tmp/derivs/crisp/1x/test.jpg" '
'srcset="/tmp/derivs/crisp/1x/test.jpg 1x, '
"/tmp/derivs/crisp/2x/test.jpg 2x, "
'/tmp/derivs/crisp/4x/test.jpg 4x"/>',
[
(
"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"],
),
],
),
(
'<img class="image-process-crisp2" src="/tmp/test.jpg" />',
'<img class="image-process-crisp2" '
'src="/tmp/derivs/crisp2/default/test.jpg" '
'srcset="/tmp/derivs/crisp2/1x/test.jpg 1x, '
"/tmp/derivs/crisp2/2x/test.jpg 2x, "
'/tmp/derivs/crisp2/4x/test.jpg 4x"/>',
# 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"],
),
],
),
(
'<img class="image-process-large-photo" src="/tmp/test.jpg" />',
'<img class="image-process-large-photo" '
'sizes="(min-width: 1200px) 800px, (min-width: 992px) 650px, '
'(min-width: 768px) 718px, 100vw" src="/tmp/derivs/large-photo/'
'800w/test.jpg" '
'srcset="/tmp/derivs/large-photo/600w/test.jpg 600w, '
"/tmp/derivs/large-photo/800w/test.jpg 800w, "
'/tmp/derivs/large-photo/1600w/test.jpg 1600w"/>',
[
(
"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"],
),
],
),
(
'<picture><source class="source-1" '
'src="/images/pelican-closeup.jpg"/><img '
'class="image-process-pict" src="/images/pelican.jpg"/>'
"</picture>",
'<picture><source media="(min-width: 640px)" sizes="100vw" '
'srcset="/images/derivs/pict/default/640w/pelican.jpg 640w, '
"/images/derivs/pict/default/1024w/pelican.jpg 1024w, "
'/images/derivs/pict/default/1600w/pelican.jpg 1600w"/>'
'<source srcset="/images/derivs/pict/source-1/1x/'
"pelican-closeup.jpg 1x, /images/derivs/pict/source-1/2x/"
'pelican-closeup.jpg 2x"/><img '
'class="image-process-pict" '
'src="/images/derivs/pict/default/640w/pelican.jpg"/>'
"</picture>",
[
(
"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"],
),
],
),
(
'<div class="figure"><img alt="Pelican" class="image-process'
'-pict2" src="/images/pelican.jpg" /> <p class="caption">'
'A nice pelican</p> <div class="legend"> <img alt="Other view of '
'pelican" class="image-process source-2" src="/images/'
'pelican-closeup.jpg" /></div></div>',
'<div class="figure"><picture><source media="(min-width: 640px)" '
'sizes="100vw" srcset="/images/derivs/pict2/default/640w/pelican'
".jpg 640w, /images/derivs/pict2/default/1024w/pelican.jpg 1024w,"
' /images/derivs/pict2/default/1600w/pelican.jpg 1600w"/>'
'<source srcset="/images/derivs/pict2/source-2/1x/pelican-closeup'
".jpg 1x, /images/derivs/pict2/source-2/2x/pelican-closeup.jpg "
'2x"/><img alt="Pelican" class="image-process-pict2" '
'src="/images/derivs/pict2/source-2/default/pelican-closeup.jpg"'
'/></picture> <p class="caption">A nice pelican</p> <div '
'class="legend"> </div></div>',
[
# 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(
'<img class="image-process-thumb" src="/tmp/test.jpg" />', 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)