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

# Source code for term_image.image.common

    """
    .. Common Interfaces For Various Image Classes
    """

    from __future__ import annotations

    __all__ = (
        "ImageSource",
        "Size",
        "BaseImage",
        "GraphicsImage",
        "TextImage",
        "ImageIterator",
    )

    import atexit
    import io
    import os
    import re
    import sys
    import time
    from abc import ABCMeta, abstractmethod
    from enum import Enum
    from functools import wraps
    from math import ceil
    from operator import gt, mul
    from shutil import rmtree
    from tempfile import mkdtemp, mkstemp
    from types import FunctionType, TracebackType
    from typing import Any, Dict, Generator, List, Optional, Set, Tuple, Union
    from urllib.parse import urlparse

    import PIL
    import requests
    from PIL import Image, UnidentifiedImageError

    from .. import get_cell_ratio
    from ..ctlseqs import CURSOR_DOWN, CURSOR_UP, HIDE_CURSOR, SGR_NORMAL, SHOW_CURSOR
    from ..exceptions import (
        InvalidSizeError,
        RenderError,
        StyleError,
        TermImageError,
        URLNotFoundError,
    )
    from ..utils import (
        ClassInstanceMethod,
        ClassProperty,
        arg_type_error,
        arg_value_error,
        arg_value_error_msg,
        arg_value_error_range,
        cached,
        get_cell_size,
        get_fg_bg_colors,
        get_terminal_name_version,
        get_terminal_size,
        no_redecorate,
    )

    _ALPHA_THRESHOLD = 40 / 255  # Default alpha threshold
    _FORMAT_SPEC = re.compile(
        r"(([<|>])?(\d+)?)?(\.([-^_])?(\d+)?)?(#(\.\d+|[0-9a-fA-F]{6}|#)?)?(\+(.+))?",
        re.ASCII,
    )
    _NO_VERTICAL_SPEC = re.compile(
        r"(([<|>])?(\d+)?)?\.(#(\.\d+|[0-9a-fA-F]{6})?)?", re.ASCII
    )
    _ALPHA_BG_FORMAT = re.compile("#([0-9a-fA-F]{6})?", re.ASCII)
    _TEMP_DIR = mkdtemp()

    @no_redecorate
    def _close_validated(func: FunctionType) -> FunctionType:
        """Enables finalization status validation before performing an operation with a
        `BaseImage` instance.
        """

        @wraps(func)
        def close_validated_wrapper(self, *args, **kwargs):
            if self._closed:
                raise TermImageError("This image has been finalized")
            return func(self, *args, **kwargs)

        return close_validated_wrapper

    class Hidden:
        """An object that hides it's original value representation."""

        def __repr__(_):
            return "<...>"

        __ascii__ = __str__ = __repr__

    class SourceAttr(Hidden, str):
        """A string that only compares equal to itself but returns the original hash of
        the string.

        Used to store the name of the attribute that holds the value for
        :py:attr:`BaseImage.source`` as the value of enum members, because some would
        normally compare equal.
        """

        def __init__(self, *_):
            self._str = super(Hidden).__str__()

        def __eq__(self, _):
            return NotImplemented

        __ne__ = __eq__
        __hash__ = str.__hash__

    class ImageSource(Enum):
        """Image source type."""

        #: The instance was derived from a path to a local image file.
        #:
        #: :meta hide-value:
        FILE_PATH = SourceAttr("_source")

        #: The instance was derived from a PIL image instance.
        #:
        #: :meta hide-value:
        PIL_IMAGE = SourceAttr("_source")

        #: The instance was derived from an image URL.
        #:
        #: :meta hide-value:
        URL = SourceAttr("_url")

    class Size(Enum):
        """Enumeration for :term:`automatic sizing`."""

        #: Equivalent to :py:attr:`ORIGINAL` if it will fit into the
        #: :term:`frame size`, else :py:attr:`FIT`.
        #:
        #: :meta hide-value:
        AUTO = Hidden()

        #: The image size is set to fit optimally **within** the :term:`frame size`.
        #:
        #: :meta hide-value:
        FIT = Hidden()

        #: The size is set such that the width is exactly the :term:`frame width`,
        #: regardless of the :term:`cell ratio`.
        #:
        #: :meta hide-value:
        FIT_TO_WIDTH = Hidden()

        #: The image size is set such that the image is rendered with as many pixels as the
        #: the original image consists of.
        #:
        #: :meta hide-value:
        ORIGINAL = Hidden()

    class ImageMeta(ABCMeta):
        """Type of all render style classes."""

        _forced_support: bool = False

        forced_support = ClassProperty(
            lambda self: self._forced_support,
            doc="""Forced render style support

            See the base instance of this metaclass for the complete description.
            """,
        )

        @forced_support.setter
        def forced_support(self, status: bool):
            if not isinstance(status, bool):
                raise arg_type_error("forced_support", status)

            self._forced_support = status

    class BaseImage(metaclass=ImageMeta):
        """Base of all render styles.

        Args:
            image: Source image.
            width: Can be

              * a positive integer; horizontal dimension of the image, in columns.
              * a :py:class:`~term_image.image.Size` enum member.

            height: Can be

              * a positive integer; vertical dimension of the image, in lines.
              * a :py:class:`~term_image.image.Size` enum member.

        Raises:
            TypeError: An argument is of an inappropriate type.
            ValueError: An argument is of an appropriate type but has an
              unexpected/invalid value.

        Propagates exceptions raised by :py:meth:`set_size`, if *width* or *height* is
        given.

        NOTE:
            * If neither *width* nor *height* is given (or both are ``None``),
              :py:attr:`~term_image.image.Size.FIT` applies.
            * If both width and height are not ``None``, they must be positive integers
              and :term:`manual sizing` applies i.e the image size is set as given without
              preserving aspect ratio.
            * For animated images, the seek position is initialized to the current seek
              position of the given image.
            * It's allowed to set properties for :term:`animated` images on non-animated
              ones, the values are simply ignored.

        ATTENTION:
            This class cannot be directly instantiated. Image instances should be created
            from its subclasses.
        """

        # Data Attributes

        _forced_support: bool = False
        _supported: Optional[bool] = None
        _render_method: Optional[str] = None
        _render_methods: Set[str] = set()
        _style_args: Dict[
            str, Tuple[Tuple[FunctionType, str], Tuple[FunctionType, str]]
        ] = {}

        # Special Methods

        def __init__(
            self,
            image: PIL.Image.Image,
            *,
            width: Union[int, Size, None] = None,
            height: Union[int, Size, None] = None,
        ) -> None:
            """See the class description"""
            if not isinstance(image, Image.Image):
                raise arg_type_error("image", image)
            if 0 in image.size:
                raise ValueError("'image' is null-sized")

            self._closed = False
            self._source = image
            self._source_type = ImageSource.PIL_IMAGE
            self._original_size = image.size
            if width is None is height:
                self.size = Size.FIT
            else:
                self.set_size(width, height)

            self._is_animated = hasattr(image, "is_animated") and image.is_animated
            if self._is_animated:
                self._frame_duration = (image.info.get("duration") or 100) / 1000
                self._seek_position = image.tell()
                self._n_frames = None

        def __del__(self) -> None:
            self.close()

        def __enter__(self) -> BaseImage:
            return self

        def __exit__(self, typ: type, val: Exception, tb: TracebackType) -> bool:
            self.close()
            return False  # Currently, no particular exception is suppressed

        def __format__(self, spec: str) -> str:
            """Renders the image with alignment, padding and transparency control"""
            # Only the currently set frame is rendered for animated images
            h_align, width, v_align, height, alpha, style_args = self._check_format_spec(
                spec
            )

            return self._format_render(
                self._renderer(self._render_image, alpha, **style_args),
                h_align,
                width,
                v_align,
                height,
            )

        def __iter__(self) -> ImageIterator:
            return ImageIterator(self, 1, "1.1", False)

        def __repr__(self) -> str:
            return "<{}: source_type={} size={} is_animated={}>".format(
                type(self).__name__,
                self._source_type.name,
                (
                    self._size.name
                    if isinstance(self._size, Size)
                    else "x".join(map(str, self._size))
                ),
                self._is_animated,
            )

        def __str__(self) -> str:
            """Renders the image with transparency enabled and without alignment"""
            # Only the currently set frame is rendered for animated images
            return self._renderer(self._render_image, _ALPHA_THRESHOLD)

        # Properties

        closed = property(
            lambda self: self._closed,
            doc="""Instance finalization status

            :type: bool

            GET:
                Returns ``True`` if the instance has been finalized (:py:meth:`close` has
                been called). Otherwise, ``False``.
            """,
        )

        forced_support = ClassProperty(
            lambda self: type(self)._forced_support,
            doc="""Forced render style support

            :type: bool

            GET:
                Returns the forced support status of the invoking class or class of the
                invoking instance.

            SET:
                Forced support is enabled or disabled for the invoking class.

                Can not be set on an instance.

            If forced support is:

            * **enabled**, the render style is treated as if it were supported,
              regardless of the return value of :py:meth:`is_supported`.
            * **disabled**, the return value of :py:meth:`is_supported` determines if
              the render style is supported or not.

            By **default**, forced support is **disabled** for all render style classes.

            NOTE:
                * This property is :term:`descendant`.
                * This doesn't affect the return value of :py:meth:`is_supported` but
                  may affect operations that require that a render style be supported e.g
                  instantiation of some render style classes.
            """,
        )

        frame_duration = property(
            lambda self: self._frame_duration if self._is_animated else None,
            doc="""Duration of a single frame

            :type: Optional[float]

            GET:
                Returns:

                * The duration of a single frame (in seconds), if the image is animated.
                * ``None``, if otherwise.

            SET:
                If the image is animated, The frame duration is set.
                Otherwise, nothing is done.
            """,
        )

        @frame_duration.setter
        def frame_duration(self, value: float) -> None:
            if not isinstance(value, float):
                raise arg_type_error("frame_duration", value)
            if value <= 0.0:
                raise arg_value_error_range("frame_duration", value)
            if self._is_animated:
                self._frame_duration = value

        height = property(
            lambda self: self._size if isinstance(self._size, Size) else self._size[1],
            lambda self, height: self.set_size(height=height),
            doc="""
            Image height

            :type: Union[Size, int]

            GET:
                Returns:

                * The image height (in lines), if the image size is
                  :term:`fixed <fixed size>`.
                * A :py:class:`~term_image.image.Size` enum member, if the image size
                  is :term:`dynamic <dynamic size>`.

            SET:
                If set to:

                * a positive :py:class:`int`; the image height is set to the given value
                  and the width is set proportionally.
                * a :py:class:`~term_image.image.Size` enum member; the image size is set
                  as prescibed by the enum member.
                * ``None``; equivalent to :py:attr:`~term_image.image.Size.FIT`.

                This results in a :term:`fixed size`.
            """,
        )

        is_animated = property(
            lambda self: self._is_animated,
            doc="""
            Animatability of the image

            :type: bool

            GET:
                Returns ``True`` if the image is :term:`animated`. Otherwise, ``False``.
            """,
        )

        original_size = property(
            lambda self: self._original_size,
            doc="""Size of the source (in pixels)

            :type: Tuple[int, int]

            GET:
                Returns the source size.
            """,
        )

        @property
        def n_frames(self) -> int:
            """Image frame count

            :type: int

            GET:
                Returns the number of frames the image has.
            """
            if not self._is_animated:
                return 1

            if not self._n_frames:
                img = self._get_image()
                try:
                    self._n_frames = img.n_frames
                finally:
                    self._close_image(img)

            return self._n_frames

        rendered_height = property(
            lambda self: (
                self._valid_size(None, self._size)
                if isinstance(self._size, Size)
                else self._size
            )[1],
            doc="""
            The height with which the image is :term:`rendered`

            :type: int

            GET:
                Returns the number of lines the image will occupy when drawn in a terminal.
            """,
        )

        rendered_size = property(
            lambda self: (
                self._valid_size(self._size, None)
                if isinstance(self._size, Size)
                else self._size
            ),
            doc="""
            The size with which the image is :term:`rendered`

            :type: Tuple[int, int]

            GET:
                Returns the number of columns and lines (respectively) the image will
                occupy when drawn in a terminal.
            """,
        )

        rendered_width = property(
            lambda self: (
                self._valid_size(self._size, None)
                if isinstance(self._size, Size)
                else self._size
            )[0],
            doc="""
            The width with which the image is :term:`rendered`

            :type: int

            GET:
                Returns the number of columns the image will occupy when drawn in a
                terminal.
            """,
        )

        size = property(
            lambda self: self._size,
            doc="""
            Image size

            :type: Union[Size, Tuple[int, int]]

            GET:
                Returns:

                * The image size, ``(columns, lines)``, if the image size is
                  :term:`fixed <fixed size>`.
                * A :py:class:`~term_image.image.Size` enum member, if the image size
                  is :term:`dynamic <dynamic size>`.

            SET:
                If set to a:

                * :py:class:`~term_image.image.Size` enum member, the image size is set
                  as prescibed by the given member.

                  This results in a :term:`dynamic size` i.e the size is computed whenever
                  the image is :term:`rendered` using the default :term:`frame size`.

                * 2-tuple of integers, ``(width, height)``, the image size set as given.

                  This results in a :term:`fixed size` i.e the size will not change until
                  it is re-set.
            """,
        )

        @size.setter
        def size(self, size: Size | Tuple[int, int]) -> None:
            if isinstance(size, Size):
                self._size = size
            elif isinstance(size, tuple):
                if len(size) != 2:
                    raise arg_value_error("size", size)
                self.set_size(*size)
            else:
                raise arg_type_error("size", size)

        source = property(
            _close_validated(lambda self: getattr(self, self._source_type.value)),
            doc="""
            Image :term:`source`

            :type: Union[PIL.Image.Image, str]

            GET:
                Returns the :term:`source` from which the instance was initialized.
            """,
        )

        source_type = property(
            lambda self: self._source_type,
            doc="""
            Image :term:`source` type

            :type: ImageSource

            GET:
                Returns the type of :term:`source` from which the instance was initialized.
            """,
        )

        width = property(
            lambda self: self._size if isinstance(self._size, Size) else self._size[0],
            lambda self, width: self.set_size(width),
            doc="""
            Image width

            :type: Union[Size, int]

            GET:
                Returns:

                * The image width (in columns), if the image size is
                  :term:`fixed <fixed size>`.
                * A :py:class:`~term_image.image.Size` enum member; if the image size
                  is :term:`dynamic <dynamic size>`.

            SET:
                If set to:

                * a positive :py:class:`int`; the image width is set to the given value
                  and the height is set proportionally.
                * a :py:class:`~term_image.image.Size` enum member; the image size is set
                  as prescibed by the enum member.
                * ``None``; equivalent to :py:attr:`~term_image.image.Size.FIT`.

                This results in a :term:`fixed size`.
            """,
        )

        # # Private

        @property
        @abstractmethod
        def _pixel_ratio(self):
            """The width-to-height ratio of a pixel drawn in the terminal"""
            raise NotImplementedError

        # Public Methods

        def close(self) -> None:
            """Finalizes the instance and releases external resources.

            * In most cases, it's not necessary to explicitly call this method, as it's
              automatically called when the instance is garbage-collected.
            * This method can be safely called multiple times.
            * If the instance was initialized with a PIL image, the PIL image is never
              finalized.
            """
            try:
                if not self._closed:
                    if self._source_type is ImageSource.URL:
                        try:
                            os.remove(self._source)
                        except FileNotFoundError:
                            pass
                        del self._url
                    del self._source
            except AttributeError:
                pass  # Instance creation or initialization was unsuccessful
            finally:
                self._closed = True

        def draw(
            self,
            h_align: Optional[str] = None,
            pad_width: int = 0,
            v_align: Optional[str] = None,
            pad_height: int = -2,
            alpha: Optional[float, str] = _ALPHA_THRESHOLD,
            *,
            animate: bool = True,
            repeat: int = -1,
            cached: Union[bool, int] = 100,
            scroll: bool = False,
            check_size: bool = True,
            **style: Any,
        ) -> None:
            """Draws the image to standard output.

            Args:
                h_align: Horizontal alignment ("left" / "<", "center" / "|" or
                  "right" / ">"). Default: center.
                pad_width: Number of columns within which to align the image.

                  * Excess columns are filled with spaces.
                  * Must not be greater than the :term:`terminal width`.

                v_align: Vertical alignment ("top"/"^", "middle"/"-" or "bottom"/"_").
                  Default: middle.
                pad_height: Number of lines within which to align the image.

                  * Excess lines are filled with spaces.
                  * Must not be greater than the :term:`terminal height`,
                    **for animations**.

                alpha: Transparency setting.

                  * If ``None``, transparency is disabled (alpha channel is removed).
                  * If a ``float`` (**0.0 <= x < 1.0**), specifies the alpha ratio
                    **above** which pixels are taken as **opaque**. **(Applies to only
                    text-based render styles)**.
                  * If a string, specifies a color to replace transparent background with.
                    Can be:

                    * **"#"** -> The terminal's default background color (or black, if
                      undetermined) is used.
                    * A hex color e.g ``ffffff``, ``7faa52``.

                animate: If ``False``, disable animation i.e draw only the current frame of
                  an animated image.
                repeat: The number of times to go over all frames of an animated image.
                  A negative value implies infinite repetition.
                cached: Determines if :term:`rendered` frames of an animated image will be
                  cached (for speed up of subsequent renders of the same frame) or not.

                  * If :py:class:`bool`, it directly sets if the frames will be cached or
                    not.
                  * If :py:class:`int`, caching is enabled only if the framecount of the
                    image is less than or equal to the given number.

                scroll: Only applies to non-animations. If ``True``, allows the image's
                  :term:`rendered height` to be greater than the :term:`terminal height`.
                check_size: If ``False``, rendered size validation is not performed for
                  non-animations. Does not affect padding size validation.
                style: Style-specific render parameters. See each subclass for it's own
                  usage.

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.
                term_image.exceptions.InvalidSizeError: The image's :term:`rendered size`
                  can not fit into the :term:`terminal size`.
                term_image.exceptions.StyleError: Unrecognized style-specific render
                  parameter(s).
                term_image.exceptions.RenderError: An error occurred during
                  :term:`rendering`.

            * If *pad_width* or *pad_height* is:

              * positive, it is **absolute** and used as-is.
              * non-positive, it is **relative** to the corresponding terminal dimension
                (**at the point of calling this method**) and equivalent to the absolute
                dimension ``max(terminal_dimension + frame_dimension, 1)``.

            * :term:`padding width` is always validated.
            * *animate*, *repeat* and *cached* apply to :term:`animated` images only.
              They are simply ignored for non-animated images.
            * For animations (i.e animated images with *animate* set to ``True``):

              * *scroll* is ignored.
              * Image size is always validated, if set.
              * :term:`Padding height` is always validated.

            * Animations, **by default**, are infinitely looped and can be terminated
              with :py:data:`~signal.SIGINT` (``CTRL + C``), **without** raising
              :py:class:`KeyboardInterrupt`.
            """
            fmt = self._check_formatting(h_align, pad_width, v_align, pad_height)

            if alpha is not None:
                if isinstance(alpha, float):
                    if not 0.0 <= alpha < 1.0:
                        raise arg_value_error_range("alpha", alpha)
                elif isinstance(alpha, str):
                    if not _ALPHA_BG_FORMAT.fullmatch(alpha):
                        raise arg_value_error_msg("Invalid hex color string", alpha)
                else:
                    raise arg_type_error("alpha", alpha)

            if self._is_animated and not isinstance(animate, bool):
                raise arg_type_error("animate", animate)

            terminal_width, terminal_height = get_terminal_size()
            if pad_width > terminal_width:
                raise arg_value_error_range(
                    "pad_width", pad_width, got_extra=f"terminal_width={terminal_width}"
                )

            animation = self._is_animated and animate

            if animation and pad_height > terminal_height:
                raise arg_value_error_range(
                    "pad_height",
                    pad_height,
                    got_extra=f"terminal_height={terminal_height}, animation={animation}",
                )

            for arg in ("scroll", "check_size"):
                arg_value = locals()[arg]
                if not isinstance(arg_value, bool):
                    raise arg_type_error(arg, arg_value)

            # Checks for *repeat* and *cached* are delegated to `ImageIterator`.

            def render(image: PIL.Image.Image) -> None:
                # Hide the cursor immediately if the output is a terminal device
                sys.stdout.isatty() and print(HIDE_CURSOR, end="", flush=True)
                try:
                    style_args = self._check_style_args(style)
                    if animation:
                        self._display_animated(
                            image, alpha, fmt, repeat, cached, **style_args
                        )
                    else:
                        try:
                            print(
                                self._format_render(
                                    self._render_image(image, alpha, **style_args),
                                    *fmt,
                                ),
                                end="",
                                flush=True,
                            )
                        except (KeyboardInterrupt, Exception):
                            self._handle_interrupted_draw()
                            raise
                finally:
                    # Reset color and show the cursor
                    print(SGR_NORMAL, SHOW_CURSOR * sys.stdout.isatty(), sep="")

            self._renderer(
                render,
                scroll=scroll,
                check_size=check_size,
                animated=animation,
            )

        @classmethod
        def from_file(
            cls,
            filepath: Union[str, os.PathLike],
            **kwargs: Union[None, int],
        ) -> BaseImage:
            """Creates an instance from an image file.

            Args:
                filepath: Relative/Absolute path to an image file.
                kwargs: Same keyword arguments as the class constructor.

            Returns:
                A new instance.

            Raises:
                TypeError: *filepath* is of an inappropriate type.
                FileNotFoundError: The given path does not exist.
                IsADirectoryError: Propagated from from :py:func:`PIL.Image.open`.
                PIL.UnidentifiedImageError: Propagated from from :py:func:`PIL.Image.open`.

            Also Propagates exceptions raised or propagated by the class constructor.
            """
            if not isinstance(filepath, (str, os.PathLike)):
                raise arg_type_error("filepath", filepath)

            if isinstance(filepath, os.PathLike):
                filepath = filepath.__fspath__()
                if isinstance(filepath, bytes):
                    filepath = filepath.decode()

            # Intentionally propagates `IsADirectoryError` since the message is OK
            try:
                img = Image.open(filepath)
            except FileNotFoundError:
                raise FileNotFoundError(f"No such file: {filepath!r}") from None
            except UnidentifiedImageError as e:
                e.args = (f"Could not identify {filepath!r} as an image",)
                raise

            with img:
                new = cls(img, **kwargs)
            # Absolute paths work better with symlinks, as opposed to real paths:
            # less confusing, Filename is as expected, helps in path comparisons
            new._source = os.path.abspath(filepath)
            new._source_type = ImageSource.FILE_PATH
            return new

        @classmethod
        def from_url(
            cls,
            url: str,
            **kwargs: Union[None, int],
        ) -> BaseImage:
            """Creates an instance from an image URL.

            Args:
                url: URL of an image file.
                kwargs: Same keyword arguments as the class constructor.

            Returns:
                A new instance.

            Raises:
                TypeError: *url* is not a string.
                ValueError: The URL is invalid.
                term_image.exceptions.URLNotFoundError: The URL does not exist.
                PIL.UnidentifiedImageError: Propagated from :py:func:`PIL.Image.open`.

            Also propagates connection-related exceptions from :py:func:`requests.get`
            and exceptions raised or propagated by the class constructor.

            NOTE:
                This method creates a temporary file, but only after successful
                initialization. The file is removed:

                - when :py:meth:`close` is called,
                - upon exiting a ``with`` statement block that uses the instance as a
                  context manager, or
                - when the instance is garbage collected.
            """
            if not isinstance(url, str):
                raise arg_type_error("url", url)
            if not all(urlparse(url)[:3]):
                raise arg_value_error_msg("Invalid URL", url)

            # Propagates connection-related errors.
            response = requests.get(url, stream=True)
            if response.status_code == 404:
                raise URLNotFoundError(f"URL {url!r} does not exist.")

            # Ensure initialization is successful before writing to file
            try:
                new = cls(Image.open(io.BytesIO(response.content)), **kwargs)
            except UnidentifiedImageError as e:
                e.args = (f"The URL {url!r} doesn't link to an identifiable image",)
                raise

            fd, filepath = mkstemp("-" + os.path.basename(url), dir=_TEMP_DIR)
            os.write(fd, response.content)
            os.close(fd)

            new._source = filepath
            new._source_type = ImageSource.URL
            new._url = url
            return new

        @classmethod
        @abstractmethod
        def is_supported(cls) -> bool:
            """Checks if the implemented :term:`render style` is supported by the
            :term:`active terminal`.

            Returns:
                ``True`` if the render style implemented by the invoking class is supported
                by the :term:`active terminal`. Otherwise, ``False``.

            ATTENTION:
                Support checks for most (if not all) render styles require :ref:`querying
                <terminal-queries>` the :term:`active terminal` the **first time** they're
                executed.

                Hence, it's advisable to perform all necessary support checks (call
                this method on required style classes) at an early stage of a program,
                before user input is expected. If using automatic style selection,
                calling :py:func:`~term_image.image.auto_image_class` only should be
                sufficient.
            """
            raise NotImplementedError

        def seek(self, pos: int) -> None:
            """Changes current image frame.

            Args:
                pos: New frame number.

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.

            Frame numbers start from 0 (zero).
            """
            if not isinstance(pos, int):
                raise arg_type_error("pos", pos)
            if not 0 <= pos < self.n_frames:
                raise arg_value_error_range("pos", pos, f"n_frames={self.n_frames}")
            if self._is_animated:
                self._seek_position = pos

        @ClassInstanceMethod
        def set_render_method(cls, method: Optional[str] = None) -> None:
            """Sets the :term:`render method` used by instances of a :term:`render style`
            class that implements multiple render methods.

            Args:
                method: The render method to be set or ``None`` for a reset
                  (case-insensitive).

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.

            See the **Render Methods** section in the description of subclasses that
            implement such for their specific usage.

            If *method* is not ``None`` and this method is called via:

            - a class, the class-wide render method is set.
            - an instance, the instance-specific render method is set.

            If *method* is ``None`` and this method is called via:

            - a class, the class-wide render method is unset, so that it uses that of
              its parent style class (if any) or the default.
            - an instance, the instance-specific render method is unset, so that it
              uses the class-wide render method thenceforth.

            Any instance without a render method set uses the class-wide render method.

            NOTE:
                *method* = ``None`` is always allowed, even if the render style doesn't
                implement multiple render methods.

                The **class-wide** render method is :term:`descendant`.
            """
            if method is not None and not isinstance(method, str):
                raise arg_type_error("method", method)
            if method is not None and method.lower() not in cls._render_methods:
                raise ValueError(f"Unknown render method {method!r} for {cls.__name__}")

            if not method:
                if cls._render_methods:
                    cls._render_method = cls._default_render_method
            else:
                cls._render_method = method

        @set_render_method.instancemethod
        def set_render_method(self, method: Optional[str] = None) -> None:
            if method is not None and not isinstance(method, str):
                raise arg_type_error("method", method)
            if method is not None and method.lower() not in type(self)._render_methods:
                raise ValueError(
                    f"Unknown render method {method!r} for {type(self).__name__}"
                )

            if not method:
                try:
                    del self._render_method
                except AttributeError:
                    pass
            else:
                self._render_method = method

        def set_size(
            self,
            width: Union[int, Size, None] = None,
            height: Union[int, Size, None] = None,
            frame_size: Tuple[int, int] = (0, -2),
        ) -> None:
            """Sets the image size (with extended control).

            Args:
                width: Can be

                  * a positive integer; horizontal dimension of the image, in columns.
                  * a :py:class:`~term_image.image.Size` enum member.

                height: Can be

                  * a positive integer; vertical dimension of the image, in lines.
                  * a :py:class:`~term_image.image.Size` enum member.

                frame_size: :term:`Frame size`, ``(columns, lines)``.
                  If *columns* or *lines* is

                  * positive, it is **absolute** and used as-is.
                  * non-positive, it is **relative** to the corresponding terminal dimension
                    and equivalent to the absolute dimension
                    ``max(terminal_dimension + frame_dimension, 1)``.

                  This is used only when neither *width* nor *height* is an ``int``.

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.

            * If both width and height are not ``None``, they must be positive integers
              and :term:`manual sizing` applies i.e the image size is set as given without
              preserving aspect ratio.
            * If *width* or *height* is a :py:class:`~term_image.image.Size` enum
              member, :term:`automatic sizing` applies as prescribed by the enum member.
            * If neither *width* nor *height* is given (or both are ``None``),
              :py:attr:`~term_image.image.Size.FIT` applies.
            """
            width_height = (width, height)
            for arg_name, arg_value in zip(("width", "height"), width_height):
                if not (arg_value is None or isinstance(arg_value, (Size, int))):
                    raise arg_type_error(arg_name, arg_value)
                if isinstance(arg_value, int) and arg_value <= 0:
                    raise arg_value_error_range(arg_name, arg_value)

            if width is not None is not height:
                if not all(isinstance(x, int) for x in width_height):
                    width_type = type(width).__name__
                    height_type = type(height).__name__
                    raise TypeError(
                        "Both 'width' and 'height' are specified but are not both integers "
                        f"(got: ({width_type}, {height_type}))"
                    )

                self._size = width_height
                return

            if not (
                isinstance(frame_size, tuple)
                and all(isinstance(x, int) for x in frame_size)
            ):
                raise arg_type_error("frame_size", frame_size)
            if not len(frame_size) == 2:
                raise arg_value_error("frame_size", frame_size)

            self._size = self._valid_size(width, height, frame_size)

        def tell(self) -> int:
            """Returns the current image frame number.

            :rtype: int
            """
            return self._seek_position if self._is_animated else 0

        # Private Methods

        @classmethod
        def _check_format_spec(
            cls, spec: str
        ) -> Tuple[
            str | None,
            int,
            str | None,
            int,
            Union[None, float, str],
            Dict[str, Any],
        ]:
            """Validates a format specifier and translates it into the required values.

            Returns:
                A tuple ``(h_align, width, v_align, height, alpha, style_args)`` containing
                values as required by ``_format_render()`` and ``_render_image()``.
            """
            match_ = _FORMAT_SPEC.fullmatch(spec)
            if not match_ or _NO_VERTICAL_SPEC.fullmatch(spec):
                raise arg_value_error_msg("Invalid format specifier", spec)

            (
                _,
                h_align,
                width,
                _,
                v_align,
                height,
                alpha,
                threshold_or_bg,
                _,
                style_spec,
            ) = match_.groups()

            return (
                *cls._check_formatting(
                    h_align,
                    int(width) if width else 0,
                    v_align,
                    int(height) if height else -2,
                ),
                (
                    threshold_or_bg
                    and (
                        "#" + threshold_or_bg.lstrip("#")
                        if _ALPHA_BG_FORMAT.fullmatch("#" + threshold_or_bg.lstrip("#"))
                        else float(threshold_or_bg)
                    )
                    if alpha
                    else _ALPHA_THRESHOLD
                ),
                style_spec and cls._check_style_format_spec(style_spec, style_spec) or {},
            )

        @staticmethod
        def _check_formatting(
            h_align: str | None = None,
            width: int = 0,
            v_align: str | None = None,
            height: int = -2,
        ) -> Tuple[str | None, int, str | None, int]:
            """Validates and transforms formatting arguments.

            Returns:
                The respective arguments appropriate for ``_format_render()``.
            """
            if not isinstance(h_align, (type(None), str)):
                raise arg_type_error("h_align", h_align)
            if None is not h_align not in set("<|>"):
                align = {"left": "<", "center": "|", "right": ">"}.get(h_align)
                if not align:
                    raise arg_value_error("h_align", h_align)
                h_align = align

            if not isinstance(v_align, (type(None), str)):
                raise arg_type_error("v_align", v_align)
            if None is not v_align not in set("^-_"):
                align = {"top": "^", "middle": "-", "bottom": "_"}.get(v_align)
                if not align:
                    raise arg_value_error("v_align", v_align)
                v_align = align

            terminal_size = get_terminal_size()

            if not isinstance(width, int):
                raise arg_type_error("pad_width", width)
            width = width if width > 0 else max(terminal_size.columns + width, 1)

            if not isinstance(height, int):
                raise arg_type_error("pad_height", height)
            height = height if height > 0 else max(terminal_size.lines + height, 1)

            return h_align, width, v_align, height

        @classmethod
        def _check_style_args(cls, style_args: Dict[str, Any]) -> Dict[str, Any]:
            """Validates style-specific arguments and translates them into the required
            values.

            Removes any argument having a value equal to the default.

            Returns:
                A mapping of keyword arguments.

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.
                term_image.exceptions.StyleError: An unknown style-specific parameter is
                  given.
            """
            for name, value in tuple(style_args.items()):
                try:
                    (
                        default,
                        (check_type, type_msg),
                        (check_value, value_msg),
                    ) = cls._style_args[name]
                except KeyError:
                    for other_cls in cls.__mro__:
                        # less costly than membership tests on every class' __bases__
                        if other_cls is __class__:
                            raise StyleError(
                                f"Unknown style-specific render parameter {name!r} for "
                                f"{cls.__name__!r}"
                            )

                        if not issubclass(
                            other_cls, __class__
                        ) or "_style_args" not in vars(other_cls):
                            continue

                        try:
                            (check_type, type_msg), (check_value, value_msg) = super(
                                other_cls, cls
                            )._style_args[name]
                            break
                        except KeyError:
                            pass
                    else:
                        raise StyleError(
                            f"Unknown style-specific render parameter {name!r} for "
                            f"{cls.__name__!r}"
                        )

                if not check_type(value):
                    raise TypeError(f"{type_msg} (got: {type(value).__name__})")
                if not check_value(value):
                    raise ValueError(f"{value_msg} (got: {value!r})")

                # Must not occur before type and value checks to avoid falling prey of
                # operator overloading
                if value == default:
                    del style_args[name]

            return style_args

        @classmethod
        def _check_style_format_spec(cls, spec: str, original: str) -> Dict[str, Any]:
            """Validates a style-specific format specifier and translates it into
            the required values.

            Returns:
                A mapping of keyword arguments.

            Raises:
                term_image.exceptions.StyleError: Invalid style-specific format specifier.

            **Every style-specific format spec should be handled as follows:**

            Every overriding method should call the overridden method (more on this below).
            At every step in the call chain, the specifier should be of the form::

                [parent] [current] [invalid]

            where:

            - *parent* is the portion to be interpreted at an higher level in the chain
            - *current* is the portion to be interpreted at the current level in the chain
            - the *invalid* portion determines the validity of the format spec

            Handle the portions in the order *invalid*, *parent*, *current*, so that
            validity can be determined before any further processing.

            At any point in the chain where the *invalid* portion exists (i.e is non-empty),
            the format spec can be correctly taken to be invalid.

            An overriding method must call the overridden method with the *parent* portion
            and the original format spec, **if** *parent* **is not empty**, such that every
            successful check ends up at `BaseImage._check_style_args()` or when *parent* is
            empty.

            :py:meth:`_get_style_format_spec` may be used to parse the format spec at each
            level of the call chain.
            """
            if spec:
                raise StyleError(
                    f"Invalid style-specific format specifier {original!r} "
                    f"for {cls.__name__!r}"
                    + (f", detected at {spec!r}" if spec != original else "")
                )
            return {}

        @classmethod
        def _clear_frame(cls) -> bool:
            """Clears an animation frame on-screen.

            Called by :py:meth:`_display_animated` just before drawing a new frame.

            | Only required by styles wherein an image is not overwritten by another image
              e.g some graphics-based styles.
            | The base implementation does nothing and should be overridden only if
              required.

            Returns:
                ``True`` if the frame was cleared. Otherwise, ``False``.
            """
            return False

        def _close_image(self, img: PIL.Image.Image) -> None:
            """Closes the given PIL image instance if it isn't the instance' source."""
            if img is not self._source:
                img.close()

        def _display_animated(
            self,
            img: PIL.Image.Image,
            alpha: Union[None, float, str],
            fmt: Tuple[str | None, int, str | None, int],
            repeat: int,
            cached: Union[bool, int],
            **style_args: Any,
        ) -> None:
            """Displays an animated GIF image in the terminal."""
            lines = max(fmt[-1], self.rendered_height)
            prev_seek_pos = self._seek_position
            duration = self._frame_duration
            image_it = ImageIterator(self, repeat, "", cached)
            image_it._animator = image_it._animate(img, alpha, fmt, style_args)
            cursor_up = CURSOR_UP % (lines - 1)
            cursor_down = CURSOR_DOWN % lines

            try:
                print(next(image_it._animator), end="", flush=True)  # First frame

                # Render next frame during current frame's duration
                start = time.time()
                for frame in image_it._animator:  # Renders next frame
                    # Left-over of current frame's duration
                    time.sleep(max(0, duration - (time.time() - start)))

                    # Clear the current frame, if necessary,
                    # move cursor up to the beginning of the first line of the image
                    # and print the new current frame.
                    self._clear_frame()
                    print("\r", cursor_up, frame, sep="", end="", flush=True)

                    # Render next frame during current frame's duration
                    start = time.time()
            except KeyboardInterrupt:
                self._handle_interrupted_draw()
            except Exception:
                self._handle_interrupted_draw()
                raise
            finally:
                image_it.close()
                self._close_image(img)
                self._seek_position = prev_seek_pos
                # Move the cursor to the last line of the image to prevent "overlaid"
                # output in the terminal
                print(cursor_down, end="")

        def _format_render(
            self,
            render: str,
            h_align: str | None,
            width: int,
            v_align: str | None,
            height: int,
        ) -> str:
            """Pads and aligns a primary :term:`render` output.

            NOTE:
                * All arguments should be passed through ``_check_formatting()`` first.
                * Only **absolute** padding dimensions are expected.
            """
            cols, lines = self.rendered_size

            if width > cols:
                if h_align == "<":  # left
                    left = ""
                    right = " " * (width - cols)
                elif h_align == ">":  # right
                    left = " " * (width - cols)
                    right = ""
                else:  # center
                    left = " " * ((width - cols) // 2)
                    right = " " * (width - cols - len(left))
                render = render.replace("\n", f"{right}\n{left}")
            else:
                left = right = ""

            if height > lines:
                if v_align == "^":  # top
                    top = 0
                    bottom = height - lines
                elif v_align == "_":  # bottom
                    top = height - lines
                    bottom = 0
                else:  # middle
                    top = (height - lines) // 2
                    bottom = height - lines - top
                top = f"{' ' * width}\n" * top
                bottom = f"\n{' ' * width}" * bottom
            else:
                top = bottom = ""

            return (
                "".join((top, left, render, right, bottom))
                if width > cols or height > lines
                else render
            )

        @_close_validated
        def _get_image(self) -> PIL.Image.Image:
            """Returns the PIL image instance corresponding to the image source as-is"""
            return (
                Image.open(self._source) if isinstance(self._source, str) else self._source
            )

        def _get_render_data(
            self,
            img: PIL.Image.Image,
            alpha: Union[None, float, str],
            *,
            size: Optional[Tuple[int, int]] = None,
            pixel_data: bool = True,
            round_alpha: bool = False,
            frame: bool = False,
        ) -> Tuple[
            PIL.Image.Image, Optional[List[Tuple[int, int, int]]], Optional[List[int]]
        ]:
            """Returns the PIL image instance and pixel data required to render an image.

            Args:
                size: If given (in pixels), it is used instead of the pixel-equivalent of
                  the image size.
                pixel_data: If ``False``, ``None`` is returned for all pixel data.
                round_alpha: Only applies when *alpha* is a ``float``.

                  If ``True``, returned alpha values are bi-level (``0`` or ``255``), based
                  on the given alpha threshold.
                  Also, the image is blended with the active terminal's BG color (or black,
                  if undetermined) while leaving the alpha intact.

                frame: If ``True``, implies *img* is being used by :py:class`ImageIterator`,
                  hence, *img* is not closed.

            The returned image is appropriately converted, resized and composited
            (if need be).

            The pixel data are the last two items of the returned tuple ``(rgb, a)``, where:
              * ``rgb`` is a list of ``(r, g, b)`` tuples containing the colour channels of
                the image's pixels in a flattened row-major order where ``r``, ``g``, ``b``
                are integers in the range [0, 255].
              * ``a`` is a list of integers in the range [0, 255] representing the alpha
                channel of the image's pixels in a flattened row-major order.
            """

            def convert_resize_img(mode: str):
                nonlocal img

                if img.mode != mode:
                    prev_img = img
                    try:
                        img = img.convert(mode)
                    # Possible for images in some modes e.g "La"
                    except Exception as e:
                        raise RenderError("Unable to convert image") from e
                    finally:
                        if frame_img is not prev_img:
                            self._close_image(prev_img)

                if img.size != size:
                    prev_img = img
                    try:
                        img = img.resize(size, Image.Resampling.BOX)
                    # Highly unlikely since render size can never be zero
                    except Exception as e:
                        raise RenderError("Unable to resize image") from e
                    finally:
                        if frame_img is not prev_img:
                            self._close_image(prev_img)

            frame_img = img if frame else None
            if self._is_animated:
                img.seek(self._seek_position)
            if not size:
                size = self._get_render_size()

            if alpha is None or img.mode in {"1", "L", "RGB", "HSV", "CMYK"}:
                convert_resize_img("RGB")
                if pixel_data:
                    rgb = list(img.getdata())
                    a = [255] * mul(*size)
            else:
                convert_resize_img("RGBA")
                if isinstance(alpha, str):
                    if alpha == "#":
                        alpha = get_fg_bg_colors(hex=True)[1] or "#000000"
                    bg = Image.new("RGBA", img.size, alpha)
                    bg.alpha_composite(img)
                    if frame_img is not img:
                        self._close_image(img)
                    img = bg.convert("RGB")
                    if pixel_data:
                        a = [255] * mul(*size)
                else:
                    if pixel_data:
                        a = list(img.getdata(3))
                        if round_alpha:
                            alpha = round(alpha * 255)
                            a = [0 if val < alpha else 255 for val in a]
                    if round_alpha:
                        bg = Image.new(
                            "RGBA", img.size, get_fg_bg_colors(hex=True)[1] or "#000000"
                        )
                        bg.alpha_composite(img)
                        bg.putalpha(img.getchannel("A"))
                        if frame_img is not img:
                            self._close_image(img)
                        img = bg

                if pixel_data:
                    rgb = list((img if img.mode == "RGB" else img.convert("RGB")).getdata())

            return (img, *(pixel_data and (rgb, a) or (None, None)))

        @abstractmethod
        def _get_render_size(self) -> Tuple[int, int]:
            """Returns the size (in pixels) required to render the image."""
            raise NotImplementedError

        @classmethod
        def _get_style_format_spec(
            cls, spec: str, original: str
        ) -> Tuple[str, List[Union[None, str, Tuple[Optional[str]]]]]:
            """Parses a style-specific format specifier.

            See :py:meth:`_check_format_spec`.

            Returns:
                The *parent* portion and a list of matches for the respective fields of the
                *current* portion of the spec.

                * Any absent field of *current* is ``None``.
                * For a field containing groups, the match, if present, is a tuple
                  containing the full match followed by the matches for each group.
                * All matches are in the same order as the fields (including their groups).

            Raises:
                term_image.exceptions.StyleError: The *invalid* portion exists.

            NOTE:
                Please avoid common fields in the format specs of parent and child classes
                (i.e fields that can match the same portion of a given string) as they
                result in ambiguities.
            """
            patterns = iter(cls._FORMAT_SPEC)
            fields = []
            for pattern in patterns:
                match = pattern.search(spec)
                if match:
                    fields.append(
                        (match.group(), *match.groups())
                        if pattern.groups
                        else match.group()
                    )
                    start = match.start()
                    end = match.end()
                    break
                else:
                    fields.append(
                        (None,) * (pattern.groups + 1) if pattern.groups else None
                    )
            else:
                start = end = len(spec)

            for pattern in patterns:
                match = pattern.match(spec, pos=end)
                if match:
                    fields.append(
                        (match.group(), *match.groups())
                        if pattern.groups
                        else match.group()
                    )
                    end = match.end()
                else:
                    fields.append(
                        (None,) * (pattern.groups + 1) if pattern.groups else None
                    )

            parent, invalid = spec[:start], spec[end:]
            if invalid:
                raise StyleError(
                    f"Invalid style-specific format specifier {original!r} "
                    f"for {cls.__name__!r}, detected at {invalid!r}"
                )

            return parent, fields

        @staticmethod
        def _handle_interrupted_draw():
            """Performs any necessary actions when image drawing is interrupted."""

        @staticmethod
        @abstractmethod
        def _pixels_cols(
            *, pixels: Optional[int] = None, cols: Optional[int] = None
        ) -> int:
            """Returns the number of pixels represented by a given number of columns
            or vice-versa.
            """
            raise NotImplementedError

        @staticmethod
        @abstractmethod
        def _pixels_lines(
            *, pixels: Optional[int] = None, lines: Optional[int] = None
        ) -> int:
            """Returns the number of pixels represented by a given number of lines
            or vice-versa.
            """
            raise NotImplementedError

        @abstractmethod
        def _render_image(
            self,
            img: PIL.Image.Image,
            alpha: Union[None, float, str],
            *,
            frame: bool = False,  # For `ImageIterator`
        ) -> str:
            """Converts an image into a string which reproduces the image when printed
            to the terminal.

            NOTE:
                This method is not meant to be used directly, use it via `_renderer()`
                instead.
            """
            raise NotImplementedError

        def _renderer(
            self,
            renderer: FunctionType,
            *args: Any,
            scroll: bool = False,
            check_size: bool = False,
            animated: bool = False,
            **kwargs,
        ) -> Any:
            """Performs common render preparations and a rendering operation.

            Args:
                renderer: The function to perform the specific rendering operation for the
                  caller of this method, ``_renderer()``.

                  This function must accept at least one positional argument, the
                  :py:class:`PIL.Image.Image` instance corresponding to the source.

                args: Positional arguments to pass on to *renderer*, after the
                  :py:class:`PIL.Image.Image` instance.
                scroll: See *scroll* in :py:meth:`draw`.
                check_size: See *check_size* in :py:meth:`draw`.
                animated: If ``True``, *scroll* and *check_size* are ignored and the size
                  is validated.
                kwargs: Keyword arguments to pass on to *renderer*.

            Returns:
                The return value of *renderer*.

            Raises:
                term_image.exceptions.InvalidSizeError: *check_size* or *animated* is
                  ``True`` and the image's :term:`rendered size` can not fit into the
                  :term:`terminal size`.
                term_image.exceptions.TermImageError: The image has been finalized.
            """
            _size = self._size
            try:
                if isinstance(_size, Size):
                    self.set_size(_size)
                elif check_size or animated:
                    terminal_size = get_terminal_size()
                    if any(
                        map(
                            gt,
                            # The compared height will be 0 if *scroll* is `True`.
                            # So, the height comparison will always be `False`
                            # since the terminal height should never be < 0.
                            map(mul, self.rendered_size, (1, not scroll)),
                            terminal_size,
                        )
                    ):
                        raise InvalidSizeError(
                            "The "
                            + ("animation" if animated else "image")
                            + " cannot fit into the terminal size"
                        )

                    # Reaching here means it's either valid or *scroll* is `True`.
                    if animated and self.rendered_height > terminal_size.lines:
                        raise InvalidSizeError(
                            "The rendered height is greater than the terminal height for "
                            "an animation"
                        )

                return renderer(self._get_image(), *args, **kwargs)

            finally:
                if isinstance(_size, Size):
                    self.size = _size

        def _valid_size(
            self,
            width: Union[int, Size, None] = None,
            height: Union[int, Size, None] = None,
            frame_size: Tuple[int, int] = (0, -2),
        ) -> Tuple[int, int]:
            """Returns an image size tuple.

            See :py:meth:`set_size` for the description of the parameters.
            """
            ori_width, ori_height = self._original_size
            columns, lines = map(
                lambda frame_dim, terminal_dim: (
                    frame_dim if frame_dim > 0 else max(terminal_dim + frame_dim, 1)
                ),
                frame_size,
                get_terminal_size(),
            )
            frame_width = self._pixels_cols(cols=columns)
            frame_height = self._pixels_lines(lines=lines)

            # As for cell ratio...
            #
            # Take for example, pixel ratio = 2.0
            # (i.e cell ratio = 1.0; square character cells).
            # To adjust the image to the proper scale, we either reduce the
            # width (i.e divide by 2.0) or increase the height (i.e multiply by 2.0).
            #
            # On the other hand, if the pixel ratio = 0.5
            # (i.e cell ratio = 0.25; vertically oblong character cells).
            # To adjust the image to the proper scale, we either increase the width
            # (i.e divide by the 0.5) or reduce the height (i.e multiply by the 0.5).
            #
            # Therefore, for the height, we always multiply by the pixel ratio
            # and for the width, we always divide by the pixel ratio.
            # The non-constraining axis is always the one directly adjusted.

            if all(not isinstance(x, int) for x in (width, height)):
                if Size.AUTO in (width, height):
                    width = height = (
                        Size.FIT
                        if (
                            ori_width > frame_width
                            or round(ori_height * self._pixel_ratio) > frame_height
                        )
                        else Size.ORIGINAL
                    )
                elif Size.FIT_TO_WIDTH in (width, height):
                    return (
                        self._pixels_cols(pixels=frame_width) or 1,
                        self._pixels_lines(
                            pixels=round(
                                self._width_height_px(w=frame_width) * self._pixel_ratio
                            )
                        )
                        or 1,
                    )

                if Size.ORIGINAL in (width, height):
                    return (
                        self._pixels_cols(pixels=ori_width) or 1,
                        self._pixels_lines(pixels=round(ori_height * self._pixel_ratio))
                        or 1,
                    )

                # The smaller fraction will fit on both axis.
                # Hence, the axis with the smaller ratio is the constraining axis.
                # Constraining by the axis with the larger ratio will cause the image
                # to not fit into the axis with the smaller ratio.
                width_ratio = frame_width / ori_width
                height_ratio = frame_height / ori_height
                smaller_ratio = min(width_ratio, height_ratio)

                # Set the dimension on the constraining axis to exactly its corresponding
                # frame dimension and the dimension on the other axis to the same ratio of
                # its corresponding original image dimension
                _width_px = ori_width * smaller_ratio
                _height_px = ori_height * smaller_ratio

                # The cell ratio should directly affect the non-constraining axis since the
                # constraining axis is already fully occupied at this point
                if height_ratio > width_ratio:
                    _height_px = _height_px * self._pixel_ratio
                    # If height becomes greater than the max, reduce it to the max
                    height_px = min(_height_px, frame_height)
                    # Calculate the corresponding width
                    width_px = round((height_px / _height_px) * _width_px)
                    # Round the height
                    height_px = round(height_px)
                else:
                    _width_px = _width_px / self._pixel_ratio
                    # If width becomes greater than the max, reduce it to the max
                    width_px = min(_width_px, frame_width)
                    # Calculate the corresponding height
                    height_px = round((width_px / _width_px) * _height_px)
                    # Round the width
                    width_px = round(width_px)
                return (
                    self._pixels_cols(pixels=width_px) or 1,
                    self._pixels_lines(pixels=height_px) or 1,
                )
            elif width is None:
                width_px = round(
                    self._width_height_px(h=self._pixels_lines(lines=height))
                    / self._pixel_ratio
                )
                width = self._pixels_cols(pixels=width_px)
            elif height is None:
                height_px = round(
                    self._width_height_px(w=self._pixels_cols(cols=width))
                    * self._pixel_ratio
                )
                height = self._pixels_lines(pixels=height_px)

            return (width or 1, height or 1)

        def _width_height_px(
            self, *, w: Optional[int] = None, h: Optional[int] = None
        ) -> float:
            """Converts the given width (in pixels) to the **unrounded** proportional height
            (in pixels) OR vice-versa.
            """
            ori_width, ori_height = self._original_size
            return (
                (w / ori_width) * ori_height
                if w is not None
                else (h / ori_height) * ori_width
            )

    class GraphicsImage(BaseImage):
        """Base of all :ref:`graphics-based`.

        Raises:
            term_image.exceptions.StyleError: The :term:`active terminal` doesn't support
              the render style.

        See :py:class:`BaseImage` for the description of the constructor.

        ATTENTION:
            This class cannot be directly instantiated. Image instances should be created
            from its subclasses.

        TIP:
            To allow instantiation regardless of whether the render style is supported or
            not, enable :py:attr:`~term_image.image.BaseImage.forced_support`.
        """

        # Size unit conversion already involves cell size calculation
        _pixel_ratio: float = 1.0

        def __new__(
            cls,
            image: PIL.Image.Image,
            *,
            width: Union[int, Size, None] = None,
            height: Union[int, Size, None] = None,
        ) -> None:
            # calls `is_supported()` first to set required class attributes, in case
            # support is forced for a style that is actually supported
            if not (cls.is_supported() or cls._forced_support):
                raise StyleError(
                    f"{cls.__name__!r} is not supported in the active terminal"
                )
            return super().__new__(cls)

        def _get_minimal_render_size(self, *, adjust: bool = False) -> Tuple[int, int]:
            render_size = self._get_render_size()
            r_height = self.rendered_height
            width, height = (
                render_size
                if mul(*render_size) < mul(*self._original_size)
                else self._original_size
            )

            # When `_original_size` is used, ensure the height is a multiple of the rendered
            # height, so that pixels can be evenly distributed among all lines.
            # If r_height == 0, height == 0, extra == 0; Handled in `_get_render_data()`.
            if adjust:
                extra = height % (r_height or 1)
                if extra:
                    # Incremented to the greater multiple to avoid losing any data
                    height = height - extra + r_height

            return width, height

        def _get_render_size(self) -> Tuple[int, int]:
            return tuple(map(mul, self.rendered_size, get_cell_size() or (1, 2)))

        @staticmethod
        def _pixels_cols(
            *, pixels: Optional[int] = None, cols: Optional[int] = None
        ) -> int:
            return (
                ceil(pixels // (get_cell_size() or (1, 2))[0])
                if pixels is not None
                else cols * (get_cell_size() or (1, 2))[0]
            )

        @staticmethod
        def _pixels_lines(
            *, pixels: Optional[int] = None, lines: Optional[int] = None
        ) -> int:
            return (
                ceil(pixels // (get_cell_size() or (1, 2))[1])
                if pixels is not None
                else lines * (get_cell_size() or (1, 2))[1]
            )

    class TextImage(BaseImage):
        """Base of all :ref:`text-based`.

        See :py:class:`BaseImage` for the description of the constructor.

        IMPORTANT:
            Instantiation of subclasses is always allowed, even if the current terminal
            does not [fully] support the render style.

            To check if the render style is fully supported in the current terminal, use
            :py:meth:`is_supported() <BaseImage.is_supported>`.

        ATTENTION:
            This class cannot be directly instantiated. Image instances should be created
            from its subclasses.
        """

        # Pixels are represented in a 1-to-2 ratio within one character cell
        # pixel-size == width * height/2
        # pixel-ratio == width / (height/2) == 2 * (width / height) == 2 * cell-ratio
        _pixel_ratio = property(lambda _: get_cell_ratio() * 2)

        @staticmethod
        @cached
        def _is_on_kitty() -> bool:
            return get_terminal_name_version()[0] == "kitty"

        @abstractmethod
        def _render_image(
            self,
            img: PIL.Image.Image,
            alpha: Union[None, float, str],
            *,
            frame: bool = False,
            split_cells: bool = False,  # For internal use only
        ) -> str:
            """
            See :py:meth:`BaseImage._render_image` for the description of the method and
            all other parameters not described here.

            Args:
                split_cells: If ``True``, the cells of the image are separated by a
                  ``NULL`` ("\\0").

                  - must be defined and implemented by every text-based style
                    (i.e subclasses of this class).
                  - required by some other parts of the library.
                  - only used internally, across the library.
            """
            raise NotImplementedError

    class ImageIterator:
        """Efficiently iterate over :term:`rendered` frames of an :term:`animated` image

        Args:
            image: Animated image.
            repeat: The number of times to go over the entire image. A negative value
              implies infinite repetition.
            format_spec: The :ref:`format specifier <format-spec>` for the rendered
              frames (default: auto).
            cached: Determines if the :term:`rendered` frames will be cached (for speed up
              of subsequent renders) or not. If it is

              * a boolean, caching is enabled if ``True``. Otherwise, caching is disabled.
              * a positive integer, caching is enabled only if the framecount of the image
                is less than or equal to the given number.

        Raises:
            TypeError: An argument is of an inappropriate type.
            ValueError: An argument is of an appropriate type but has an
              unexpected/invalid value.
            term_image.exceptions.StyleError: Invalid style-specific format specifier.

        * If *repeat* equals ``1``, caching is disabled.
        * The iterator has immediate response to changes in the image size.
        * If the image size is :term:`dynamic <dynamic size>`, it's computed per frame.
        * The number of the last yielded frame is set as the image's seek position.
        * Directly adjusting the seek position of the image doesn't affect iteration.
          Use :py:meth:`ImageIterator.seek` instead.
        * After the iterator is exhausted, the underlying image is set to frame ``0``.
        """

        def __init__(
            self,
            image: BaseImage,
            repeat: int = -1,
            format_spec: str = "",
            cached: Union[bool, int] = 100,
        ) -> None:
            if not isinstance(image, BaseImage):
                raise arg_type_error("image", image)
            if not image._is_animated:
                raise ValueError("'image' is not animated")

            if not isinstance(repeat, int):
                raise arg_type_error("repeat", repeat)
            if not repeat:
                raise arg_value_error("repeat", repeat)

            if not isinstance(format_spec, str):
                raise arg_type_error("format_spec", format_spec)
            *fmt, alpha, style_args = image._check_format_spec(format_spec)

            if not isinstance(cached, int):  # `bool` is a subclass of `int`
                raise arg_type_error("cached", cached)
            if False is not cached <= 0:
                raise arg_value_error_range("cached", cached)

            self._image = image
            self._repeat = repeat
            self._format = format_spec
            self._cached = repeat != 1 and (
                cached if isinstance(cached, bool) else image.n_frames <= cached
            )
            self._loop_no = None
            self._animator = image._renderer(
                self._animate, alpha, fmt, style_args, check_size=False
            )

        def __del__(self) -> None:
            self.close()

        def __iter__(self) -> ImageIterator:
            return self

        def __next__(self) -> str:
            try:
                return next(self._animator)
            except StopIteration:
                self.close()
                raise StopIteration(
                    "Iteration has reached the given repeat count"
                ) from None
            except AttributeError as e:
                if str(e).endswith("'_animator'"):
                    raise StopIteration("Iterator exhausted or closed") from None
                else:
                    self.close()
                    raise
            except Exception:
                self.close()
                raise

        def __repr__(self) -> str:
            return (
                "{}(image={!r}, repeat={}, format_spec={!r}, cached={}, loop_no={})".format(
                    type(self).__name__,
                    *self.__dict__.values(),
                )
            )

        loop_no = property(
            lambda self: self._loop_no,
            doc="""Iteration repeat countdown

            :type: Optional[int]

            GET:
                Returns:

                * ``None``, if iteration hasn't started.
                * Otherwise, the current iteration repeat countdown value.

            Changes on the first iteration of each loop, except for infinite iteration
            where it's always ``-1``. When iteration has ended, the value is zero.
            """,
        )

        def close(self) -> None:
            """Closes the iterator and releases resources used.

            Does not reset the frame number of the underlying image.

            NOTE:
                This method is automatically called when the iterator is exhausted or
                garbage-collected.
            """
            try:
                self._animator.close()
                del self._animator
                self._image._close_image(self._img)
                del self._img
            except AttributeError:
                pass

        def seek(self, pos: int) -> None:
            """Sets the frame number to be yielded on the next iteration without affecting
            the repeat count.

            Args:
                pos: Next frame number.

            Raises:
                TypeError: An argument is of an inappropriate type.
                ValueError: An argument is of an appropriate type but has an
                  unexpected/invalid value.
                term_image.exceptions.TermImageError: Iteration has not yet started or the
                  iterator is exhausted/closed.

            Frame numbers start from ``0`` (zero).
            """
            if not isinstance(pos, int):
                raise arg_type_error("pos", pos)
            if not 0 <= pos < self._image.n_frames:
                raise arg_value_error_range("pos", pos, f"n_frames={self._image.n_frames}")

            try:
                self._animator.send(pos)
            except TypeError:
                raise TermImageError("Iteration has not yet started") from None
            except AttributeError:
                raise TermImageError("Iterator exhausted or closed") from None

        def _animate(
            self,
            img: PIL.Image.Image,
            alpha: Union[None, float, str],
            fmt: Tuple[Union[None, str, int]],
            style_args: Dict[str, Any],
        ) -> Generator[str, int, None]:
            """Returns a generator that yields rendered and formatted frames of the
            underlying image.
            """
            self._img = img  # For cleanup
            image = self._image
            cached = self._cached
            self._loop_no = repeat = self._repeat
            if cached:
                cache = [(None,) * 2] * image.n_frames

            sent = None
            n = 0
            while repeat:
                if sent is None:
                    image._seek_position = n
                    try:
                        frame = image._format_render(
                            image._render_image(img, alpha, frame=True, **style_args), *fmt
                        )
                    except EOFError:
                        image._seek_position = n = 0
                        if repeat > 0:  # Avoid infinitely large negative numbers
                            self._loop_no = repeat = repeat - 1
                        if cached:
                            break
                        continue
                    else:
                        if cached:
                            cache[n] = (frame, hash(image.rendered_size))

                sent = yield frame
                n = n + 1 if sent is None else sent - 1

            if cached:
                n_frames = len(cache)
            while repeat:
                while n < n_frames:
                    if sent is None:
                        image._seek_position = n
                        frame, size_hash = cache[n]
                        if hash(image.rendered_size) != size_hash:
                            frame = image._format_render(
                                image._render_image(img, alpha, frame=True, **style_args),
                                *fmt,
                            )
                            cache[n] = (frame, hash(image.rendered_size))

                    sent = yield frame
                    n = n + 1 if sent is None else sent - 1

                image._seek_position = n = 0
                if repeat > 0:  # Avoid infinitely large negative numbers
                    self._loop_no = repeat = repeat - 1

            # For consistency in behaviour
            if img is image._source:
                img.seek(0)

    @atexit.register
    def _cleanup_temp_dir():
        rmtree(_TEMP_DIR, ignore_errors=True)