Coverage for src/lektor_ng/imagetools/thumbnail.py: 99%
212 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 15:22 +0000
1"""Thumbnail generation."""
3from __future__ import annotations
5import dataclasses
6import io
7import math
8import posixpath
9from collections.abc import Iterable, Iterator, Mapping, Sequence
10from enum import Enum
11from functools import partial
12from pathlib import Path
13from typing import TYPE_CHECKING, Any, ClassVar, Final, NamedTuple
15import PIL.Image
16import PIL.ImageCms
18from ..utils import get_dependent_url
19from ._compat import (
20 PILLOW_VERSION_INFO,
21 Transpose, # PIL.Image.Transpose
22)
23from .image_info import (
24 SvgImageInfo,
25 TiffOrientation,
26 UnknownImageInfo,
27 get_image_info,
28 get_image_orientation,
29)
31if TYPE_CHECKING:
32 from _typeshed import SupportsRead
33 from lektor.builder import Artifact
34 from lektor.context import Context
37class ThumbnailMode(Enum):
38 FIT = "fit"
39 CROP = "crop"
40 STRETCH = "stretch"
42 DEFAULT = "fit"
45class _FormatInfo:
46 format: ClassVar[str]
47 default_save_params: ClassVar[dict[str, Any]] = {}
48 extensions: ClassVar[Sequence[str]]
50 @classmethod
51 def get_save_params(cls, thumbnail_params: ThumbnailParams) -> dict[str, Any]:
52 """Compute kwargs to be passed to Image.save() when writing the thumbnail."""
53 params = dict(cls.default_save_params)
54 params.update(cls._extra_save_params(thumbnail_params))
55 params["format"] = cls.format
56 return params
58 @classmethod
59 def get_thumbnail_tag(cls, thumbnail_params: ThumbnailParams) -> str:
60 """Get a string which serializes the thumbnail_params.
62 This is value is used as a suffix when generating the file name for the
63 thumbnail.
64 """
65 width, height = thumbnail_params.size
66 bits = [f"{width}x{height}"]
67 if thumbnail_params.crop:
68 bits.append("crop")
69 bits.extend(cls._extra_tag_bits(thumbnail_params))
70 return "_".join(bits)
72 @classmethod
73 def get_ext(cls, proposed_ext: str | None = None) -> str:
74 """Get file extension suitable for image format.
76 If proposed_ext is an acceptable extension for the format, return that.
77 Otherwise return the default extension for the format.
78 """
79 if proposed_ext is not None and proposed_ext.lower() in cls.extensions:
80 return proposed_ext
81 return cls.extensions[0]
83 @staticmethod
84 def _extra_save_params(
85 thumbnail_params: ThumbnailParams,
86 ) -> Mapping[str, Any] | Iterable[tuple[str, Any]]:
87 return {}
89 @staticmethod
90 def _extra_tag_bits(thumbnail_params: ThumbnailParams) -> Iterable[str]:
91 return ()
94class _GifFormatInfo(_FormatInfo):
95 format = "GIF"
96 extensions = (".gif",)
99class _PngFormatInfo(_FormatInfo):
100 format = "PNG"
101 default_save_params = {"compress_level": 7}
102 extensions = (".png",)
104 @staticmethod
105 def _extra_save_params(
106 thumbnail_params: ThumbnailParams,
107 ) -> Iterator[tuple[str, Any]]:
108 quality = thumbnail_params.quality
109 if quality is not None:
110 yield "compress_level", min(9, max(0, quality // 10))
112 @classmethod
113 def _extra_tag_bits(cls, thumbnail_params: ThumbnailParams) -> Iterable[str]:
114 for key, value in cls._extra_save_params(thumbnail_params):
115 assert key == "compress_level"
116 yield f"q{value}"
119class _JpegFormatInfo(_FormatInfo):
120 format = "JPEG"
121 default_save_params = {"quality": 85}
122 extensions = (".jpeg", ".jpg")
124 @staticmethod
125 def _extra_save_params(
126 thumbnail_params: ThumbnailParams,
127 ) -> Iterator[tuple[str, Any]]:
128 quality = thumbnail_params.quality
129 if quality is not None:
130 yield "quality", quality
132 @classmethod
133 def _extra_tag_bits(cls, thumbnail_params: ThumbnailParams) -> Iterable[str]:
134 for key, value in cls._extra_save_params(thumbnail_params):
135 assert key == "quality"
136 yield f"q{value}"
139class ImageSize(NamedTuple):
140 width: int
141 height: int
144@dataclasses.dataclass
145class ThumbnailParams:
146 """Encapsulates the parameters necessary to generate a thumbnail."""
148 size: ImageSize
149 format: str
150 quality: int | None = None
151 crop: bool = False
153 def __post_init__(self) -> None:
154 format = self.format.upper()
155 for format_info_cls in _FormatInfo.__subclasses__():
156 if format_info_cls.format == format:
157 break
158 else:
159 raise ValueError(f"unrecognized format ({self.format!r})")
160 self.format_info = format_info_cls
162 def get_save_params(self) -> Mapping[str, Any]:
163 """Get kwargs to pass to Image.save() when writing the thumbnail."""
164 return self.format_info.get_save_params(self)
166 def get_ext(self, proposed_ext: str | None = None) -> str:
167 """Get file extension for thumbnail.
169 If proposed_ext is an acceptable extension for the thumbnail, return that.
170 Otherwise return the default extension for the thumbnail format.
171 """
172 return self.format_info.get_ext(proposed_ext)
174 def get_tag(self) -> str:
175 """Get a string which serializes the thumbnail_params.
177 This is value is used as a suffix when generating the file name for the
178 thumbnail.
179 """
180 return self.format_info.get_thumbnail_tag(self)
183def _scale(x: int, num: float, denom: float) -> int:
184 """Compute x * num / denom, rounded to integer.
186 ``x``, ``num``, and ``denom`` should all be positive.
188 Rounds 0.5 up to be consistent with imagemagick.
190 """
191 if isinstance(num, int) and isinstance(denom, int):
192 # If all arguments are integers, carry out the computation using integer math to
193 # ensure that 0.5 rounds up.
194 return (x * num + denom // 2) // denom
195 # If floats are involved, we do our best to round 0.5 up, but loss of precision
196 # involved in floating point math makes the idea of "exactly" 0.5 a little fuzzy.
197 return math.trunc((x * num + denom / 2) // denom)
200def compute_dimensions(width: int | None, height: int | None, source_width: float, source_height: float) -> ImageSize:
201 """Compute "fit"-mode dimensions of thumbnail.
203 Returns the maximum size of a thumbnail with that has (nearly) the same aspect ratio
204 as the source and whose maximum size is set by ``width`` and ``height``.
206 One, but not both, of ``width`` or ``height`` can be ``None``.
207 """
208 if width is None and height is None:
209 raise ValueError("width and height may not both be None")
210 if width is not None:
211 size = ImageSize(width, _scale(width, source_height, source_width))
212 if height is not None and (width is None or height < size.height):
213 size = ImageSize(_scale(height, source_width, source_height), height)
214 return size
217class CropBox(NamedTuple):
218 # field names taken from
219 # https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.crop
220 left: int
221 upper: int
222 right: int
223 lower: int
226def _compute_cropbox(size: ImageSize, source_width: int, source_height: int) -> CropBox:
227 """Compute "crop"-mode crop-box to be applied to the source image before
228 it is scaled to the final thumbnail dimensions.
230 """
231 use_width = min(source_width, _scale(source_height, size.width, size.height))
232 use_height = min(source_height, _scale(source_width, size.height, size.width))
233 crop_l = (source_width - use_width) // 2
234 crop_t = (source_height - use_height) // 2
235 return CropBox(crop_l, crop_t, crop_l + use_width, crop_t + use_height)
238SRGB_PROFILE: Final = PIL.ImageCms.createProfile("sRGB")
239SRGB_PROFILE_BYTES: Final = PIL.ImageCms.ImageCmsProfile(SRGB_PROFILE).tobytes()
242def _get_icc_transform(icc_profile: bytes, inMode: str, outMode: str) -> PIL.ImageCms.ImageCmsTransform:
243 """Construct ICC transform mapping icc_profile to sRGB."""
244 profile = PIL.ImageCms.getOpenProfile(io.BytesIO(icc_profile))
245 transform = PIL.ImageCms.buildTransform(profile, SRGB_PROFILE, inMode, outMode)
246 assert isinstance(transform, PIL.ImageCms.ImageCmsTransform)
247 return transform
250def _convert_to_rgb(image: PIL.Image.Image) -> PIL.Image.Image:
251 # Ensure image is RGB before scaling
252 targetMode = "RGBA" if image.mode.upper().endswith("A") else "RGB"
253 if image.mode != targetMode:
254 icc_profile = image.info.get("icc_profile")
255 if icc_profile is not None:
256 icc_transform = _get_icc_transform(icc_profile, image.mode, targetMode)
257 image = PIL.ImageCms.applyTransform(image, icc_transform)
258 del image.info["icc_profile"]
259 else:
260 image = image.convert(targetMode)
261 return image
264def _convert_icc_profile_to_srgb(image: PIL.Image.Image) -> None:
265 """Convert image from embedded ICC profile to sRGB.
267 The image is modified **in place**.
269 After conversion, any embedded color profile is removed. (The default color
270 space for the web is "sRGB", so we don't need to embed it.)
271 """
272 # XXX: The old imagemagick code (which ran `convert` with `-strip -colorspace sRGB`)
273 # did not attempt any colorspace conversion. It simply stripped and ignored any
274 # color profile in the input image (causing the resulting thumbnail to be
275 # interpreted as if it were in sRGB even though its not.)
276 #
277 # Here we attempt to convert from any embedded colorspace in the source image
278 # to sRGB.
279 #
280 # Note: _convert_to_rgb may have already done this if the original image was not in
281 # RGB(A) mode. In that case it will have removed the icc_profile from the images
282 # .info dict.
283 #
284 # XXX: There seems to be no real way to compare to color profiles to see whether
285 # they are the same. It's not even clear that all "sRGB" profiles are really the
286 # same. (See
287 # https://ninedegreesbelow.com/photography/srgb-profile-comparison.html.) So we
288 # always convert if the image has a color profile.
289 #
290 icc_profile = image.info.get("icc_profile")
291 if icc_profile is not None and icc_profile != SRGB_PROFILE_BYTES:
292 icc_transform = _get_icc_transform(icc_profile, image.mode, image.mode)
293 PIL.ImageCms.applyTransform(image, icc_transform, inPlace=True)
294 del image.info["icc_profile"]
297_TRANSPOSE_FOR_ORIENTATION: Final[Mapping[TiffOrientation, int]] = {
298 TiffOrientation.TOPRIGHT: Transpose.FLIP_LEFT_RIGHT,
299 TiffOrientation.BOTRIGHT: Transpose.ROTATE_180,
300 TiffOrientation.BOTLEFT: Transpose.FLIP_TOP_BOTTOM,
301 TiffOrientation.LEFTTOP: Transpose.TRANSPOSE,
302 TiffOrientation.RIGHTTOP: Transpose.ROTATE_270,
303 TiffOrientation.RIGHTBOT: Transpose.TRANSVERSE,
304 TiffOrientation.LEFTBOT: Transpose.ROTATE_90,
305}
308def _auto_orient_image(image: PIL.Image.Image) -> PIL.Image.Image:
309 """Transpose image as indicated by the Exif Orientation tag.
311 We only do this for JPEG images. See _get_image_orientation for notes on why.
313 """
314 orientation = get_image_orientation(image)
315 if orientation in _TRANSPOSE_FOR_ORIENTATION:
316 image = image.transpose(
317 _TRANSPOSE_FOR_ORIENTATION[orientation] # type: ignore[arg-type]
318 )
319 return image
322def _create_thumbnail(image: PIL.Image.Image, params: ThumbnailParams) -> PIL.Image.Image:
323 # XXX: There is an Image.thumbnail() method that can be significantly faster at
324 # down-scaling than Image.resize() in some particular cases. Perhaps we want to use
325 # that. (Image.thumbnail() never upscales, so we can only use it when down-scaling.)
326 #
327 # Image.thumbnail *only* has a possible advantage when down-scaling JPEG images,
328 # where it configures the image loader to help with the down-scaling. (With other
329 # image types, .thumbnail just loads the image normally then uses .resize.)
330 #
331 # Some tests, downscaling a 5MB 4032x3024 JPEG, using:
332 #
333 # python -m timeit -s "from PIL import Image" \
334 # "im = Image.open('in.jpg'); im.thumbnail((W,H)); im.save('out.jpg')"
335 # or
336 # python -m timeit -s "from PIL import Image" \
337 # "Image.open('in.jpg').resize((W,H)[,reducing_gap=3]).save('out.jpg')"
338 #
339 # WxH | .resize() | .thumbnail() | .resize(reducing_gap=3)
340 # ===========|=============|================|===========================
341 # 1024x768 | 117 msec | 115 msec | 130 msec
342 # 512x384 | 105 msec | 51 msec | 82 msec
343 # 256x192 | 103 msec | 33 msec | 63 msec
344 # 120x90 | 100 msec | 22 msec | 60 msec
345 # 4x3 | 88 msec | 22 msec | 58 msec
346 #
347 # Thumbnail() by default uses reducing_gap=2.
348 #
349 # The big wins for .thumbnail() appear to come when downscaling by a factor of ~8 or
350 # more.
352 # Ensure image is in RGB (or RGBA) mode before scaling
353 image = _convert_to_rgb(image)
355 # transpose according to EXIF Orientation
356 image = _auto_orient_image(image)
358 # resize
359 resize_params: dict[str, Any] = {"reducing_gap": 3.0}
360 if params.crop:
361 resize_params["box"] = _compute_cropbox(params.size, image.width, image.height)
362 if PILLOW_VERSION_INFO < (7, 0):
363 del resize_params["reducing_gap"] # not supported in older Pillow
364 thumbnail = image.resize(params.size, **resize_params)
366 # Convert from any embedded ICC color profile to sRGB.
367 _convert_icc_profile_to_srgb(thumbnail)
369 # Do not propagate comment tag, or XMP data to thumbnail
370 thumbnail.info.pop("comment", None)
371 thumbnail.info.pop("xmp", None)
373 return thumbnail
376def _create_artifact(
377 source_image: str | Path | SupportsRead[bytes],
378 thumbnail_params: ThumbnailParams,
379 artifact: Artifact,
380) -> None:
381 """Create artifact by computing thumbnail for source image."""
382 with PIL.Image.open(source_image) as image:
383 thumbnail = _create_thumbnail(image, thumbnail_params)
384 save_params = thumbnail_params.get_save_params()
385 with artifact.open("wb") as fp:
386 thumbnail.save(fp, **save_params)
389def _get_thumbnail_url_path(source_url_path: str, thumbnail_params: ThumbnailParams) -> str:
390 source_ext = posixpath.splitext(source_url_path)[1]
391 # leave ext unchanged from source if valid for the thumbnail format
392 ext = thumbnail_params.get_ext(source_ext)
393 suffix = thumbnail_params.get_tag()
394 return get_dependent_url( # type: ignore[no-any-return]
395 source_url_path, suffix, ext=ext
396 )
399def make_image_thumbnail(
400 ctx: Context,
401 source_image: str | Path,
402 source_url_path: str,
403 *,
404 width: int | None = None,
405 height: int | None = None,
406 mode: ThumbnailMode = ThumbnailMode.DEFAULT,
407 upscale: bool | None = None,
408 quality: int | None = None,
409) -> Thumbnail:
410 """Helper method that can create thumbnails from within the build process
411 of an artifact.
412 """
413 image_info = get_image_info(source_image)
414 if isinstance(image_info, UnknownImageInfo):
415 raise RuntimeError("Cannot process unknown images")
417 if mode == ThumbnailMode.FIT:
418 if width is None and height is None:
419 raise ValueError("Must specify at least one of width or height.")
420 if image_info.width is None or image_info.height is None:
421 assert isinstance(image_info, SvgImageInfo)
422 raise ValueError("Cannot determine aspect ratio of SVG image.")
423 if upscale is None:
424 upscale = False
425 size = compute_dimensions(width, height, image_info.width, image_info.height)
426 else:
427 if width is None or height is None:
428 raise ValueError(f'"{mode.value}" mode requires both `width` and `height` to be specified.')
429 if upscale is None:
430 upscale = True
431 size = ImageSize(width, height)
433 # If we are dealing with an actual svg image, we do not actually
434 # resize anything, we just return it. This is not ideal but it's
435 # better than outright failing.
436 if isinstance(image_info, SvgImageInfo):
437 # XXX: Since we don't always know the original dimensions,
438 # we currently omit the upscaling check for SVG images.
439 return Thumbnail(source_url_path, size.width, size.height)
441 would_upscale = size.width > image_info.width or size.height > image_info.height
442 if would_upscale and not upscale:
443 return Thumbnail(source_url_path, image_info.width, image_info.height)
445 thumbnail_params = ThumbnailParams(
446 size=size,
447 format=image_info.format.upper(),
448 quality=quality,
449 crop=mode == ThumbnailMode.CROP,
450 )
451 dst_url_path = _get_thumbnail_url_path(source_url_path, thumbnail_params)
453 ctx.add_sub_artifact(
454 artifact_name=dst_url_path,
455 sources=[source_image],
456 build_func=partial(_create_artifact, source_image, thumbnail_params),
457 )
459 return Thumbnail(dst_url_path, size.width, size.height)
462@dataclasses.dataclass(frozen=True)
463class Thumbnail:
464 """Holds information about a thumbnail."""
466 url_path: str
467 width: int
468 height: int
470 def __str__(self) -> str:
471 return posixpath.basename(self.url_path)