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