Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interim indexed-color (256-color) Rendering #109

Merged
merged 10 commits into from
Jun 6, 2024
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- indexed-color (256-color) rendering for `BlockImage` ([#109]).
- `DIRECT` and `INDEXED` render methods.
- *method* style-specific render parameter and format spec field.

[#109]: https://github.com/AnonymouX47/term-image/pull/109


## [0.7.1] - 2023-02-10
### Fixed
- Undefined references in some top-level functions ([497d9b7], [4e8b3e7]).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@

- support for the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
- support for the [iTerm2 inline image protocol](https://iterm2.com/documentation-images.html).
- full Unicode support and ANSI 24-bit color support
- Unicode support and direct-color (truecolor) or indexed-color (256-color) support.

**Plans to support a wider variety of terminal emulators are in motion** (see [Planned Features](#planned-features)).

Expand Down
4 changes: 2 additions & 2 deletions docs/source/faqs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ What about Windows support?
- Drawing images and animations doesn't work completely well with Python for Windows. See :doc:`issues`.
- If stuck on Windows and want to use all features, you could use WSL + Windows Terminal.

Why are colours not properly reproduced?
- Some terminals support 24-bit colors but have a **256-color pallete**. This limits color reproduction.
Why are colours not properly reproduced with :py:class:`BlockImage`'s ``DIRECT`` render method?
- Some terminals support direct-color control sequences but actually use a **256-color pallete**. This limits color reproduction.

Why are images out of scale?
- If :ref:`auto-cell-ratio` is supported and enabled, call
Expand Down
22 changes: 12 additions & 10 deletions docs/source/start/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Requirements

* support for the `Kitty graphics protocol <https://sw.kovidgoyal.net/kitty/graphics-protocol/>`_.
* support for the `iTerm2 inline image protocol <https://iterm2.com/documentation-images.html>`_.
* full Unicode support and ANSI 24-bit color support
* Unicode support and direct-color (truecolor) or indexed-color (256-color) support.

**Plans to support a wider variety of terminal emulators are in motion** (see :doc:`/planned`).

Expand All @@ -36,21 +36,23 @@ Supported Terminal Emulators

Some terminals emulators that have been tested to meet the requirements for at least one render style include:

- Alacritty
- iTerm2
- Kitty
- Konsole
- MinTTY (on Windows)
- Terminal (on Mac OS)
- Terminology
- Termux (on Android)
- WezTerm
- Windows Terminal
- XTerm
- **libvte**-based terminal emulators such as:

- Gnome Terminal
- Terminator
- Tilix

- Kitty
- Konsole
- iTerm2
- WezTerm
- Alacritty
- Windows Terminal
- MinTTY (on Windows)
- Termux (on Android)

.. note::
If you've tested ``term-image`` on any other terminal emulator that meets all
requirements, please mention the name in a new thread under `this discussion
Expand Down
4 changes: 4 additions & 0 deletions src/term_image/ctlseqs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
SGR_FG_RGB_2 = f"{CSI}38:2::{Ps}:{Ps}:{Ps}m"
SGR_BG_RGB = f"{CSI}48;2;{Pm(3)}m"
SGR_BG_RGB_2 = f"{CSI}48:2::{Ps}:{Ps}:{Ps}m"
SGR_FG_INDEXED = f"{CSI}38;5;{Ps}m"
SGR_FG_INDEXED_2 = f"{CSI}38:5:{Ps}m"
SGR_BG_INDEXED = f"{CSI}48;5;{Ps}m"
SGR_BG_INDEXED_2 = f"{CSI}48:5:{Ps}m"

# DEC Modes
DECSET = f"{CSI}?{Ps}h"
Expand Down
144 changes: 129 additions & 15 deletions src/term_image/image/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,113 @@

import io
import os
import re
from math import ceil
from operator import mul
from typing import Optional, Tuple, Union
from typing import Any, Optional, Tuple, Union

import PIL

from ..ctlseqs import SGR_BG_RGB, SGR_FG_RGB, SGR_NORMAL
from ..ctlseqs import SGR_BG_INDEXED, SGR_BG_RGB, SGR_FG_INDEXED, SGR_FG_RGB, SGR_NORMAL
from ..utils import get_fg_bg_colors
from .common import TextImage

LOWER_PIXEL = "\u2584" # lower-half block element
UPPER_PIXEL = "\u2580" # upper-half block element

# Constants for render methods
DIRECT = "direct"
INDEXED = "indexed"


class BlockImage(TextImage):
"""A render style using unicode half blocks and 24-bit colour escape codes.
"""A render style using Unicode half blocks with direct-color or indexed-color
control sequences.

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

|

**Render Methods**

:py:class:`BlockImage` provides two methods of :term:`rendering` images, namely:

DIRECT (default)
Renders an image using direct-color (truecolor) control sequences.

Pros:

* Better color reproduction.

Cons:

* Lesser terminal emulator support (though any terminal emulator worthy of use
today actually does provide support).

INDEXED
Renders an image using indexed-color control sequences but with only the upper
**240 colors** of the terminal's 256-color palette.

Pros:

* Wider terminal emulator support.

Cons:

* Worse color reproduction.

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
* A valid render method name (as specified in the **Render Methods** section
above) → used instead of the current effective render method of the instance
* *default* → ``None``

|

**Format Specification**

See :ref:`format-spec`.

::

[ <method> ]

* ``method`` → render method override

* ``D`` → **DIRECT** render method (current frame only, for animated images)
* ``I`` → **INDEXED** render method (current frame only, for animated images)
* *default* → Current effective render method of the image
"""

_FORMAT_SPEC: tuple[re.Pattern] = (re.compile("[DI]"),)
_render_methods: set[str] = {DIRECT, INDEXED}
_default_render_method: str = DIRECT
_render_method: str = DIRECT
_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 'block' render style",
),
),
}

@classmethod
def is_supported(cls):
if cls._supported is None:
Expand All @@ -35,6 +122,17 @@ def is_supported(cls):

return cls._supported

@classmethod
def _check_style_format_spec(cls, spec: str, original: str) -> dict[str, Any]:
parent, (method,) = cls._get_style_format_spec(spec, original)
args = {}
if parent:
args.update(super()._check_style_format_spec(parent, original))
if method:
args["method"] = DIRECT if method == "D" else INDEXED

return cls._check_style_args(args)

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

Expand All @@ -56,6 +154,7 @@ def _render_image(
alpha: Union[None, float, str],
*,
frame: bool = False,
method: str | None = None,
split_cells: bool = False,
) -> str:
# NOTE:
Expand All @@ -70,32 +169,39 @@ def update_buffer():
buf_write(blank * n)
elif a_cluster1 == 0: # up is transparent
buf_write(SGR_NORMAL)
buf_write(SGR_FG_RGB % cluster2)
buf_write(sgr_fg % cluster2)
buf_write(lower_pixel * n)
elif a_cluster2 == 0: # down is transparent
buf_write(SGR_NORMAL)
buf_write(SGR_FG_RGB % cluster1)
buf_write(sgr_fg % cluster1)
buf_write(upper_pixel * n)
else:
no_alpha = True

if not alpha or no_alpha:
r, g, b = cluster2
# Kitty does not render BG colors equal to the default BG color
if is_on_kitty and cluster2 == bg_color:
r += r < 255 or -1
buf_write(SGR_BG_RGB % (r, g, b))
if method_is_direct:
r, g, b = cluster2
# Kitty does not render BG colors equal to the default BG color
if is_on_kitty and cluster2 == bg_color:
r += r < 255 or -1
buf_write(sgr_bg % (r, g, b))
else:
buf_write(sgr_bg % cluster2)

if cluster1 == cluster2:
buf_write(blank * n)
else:
buf_write(SGR_FG_RGB % cluster1)
buf_write(sgr_fg % cluster1)
buf_write(upper_pixel * n)

buffer = io.StringIO()
buf_write = buffer.write # Eliminate attribute resolution cost

bg_color = get_fg_bg_colors()[1]
is_on_kitty = self._is_on_kitty()
render_method = (method or self._render_method).lower()
method_is_direct = render_method == DIRECT
if method_is_direct:
bg_color = get_fg_bg_colors()[1]
is_on_kitty = self._is_on_kitty()
if split_cells:
blank = " \0"
lower_pixel = LOWER_PIXEL + "\0"
Expand All @@ -105,11 +211,19 @@ def update_buffer():
lower_pixel = LOWER_PIXEL
upper_pixel = UPPER_PIXEL
end_of_line = SGR_NORMAL + "\n"
sgr_fg = SGR_FG_RGB if method_is_direct else SGR_FG_INDEXED
sgr_bg = SGR_BG_RGB if method_is_direct else SGR_BG_INDEXED

width, height = self._get_render_size()
frame_img = img if frame else None
img, rgb, a = self._get_render_data(img, alpha, round_alpha=True, frame=frame)
alpha = img.mode == "RGBA"
img, rgb, a = self._get_render_data(
img,
alpha,
round_alpha=True,
frame=frame,
indexed_color=not method_is_direct,
)
alpha = img.mode == ("RGBA" if method_is_direct else "PA")

# clean up (ImageIterator uses one PIL image throughout)
if frame_img is not img:
Expand Down
58 changes: 58 additions & 0 deletions src/term_image/image/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
URLNotFoundError,
)
from ..utils import (
XTERM_256_PALETTE,
ClassInstanceMethod,
ClassProperty,
arg_type_error,
Expand Down Expand Up @@ -1941,6 +1942,10 @@ class TextImage(BaseImage):
from its subclasses.
"""

# 240 colors i.e excluding the first 16
_XTERM_240_PALETTE_IMAGE = Image.new("P", (1, 1))
_XTERM_240_PALETTE_IMAGE.putpalette(XTERM_256_PALETTE[16 * 3 :])

# 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
Expand All @@ -1951,6 +1956,59 @@ class TextImage(BaseImage):
def _is_on_kitty() -> bool:
return get_terminal_name_version()[0] == "kitty"

def _get_render_data(
self,
img: PIL.Image.Image,
*args,
frame: bool = False,
indexed_color: bool = False,
**kwargs,
) -> tuple[
PIL.Image.Image,
list[tuple[int, int, int]] | list[int] | None,
list[int] | None,
]:
"""
See :py:meth:`BaseImage._render_image` for the description of the method and
all other parameters not described here.

Args:
indexed_color: Whether to quantize the render image to a 240-color palette.

Returns:
The same as the overriden method if *indexed_color* is ``False``. Otherwise,

* The returned image has mode ``P`` or ``PA``, depending on the mode of the
source image.
* ``rgb`` is a list of integers in the range [0, 255], where each integer
is a valid index for a 256-color terminal palette.
"""
if indexed_color:
frame_img = img if frame else None
img, rgb, a = super()._get_render_data(img, *args, frame=frame, **kwargs)

if indexed_color:
orig_img = img

img = img.copy() if img.mode == "RGB" else img.convert("RGB")
with img:
quantized_img = img.quantize(
palette=__class__._XTERM_240_PALETTE_IMAGE, dither=Image.Dither.NONE
)

if orig_img.mode == "RGBA":
with quantized_img:
quantized_img = quantized_img.convert("PA")
quantized_img.putalpha(orig_img.getchannel("A"))

if frame_img is not orig_img:
self._close_image(orig_img)

img = quantized_img
rgb = [index + 16 for index in img.getdata(0)]

return (img, rgb, a)

@abstractmethod
def _render_image(
self,
Expand Down
Loading
Loading