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__


[docs] 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")
[docs] 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
[docs] 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
[docs] 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
[docs] 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, )
[docs] @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. Propagates exceptions raised (or propagated) by :py:func:`PIL.Image.open` and 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
[docs] @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
[docs] @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
[docs] 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
[docs] @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
[docs] 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)
[docs] 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 )
[docs] 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] )
[docs] 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
[docs] 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. """, )
[docs] 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
[docs] 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)