Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

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

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