from __future__ import annotations
__all__ = ("ITerm2Image",)
import io
import os
import re
import sys
import warnings
from base64 import standard_b64encode
from operator import mul
from typing import Any, Dict, Optional, Set, Tuple, Union
import PIL
from .. import _ctlseqs as ctlseqs
# These sequences are used during performance-critical operations that occur often
from .._ctlseqs import CURSOR_FORWARD, CURSOR_UP, ERASE_CHARS, ITERM2_START, ST
from ..exceptions import RenderError, TermImageUserWarning
from ..utils import (
ClassInstanceProperty,
ClassProperty,
arg_type_error,
arg_value_error_range,
get_terminal_name_version,
write_tty,
)
from .common import GraphicsImage, ImageMeta, ImageSource
# Constants for render methods
LINES = "lines"
WHOLE = "whole"
ANIM = "anim"
class ITerm2ImageMeta(ImageMeta):
"""Type of iterm2 render style classes."""
__native_anim_max_bytes = _native_anim_max_bytes = 2 * 2**20 # 2 MiB default
jpeg_quality = ClassInstanceProperty(
lambda self: getattr(self, "_jpeg_quality", -1),
doc="""JPEG encoding quality
See the base instance of this metaclass for the complete description.
""",
)
@jpeg_quality.setter
def jpeg_quality(self, quality: int) -> None:
if not isinstance(quality, int):
raise arg_type_error("jpeg_quality", quality)
if quality > 95:
raise arg_value_error_range("jpeg_quality", quality)
self._jpeg_quality = quality
@jpeg_quality.deleter
def jpeg_quality(self) -> None:
try:
del self._jpeg_quality
except AttributeError:
pass
native_anim_max_bytes = ClassProperty(
lambda self: __class__._native_anim_max_bytes,
doc="""Maximum size (in bytes) of image data for native animation
See the base instance of this metaclass for the complete description.
""",
)
@native_anim_max_bytes.setter
def native_anim_max_bytes(self, max_bytes: int):
if not isinstance(max_bytes, int):
raise arg_type_error("native_anim_max_bytes", max_bytes)
if max_bytes <= 0:
raise arg_value_error_range("native_anim_max_bytes", max_bytes)
__class__._native_anim_max_bytes = max_bytes
@native_anim_max_bytes.deleter
def native_anim_max_bytes(self):
__class__._native_anim_max_bytes = __class__.__native_anim_max_bytes
read_from_file = ClassInstanceProperty(
lambda self: getattr(self, "_read_from_file", True),
doc="""Read-from-file optimization
See the base instance of this metaclass for the complete description.
""",
)
@read_from_file.setter
def read_from_file(self, policy: bool) -> None:
if not isinstance(policy, bool):
raise arg_type_error("read_from_file", policy)
self._read_from_file = policy
@read_from_file.deleter
def read_from_file(self) -> None:
try:
del self._read_from_file
except AttributeError:
pass
[docs]
class ITerm2Image(GraphicsImage, metaclass=ITerm2ImageMeta):
"""A render style using the iTerm2 inline image protocol.
See :py:class:`GraphicsImage` for the complete description of the constructor.
|
**Render Methods**
:py:class:`ITerm2Image` provides two methods of :term:`rendering` images, namely:
LINES (default)
Renders an image line-by-line i.e the image is evenly split across the number
of lines it should occupy.
Pros:
* Good for use cases where it might be required to trim some lines of the image.
Cons:
* Image drawing is significantly slower on iTerm2 due to the terminal emulator's
performance.
WHOLE
Renders an image all at once i.e the entire image data is encoded into one
line of the :term:`render` output, such that the entire image is drawn once
by the terminal and still occupies the correct amount of lines and columns.
Pros:
* Render results are more compact (i.e less in character count) than with
the **LINES** method since the entire image is encoded at once.
* Image drawing is faster than with **LINES** on most terminals.
* Smoother animations.
Cons:
* This method currently doesn't work well on iTerm2 and WezTerm when the image
height is greater than the terminal height.
ANIM
Renders an animated image to utilize the protocol's native animation feature
[1]_.
Similar to the **WHOLE** render method, except that the terminal emulator
animates the image, provided it supports the feature of the protocol.
The animation is completely controlled by the terminal emulator.
.. note::
* If the image data size (in bytes) is greater than the value of
:py:attr:`native_anim_max_bytes`, a warning is issued.
* If used with :py:class:`~term_image.image.ImageIterator` or an animation,
the **WHOLE** render method is used instead.
* If the image is non-animated, the **WHOLE** render method is used instead.
NOTE:
The **LINES** method is the default only because it works properly in all cases,
it's more advisable to use the **WHOLE** method except when the image height is
greater than the terminal height or when trimming the image is required.
The render method can be set with
:py:meth:`set_render_method() <BaseImage.set_render_method>` using the names
specified above.
|
**Style-Specific Render Parameters**
See :py:meth:`BaseImage.draw` (particularly the *style* parameter).
* **method** (*None | str*) → Render method override.
* ``None`` → the current effective render method of the instance is used.
* *default* → ``None``
* **mix** (*bool*) → Cell content inter-mix policy (**Only supported on WezTerm**,
ignored otherwise).
* ``False`` → existing contents of cells within the region covered by
the drawn render output are erased
* ``True`` → existing cell contents show under transparent areas of the
drawn render output
* *default* → ``False``
* **compress** (*int*) → ZLIB compression level, for renders re-encoded in PNG
format.
* ``0`` <= *compress* <= ``9``
* ``1`` → best speed, ``9`` → best compression, ``0`` → no compression
* *default* → ``4``
* Results in a trade-off between render time and data size/draw speed
|
**Format Specification**
See :ref:`format-spec`.
::
[ <method> ] [ m <mix> ] [ c <compress> ]
* ``method`` → render method override
* ``L`` → **LINES** render method (current frame only, for animated images)
* ``W`` → **WHOLE** render method (current frame only, for animated images)
* ``A`` → **ANIM** render method [1]_
* *default* → current effective render method of the instance
* ``m`` → cell content inter-mix policy (**Only supported in WezTerm**, ignored
otherwise)
* ``mix`` → inter-mix policy
* ``0`` → existing contents of cells in the region covered by the drawn
render output will be erased
* ``1`` → existing cell contents show under transparent areas of the drawn
render output
* *default* → ``m0``
* e.g ``m0``, ``m1``
* ``c`` → ZLIB compression level, for renders re-encoded in PNG format
* ``compress`` → compression level
* An integer in the range ``0`` <= ``x`` <= ``9``
* ``1`` → best speed, ``9`` → best compression, ``0`` → no compression
* *default* → ``c4``
* e.g ``c0``, ``c9``
* Results in a trade-off between render time and data size/draw speed
|
IMPORTANT:
Currently supported terminal emulators are:
* `iTerm2 <https://iterm2.com>`_
* `Konsole <https://konsole.kde.org>`_ >= 22.04.0
* `WezTerm <https://wezfurlong.org/wezterm/>`_
.. [1] Native animation support:
* Not all animated image formats may be supported by every supported terminal
emulator
* Not all supported terminal emulators implement this feature of the protocol
e.g on Konsole, the first frame is drawn but the image is not animated
|
"""
_FORMAT_SPEC: Tuple[re.Pattern] = tuple(
map(re.compile, "[LWA] m[01] c[0-9]".split(" "))
)
_render_methods: Set[str] = {LINES, WHOLE, ANIM}
_default_render_method: str = LINES
_render_method: str = LINES
_style_args = {
"method": (
None,
(
lambda x: isinstance(x, str),
"Render method must be a string",
),
(
lambda x: x.lower() in __class__._render_methods,
"Unknown render method for 'iterm2' render style",
),
),
"mix": (
False,
(
lambda x: isinstance(x, bool),
"Cell content inter-mix policy must be a boolean",
),
(lambda _: True, ""),
),
"compress": (
4,
(
lambda x: isinstance(x, int),
"Compression level must be an integer",
),
(
lambda x: 0 <= x <= 9,
"Compression level must be between 0 and 9, both inclusive",
),
),
}
_TERM: str = ""
_TERM_VERSION: str = ""
jpeg_quality = ClassInstanceProperty(
ITerm2ImageMeta.jpeg_quality.fget,
ITerm2ImageMeta.jpeg_quality.fset,
ITerm2ImageMeta.jpeg_quality.fdel,
doc="""JPEG encoding quality
:type: int
GET:
Returns the effective JPEG encoding quality of the invoker
(class or instance).
SET:
If invoked via:
* a **class**, the **class-wide** quality is set.
* an **instance**, the **instance-specific** quality is set.
DELETE:
If invoked via:
* a **class**, the **class-wide** quality is unset.
* an **instance**, the **instance-specific** quality is unset.
If:
* *value* < ``0``; JPEG encoding is disabled.
* ``0`` <= *value* <= ``95``; JPEG encoding is enabled with the given quality.
If **unset** for:
* a **class**, it uses that of its parent *iterm2* style class (if any) or the
default (disabled), if unset for all parents or the class has no parent
*iterm2* style class.
* an **instance**, it uses that of its class.
By **default**, the quality is **unset** (i.e JPEG encoding is **disabled**) and
images are encoded in the PNG format (when not reading directly from file) but
in some cases, higher and/or faster compression may be desired.
JPEG encoding is significantly faster than PNG encoding and produces smaller
(in data size) output but **at the cost of image quality**.
NOTE:
* This property is :term:`descendant`.
* This optimization applies to only **re-encoded** (i.e not read directly
from file) **non-transparent** renders.
TIP:
The transparency status of some images can not be correctly determined
in an efficient way at render time. To ensure JPEG encoding is always used
for a re-encoded render, disable transparency or set a background color.
Furthermore, to ensure that renders with the **WHOLE** :term:`render method`
are always re-encoded, disable :py:attr:`read_from_file`.
This optimization is useful in improving non-native animation performance.
SEE ALSO:
* the *alpha* parameter of :py:meth:`~term_image.image.BaseImage.draw`
and the ``#``, ``bgcolor`` fields of the :ref:`format-spec`
* :py:attr:`read_from_file`
""",
)
native_anim_max_bytes = ClassProperty(
lambda self: type(self)._native_anim_max_bytes,
doc="""Maximum size (in bytes) of image data for native animation
:type: int
GET:
Returns the set value.
SET:
A positive integer; the value is set.
Can not be set via an instance.
DELETE:
The value is reset to the default.
Can not be reset via an instance.
:py:class:`~term_image.exceptions.TermImageUserWarning` is issued (and shown
**only the first time**, except the warning filters are modified to do
otherwise) if the image data size for a native animation is above this value.
NOTE:
This property is a global setting. Hence, setting/resetting it on this
class or any subclass affects all classes and their instances.
WARNING:
This property should be altered with caution to avoid excessive memory
usage, particularly on the terminal emulator's end.
""",
)
read_from_file = ClassInstanceProperty(
ITerm2ImageMeta.read_from_file.fget,
ITerm2ImageMeta.read_from_file.fset,
ITerm2ImageMeta.read_from_file.fdel,
doc="""Read-from-file optimization
:type: bool
GET:
Returns the effective read-from-file policy of the invoker
(class or instance).
SET:
If invoked via:
* a **class**, the **class-wide** policy is set.
* an **instance**, the **instance-specific** policy is set.
DELETE:
If invoked via:
* a **class**, the **class-wide** policy is unset.
* an **instance**, the **instance-specific** policy is unset.
If the value is:
* ``True``, image data is read directly from file when possible and no image
manipulation is required.
* ``False``, images are always re-encoded (in the PNG format by default).
If **unset** for:
* a **class**, it uses that of its parent *iterm2* style class (if any) or the
default (``True``), if unset for all parents or the class has no parent
*iterm2* style class.
* an **instance**, it uses that of its class.
By **default**, the policy is **unset**, which is equivalent to ``True``
i.e the optimization is **enabled**.
NOTE:
* This property is :term:`descendant`.
* This is an optimization to reduce render times and is only applicable to
the **WHOLE** render method, since the the **LINES** method inherently
requires image manipulation.
* This property does not affect animations. Native animations are always
read from file when possible and frames of non-native animations have
to be re-encoded.
SEE ALSO:
:py:attr:`jpeg_quality`
""",
)
[docs]
@classmethod
def clear(cls, cursor: bool = False, now: bool = False) -> None:
"""Clears images.
Args:
cursor: If ``True``, all images intersecting with the current cursor
position are cleared. Otherwise, all visible images are cleared.
now: If ``True`` the images are cleared immediately, without affecting
any standard I/O stream.
Otherwise they're cleared when next :py:data:`sys.stdout` is flushed.
NOTE:
Required and works only on Konsole, as text doesn't overwrite images.
"""
if not isinstance(cursor, bool):
raise arg_type_error("cursor", cursor)
if not isinstance(now, bool):
raise arg_type_error("now", now)
# There's no point checking for forced support since this is only required on
# konsole which supports the protocol.
# `is_supported()` is first called to ensure `_TERM` has been set.
if cls.is_supported() and cls._TERM == "konsole":
# Konsole utilizes the same image rendering implementation as it
# uses for the kiity graphics protocol.
(write_tty if now else _stdout_write)(
(ctlseqs.KITTY_DELETE_CURSOR_b if now else ctlseqs.KITTY_DELETE_CURSOR)
if cursor
else (ctlseqs.KITTY_DELETE_ALL_b if now else ctlseqs.KITTY_DELETE_ALL)
)
@classmethod
def is_supported(cls):
if cls._supported is None:
cls._supported = False
name, version = get_terminal_name_version()
if name in {"iterm2", "konsole", "wezterm"}:
try:
if name != "konsole" or (
tuple(map(int, version.split("."))) >= (22, 4, 0)
):
cls._supported = True
cls._TERM, cls._TERM_VERSION = name, version
except ValueError: # version string not "understood"
pass
return cls._supported
@classmethod
def _check_style_format_spec(cls, spec: str, original: str) -> Dict[str, Any]:
parent, (method, mix, compress) = cls._get_style_format_spec(spec, original)
args = {}
if parent:
args.update(super()._check_style_format_spec(parent, original))
if method:
args["method"] = {"L": LINES, "W": WHOLE, "A": ANIM}[method]
if mix:
args["mix"] = bool(int(mix[-1]))
if compress:
args["compress"] = int(compress[-1])
return cls._check_style_args(args)
def _display_animated(
self,
img,
alpha,
fmt,
*args,
mix: bool = False,
**kwargs,
):
if not mix and self._TERM == "wezterm":
lines = max(fmt[-1], self.rendered_height)
r_width = self.rendered_width
erase_and_move_cursor = ERASE_CHARS % r_width + CURSOR_FORWARD % r_width
first_frame = self._format_render(
f"{erase_and_move_cursor}\n" * (lines - 1) + erase_and_move_cursor,
*fmt,
)
print(
first_frame,
"\r",
CURSOR_UP % (lines - 1),
sep="",
end="",
flush=True,
)
super()._display_animated(img, alpha, fmt, *args, mix=True, **kwargs)
@staticmethod
def _handle_interrupted_draw():
"""Performs necessary actions when image drawing is interrupted.
If drawing is interrupted while transmitting an image, it causes terminal to
wait for more data (while consuming any output following) until the output
reaches the expected payload size or ST (String Terminator) is written.
"""
# End last transmission (does no harm if there wasn't an unterminated
# transmission)
# Konsole sometimes requires ST to be written twice.
print(ctlseqs.ST * 2, end="", flush=True)
def _render_image(
self,
img: PIL.Image.Image,
alpha: Union[None, float, str],
*,
frame: bool = False,
method: Optional[str] = None,
mix: bool = False,
compress: int = 4,
) -> str:
# NOTE: It's more efficient to write separate strings to the buffer separately
# than concatenate and write together.
# Using `width=<columns>`, `height=<lines>` and `preserveAspectRatio=0` ensures
# that an image always occupies the correct amount of columns and lines even if
# the cell size has changed when it's drawn.
# Since we use `width` and `height` control data keys, there's no need
# upscaling the image on this end to reduce payload.
# Anyways, this also implies that the image(s) have to be resized by the
# terminal emulator, thereby leaving various details of resizing in the hands
# of the terminal emulator such as the resampling method, etc.
# This particularly affects the LINES render method negatively, resulting in
# slant/curved edges not lining up across lines (amongst other artifacts
# observed on Konsole) supposedly because the terminal emulator resizes each
# line separately.
# Hence, this optimization is only used for the WHOLE render method.
r_width, r_height = self.rendered_size
render_method = (method or self._render_method).lower()
# Workarounds
is_on_konsole = self._TERM == "konsole"
is_on_wezterm = self._TERM == "wezterm"
cursor_right = CURSOR_FORWARD % r_width
cursor_up = CURSOR_UP % (r_height - 1) if r_height > 1 else ""
erase = ERASE_CHARS % r_width if not mix and is_on_wezterm else ""
file_is_readable = True
if self._source_type is ImageSource.PIL_IMAGE:
try:
file_is_readable = os.access(img.filename, os.R_OK)
except (AttributeError, OSError):
file_is_readable = False
if render_method == ANIM and self._is_animated and not frame:
if self._source_type is ImageSource.PIL_IMAGE:
if file_is_readable:
compressed_image = open(img.filename, "rb")
else:
compressed_image = io.BytesIO()
try:
img.save(compressed_image, img.format, save_all=True)
except ValueError as e:
self._close_image(img)
raise RenderError(
"iTerm2 native animation not supported: This image was "
"sourced from a PIL image with an unknown format"
) from e
else:
compressed_image = open(self._source, "rb")
self._close_image(img)
with compressed_image:
compressed_image.seek(0, 2)
if compressed_image.tell() > self.native_anim_max_bytes:
warnings.warn(
"Image data size above the maximum for native animation",
TermImageUserWarning,
)
control_data = "".join(
(
f"size={compressed_image.tell()};width={r_width}"
f";height={r_height};preserveAspectRatio=0;inline=1"
f"{';doNotMoveCursor=1' * is_on_konsole}:"
)
)
compressed_image.seek(0)
return "".join(
(
(
""
if is_on_konsole
else f"{erase}{cursor_right}\n" * (r_height - 1)
),
erase,
"" if is_on_konsole else cursor_up,
ITERM2_START,
control_data,
standard_b64encode(compressed_image.read()).decode(),
ST,
f"{cursor_right}\n" * (r_height - 1) if is_on_konsole else "",
cursor_right * is_on_konsole,
)
)
width, height = (
self._get_minimal_render_size()
if render_method == WHOLE
else self._get_render_size()
)
if ( # Read directly from file when possible and reasonable
self.read_from_file
and not self._is_animated
and file_is_readable
and render_method == WHOLE
and mul(*self._original_size) <= mul(*self._get_render_size())
and (
# None of the *alpha* options can affect these
img.mode in {"1", "L", "RGB", "HSV", "CMYK"}
# Alpha threshold is unused with graphics-based styles.
# The transparency of some "P" mode images is missing on some terminals
# Making the output inconsistent with other render styles.
or (isinstance(alpha, float) and img.mode not in {"P", "PA"})
)
):
compressed_image = open(
img.filename
if self._source_type is ImageSource.PIL_IMAGE
else self._source,
"rb",
)
frame_img = None
else:
frame_img = img if frame else None
img = self._get_render_data(
img, alpha, size=(width, height), pixel_data=False, frame=frame
)[0] # fmt: skip
if self.jpeg_quality >= 0 and img.mode == "RGB":
format = "jpeg"
jpeg_quality = self.jpeg_quality
else:
format = "png"
jpeg_quality = None
if render_method == LINES:
raw_image = io.BytesIO(img.tobytes())
compressed_image = io.BytesIO()
else:
compressed_image = io.BytesIO()
img.save(
compressed_image,
format,
compress_level=compress, # PNG
quality=jpeg_quality,
)
# clean up (ImageIterator uses one PIL image throughout)
if frame_img is not img:
self._close_image(img)
if render_method == LINES:
# NOTE: It's more efficient to write separate strings to the buffer
# separately than concatenate and write together.
cell_height = height // r_height
bytes_per_line = width * cell_height * (len(img.mode))
control_data = (
f";width={r_width};height=1;preserveAspectRatio=0;inline=1"
f"{';doNotMoveCursor=1' * is_on_konsole}:"
)
with io.StringIO() as buffer, raw_image, compressed_image:
for line in range(1, r_height + 1):
compressed_image.seek(0)
with PIL.Image.frombytes(
img.mode, (width, cell_height), raw_image.read(bytes_per_line)
) as img:
img.save(
compressed_image,
format,
compress_level=compress, # PNG
quality=jpeg_quality,
)
compressed_image.truncate()
buffer.write(erase)
buffer.write(ITERM2_START)
buffer.write(f"size={compressed_image.tell()}")
buffer.write(control_data)
buffer.write(
standard_b64encode(compressed_image.getvalue()).decode()
)
buffer.write(ST)
is_on_konsole and buffer.write(cursor_right)
line < r_height and buffer.write("\n")
return buffer.getvalue()
# WHOLE
with compressed_image:
compressed_image.seek(0, 2)
control_data = "".join(
(
f"size={compressed_image.tell()};width={r_width}"
f";height={r_height};preserveAspectRatio=0;inline=1"
f"{';doNotMoveCursor=1' * is_on_konsole}:"
)
)
compressed_image.seek(0)
return "".join(
(
(
""
if is_on_konsole
else f"{erase}{cursor_right}\n" * (r_height - 1)
),
erase,
"" if is_on_konsole else cursor_up,
ITERM2_START,
control_data,
standard_b64encode(compressed_image.read()).decode(),
ST,
f"{cursor_right}\n" * (r_height - 1) if is_on_konsole else "",
cursor_right * is_on_konsole,
)
)
_stdout_write = sys.stdout.write