Source code for betty.image

"""
Manipulate images.
"""

from typing import TypeAlias

import math

from PIL.Image import Image


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]


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