betty.image - Betty documentation

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

    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]

    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

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

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