Source code for betty.image

"""
Manipulate images.
"""

import math
from pathlib import Path
from typing import TypeAlias

from PIL import UnidentifiedImageError
from PIL.Image import Image, EXTENSION, preinit, init

from betty.media_type import MediaType
from betty.media_type.media_types import PDF

Percentage: TypeAlias = int
Pixel: TypeAlias = int
OneDimensionalSize: TypeAlias = tuple[Pixel, None] | tuple[None, Pixel]
TwoDimensionalSize: TypeAlias = tuple[Pixel, Pixel]
Size: TypeAlias = OneDimensionalSize | TwoDimensionalSize
FocusArea: TypeAlias = tuple[Percentage, Percentage, Percentage, Percentage]


[docs] def is_supported_media_type(media_type: MediaType) -> bool: """ Test if a media type is supported by the image API. """ if media_type.type == "image": return True if media_type == PDF: return True return False
[docs] def image_file_path_format(image_file_path: Path) -> str: """ Get the PIL image format for an image's file path. """ if image_file_path.suffix not in EXTENSION: preinit() if image_file_path.suffix not in EXTENSION: init() if image_file_path.suffix not in EXTENSION: raise UnidentifiedImageError( f"cannot identify image file {image_file_path}" ) return EXTENSION[image_file_path.suffix]
def _assert_size(size: Size) -> None: if size[0] is not None and size[0] <= 0 or size[1] is not None and size[1] <= 0: raise ValueError("Invalid size: sizes must be greater than 0") def _assert_area(area: FocusArea) -> None: if area[0] >= area[2] or area[1] >= area[3]: raise ValueError("Invalid area") def _assert_boundaries(image: Image, area: FocusArea) -> None: if ( 0 > area[0] > image.width or 0 > area[2] > image.width or 0 > area[1] > image.height or 0 > area[3] > image.height ): raise ValueError("Given area does not fit within the image.")
[docs] def resize_cover( original_image: Image, resize_size: Size, *, focus: FocusArea | None = None ) -> Image: """ Resize an image to cover an area. :arg focus: An area within the image of which as much as possible should be part of the resized image. """ _assert_size(resize_size) if focus is not None: _assert_area(focus) _assert_boundaries(original_image, focus) resize_width, resize_height = resize_size focus_left = 0 if focus is None else focus[0] * original_image.width / 100 focus_top = 0 if focus is None else focus[1] * original_image.height / 100 focus_right = ( original_image.width if focus is None else focus[2] * original_image.width / 100 ) focus_bottom = ( original_image.height if focus is None else focus[3] * original_image.height / 100 ) focus_width = focus_right - focus_left focus_height = focus_bottom - focus_top focus_ratio = focus_width / focus_height # Bind the maximum size by the original image. # This ensures the resized image won't have empty bars. max_width: float = original_image.width max_height: float = original_image.height # Bind the minimum size by the requested resize area, as long as the maximum allows. # This ensures we use at least as many pixels as needed for the resize area, reducing # the likelihood of the resulting image being *enlarged*. min_width: float = 0 if resize_width is None else min(max_width, resize_width) min_height: float = 0 if resize_height is None else min(max_height, resize_height) max_ratio = max_width / max_height # Adjust the minimum and maximum dimensions if the # resize area has a ratio (a width *and* a height). if resize_width is not None and resize_height is not None: resize_ratio = resize_width / resize_height # Adjust the maximum dimensions. if resize_ratio > max_ratio: # The resize area is more landscape than the original image. # The height is the constrained dimension. max_height = min(max_height, max_width / resize_ratio) else: # The resize's area is more portrait than the original image. # The width is the constrained dimension. max_width = min(max_width, max_height * resize_ratio) # Adjust the minimum dimensions. if resize_ratio > focus_ratio: # The resize area is more landscape than the focus area. # The height is the constrained dimension. min_height = max(min_height, focus_width / resize_ratio) crop_height = min(max_height, max(min_height, focus_height)) crop_width = crop_height * resize_ratio else: # The resize's area is more portrait than the focus area. # The width is the constrained dimension. min_width = max(min_width, focus_height * resize_ratio) crop_width = min(max_width, max(min_width, focus_width)) crop_height = crop_width / resize_ratio elif resize_width is not None: crop_width = max(min_width, focus_width) crop_height = max_height else: crop_width = max_width crop_height = max(min_height, focus_height) crop_focus_width_diff = crop_width - focus_width crop_focus_height_diff = crop_height - focus_height ideal_margin_left = math.ceil(crop_focus_width_diff / 2) ideal_margin_top = math.ceil(crop_focus_height_diff / 2) ideal_margin_right = crop_focus_width_diff - ideal_margin_left ideal_margin_bottom = crop_focus_height_diff - ideal_margin_top # Place the crop area centrally on top of the focus area. # At this point the crop area may still be out of bounds. crop_left = math.ceil(focus_left - ideal_margin_left) crop_top = math.ceil(focus_top - ideal_margin_top) crop_right = math.ceil(focus_right + ideal_margin_right) crop_bottom = math.ceil(focus_bottom + ideal_margin_bottom) # Ensure the crop area lies within the original image bounds. if crop_left < 0: crop_right -= crop_left crop_left = 0 if crop_top < 0: crop_bottom -= crop_top crop_top = 0 if crop_right > original_image.width: crop_left -= crop_right - original_image.width crop_right = original_image.width if crop_bottom > original_image.height: crop_top -= crop_bottom - original_image.height crop_bottom = original_image.height return original_image.crop((crop_left, crop_top, crop_right, crop_bottom)).resize( (resize_width or original_image.width, resize_height or original_image.height) )