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