From 3909a77d6eea2b0bad01ae3c745d8c9c3cc0574a Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:33:54 +0200 Subject: [PATCH 01/15] First stab at implementation. --- .../plugins/image_process/image_process.py | 225 +++++++++++++----- .../image_process/test_format_conversion.py | 191 +++++++++++++++ 2 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 pelican/plugins/image_process/test_format_conversion.py diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index 644fc5e..cd4cfb3 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -140,6 +140,47 @@ def _send_command(self, params): ) +def get_target_format(config, default_format=None): + """Extract the target format from various configuration structures. + + Target format can be specified in a number of different ways in the configuration: + + - Top-level default format: "output-format": "webp" + - Responsive image: "srcset": [ ("small", ["scale_in 100 100 True"], "webp"),] + - Picture: "sources": [ ("small", ["scale_in 100 100 True"], "webp"),] + + Returns the target format string (e.g., "webp") or None. + """ + if isinstance(config, dict): + return config.get("output-format", default_format) + + if isinstance(config, (list, str)): + return default_format + + if isinstance(config, tuple): + # Handle (condition, ops, format) or (ops, format) + match config: + # Matches (condition, ops, format) where 1st element is a string + case (str(), ops, str() as format_str): + return format_str + + # Matches (ops, format) with ops not string + case (ops, format_str) if not isinstance(ops, str): + return format_str + + return default_format + + +def get_target_filename(filename, target_format): + """Return the filename with the target format extension.""" + if not target_format or target_format == "original": + return filename + + base, _ext = os.path.splitext(filename) + target_format = target_format.lstrip(".") + return f"{base}.{target_format}" + + def convert_box(image, top, left, right, bottom): """Convert box coordinates strings to integer. @@ -448,8 +489,11 @@ def process_img_tag(img, settings, derivative): path = compute_paths(img["src"], settings, derivative) process = settings["IMAGE_PROCESS"][derivative] - img["src"] = posixpath.join(path.base_url, path.filename) - destination = os.path.join(str(path.base_path), path.filename) + target_format = get_target_format(process) + filename = get_target_filename(path.filename, target_format) + + img["src"] = posixpath.join(path.base_url, filename) + destination = os.path.join(str(path.base_path), filename) if not isinstance(process, list): process = process["ops"] @@ -474,10 +518,20 @@ def build_srcset(img, settings, derivative): path = compute_paths(img["src"], settings, derivative) process = settings["IMAGE_PROCESS"][derivative] + # Top-level default format. + top_default_format = get_target_format(process) + default = process["default"] default_name = "" + default_format = top_default_format if isinstance(default, str): - breakpoints = {i for i, _ in process["srcset"]} + # find the entry in srcset to get its format + for entry in process["srcset"]: + if entry[0] == default: + default_format = get_target_format(entry, top_default_format) + break + + breakpoints = {entry[0] for entry in process["srcset"]} if default not in breakpoints: logger.error( '%s srcset "%s" does not define default "%s"', @@ -486,34 +540,36 @@ def build_srcset(img, settings, derivative): default, ) default_name = default - elif isinstance(default, list): + elif isinstance(default, (list, tuple)): default_name = "default" - destination = os.path.join(str(path.base_path), default_name, path.filename) - process_image((path.source, destination, default), settings) + default_format = get_target_format(default, top_default_format) + ops = default[0] if isinstance(default, tuple) else default + filename = get_target_filename(path.filename, default_format) + destination = os.path.join(str(path.base_path), default_name, filename) + process_image((path.source, destination, ops), settings) - img["src"] = posixpath.join(path.base_url, default_name, path.filename) + filename = get_target_filename(path.filename, default_format) + img["src"] = posixpath.join(path.base_url, default_name, filename) if "sizes" in process: img["sizes"] = process["sizes"] srcset = [] for src in process["srcset"]: - file_path = posixpath.join(path.base_url, src[0], path.filename) + entry_format = get_target_format(src, top_default_format) + filename = get_target_filename(path.filename, entry_format) + file_path = posixpath.join(path.base_url, src[0], filename) srcset.append(format_srcset_element(file_path, src[0])) - destination = os.path.join(str(path.base_path), src[0], path.filename) + destination = os.path.join(str(path.base_path), src[0], filename) process_image((path.source, destination, src[1]), settings) if len(srcset) > 0: img["srcset"] = ", ".join(srcset) -def convert_div_to_picture_tag(soup, img, group, settings, derivative): - """Convert a div containing multiple images to a picture.""" +def prepare_image_sources(img, group, settings, derivative): + """Prepare image sources for the picture tag.""" process_dir = settings["IMAGE_PROCESS_DIR"] - # Compile sources URL. Special source "default" uses the main - # image URL. Other sources use the img with classes - # [source['name'], 'image-process']. We also remove the img from - # the DOM. sources = copy.deepcopy(settings["IMAGE_PROCESS"][derivative]["sources"]) for s in sources: if s["name"] == "default": @@ -529,6 +585,42 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): url_path, s["filename"] = os.path.split(s["url"]) s["base_url"] = os.path.join(url_path, process_dir, derivative) s["base_path"] = os.path.join(settings["OUTPUT_PATH"], s["base_url"][1:]) + return sources + + +def construct_picture_tag(soup, img, sources, settings): + """Construct the picture tag and add it to the DOM.""" + picture_tag = soup.new_tag("picture") + for s in sources: + # Create new + source_attrs = {k: s[k] for k in s if k in ["media", "sizes"]} + source_tag = soup.new_tag("source", **source_attrs) + + top_source_format = get_target_format(s) + + srcset = [] + for src in s["srcset"]: + entry_format = get_target_format(src, top_source_format) + filename = get_target_filename(s["filename"], entry_format) + url = os.path.join(s["base_url"], s["name"], src[0], filename) + srcset.append(format_srcset_element(str(url), src[0])) + + source = os.path.join(settings["PATH"], s["url"][1:]) + destination = os.path.join(s["base_path"], s["name"], src[0], filename) + process_image((source, destination, src[1]), settings) + + if len(srcset) > 0: + source_tag["srcset"] = ", ".join(srcset) + + picture_tag.append(source_tag) + + # Wrap img with + img.wrap(picture_tag) + + +def convert_div_to_picture_tag(soup, img, group, settings, derivative): + """Convert a div containing multiple images to a picture.""" + sources = prepare_image_sources(img, group, settings, derivative) # If default is not None, change default img source to the image # derivative referenced. @@ -550,18 +642,29 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): if isinstance(default[1], str): default_item_name = default[1] + # find format from srcset + default_item_format = None + for entry in default_source["srcset"]: + if entry[0] == default_item_name: + default_item_format = get_target_format(entry) + break - elif isinstance(default[1], list): + elif isinstance(default[1], (list, tuple)): default_item_name = "default" + default_item_format = get_target_format(default[1]) + ops = default[1][0] if isinstance(default[1], tuple) else default[1] source = os.path.join(settings["PATH"], default_source["url"][1:]) + filename = get_target_filename( + default_source["filename"], default_item_format + ) destination = os.path.join( default_source["base_path"], default_source_name, default_item_name, - default_source["filename"], + filename, ) - process_image((source, destination, default[1]), settings) + process_image((source, destination, ops), settings) else: raise RuntimeError( "Unexpected type for the second value of tuple " @@ -569,37 +672,39 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): (derivative,), ) + filename = get_target_filename(default_source["filename"], default_item_format) # Change img src to url of default processed image. img["src"] = os.path.join( default_source["base_url"], default_source_name, default_item_name, - default_source["filename"], + filename, ) - # Create picture tag. - picture_tag = soup.new_tag("picture") - for s in sources: - # Create new - source_attrs = {k: s[k] for k in s if k in ["media", "sizes"]} - source_tag = soup.new_tag("source", **source_attrs) + construct_picture_tag(soup, img, sources, settings) - srcset = [] - for src in s["srcset"]: - url = os.path.join(s["base_url"], s["name"], src[0], s["filename"]) - srcset.append(format_srcset_element(str(url), src[0])) - source = os.path.join(settings["PATH"], s["url"][1:]) - destination = os.path.join(s["base_path"], s["name"], src[0], s["filename"]) - process_image((source, destination, src[1]), settings) +def generate_srcset_and_insert_source(img, s, settings): + """Generate srcset for a source and insert it into the DOM.""" + top_source_format = get_target_format(s) - if len(srcset) > 0: - source_tag["srcset"] = ", ".join(srcset) + srcset = [] + for src in s["srcset"]: + entry_format = get_target_format(src, top_source_format) + filename = get_target_filename(s["filename"], entry_format) + url = posixpath.join(s["base_url"], s["name"], src[0], filename) + srcset.append(format_srcset_element(str(url), src[0])) - picture_tag.append(source_tag) + source = os.path.join(settings["PATH"], s["url"][1:]) + destination = os.path.join(s["base_path"], s["name"], src[0], filename) + process_image((source, destination, src[1]), settings) - # Wrap img with - img.wrap(picture_tag) + if len(srcset) > 0: + # Append source elements to the picture in the same order + # as they are found in + # settings['IMAGE_PROCESS'][derivative]['sources']. + s["element"]["srcset"] = ", ".join(srcset) + img.insert_before(s["element"]) def process_picture(soup, img, group, settings, derivative): @@ -662,18 +767,29 @@ def process_picture(soup, img, group, settings, derivative): if isinstance(default[1], str): default_item_name = default[1] + # find format from srcset + default_item_format = None + for entry in default_source["srcset"]: + if entry[0] == default_item_name: + default_item_format = get_target_format(entry) + break - elif isinstance(default[1], list): + elif isinstance(default[1], (list, tuple)): default_item_name = "default" + default_item_format = get_target_format(default[1]) + ops = default[1][0] if isinstance(default[1], tuple) else default[1] source = os.path.join(settings["PATH"], default_source["url"][1:]) + filename = get_target_filename( + default_source["filename"], default_item_format + ) destination = os.path.join( default_source["base_path"], default_source_name, default_item_name, - default_source["filename"], + filename, ) - process_image((source, destination, default[1]), settings) + process_image((source, destination, ops), settings) else: raise RuntimeError( @@ -682,31 +798,18 @@ def process_picture(soup, img, group, settings, derivative): (derivative,), ) + filename = get_target_filename(default_source["filename"], default_item_format) # Change img src to url of default processed image. img["src"] = posixpath.join( default_source["base_url"], default_source_name, default_item_name, - default_source["filename"], + filename, ) # Generate srcsets and put back s in . for s in sources: - srcset = [] - for src in s["srcset"]: - url = posixpath.join(s["base_url"], s["name"], src[0], s["filename"]) - srcset.append(format_srcset_element(str(url), src[0])) - - source = os.path.join(settings["PATH"], s["url"][1:]) - destination = os.path.join(s["base_path"], s["name"], src[0], s["filename"]) - process_image((source, destination, src[1]), settings) - - if len(srcset) > 0: - # Append source elements to the picture in the same order - # as they are found in - # settings['IMAGE_PROCESS'][derivative]['sources']. - s["element"]["srcset"] = ", ".join(srcset) - img.insert_before(s["element"]) + generate_srcset_and_insert_source(img, s, settings) def try_open_image(path): @@ -828,10 +931,12 @@ def process_metadata(generator, metadata): path = compute_paths(value, generator.context, derivative) original_values[key] = value - metadata[key] = urljoin( - site_url, posixpath.join(path.base_url, path.filename) - ) - destination = os.path.join(str(path.base_path), path.filename) + + target_format = get_target_format(process) + filename = get_target_filename(path.filename, target_format) + + metadata[key] = urljoin(site_url, posixpath.join(path.base_url, filename)) + destination = os.path.join(str(path.base_path), filename) if not isinstance(process, list): process = process["ops"] diff --git a/pelican/plugins/image_process/test_format_conversion.py b/pelican/plugins/image_process/test_format_conversion.py new file mode 100644 index 0000000..b417d3d --- /dev/null +++ b/pelican/plugins/image_process/test_format_conversion.py @@ -0,0 +1,191 @@ +from pathlib import Path + +from bs4 import BeautifulSoup +from PIL import Image +import pytest + +from pelican.plugins.image_process import ( + harvest_images_in_fragment, + process_metadata, + set_default_settings, +) + +HERE = Path(__file__).resolve().parent +TEST_DATA = HERE.joinpath("test_data").resolve() + + +def get_settings(**kwargs): + DEFAULT_CONFIG = { + "PATH": str(TEST_DATA), + "OUTPUT_PATH": "output", + "static_content": {}, + "filenames": {}, + "SITEURL": "https://www.example.com", + "IMAGE_PROCESS": {}, + } + settings = DEFAULT_CONFIG.copy() + settings.update(kwargs) + set_default_settings(settings) + return settings + + +@pytest.fixture +def output_dir(tmp_path): + out = tmp_path / "output" + out.mkdir() + return out + + +def test_single_image_conversion(output_dir): + settings = get_settings( + OUTPUT_PATH=str(output_dir), + IMAGE_PROCESS={ + "webp": { + "type": "image", + "ops": ["scale_in 100 100 True"], + "output-format": "webp", + } + }, + ) + + fragment = '' + result = harvest_images_in_fragment(fragment, settings) + + soup = BeautifulSoup(result, "html.parser") + img = soup.find("img") + + assert img["src"] == "/derivatives/webp/pelican-bird.webp" + + dest_path = output_dir / "derivatives" / "webp" / "pelican-bird.webp" + assert dest_path.exists() + + with Image.open(dest_path) as im: + assert im.format == "WEBP" + + +def test_responsive_image_conversion(output_dir): + settings = get_settings( + OUTPUT_PATH=str(output_dir), + IMAGE_PROCESS={ + "responsive": { + "type": "responsive-image", + "srcset": [ + ("small", ["scale_in 100 100 True"], "webp"), + ( + "large", + ["scale_in 800 800 True"], + ), # uses top-level default or original + ], + "default": "small", + "output-format": "jpg", + } + }, + ) + + fragment = '' + result = harvest_images_in_fragment(fragment, settings) + + soup = BeautifulSoup(result, "html.parser") + img = soup.find("img") + + assert img["src"] == "/derivatives/responsive/small/pelican-bird.webp" + assert "srcset" in img.attrs + srcset = img["srcset"] + assert "/derivatives/responsive/small/pelican-bird.webp small" in srcset + assert "/derivatives/responsive/large/pelican-bird.jpg large" in srcset + + assert ( + output_dir / "derivatives" / "responsive" / "small" / "pelican-bird.webp" + ).exists() + assert ( + output_dir / "derivatives" / "responsive" / "large" / "pelican-bird.jpg" + ).exists() + + +def test_picture_conversion(output_dir): + settings = get_settings( + OUTPUT_PATH=str(output_dir), + IMAGE_PROCESS={ + "viz": { + "type": "picture", + "sources": [ + { + "name": "default", + "srcset": [("small", ["scale_in 100 100 True"], "webp")], + }, + { + "name": "source-1", + "srcset": [("large", ["scale_in 800 800 True"], "jpg")], + }, + ], + "default": ("default", "small"), + } + }, + ) + + fragment = """ +
+ + +
+ """ + result = harvest_images_in_fragment(fragment, settings) + + soup = BeautifulSoup(result, "html.parser") + picture = soup.find("picture") + assert picture is not None + + sources = picture.find_all("source") + assert "webp" in sources[0]["srcset"] + assert "jpg" in sources[1]["srcset"] + + assert "/derivatives/viz/default/small/pelican-bird.webp" in sources[0]["srcset"] + assert "/derivatives/viz/source-1/large/black-borders.jpg" in sources[1]["srcset"] + + img = picture.find("img") + assert img["src"] == "/derivatives/viz/default/small/pelican-bird.webp" + + +def test_metadata_conversion(output_dir): + settings = get_settings( + OUTPUT_PATH=str(output_dir), + IMAGE_PROCESS={ + "webp-meta": { + "type": "image", + "ops": ["scale_in 100 100 True"], + "output-format": "webp", + } + }, + IMAGE_PROCESS_METADATA={"og_image": "webp-meta"}, + ) + + class MockGenerator: + def __init__(self, context): + self.context = context + + generator = MockGenerator(settings) + metadata = {"og_image": "/pelican-bird.png"} + + process_metadata(generator, metadata) + + assert ( + metadata["og_image"] + == "https://www.example.com/derivatives/webp-meta/pelican-bird.webp" + ) + assert (output_dir / "derivatives" / "webp-meta" / "pelican-bird.webp").exists() + + +def test_backward_compatibility(output_dir): + # Ensure that without output-format, it keeps the original extension + settings = get_settings( + OUTPUT_PATH=str(output_dir), IMAGE_PROCESS={"legacy": ["scale_in 100 100 True"]} + ) + + fragment = '' + result = harvest_images_in_fragment(fragment, settings) + + soup = BeautifulSoup(result, "html.parser") + img = soup.find("img") + + assert img["src"] == "/derivatives/legacy/pelican-bird.png" + assert (output_dir / "derivatives" / "legacy" / "pelican-bird.png").exists() From c394c50feef9084fefbd06c978681072632f3ada Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:26:56 +0200 Subject: [PATCH 02/15] Added release. --- RELEASE.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..a5ff239 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,3 @@ +Release type: major + +Added option to specify output image format to automatically convert images. From 1e959d50db0aa0bb01e7c8a183129745dbce56c6 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:54:04 +0200 Subject: [PATCH 03/15] Fix release information according to PR comment. --- RELEASE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index a5ff239..408559c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,3 @@ -Release type: major +Release type: minor -Added option to specify output image format to automatically convert images. +Feature: Specify output file format of images to transcode them automatically. From d8a00ec17a25eb67ddb482a8a7f1b4164c582f0e Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:54:51 +0200 Subject: [PATCH 04/15] Updated documentation for new feature (WIP) --- README.md | 90 ++- image-process-overview.svg | 1187 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1275 insertions(+), 2 deletions(-) create mode 100644 image-process-overview.svg diff --git a/README.md b/README.md index b424f19..98311d4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ by generating multiple derivative images from one or more sources. *Image Process* will not overwrite your original images. +![Image Process overview](image-process-overview.svg) + ## Installation The easiest way to install *Image Process* is via Pip. This @@ -74,9 +76,17 @@ referred to by the `src` attribute of an `` according to the list of operations specified, and replace the `src` attribute with the URL of the transformed image. +You can also transcode the image from one image format into another, for +example, from `png` to `webp`. Supported are all image formats the are also +supported by the underlying pillow-library. This is useful, when you want to +keep a single large high-resolution image in your repository, but distribute a +more lightweight, web-optimized image with your website. + For consistency with other types of transformations described below, there is an alternative syntax for the processing instructions: +**FIXME**: Check how and if the format syntax works with these constructs. + ```python IMAGE_PROCESS = { "thumb": { @@ -149,6 +159,7 @@ dictionary, with the following syntax: IMAGE_PROCESS = { "crisp": { "type": "responsive-image", + "output-format": "webp", "srcset": [ ("1x", ["scale_in 800 600 True"]), ("2x", ["scale_in 1600 1200 True"]), @@ -158,6 +169,7 @@ IMAGE_PROCESS = { }, "large-photo": { "type": "responsive-image", + "output-format": "jpg", "sizes": ( "(min-width: 1200px) 800px, " "(min-width: 992px) 650px, " @@ -165,9 +177,9 @@ IMAGE_PROCESS = { "100vw" ), "srcset": [ - ("600w", ["scale_in 600 450 True"]), + ("600w", ["scale_in 600 450 True"], "webp"), ("800w", ["scale_in 800 600 True"]), - ("1600w", ["scale_in 1600 1200 True"]), + ("1600w", ["scale_in 1600 1200 True"], "original"), ], "default": "800w", }, @@ -199,6 +211,17 @@ width in pixels of the associated image and must have the suffix attribute of the image. This is the image that will be displayed by browsers that do not support the `srcset` syntax. +Both, the `crisp` and the `large-photo` definitions above, also demonstrate how +the input image may be transcoded into another file format. This allows you to +transcode your original image from - for example - `png` into `webp` for the +derivative images. The setting `"output-format": "jpg"` sets the default for the +derivative images. This default can be overriden in every `srcset` +specification. In the `large-photo`-example above, by default, all derivative +images will be transcoded into `jpg`, however the line `("600w", ["scale_in 600 +450 True"], "webp"),` will override this for the specified derivative image. You +can also specify the original format, by using the keyword `original` instead of +a image file format specification. + In the two examples above, the `default` setting is a string referring to one of the images in the `srcset`. However, the `default` value could also be a list of operations to generate a different derivative @@ -246,6 +269,8 @@ To tell *Image Process* to generate the images for a ``, add a `picture` entry to your `IMAGE_PROCESS` dictionary with the following syntax: +**FIXME**: Check syntax for transcoding here. + ```python IMAGE_PROCESS = { "example-pict": { @@ -430,6 +455,67 @@ IMAGE_PROCESS = { } ``` +### Image File Formats + +*Image Process* uses python's pillow library (PIL) to read and write files. The +file formats, that pillow can read and write depend on libraries/plugins that +may or may not be installed on a particular system. While most common image +formats will likely work out of the box (`png`, `jpg`, `jpeg`, `gif`, `tif`, +`webp`), uncommon formats may cause issues depending on the system you are +working on. + +To specify an image format for the derivative image, pillow will infer the image +format from the file extension you specify. This follows common conventions, for +example: the extensions `j2c`, `j2k`, `jp2` and `jpx` will all result in a +*JPEG2000* file, while `jpe`, `jpg` and `jpeg` will produce a *JPEG* derivative +file. + +To see a full list of extensions and file formats available on your system, run +the following python snippet: + +```python +from PIL import Image + +# Map every available image extension to its format +Image.init() +print(f"{'Extension'.ljust(10)} -> {'Format'.ljust(10)} | Read/Write") +for ext, fmt in sorted(Image.EXTENSION.items()): + readonly = ("" if Image.SAVE.get(fmt) else "| READ-ONLY") + writeonly = ("" if Image.OPEN.get(fmt) else "| WRITE-ONLY") + print(f"{ext.ljust(10)} -> {fmt.ljust(10)} {readonly}{writeonly}") +``` + +Not all image formats can be read *and* written. For example the *PDF* image +format can be written with PIL, but cannot be read. Consequently, it can be used +as `output-format` by *Image Process* but does not work when you attempt to use +it as the original input format. + +The ability to *display* a particular image format, depends on the browser. +Modern browsers will typically support the following formats: JPEG, PNG, GIF, +SVG, WebP, AVIF (and ICO). + +For displaying images on your pelican website consider the following output formats: + +| Format | Best For... | Browser Support | +|---|---|---| +| JPEG | Standard photo (no transparency) | 100% | +| PNG | Graphics including transparency | 100% | +| WebP | All-purpose images (smaller size than JPEG/PNG) | ~97% (modern) | +| AVIF | All-purpose images (smaller size than WebP) | ~94% (latest) | +| GIF | Simple, low-resolution animations. | 100% | + +For most use cases, selecting either *WebP* or *AVIF* as output format (setting +`output-format`), with a fallback (setting `default`) of *JPEG* or *PNG* will +give good results. + +The *SVG* image format is omitted on purpose from the list above; it is a +*vector* image format (as opposed to the others, which are *raster* formats), +that is best used for logos and illustrations and you should not blindly convert +images (especially not photographs!) to this format unless you are sure what you +are doing. For more information on how vector image formats compare to raster +image formats see this [Wikipedia +article](https://en.wikipedia.org/wiki/Vector_graphics). + ### Additional Settings #### Destination Directory diff --git a/image-process-overview.svg b/image-process-overview.svg new file mode 100644 index 0000000..34c454a --- /dev/null +++ b/image-process-overview.svg @@ -0,0 +1,1187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + elican + + large, high resolution image + Transformations: crop, blur, grayscale, ... + Responsive images +(srcset or picture tag) + Image Process + Image Process automates the image conversion and updates the html output. + + + + + + + + + + + + + + + + + + + + + + + + + + + elican + + + mobile + + + + + + + + + + + + + + + + + + + + + + + + + + + elican + + + tablet + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + elican + + + large monitor + + + + + + + + + + + + + + + + + + + + + + elican + + + + + + + + + + + + + + + + + + + + + + + elican + + + + + + + + Pelican + + + + Image Process + + + + Content + + + + + + + + + + + + + + + + + + + + + + + + + + + + elican + + + Optionally convert image into web-formats (webp, avif, jpg, ...) + 1920 x 1080 + From a single image generate various sizes to make your website-images responsive and faster. + + + + + 1920 x 1080 + 1280 x 720 + 640 x 320 + + From edc29a59073a30bf8b3acf28d2aafe3671a277f0 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:04:20 +0200 Subject: [PATCH 05/15] Add test images and incorporate basic tests for format transcoding in original test script. --- README.md | 5 +- .../results/scale_in_avif/alpha-borders.avif | Bin 0 -> 2245 bytes .../results/scale_in_avif/black-borders.avif | Bin 0 -> 1839 bytes .../results/scale_in_avif/pelican-bird.avif | Bin 0 -> 2010 bytes .../results/scale_in_webp/alpha-borders.webp | Bin 0 -> 1788 bytes .../results/scale_in_webp/black-borders.webp | Bin 0 -> 1494 bytes .../results/scale_in_webp/pelican-bird.webp | Bin 0 -> 1644 bytes .../image_process/test_format_conversion.py | 191 ------------------ .../image_process/test_image_process.py | 88 +++++++- 9 files changed, 88 insertions(+), 196 deletions(-) create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_avif/alpha-borders.avif create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_avif/black-borders.avif create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_avif/pelican-bird.avif create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_webp/alpha-borders.webp create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_webp/black-borders.webp create mode 100644 pelican/plugins/image_process/test_data/results/scale_in_webp/pelican-bird.webp delete mode 100644 pelican/plugins/image_process/test_format_conversion.py diff --git a/README.md b/README.md index 98311d4..05c76f9 100644 --- a/README.md +++ b/README.md @@ -724,9 +724,12 @@ is a helper function to do this for you. From the Python REPL: ```python >>> from pelican.plugins.image_process.test_image_process import generate_test_images >>> generate_test_images() -36 test images generated! +60 test images generated! ``` +This generates both standard transform test images (54) and format conversion +test images (6 for WebP and AVIF). + ## License This project is licensed under the [AGPL-3.0 license](http://www.gnu.org/licenses/agpl-3.0.html). diff --git a/pelican/plugins/image_process/test_data/results/scale_in_avif/alpha-borders.avif b/pelican/plugins/image_process/test_data/results/scale_in_avif/alpha-borders.avif new file mode 100644 index 0000000000000000000000000000000000000000..32e78c5e7f6dd2bc134876e73f71f47d7dd415b7 GIT binary patch literal 2245 zcmYjN2{@E(7kp`An3cl!oDKSBsi9Zbpso$i8$+r_uHTRKZPl?6azfL3(Im!g8{hT zUjP6BY!C^*Ka(}J6(j@@n3xR`-29j*1_lyvOk_m|Gt-zUL;y)3s{!Ta=4JvvHW+c1 z4DhpBqHccp0ADveiMh=IfHRWWVm|b@9rAx>8J77pbM#qO5ftEy^daCp_YXAE4Rj+Q z149FeIG;cy(*xo`Y(OXy`)7SPNbuQz2lFcPXR$-rqS*m9b_ho-7XWa{`M6_=A^PebTURBCLIfW=&OHiB)t(%k1#hJ1gR6K9s8a&}eOIux5h*)&w*ekcd~#!4 z>UD6f#)7pXPRZ)i>;d05Lw@houI8ASH>FLf8YY?N-;&KS)J)YRogObu#C zDl}vg>iq>;;Ni4~aevDXd-rLbYYR7V+0C%!sjQfc_@;Y1+wl@3 z3D;H&5seQ)g$8MFVHC3K8`F;G^GANg={jgXF7v-|zP6GJ(W+;ydV5E*`5DaP9_;5z zolU9<*Q~MT&~mi5^e$#I;zFY_!e&J6y?j+ z>+r1{FoN0^rqyz?n<@OGd~?iY3{3%3&tPtgQ<-; zz-2AUTjF@+IZOS&6XbU>)ltTJ@iqJj;);EZiF~-~Wf9aGltOR%I5lgjvvTtx`l${i zl5{8NaKBm`FO^Z(Rlt6x=@U)8@x=p_%(3k}gQ^8aE=+blPPNLx_WZA#&+Aebx@2%( z!w*zAMkg?5HsRM-9T5vKe;=N^tbMP>&_Rm2cZG zKl)&H{}fWHj_2egxkU7%Vzr$2C_NSEsIMfU~2piZWLEI4A4u zQmI)xAw`4Iu5VsPCf5iD=dXfmd0^_*tLF1hySgEXv2TIasb#*c6}p5J*Q!yN@$ri}g)`*!0;KDGqG@2CI zQ}KLHoGnPk09jys9X*kg8wvkON|NRow_e5%=YuZ;7@Nl6D?Zk5V7Bq55> zsWeWyfqZ|&-9sUpZTOfRizaVz5zoD*H61F*(J^~cFPrhGIQKio9U*A~5sL5eNi?KVU^ptfVHg8hfv`8(a?d*vC0d$Ta3h|{N((u)&Dsz+jIXaj4>D6fT(_La! zPShpwrJfUAqpR0k4kfL47LOZ@`yTx`sHdJ2&Fc~IjdY~M(N{Nt5+?I`&~(}q+QCtH z&oGhpq(3g)p{p__(LW0wlRCDDq}=1cx|<%T0u=~%)`T+R+`fMGI&5h`UG(iNPrIpb zc>-EcbAwkzI7M}Epw`}zGjEOV_i>KKM|1kFUDu*3Ec23XQpx>>0y}b4Ev2V%#e6s_ zTe$qK(FF+7F$Ix|>cuG6Rtrs9ceLFSokJ?V|Dte+t0(=-5L87u3MC>Fv01kut`|_0 zNwNKWExo4%p663_LqK4i+Qy^16$#sPyxegi>BPBD?Cd7%%K&< z!OxQ^mp@eW)(oxl;N7D#6my)-ZHXEi5`0dP5!w#1w{$awq~cmRNE5vrFfUP*Axh0u zBzDs{r%HdwhR15|x+dGK3|W?DIXRY8Yc>PxiRNX5nRGwW)Sh(wA>6Ah*!h>?=5fIV zkN3YO=YF1tEmxdDond!1y=W|QcU4Glt~rss2{BCw?9lI987d&pOmZcApjpkgL<>_~MU@+t&y%J58PiwGFSTF)yqncVtjQWbj)7du-}*1viOX*Q literal 0 HcmV?d00001 diff --git a/pelican/plugins/image_process/test_data/results/scale_in_avif/black-borders.avif b/pelican/plugins/image_process/test_data/results/scale_in_avif/black-borders.avif new file mode 100644 index 0000000000000000000000000000000000000000..57648bef095e8d73e65a6c87a2edfaceccd5db90 GIT binary patch literal 1839 zcmXv|2{_d27yb>#$PyRfu^T&GGS;g>NJ93)xR;D!^c%*EU6vX|rf3DHRYqyKZ??>Xl^=Y7xfodW=XBsTCa5giO-0S3+(4`S8uAR23FsAj~F zHu0W;=rfN2sXHD%{{JrlfCyp&|EvGw^F%NZ|FeNGvN-5Nz?^kg0RX~yXBmL-0RXEg zqgDVx-=9l=I>L;`lj&@jF@p}iq-IR;A^hBh1Bw1bhHe28F$4zlg8@X(vq1*>8IjEh z9wHbDo(arsY-|kTLBVym||EM zSIOW#(1E-FJG+qB?uxUdRN*KIk<5AZMrbJ3Ml$-`GSq40t!i`C6yf{e z;WEW#Q7eDaY%sBWPQd(I`aH8DYWv``i=p3oq41o3TZ{8{vA}ulwmBa53JB6Bvgv{7 zDkTwU)kxs3E^cAjdC}^hrt0S=XPpOW_S%<~fMtDOhh0c7lO-V>yKf{4ylKb`9Vk*! zF_eZ=9zdBpVP2A2_)w*xf@CE3R(Qwk`P_KVj3nbe`xn&sW^*xo4h72Ei-m;Vb7b?}BOo~_*Dte+@ zQA|}M?jBl)!bp!#UF=K-a$>EosS<ea9!Qng z4-i*9E4&BJ5DubtXHjW~D8WMO7fAzm&!>mLFKRLEvf z{YPcNm<4D5zHF;w@)z;|v`I0`GDPJ);hdkCxQi_x$K04J0J5yRAVC7yW*Whq7Q zY3@8LWtA_4tY~0`Gc*%`7frT{I9*KWmKt$1Rv7=3)P+lO>7OG{&<-kKC@PIFg1T6q{Uf`HzlvY~zmho`dn? zYJ5%{f0yvJO;qJ#{rw589zE9(L#9qH70oP*NiTe1)^FC9O5aa z^ACygxPV`!iZNuH58#snj+fMYx*n##=34rl!%A;%_XxHV9*v1nF4$m=%r2SwM!}TE zL2rsh54?EJd2u-Ec!4(N{47)8kj7rsDe*n9la(?Xj*~aXn7;1Pn5~qxep6AcKOGL! zfY$_x;ldnDquoCr9tRX8y!eL9cU`*l!pu96nL`=p4FB8hW>y0qvMiiCJlaoR zXWuCJ!6d6a`x{hk@pM+PI_JQt#QbCN#8?n***o>g+!8wx$t(9yZroqgp;yOx>e=R! ziZyaZ4$xuU+tdh=ABcr6RZ+t+B)uHBNJI=7fa4nmG*0pTFs>SG&@&b_r;ZXqr1{H1 z7&5iCDL+d6&(R-)`SXp%o1g1E&*e#*#3L4Tf+ZJtGs67QT(rPGGQEFwibwxSz5k_f z_cX->ZB5b3BJWk2rmxr5CwbLaH*;34p%yrpvPJd0Wk{bKgiexXmTzHw~4>ys~N8+3vU$Eyp_i=p!s1qW&n+FF}9fQQl^k|K<9ayX)@COGt0SKIPG@ IB6+9(0aZ6NY5)KL literal 0 HcmV?d00001 diff --git a/pelican/plugins/image_process/test_data/results/scale_in_avif/pelican-bird.avif b/pelican/plugins/image_process/test_data/results/scale_in_avif/pelican-bird.avif new file mode 100644 index 0000000000000000000000000000000000000000..25ce34b0900ee74a176457761b56897e62b685f7 GIT binary patch literal 2010 zcmXv|2|N@08{cMTbEmn>u~&`|8#z)oCJ`+W{&FSfWHuU^k|U8bFPS5~?v+IUt{k#H#|6c+CB)lj2zxqEGCgI71-wlwPp1;qkp6slI}5OQ)d2NMHTNfTEX=(jzB6t^5>l`?!+ zvvkfjTpAOci)Yb?EnHf!4BQ^18CT19KLC&`PJZM0+~%WD0wMB+*Q<0^Dt@b31lP|+ z6w1rIk||7$&Yrb=!T%QAj+Lc!?CyvS>-WCaCJ` z&b8}ldweWRt6B6VskZ$2zeA!S0yPX2jFm}vO#2?|t^kA@r#Xq7d$`?rpSJ+T*2=77 zZb?nidL<$aaF%HER-94?O}tB}D?0tBwo%WWuM)*WlgTRIA^lBs9lK|0z>%<~a~urJAFV(d?cl*e^djB{jz z4hNmqh!&Xlu8g}A%2Sb|?DB=VhkV6(^1akXa!;?gZY0!vkUgv|5ct%hxiGz`n-5+p zL%!F9iPSZ-Gs`de4vVarM>C48h9mP9k7sDjk|z>bXkjQTcpdR9voKz({7(%hbf>eN|hB2rHi zf3Xah&EM{uPKFoUKb{kH>Lc`gxi)*GYFAyKz7=p;VZ_*jJTfG> z{<&X^iPjPeVP!e=M6+p)XGhp7iIDKJc3KHsLluY!W ziMGBLF5GNucbTSbBXKtyyezE*awZwFV!~U0iEO&S`ZN)tsh$&yn{oYEnTFvgy;R4z zQL0&;P^>{CGuQUYGw%*qq634G@W_7_5ThZ#ANwgi=^v+l^9;bcXnKz1ii5jJB#5}I-)?km zKbzJuv%+Q>97{DhytuHLow1CsehMUpRLy#lc^fH#}ga+yTs{{-> zeyz?&63r>;q!m!2ND+MBvvi*Gjid$lwUzrYewsE@dH>vc6i%REA2(r*-y}!{H2L{vqIxgf<1?>-m#Hj`g&Ud}^#-EIDDB9;D z@&ugHv5N!yKTf2G<%yFkEQgm8*LE-;eC-%hd9=JcuNn?zU{cnKM0@FonXOi&u9e$p z>57N-+(@XeiI_&qLs zpt-UtTyZ(ZJDwD8rB|Q%WBS2(Wms^tW}$pjU%kG2RIQ5QrZ@ZOU6~6^L%x{))9B~q biq`d_<#t`#lxDMTP{^(3@HO*REa%C;wLFkU literal 0 HcmV?d00001 diff --git a/pelican/plugins/image_process/test_data/results/scale_in_webp/alpha-borders.webp b/pelican/plugins/image_process/test_data/results/scale_in_webp/alpha-borders.webp new file mode 100644 index 0000000000000000000000000000000000000000..d4aee72ea88c8c4e6cf59b10154d8c0e71db2676 GIT binary patch literal 1788 zcmVrprG_q#)EWn+?;YP#+0L_)lJCWmi6gh-tQQmf+jVdw6|2_&{woz)$9 zRyn@jDR&1dSCaq8f8;;%-+wl(zUVvfqH}y3cP9?m1Z1cE=?>hVA`rM9Nha<@hL8OJ zogDyHP&go51^@u?BLJNND#!qq06uLrl}IEbA|WI*%J_f{iA~%s`W_2o{ARhAM&k$L zNkhMewz1G{2a4F2<5*|; z=DeH^y)s>K#xRLuU|Lu50ZKTR>a;{Go4?pSPbP3-V};)Usu&OiD!7AeEB(MsBSur* z=t#2*HEeW@tp<7eQ6YyZ*-2Bl>p&;V=o*K0OxdLeh7WhLL%1B|7>skpLu1(ZsT>6l zUlu*0MA27B-N2bP>|||nEp1Fh2qlnD@*3SH@(my4fWfj3+T|Ef*8*D-q82~^{{K>p z0u%n+TA%`ZtWldT-6xHoz@R_Dt!FILDAZHsbm830)cT_*TyTF$adY}?*kvs`O6nLm znWTM6wH14poOgbDSBd$X#aV&7pR;(g{E07QxYM8tZi!+>j2^2PGwGrf!eo<6Q`-CcypQ5#BldrTjZeSLok;M@RcG z14g`)mP{hJ8F<3^vx*wI5j%m3mF09BuP$L+`7ebX0j4%vl$Rp0H#^ zu(h=R8bUw8Kn3X3HhWy}P5%DHz3e9zFfs=ahYgGIIgu_HV%Ho&Hi0OYC`at6Qdr$% zeFO$=D|n0?l$EYYk553C(E+w#!2ozYkcm`;5qzv2^k0H7BYW2r6ldJV1mwc><#|fr z-@{zHQ#O#ru&Gix0c2K4!`FQf@P3y0>$BrkZ}@$_42u(ws0b4>Faq&tCIVa%Z;)Em zbL~{;s9Zp^?vqi%C0Q!lo~S3%)`t zPFU~fhw+|wA)6yrI8HX%cl)BW5i9!ZkbEwEMhstW7`cyc{2uYp$)NPWJC`zq^UXAt zdu~TtDw)_^b>uFrcejsws(v*n{20;ovovLhrwM`&PQeze(mXhNNSw{W7?93%k}1-Z zQ|#Q)4hRPwY5wvRgj$}(Tu7_j%kY;1Y=e{# z#dWa;xFgl5oVqQO>rlgR9|yV%%P07Z=DiWX_YjrX99vr+6c^ z>7g}HF{6n31mO*Kz^NhrAHQVVgdPOJt&>sCL-3Ng3xecV+S|Z1!q~vy?R?pkd=6Y( zsB(Gz=c6EQUz~*s97Ar^Ty6JP<;RZ=Cu(#| zHO4`ykqd_4zl%^Sf}sXvXO>62xBBFzu&3W*(kW25%o1#{K!A?a!W-NSC`mHlHqy?4 zOlp)vlpHE8c7I57jvpbq_l%eTe`=DRdUVJ$fD`XOiu?r=MLNB3IJvvJ0zpRCv+igSFMX{bSxng9a^$_i3G)0Lef z1q&Dw>F5^RAx4*{E&mGDZsG^BpkW7YQRTRCF`1)p1+vd%*uDK zXd;QR)_vsF&Z4w|@~RWr$Y(FD3hqlln%ch)_}*@C3HQNx@MO+&EbbB8fn=?XHUq#7 z!t{UQ4Mgd0d_MSLGh5PeO(8o!J5Kn6wHW66L%k>BF2tm_u`#2i=?YpcOOnT*zx{tY eKZ{#0p!8RsGA-->FB?m!=YaQ)a4^bK{1U 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 - - - - """ - result = harvest_images_in_fragment(fragment, settings) - - soup = BeautifulSoup(result, "html.parser") - picture = soup.find("picture") - assert picture is not None - - sources = picture.find_all("source") - assert "webp" in sources[0]["srcset"] - assert "jpg" in sources[1]["srcset"] - - assert "/derivatives/viz/default/small/pelican-bird.webp" in sources[0]["srcset"] - assert "/derivatives/viz/source-1/large/black-borders.jpg" in sources[1]["srcset"] - - img = picture.find("img") - assert img["src"] == "/derivatives/viz/default/small/pelican-bird.webp" - - -def test_metadata_conversion(output_dir): - settings = get_settings( - OUTPUT_PATH=str(output_dir), - IMAGE_PROCESS={ - "webp-meta": { - "type": "image", - "ops": ["scale_in 100 100 True"], - "output-format": "webp", - } - }, - IMAGE_PROCESS_METADATA={"og_image": "webp-meta"}, - ) - - class MockGenerator: - def __init__(self, context): - self.context = context - - generator = MockGenerator(settings) - metadata = {"og_image": "/pelican-bird.png"} - - process_metadata(generator, metadata) - - assert ( - metadata["og_image"] - == "https://www.example.com/derivatives/webp-meta/pelican-bird.webp" - ) - assert (output_dir / "derivatives" / "webp-meta" / "pelican-bird.webp").exists() - - -def test_backward_compatibility(output_dir): - # Ensure that without output-format, it keeps the original extension - settings = get_settings( - OUTPUT_PATH=str(output_dir), IMAGE_PROCESS={"legacy": ["scale_in 100 100 True"]} - ) - - fragment = '' - result = harvest_images_in_fragment(fragment, settings) - - soup = BeautifulSoup(result, "html.parser") - img = soup.find("img") - - assert img["src"] == "/derivatives/legacy/pelican-bird.png" - assert (output_dir / "derivatives" / "legacy" / "pelican-bird.png").exists() diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index cfc2868..5e9d801 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -12,6 +12,7 @@ from pelican.plugins.image_process import ( ExifTool, compute_paths, + get_target_filename, harvest_images_in_fragment, process_image, process_metadata, @@ -71,6 +72,19 @@ "sharpen": ["sharpen"], } +FORMAT_TRANSFORMS = { + "scale_in_webp": { + "type": "image", + "ops": ["scale_in 200 250 False"], + "output-format": "webp", + }, + "scale_in_avif": { + "type": "image", + "ops": ["scale_in 200 250 False"], + "output-format": "avif", + }, +} + # The expected sizes of the transformed images. EXPECTED_SIZES = { "crop": (300, 200), @@ -151,6 +165,53 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): raise ValueError(f"Unsupported image mode: {transformed.mode}") +@pytest.mark.parametrize("transform_id, transform_config", FORMAT_TRANSFORMS.items()) +@pytest.mark.parametrize("image_path", TRANSFORM_TEST_IMAGES) +def test_format_conversion(tmp_path, transform_id, transform_config, image_path): + """Test format conversion (WebP, AVIF) with binary match vs pre-rendered images.""" + settings = get_settings() + + image_name = image_path.name + target_format = transform_config["output-format"] + expected_filename = get_target_filename(image_name, target_format) + destination_path = tmp_path.joinpath(transform_id, expected_filename) + expected_path = TRANSFORM_RESULTS.joinpath(transform_id, expected_filename) + + process_image( + (str(image_path), str(destination_path), transform_config["ops"]), settings + ) + + transformed = Image.open(destination_path) + expected = Image.open(expected_path) + + assert transformed.size == expected.size + assert transformed.format.upper() == target_format.upper() + assert expected.format.upper() == target_format.upper() + + if transformed.mode == "RGB": + for _, (transformed_pixel, expected_pixel) in enumerate( + zip(transformed.getdata(), expected.getdata(), strict=False) + ): + assert abs(transformed_pixel[0] - expected_pixel[0]) <= 1 + assert abs(transformed_pixel[1] - expected_pixel[1]) <= 1 + assert abs(transformed_pixel[2] - expected_pixel[2]) <= 1 + elif transformed.mode == "RGBA": + for _, (transformed_pixel, expected_pixel) in enumerate( + zip(transformed.getdata(), expected.getdata(), strict=False) + ): + assert abs(transformed_pixel[0] - expected_pixel[0]) <= 1 + assert abs(transformed_pixel[1] - expected_pixel[1]) <= 1 + assert abs(transformed_pixel[2] - expected_pixel[2]) <= 1 + assert abs(transformed_pixel[3] - expected_pixel[3]) <= 1 + elif transformed.mode == "L": + for _, (transformed_pixel, expected_pixel) in enumerate( + zip(transformed.getdata(), expected.getdata(), strict=False) + ): + assert abs(transformed_pixel - expected_pixel) <= 1 + else: + raise ValueError(f"Unsupported image mode: {transformed.mode}") + + @pytest.mark.parametrize("image_path", FILE_FORMAT_TEST_IMAGES) def test_image_formats(tmp_path, image_path): """Test that we can process images in various formats.""" @@ -992,24 +1053,43 @@ def test_process_metadata_image( # noqa: PLR0913 def generate_test_images(): settings = get_settings() image_count = 0 + + all_transforms = {**SINGLE_TRANSFORMS, **FORMAT_TRANSFORMS} + for image_path in TRANSFORM_TEST_IMAGES: - for transform_id, transform_params in SINGLE_TRANSFORMS.items(): + for transform_id, transform_config in all_transforms.items(): + if isinstance(transform_config, list): + ops = transform_config + output_format = None + else: + ops = transform_config.get("ops", []) + output_format = transform_config.get("output-format") + + if output_format: + dest_filename = get_target_filename(image_path.name, output_format) + else: + dest_filename = image_path.name + destination_path = str( - TRANSFORM_RESULTS.joinpath(transform_id, image_path.name) + TRANSFORM_RESULTS.joinpath(transform_id, dest_filename) ) process_image( ( str(image_path), destination_path, - transform_params, + ops, ), settings, ) image_count += 1 # Check the size of the transformed image. - expected_size = EXPECTED_SIZES.get(transform_id) + base_transform_id = transform_id.replace("_webp", "").replace("_avif", "") + expected_size = EXPECTED_SIZES.get(base_transform_id) transformed = Image.open(destination_path) assert expected_size is None or expected_size == transformed.size + # Check the format of the transformed image (if specified). + if output_format: + assert transformed.format.upper() == output_format.upper() print(f"{image_count} test images generated!") # noqa: T201 From e22ee047eefd113bac3a709cfd7117724eb72276 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:21:32 +0200 Subject: [PATCH 06/15] Added tests for complex setting for transcoding image formats. --- README.md | 30 ++- .../image_process/test_data/black-borders.jpg | Bin 0 -> 42604 bytes .../image_process/test_image_process.py | 228 ++++++++++++++++++ 3 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 pelican/plugins/image_process/test_data/black-borders.jpg diff --git a/README.md b/README.md index 05c76f9..90c0ebc 100644 --- a/README.md +++ b/README.md @@ -78,19 +78,20 @@ URL of the transformed image. You can also transcode the image from one image format into another, for example, from `png` to `webp`. Supported are all image formats the are also -supported by the underlying pillow-library. This is useful, when you want to -keep a single large high-resolution image in your repository, but distribute a -more lightweight, web-optimized image with your website. +supported by the underlying pillow-library (see [Image File +Formats](#image-file-formats)). This is useful, when you want to keep a single +large high-resolution image in your repository, but distribute a more +lightweight, web-optimized image with your website. For consistency with other types of transformations described below, there is an alternative syntax for the processing instructions: -**FIXME**: Check how and if the format syntax works with these constructs. ```python IMAGE_PROCESS = { "thumb": { "type": "image", + "output-format": "webp" "ops": ["crop 0 0 50% 50%", "scale_out 150 150 True", "crop 0 0 150 150"], }, "article-image": { @@ -161,9 +162,9 @@ IMAGE_PROCESS = { "type": "responsive-image", "output-format": "webp", "srcset": [ - ("1x", ["scale_in 800 600 True"]), + ("1x", ["scale_in 800 600 True"], "avif"), ("2x", ["scale_in 1600 1200 True"]), - ("4x", ["scale_in 3200 2400 True"]), + ("4x", ["scale_in 3200 2400 True"], "original"), ], "default": "1x", }, @@ -222,6 +223,14 @@ images will be transcoded into `jpg`, however the line `("600w", ["scale_in 600 can also specify the original format, by using the keyword `original` instead of a image file format specification. +Similarly the `crisp` transformation also specifies a top-level output format +`"output-format": "webp"` which means, that in absence of other specifications, +the derivative images will be transcoded into the *WebP* image format. However +within the `srcset` this is overruled: the `1x` derivative image will be +transcoded into `avif`, the `2x` image will be transcoded into `webp` (as +specified by `output-format`) and lastly the `4x` image will retain the original +image format. + In the two examples above, the `default` setting is a string referring to one of the images in the `srcset`. However, the `default` value could also be a list of operations to generate a different derivative @@ -269,8 +278,6 @@ To tell *Image Process* to generate the images for a ``, add a `picture` entry to your `IMAGE_PROCESS` dictionary with the following syntax: -**FIXME**: Check syntax for transcoding here. - ```python IMAGE_PROCESS = { "example-pict": { @@ -278,6 +285,7 @@ IMAGE_PROCESS = { "sources": [ { "name": "default", + "output-format": "webp", "media": "(min-width: 640px)", "srcset": [ ("640w", ["scale_in 640 480 True"]), @@ -289,7 +297,7 @@ IMAGE_PROCESS = { { "name": "source-1", "srcset": [ - ("1x", ["crop 100 100 200 200"]), + ("1x", ["crop 100 100 200 200"], "avif"), ("2x", ["crop 100 100 300 300"]), ] }, @@ -310,6 +318,10 @@ displayed by browsers that do not support the `` syntax. In this example, it will use the image `640w` from the source `default`. A list of operations could have been specified instead of `640w`. +Similar to `responsive image` described above, also `` allows the +specification of "output-format" and image format extensions like `webp`, `avif` +and `jpg`. + To generate a responsive `` for the images in your articles, you must add to your article a pseudo `` tag that looks like this: diff --git a/pelican/plugins/image_process/test_data/black-borders.jpg b/pelican/plugins/image_process/test_data/black-borders.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9554a6ac1fa8b3898c2c6cb93fabc0923d8c9c10 GIT binary patch literal 42604 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 o<=-N' + + result = harvest_images_in_fragment(tag, settings) + soup = BeautifulSoup(result, "html.parser") + urls = self._extract_urls(soup) + + assert len(urls) > 0, f"No URLs generated for {transform_id}" + + transform_config = COMPLEX_FORMAT_TRANSFORMS[transform_id] + for url in urls: + ext = Path(url).suffix.lower() + expected_ext = self._determine_expected_ext( + url, transform_config, image_path.suffix.lower() + ) + assert ext == expected_ext, ( + f"Extension mismatch for {transform_id}: " + f"expected {expected_ext}, got {ext} in URL {url}" + ) + + def _extract_urls(self, soup): + """Extract all image URLs from parsed HTML soup.""" + urls = [] + if soup.img.get("src"): + urls.append(soup.img["src"]) + if soup.img.get("srcset"): + for item in soup.img["srcset"].split(","): + parts = item.strip().split() + if parts: + urls.append(parts[0]) + for source in soup.find_all("source"): + if source.get("srcset"): + for item in source["srcset"].split(","): + parts = item.strip().split() + if parts: + urls.append(parts[0]) + return urls + + def _determine_expected_ext(self, url, transform_config, source_ext): + """Determine expected extension for a given URL based on transform config.""" + transform_type = transform_config["type"] + + if transform_type == "responsive-image": + return self._get_responsive_image_ext(url, transform_config, source_ext) + if transform_type == "picture": + return self._get_picture_ext(url, transform_config, source_ext) + + return source_ext + + def _get_responsive_image_ext(self, url, transform_config, source_ext): + """Get expected extension for responsive-image transform.""" + top_format = transform_config.get("output-format") + srcset = transform_config.get("srcset", []) + + if "/default/" in url: + return self._get_default_ext( + transform_config, srcset, top_format, source_ext + ) + + entry = self._find_matching_srcset_entry(url, srcset) + if entry: + return self._get_entry_ext(entry, top_format, source_ext) + + return source_ext if not top_format else self._format_to_ext(top_format) + + def _get_picture_ext(self, url, transform_config, source_ext): + """Get expected extension for picture transform.""" + url_dir = Path(url).parent.name + sources = transform_config.get("sources", []) + + for source in sources: + src_name = source.get("name") + if src_name == url_dir or f"/{src_name}/" in url: + return self._get_source_ext(url, source, source_ext) + + return source_ext + + def _get_default_ext(self, transform_config, srcset, top_format, source_ext): + """Get extension for default URL in responsive-image.""" + default = transform_config.get("default") + + if isinstance(default, tuple): + return self._format_to_ext(default[1]) + + if isinstance(default, str): + for entry in srcset: + if entry[0] == default: + return self._get_entry_ext(entry, top_format, source_ext) + + return source_ext if not top_format else self._format_to_ext(top_format) + + def _get_source_ext(self, url, source, source_ext): + """Get extension for a source in picture transform.""" + src_format = source.get("output-format") + srcset = source.get("srcset", []) + + entry = self._find_matching_srcset_entry(url, srcset) + if entry: + return self._get_entry_ext(entry, src_format, source_ext) + + return source_ext if not src_format else self._format_to_ext(src_format) + + def _find_matching_srcset_entry(self, url, srcset): + """Find the srcset entry that matches the given URL.""" + for entry in srcset: + entry_name = entry[0] + # entry may be density specified ("1x") or width specified ("640w") + if entry_name in url or entry_name.replace("x", "w") in url: + return entry + return None + + def _get_entry_ext(self, entry, default_format, source_ext): + """Extract extension from a srcset entry tuple.""" + match entry: + case (_, _, str() as fmt): + if fmt == "original": + return source_ext + return self._format_to_ext(fmt) + + if default_format: + return self._format_to_ext(default_format) + return source_ext + + def _format_to_ext(self, fmt): + """Convert format string to extension.""" + if not fmt: + return None + fmt = fmt.lower().lstrip(".") + if fmt == "jpeg": + fmt = "jpg" + return f".{fmt}" + + @pytest.mark.parametrize("transform_id, transform_config", FORMAT_TRANSFORMS.items()) @pytest.mark.parametrize("image_path", TRANSFORM_TEST_IMAGES) def test_format_conversion(tmp_path, transform_id, transform_config, image_path): @@ -1054,6 +1275,13 @@ def generate_test_images(): settings = get_settings() image_count = 0 + for jpg_image_path in FORMAT_TEST_IMAGES_JPG: + if not jpg_image_path.exists(): + png_path = jpg_image_path.with_suffix(".png") + if png_path.exists(): + img = Image.open(png_path).convert("RGB") + img.save(jpg_image_path, "JPEG", quality=85) + all_transforms = {**SINGLE_TRANSFORMS, **FORMAT_TRANSFORMS} for image_path in TRANSFORM_TEST_IMAGES: From 154a987262c8e76112dcfc89c66754c14ed2ad88 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:11:39 +0200 Subject: [PATCH 07/15] Fix: Handling short syntax image replacement + test. --- .../plugins/image_process/image_process.py | 31 ++++++++++++++----- .../image_process/test_image_process.py | 6 ++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index cd4cfb3..7a07520 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -171,6 +171,15 @@ def get_target_format(config, default_format=None): return default_format +def normalize_shorthand_transform(config): + """Normalize shorthand tuple (ops, format) to a dict transform config.""" + match config: + case (ops, str() as format_str) if not isinstance(ops, str): + return {"type": "image", "ops": ops, "output-format": format_str} + + raise TypeError(f"Cannot normalize shorthand config: {config}") + + def get_target_filename(filename, target_format): """Return the filename with the target format extension.""" if not target_format or target_format == "original": @@ -400,19 +409,25 @@ def harvest_images_in_fragment(fragment, settings): if isinstance(d, list): # Single source image specification. - process_img_tag(img, settings, derivative) + process_img_tag(img, settings, derivative, d) + continue - elif not isinstance(d, dict): + if isinstance(d, tuple): + # Handle shorthand tuple format: (ops, format) + d = normalize_shorthand_transform(d) + # Fall through to dict handling + + if not isinstance(d, dict): raise TypeError( f"Derivative {derivative} definition not handled (must be list or dict)" ) - elif "type" not in d: + if "type" not in d: raise RuntimeError(f'"type" is mandatory for {derivative}.') - elif d["type"] == "image": + if d["type"] == "image": # Single source image specification. - process_img_tag(img, settings, derivative) + process_img_tag(img, settings, derivative, d) elif d["type"] == "responsive-image" and "srcset" not in img.attrs: # srcset image specification. @@ -485,9 +500,11 @@ def compute_paths(image_url, settings, derivative): return Path(base_url, source, base_path, filename) -def process_img_tag(img, settings, derivative): +def process_img_tag(img, settings, derivative, process_config=None): path = compute_paths(img["src"], settings, derivative) - process = settings["IMAGE_PROCESS"][derivative] + process = ( + process_config if process_config else settings["IMAGE_PROCESS"][derivative] + ) target_format = get_target_format(process) filename = get_target_filename(path.filename, target_format) diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index 908b23b..af0b992 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -170,6 +170,7 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): COMPLEX_FORMAT_TRANSFORMS = { + "short_webp": (["scale_in 300 300 True"], "webp"), "resp_top_webp": { "type": "responsive-image", "output-format": "webp", @@ -293,6 +294,11 @@ def _extract_urls(self, soup): def _determine_expected_ext(self, url, transform_config, source_ext): """Determine expected extension for a given URL based on transform config.""" + if isinstance(transform_config, tuple): + # Handle shorthand tuple format: (ops, format) + _ops, fmt = transform_config + return self._format_to_ext(fmt) + transform_type = transform_config["type"] if transform_type == "responsive-image": From ea13d98c7c26cf2be4a62f408b763eb805ee7f6f Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:16:41 +0200 Subject: [PATCH 08/15] Added test for responsive image with no "output-format" setting. --- pelican/plugins/image_process/test_image_process.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index af0b992..71d98df 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -180,6 +180,14 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): ], "default": "1x", }, + "resp_no_top": { + "type": "responsive-image", + "srcset": [ + ("1x", ["scale_in 800 600 True"]), + ("2x", ["scale_in 1600 1200 True"], "webp"), + ], + "default": "1x", + }, "resp_per_entry_mixed": { "type": "responsive-image", "srcset": [ From b4219987d6b4d9a237fac17c049f86157e9cb390 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:49:46 +0200 Subject: [PATCH 09/15] Fix: Handle "default" file format change in picture-tag + test. --- .../plugins/image_process/image_process.py | 34 ++++++++++++------- .../image_process/test_image_process.py | 20 +++++------ 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index 7a07520..24d9eda 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -659,12 +659,17 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): if isinstance(default[1], str): default_item_name = default[1] - # find format from srcset - default_item_format = None - for entry in default_source["srcset"]: - if entry[0] == default_item_name: - default_item_format = get_target_format(entry) - break + # Check for format in position 3: ("default", "640w", "webp") + match default: + case (_, _, str() as default_item_format): + pass + case _: + # find format from srcset + default_item_format = None + for entry in default_source["srcset"]: + if entry[0] == default_item_name: + default_item_format = get_target_format(entry) + break elif isinstance(default[1], (list, tuple)): default_item_name = "default" @@ -784,12 +789,17 @@ def process_picture(soup, img, group, settings, derivative): if isinstance(default[1], str): default_item_name = default[1] - # find format from srcset - default_item_format = None - for entry in default_source["srcset"]: - if entry[0] == default_item_name: - default_item_format = get_target_format(entry) - break + # Check for format in position 3: ("default", "640w", "webp") + match default: + case (_, _, str() as default_item_format): + pass + case _: + # find format from srcset + default_item_format = None + for entry in default_source["srcset"]: + if entry[0] == default_item_name: + default_item_format = get_target_format(entry) + break elif isinstance(default[1], (list, tuple)): default_item_name = "default" diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index 71d98df..85b4edf 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -204,6 +204,15 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): ], "default": (["scale_in 400 300 True"], "jpg"), }, + "resp_mixed_top_and_entry": { + "type": "responsive-image", + "output-format": "jpg", + "srcset": [ + ("1x", ["scale_in 800 600 True"]), + ("2x", ["scale_in 1600 1200 True"], "webp"), + ], + "default": "1x", + }, "picture_formats": { "type": "picture", "sources": [ @@ -229,16 +238,7 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): ], }, ], - "default": ("webp-src", "640w"), - }, - "resp_mixed_top_and_entry": { - "type": "responsive-image", - "output-format": "jpg", - "srcset": [ - ("1x", ["scale_in 800 600 True"]), - ("2x", ["scale_in 1600 1200 True"], "webp"), - ], - "default": "1x", + "default": ("webp-src", "640w", "webp"), }, } From a6e3a73b905ee346e321d0b12e0ae6ab87552d4d Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:57:06 +0200 Subject: [PATCH 10/15] Added fallback to default output-format when get_target_format() returns None as requested. --- pelican/plugins/image_process/image_process.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index 24d9eda..f0b0988 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -670,6 +670,11 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): if entry[0] == default_item_name: default_item_format = get_target_format(entry) break + # fallback to top-level output-format + if default_item_format is None: + default_item_format = settings["IMAGE_PROCESS"][derivative].get( + "output-format" + ) elif isinstance(default[1], (list, tuple)): default_item_name = "default" @@ -800,6 +805,11 @@ def process_picture(soup, img, group, settings, derivative): if entry[0] == default_item_name: default_item_format = get_target_format(entry) break + # fallback to top-level output-format + if default_item_format is None: + default_item_format = settings["IMAGE_PROCESS"][derivative].get( + "output-format" + ) elif isinstance(default[1], (list, tuple)): default_item_name = "default" From 827b3af44c5d067b063fceb58c4d4345c705a091 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:45:30 +0200 Subject: [PATCH 11/15] Undo deletion of relevant comment. --- pelican/plugins/image_process/image_process.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index f0b0988..87fb839 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -587,6 +587,10 @@ def build_srcset(img, settings, derivative): def prepare_image_sources(img, group, settings, derivative): """Prepare image sources for the picture tag.""" process_dir = settings["IMAGE_PROCESS_DIR"] + # Compile sources URL. Special source "default" uses the main + # image URL. Other sources use the img with classes + # [source['name'], 'image-process']. We also remove the img from + # the DOM. sources = copy.deepcopy(settings["IMAGE_PROCESS"][derivative]["sources"]) for s in sources: if s["name"] == "default": From 6e4709741a411badf807d3021f14f664e0ccfa59 Mon Sep 17 00:00:00 2001 From: mluisser <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 10 May 2026 11:36:56 +0200 Subject: [PATCH 12/15] Apply suggestions from code review Co-authored-by: Patrick Fournier --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 90c0ebc..11923e9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ compute a thumbnail from a larger image: ```python IMAGE_PROCESS = { - "article-image": ["scale_in 300 300 True"], + "article-image": (["scale_in 300 300 True"], "webp") "thumb": ["crop 0 0 50% 50%", "scale_out 150 150 True", "crop 0 0 150 150"], } ``` @@ -77,7 +77,7 @@ list of operations specified, and replace the `src` attribute with the URL of the transformed image. You can also transcode the image from one image format into another, for -example, from `png` to `webp`. Supported are all image formats the are also +example, from `png` to `webp`. Supported are all image formats that are also supported by the underlying pillow-library (see [Image File Formats](#image-file-formats)). This is useful, when you want to keep a single large high-resolution image in your repository, but distribute a more @@ -212,16 +212,16 @@ width in pixels of the associated image and must have the suffix attribute of the image. This is the image that will be displayed by browsers that do not support the `srcset` syntax. -Both, the `crisp` and the `large-photo` definitions above, also demonstrate how +Both definitions above also demonstrate how the input image may be transcoded into another file format. This allows you to -transcode your original image from - for example - `png` into `webp` for the +transcode your image from, for example, a `png` original to `webp` derivative images. The setting `"output-format": "jpg"` sets the default for the -derivative images. This default can be overriden in every `srcset` -specification. In the `large-photo`-example above, by default, all derivative +derivative images. This default can be overriden in each `srcset` +specification. In the `large-photo` example above, by default, all derivative images will be transcoded into `jpg`, however the line `("600w", ["scale_in 600 -450 True"], "webp"),` will override this for the specified derivative image. You -can also specify the original format, by using the keyword `original` instead of -a image file format specification. +450 True"], "webp"),` will override this for this specific derivative image. You +can also specify that you want to keep the original format, by using the keyword `original` instead of +an image file format specification. Similarly the `crisp` transformation also specifies a top-level output format `"output-format": "webp"` which means, that in absence of other specifications, @@ -318,7 +318,7 @@ displayed by browsers that do not support the `` syntax. In this example, it will use the image `640w` from the source `default`. A list of operations could have been specified instead of `640w`. -Similar to `responsive image` described above, also `` allows the +Similar to `responsive image` described above, `` also allows the specification of "output-format" and image format extensions like `webp`, `avif` and `jpg`. @@ -469,14 +469,14 @@ IMAGE_PROCESS = { ### Image File Formats -*Image Process* uses python's pillow library (PIL) to read and write files. The -file formats, that pillow can read and write depend on libraries/plugins that +*Image Process* uses Python's Pillow library (PIL) to read and write files. The +file formats that Pillow can read and write depend on libraries/plugins that may or may not be installed on a particular system. While most common image formats will likely work out of the box (`png`, `jpg`, `jpeg`, `gif`, `tif`, `webp`), uncommon formats may cause issues depending on the system you are working on. -To specify an image format for the derivative image, pillow will infer the image +To specify an image format for the derivative image, Pillow will infer the image format from the file extension you specify. This follows common conventions, for example: the extensions `j2c`, `j2k`, `jp2` and `jpx` will all result in a *JPEG2000* file, while `jpe`, `jpg` and `jpeg` will produce a *JPEG* derivative @@ -522,7 +522,7 @@ give good results. The *SVG* image format is omitted on purpose from the list above; it is a *vector* image format (as opposed to the others, which are *raster* formats), -that is best used for logos and illustrations and you should not blindly convert +that is best used for logos and illustrations. You should not blindly convert images (especially not photographs!) to this format unless you are sure what you are doing. For more information on how vector image formats compare to raster image formats see this [Wikipedia From 22f49c6068baa5e11887901a05e0b9168ca91a41 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 10 May 2026 14:42:36 +0200 Subject: [PATCH 13/15] Temporary test to ensure the fallback functionality works (TDD). --- .../image_process/test_image_process.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index 85b4edf..7b28290 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -400,6 +400,114 @@ def _format_to_ext(self, fmt): return f".{fmt}" +PICTURE_DEFAULT_FORMAT_FALLBACK = { + "pic_default_fmt_fallback": { + "type": "picture", + "sources": [ + { + "name": "main", + "output-format": "webp", + "srcset": [ + ("640w", ["scale_in 640 480 True"]), + ], + }, + ], + "default": ("main", ["scale_in 500 500 True"]), + }, + "pic_default_fmt_fallback_div": { + "type": "picture", + "sources": [ + { + "name": "main", + "output-format": "webp", + "srcset": [ + ("640w", ["scale_in 640 480 True"]), + ], + }, + ], + "default": ("main", ["scale_in 500 500 True"]), + }, +} + + +@pytest.mark.parametrize("transform_id", ["pic_default_fmt_fallback"]) +def test_picture_default_falls_back_to_source_format_when_using_ops_list( + mocker, transform_id +): + """Picture default (source_name, ops_list) must fall back to source output-format. + + When default is a 2-tuple of (source_name, ops_list) and the source has + output-format set (e.g. "webp"), get_target_format(ops_list) returns None + because a plain ops list carries no format info. Without a fallback to + the source's output-format, the default image silently keeps its original + extension instead of being transcoded. + + Regression test for the bug at image_process.py:~820 (process_picture). + """ + process = mocker.patch("pelican.plugins.image_process.image_process.process_image") + process.return_value = (512, 384) + + settings = get_settings( + IMAGE_PROCESS=PICTURE_DEFAULT_FORMAT_FALLBACK, + IMAGE_PROCESS_DIR="derivs", + ) + + tag = ( + "" + '' + '' + "" + ) + + result = harvest_images_in_fragment(tag, settings) + soup = BeautifulSoup(result, "html.parser") + + img_src = soup.img["src"] + assert img_src.endswith(".webp"), ( + f"Expected default img src to end with .webp " + f"(source has output-format: webp), got: {img_src}" + ) + + +@pytest.mark.parametrize("transform_id", ["pic_default_fmt_fallback_div"]) +def test_div_picture_default_falls_back_to_source_format_when_using_ops_list( + mocker, transform_id +): + """Same as above, but for the div-to-picture code path (convert_div_to_picture_tag). + + Regression test for the bug at image_process.py:~685 (convert_div_to_picture_tag). + """ + process = mocker.patch("pelican.plugins.image_process.image_process.process_image") + process.return_value = (512, 384) + + settings = get_settings( + IMAGE_PROCESS=PICTURE_DEFAULT_FORMAT_FALLBACK, + IMAGE_PROCESS_DIR="derivs", + ) + + tag = ( + '
' + 'pelican' + '

A pelican

' + '
' + 'Other view' + "
" + "
" + ) + + result = harvest_images_in_fragment(tag, settings) + soup = BeautifulSoup(result, "html.parser") + + img_src = soup.img["src"] + assert img_src.endswith(".webp"), ( + f"Expected default img src to end with .webp " + f"(source has output-format: webp), got: {img_src}" + ) + + @pytest.mark.parametrize("transform_id, transform_config", FORMAT_TRANSFORMS.items()) @pytest.mark.parametrize("image_path", TRANSFORM_TEST_IMAGES) def test_format_conversion(tmp_path, transform_id, transform_config, image_path): From fe32ae1f7a6623fcf39b5260c1d71f6b48f22790 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 10 May 2026 15:28:55 +0200 Subject: [PATCH 14/15] Fix fallback when get_target_format() returns None. --- pelican/plugins/image_process/image_process.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pelican/plugins/image_process/image_process.py b/pelican/plugins/image_process/image_process.py index 87fb839..5891605 100644 --- a/pelican/plugins/image_process/image_process.py +++ b/pelican/plugins/image_process/image_process.py @@ -682,7 +682,9 @@ def convert_div_to_picture_tag(soup, img, group, settings, derivative): elif isinstance(default[1], (list, tuple)): default_item_name = "default" - default_item_format = get_target_format(default[1]) + default_item_format = get_target_format(default[1]) or default_source.get( + "output-format" + ) ops = default[1][0] if isinstance(default[1], tuple) else default[1] source = os.path.join(settings["PATH"], default_source["url"][1:]) @@ -817,7 +819,9 @@ def process_picture(soup, img, group, settings, derivative): elif isinstance(default[1], (list, tuple)): default_item_name = "default" - default_item_format = get_target_format(default[1]) + default_item_format = get_target_format(default[1]) or default_source.get( + "output-format" + ) ops = default[1][0] if isinstance(default[1], tuple) else default[1] source = os.path.join(settings["PATH"], default_source["url"][1:]) filename = get_target_filename( From 7cad4aaff0796c4f962734e92678da958889e552 Mon Sep 17 00:00:00 2001 From: cargocultprogramming <10373572+cargocultprogramming@users.noreply.github.com> Date: Sun, 10 May 2026 16:21:59 +0200 Subject: [PATCH 15/15] Simplified test procedure with expected image formats mapped as dict. --- .../image_process/test_image_process.py | 146 +++++------------- 1 file changed, 40 insertions(+), 106 deletions(-) diff --git a/pelican/plugins/image_process/test_image_process.py b/pelican/plugins/image_process/test_image_process.py index 7b28290..89548ae 100644 --- a/pelican/plugins/image_process/test_image_process.py +++ b/pelican/plugins/image_process/test_image_process.py @@ -243,6 +243,39 @@ def test_all_transforms(tmp_path, transform_id, transform_params, image_path): } +# Expected file extensions per transform and source extension. +COMPLEX_FORMAT_EXPECTED_EXTENSIONS = { + "short_webp": { + ".png": {".webp"}, + ".jpg": {".webp"}, + }, + "resp_top_webp": { + ".png": {".webp"}, + ".jpg": {".webp"}, + }, + "resp_no_top": { + ".png": {".png", ".webp"}, + ".jpg": {".jpg", ".webp"}, + }, + "resp_per_entry_mixed": { + ".png": {".png", ".webp", ".avif"}, + ".jpg": {".jpg", ".webp", ".avif"}, + }, + "resp_custom_default_jpg": { + ".png": {".png", ".jpg"}, + ".jpg": {".jpg"}, + }, + "resp_mixed_top_and_entry": { + ".png": {".jpg", ".webp"}, + ".jpg": {".jpg", ".webp"}, + }, + "picture_formats": { + ".png": {".png"}, + ".jpg": {".jpg"}, + }, +} + + class TestComplexFormatTransforms: """Test complex format transforms of file format conversions.""" @@ -271,15 +304,15 @@ def test_complex_format_transforms(self, tmp_path, transform_id, image_path): assert len(urls) > 0, f"No URLs generated for {transform_id}" - transform_config = COMPLEX_FORMAT_TRANSFORMS[transform_id] + # find the expected extension from the transform_id and source_ext in + # the COMPLEX_FORMAT_EXPECTED_EXTENSIONS dict. + source_ext = image_path.suffix.lower() + expected_exts = COMPLEX_FORMAT_EXPECTED_EXTENSIONS[transform_id][source_ext] for url in urls: ext = Path(url).suffix.lower() - expected_ext = self._determine_expected_ext( - url, transform_config, image_path.suffix.lower() - ) - assert ext == expected_ext, ( - f"Extension mismatch for {transform_id}: " - f"expected {expected_ext}, got {ext} in URL {url}" + assert ext in expected_exts, ( + f"Extension mismatch for {transform_id} with {image_path.name}: " + f"expected one of {expected_exts}, got {ext} in URL {url}" ) def _extract_urls(self, soup): @@ -300,105 +333,6 @@ def _extract_urls(self, soup): urls.append(parts[0]) return urls - def _determine_expected_ext(self, url, transform_config, source_ext): - """Determine expected extension for a given URL based on transform config.""" - if isinstance(transform_config, tuple): - # Handle shorthand tuple format: (ops, format) - _ops, fmt = transform_config - return self._format_to_ext(fmt) - - transform_type = transform_config["type"] - - if transform_type == "responsive-image": - return self._get_responsive_image_ext(url, transform_config, source_ext) - if transform_type == "picture": - return self._get_picture_ext(url, transform_config, source_ext) - - return source_ext - - def _get_responsive_image_ext(self, url, transform_config, source_ext): - """Get expected extension for responsive-image transform.""" - top_format = transform_config.get("output-format") - srcset = transform_config.get("srcset", []) - - if "/default/" in url: - return self._get_default_ext( - transform_config, srcset, top_format, source_ext - ) - - entry = self._find_matching_srcset_entry(url, srcset) - if entry: - return self._get_entry_ext(entry, top_format, source_ext) - - return source_ext if not top_format else self._format_to_ext(top_format) - - def _get_picture_ext(self, url, transform_config, source_ext): - """Get expected extension for picture transform.""" - url_dir = Path(url).parent.name - sources = transform_config.get("sources", []) - - for source in sources: - src_name = source.get("name") - if src_name == url_dir or f"/{src_name}/" in url: - return self._get_source_ext(url, source, source_ext) - - return source_ext - - def _get_default_ext(self, transform_config, srcset, top_format, source_ext): - """Get extension for default URL in responsive-image.""" - default = transform_config.get("default") - - if isinstance(default, tuple): - return self._format_to_ext(default[1]) - - if isinstance(default, str): - for entry in srcset: - if entry[0] == default: - return self._get_entry_ext(entry, top_format, source_ext) - - return source_ext if not top_format else self._format_to_ext(top_format) - - def _get_source_ext(self, url, source, source_ext): - """Get extension for a source in picture transform.""" - src_format = source.get("output-format") - srcset = source.get("srcset", []) - - entry = self._find_matching_srcset_entry(url, srcset) - if entry: - return self._get_entry_ext(entry, src_format, source_ext) - - return source_ext if not src_format else self._format_to_ext(src_format) - - def _find_matching_srcset_entry(self, url, srcset): - """Find the srcset entry that matches the given URL.""" - for entry in srcset: - entry_name = entry[0] - # entry may be density specified ("1x") or width specified ("640w") - if entry_name in url or entry_name.replace("x", "w") in url: - return entry - return None - - def _get_entry_ext(self, entry, default_format, source_ext): - """Extract extension from a srcset entry tuple.""" - match entry: - case (_, _, str() as fmt): - if fmt == "original": - return source_ext - return self._format_to_ext(fmt) - - if default_format: - return self._format_to_ext(default_format) - return source_ext - - def _format_to_ext(self, fmt): - """Convert format string to extension.""" - if not fmt: - return None - fmt = fmt.lower().lstrip(".") - if fmt == "jpeg": - fmt = "jpg" - return f".{fmt}" - PICTURE_DEFAULT_FORMAT_FALLBACK = { "pic_default_fmt_fallback": {