Coverage for src/lektor_ng/imagetools/thumbnail.py: 99%

212 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 10:32 +0000

1"""Thumbnail generation.""" 

2 

3from __future__ import annotations 

4 

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 

14 

15import PIL.Image 

16import PIL.ImageCms 

17 

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) 

30 

31if TYPE_CHECKING: 

32 from _typeshed import SupportsRead 

33 from lektor.builder import Artifact 

34 from lektor.context import Context 

35 

36 

37class ThumbnailMode(Enum): 

38 FIT = "fit" 

39 CROP = "crop" 

40 STRETCH = "stretch" 

41 

42 DEFAULT = "fit" 

43 

44 

45class _FormatInfo: 

46 format: ClassVar[str] 

47 default_save_params: ClassVar[dict[str, Any]] = {} 

48 extensions: ClassVar[Sequence[str]] 

49 

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 

57 

58 @classmethod 

59 def get_thumbnail_tag(cls, thumbnail_params: ThumbnailParams) -> str: 

60 """Get a string which serializes the thumbnail_params. 

61 

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) 

71 

72 @classmethod 

73 def get_ext(cls, proposed_ext: str | None = None) -> str: 

74 """Get file extension suitable for image format. 

75 

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] 

82 

83 @staticmethod 

84 def _extra_save_params( 

85 thumbnail_params: ThumbnailParams, 

86 ) -> Mapping[str, Any] | Iterable[tuple[str, Any]]: 

87 return {} 

88 

89 @staticmethod 

90 def _extra_tag_bits(thumbnail_params: ThumbnailParams) -> Iterable[str]: 

91 return () 

92 

93 

94class _GifFormatInfo(_FormatInfo): 

95 format = "GIF" 

96 extensions = (".gif",) 

97 

98 

99class _PngFormatInfo(_FormatInfo): 

100 format = "PNG" 

101 default_save_params = {"compress_level": 7} 

102 extensions = (".png",) 

103 

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)) 

111 

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}" 

117 

118 

119class _JpegFormatInfo(_FormatInfo): 

120 format = "JPEG" 

121 default_save_params = {"quality": 85} 

122 extensions = (".jpeg", ".jpg") 

123 

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 

131 

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}" 

137 

138 

139class ImageSize(NamedTuple): 

140 width: int 

141 height: int 

142 

143 

144@dataclasses.dataclass 

145class ThumbnailParams: 

146 """Encapsulates the parameters necessary to generate a thumbnail.""" 

147 

148 size: ImageSize 

149 format: str 

150 quality: int | None = None 

151 crop: bool = False 

152 

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 

161 

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) 

165 

166 def get_ext(self, proposed_ext: str | None = None) -> str: 

167 """Get file extension for thumbnail. 

168 

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) 

173 

174 def get_tag(self) -> str: 

175 """Get a string which serializes the thumbnail_params. 

176 

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) 

181 

182 

183def _scale(x: int, num: float, denom: float) -> int: 

184 """Compute x * num / denom, rounded to integer. 

185 

186 ``x``, ``num``, and ``denom`` should all be positive. 

187 

188 Rounds 0.5 up to be consistent with imagemagick. 

189 

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) 

198 

199 

200def compute_dimensions(width: int | None, height: int | None, source_width: float, source_height: float) -> ImageSize: 

201 """Compute "fit"-mode dimensions of thumbnail. 

202 

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``. 

205 

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 

215 

216 

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 

224 

225 

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. 

229 

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) 

236 

237 

238SRGB_PROFILE: Final = PIL.ImageCms.createProfile("sRGB") 

239SRGB_PROFILE_BYTES: Final = PIL.ImageCms.ImageCmsProfile(SRGB_PROFILE).tobytes() 

240 

241 

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 

248 

249 

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 

262 

263 

264def _convert_icc_profile_to_srgb(image: PIL.Image.Image) -> None: 

265 """Convert image from embedded ICC profile to sRGB. 

266 

267 The image is modified **in place**. 

268 

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"] 

295 

296 

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} 

306 

307 

308def _auto_orient_image(image: PIL.Image.Image) -> PIL.Image.Image: 

309 """Transpose image as indicated by the Exif Orientation tag. 

310 

311 We only do this for JPEG images. See _get_image_orientation for notes on why. 

312 

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 

320 

321 

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. 

351 

352 # Ensure image is in RGB (or RGBA) mode before scaling 

353 image = _convert_to_rgb(image) 

354 

355 # transpose according to EXIF Orientation 

356 image = _auto_orient_image(image) 

357 

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) 

365 

366 # Convert from any embedded ICC color profile to sRGB. 

367 _convert_icc_profile_to_srgb(thumbnail) 

368 

369 # Do not propagate comment tag, or XMP data to thumbnail 

370 thumbnail.info.pop("comment", None) 

371 thumbnail.info.pop("xmp", None) 

372 

373 return thumbnail 

374 

375 

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) 

387 

388 

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 ) 

397 

398 

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") 

416 

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) 

432 

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) 

440 

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) 

444 

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) 

452 

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 ) 

458 

459 return Thumbnail(dst_url_path, size.width, size.height) 

460 

461 

462@dataclasses.dataclass(frozen=True) 

463class Thumbnail: 

464 """Holds information about a thumbnail.""" 

465 

466 url_path: str 

467 width: int 

468 height: int 

469 

470 def __str__(self) -> str: 

471 return posixpath.basename(self.url_path)