diff --git a/.gitignore b/.gitignore index b0b6f3a..3e50345 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,9 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ \ No newline at end of file +.idea/ + +# Mac +.DS_Store +.AppleDouble +.LSOverride diff --git a/rich_pixels/__init__.py b/rich_pixels/__init__.py index 652f14d..b233abc 100644 --- a/rich_pixels/__init__.py +++ b/rich_pixels/__init__.py @@ -1,3 +1,9 @@ from rich_pixels._pixel import Pixels +from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer -__all__ = [ "Pixels" ] +__all__ = [ + "Pixels", + "Renderer", + "HalfcellRenderer", + "FullcellRenderer", +] diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index 0293c0e..ac57494 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -1,73 +1,55 @@ from __future__ import annotations from pathlib import Path, PurePath -from typing import Iterable, Mapping, Tuple, Union, Optional, List +from typing import Iterable, Mapping, Tuple, Union, Optional from PIL import Image as PILImageModule from PIL.Image import Image -from PIL.Image import Resampling from rich.console import Console, ConsoleOptions, RenderResult from rich.segment import Segment, Segments from rich.style import Style +from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer + class Pixels: + def __init__(self) -> None: self._segments: Segments | None = None @staticmethod def from_image( image: Image, + renderer: Renderer = HalfcellRenderer(), ): - segments = Pixels._segments_from_image(image) + segments = Pixels._segments_from_image(image, renderer=renderer) return Pixels.from_segments(segments) @staticmethod def from_image_path( path: Union[PurePath, str], resize: Optional[Tuple[int, int]] = None, + renderer: Renderer = HalfcellRenderer(), ) -> Pixels: """Create a Pixels object from an image. Requires 'image' extra dependencies. Args: path: The path to the image file. resize: A tuple of (width, height) to resize the image to. + renderer: The renderer to use. Defaults to HalfcellRenderer. """ with PILImageModule.open(Path(path)) as image: - segments = Pixels._segments_from_image(image, resize) + segments = Pixels._segments_from_image(image, resize, renderer=renderer) return Pixels.from_segments(segments) @staticmethod def _segments_from_image( - image: Image, resize: Optional[Tuple[int, int]] = None + image: Image, + resize: Optional[Tuple[int, int]] = None, + renderer: Renderer = HalfcellRenderer(), ) -> list[Segment]: - if resize: - image = image.resize(resize, resample=Resampling.NEAREST) - - width, height = image.width, image.height - rgba_image = image.convert("RGBA") - get_pixel = rgba_image.getpixel - parse_style = Style.parse - null_style = Style.null() - segments = [] - - for y in range(height): - this_row: List[Segment] = [] - row_append = this_row.append - - for x in range(width): - r, g, b, a = get_pixel((x, y)) - style = parse_style(f"on rgb({r},{g},{b})") if a > 0 else null_style - row_append(Segment(" ", style)) - - row_append(Segment("\n", null_style)) - - # TODO: Double-check if this is required - I've forgotten... - if not all(t[1] == "" for t in this_row[:-1]): - segments += this_row - - return segments + return renderer.render(image, resize) @staticmethod def from_segments( @@ -114,7 +96,24 @@ def __rich_console__( if __name__ == "__main__": console = Console() images_path = Path(__file__).parent / "../tests/.sample_data/images" - pixels = Pixels.from_image_path(images_path / "bulbasaur.png") + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", + renderer=FullcellRenderer()) + console.print("\\[case.1] print with fullpixels renderer") + console.print(pixels) + + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", + renderer=FullcellRenderer(default_color="black")) + console.print("\\[case.2] print with fullpixels renderer and default_color") + console.print(pixels) + + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", + renderer=HalfcellRenderer()) + console.print("\\[case.3] print with halfpixels renderer") + console.print(pixels) + + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", + renderer=HalfcellRenderer(default_color="black")) + console.print("\\[case.4] print with halfpixels renderer and default_color") console.print(pixels) grid = """\ @@ -131,4 +130,5 @@ def __rich_console__( "O": Segment("O", Style.parse("white on blue")), } pixels = Pixels.from_ascii(grid, mapping) + console.print("\\[case.5] print ascii") console.print(pixels) diff --git a/rich_pixels/_renderer.py b/rich_pixels/_renderer.py new file mode 100644 index 0000000..d1c125f --- /dev/null +++ b/rich_pixels/_renderer.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import Callable, Tuple +from rich.segment import Segment +from rich.style import Style +from PIL.Image import Image, Resampling + +RGBA = Tuple[int, int, int, int] +GetPixel = Callable[[Tuple[int, int]], RGBA] + + +def _get_color(pixel: RGBA, default_color: str | None = None) -> str | None: + r, g, b, a = pixel + return f"rgb({r},{g},{b})" if a > 0 else default_color + + +class Renderer: + """ + Base class for renderers. + """ + + default_color: str | None + null_style: Style | None + + def __init__( + self, + *, + default_color: str | None = None, + ) -> None: + self.default_color = default_color + self.null_style = None if default_color is None else Style.parse( + f"on {default_color}") + + def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]: + """ + Render an image to Segments. + """ + + rgba_image = image.convert("RGBA") + if resize: + rgba_image = rgba_image.resize(resize, resample=Resampling.NEAREST) + + get_pixel = rgba_image.getpixel + width, height = rgba_image.width, rgba_image.height + + segments = [] + + for y in self._get_range(height): + this_row: list[Segment] = [] + + this_row += self._render_line(line_index=y, width=width, + get_pixel=get_pixel) + this_row.append(Segment("\n", self.null_style)) + + # TODO: Double-check if this is required - I've forgotten... + if not all(t[1] == "" for t in this_row[:-1]): + segments += this_row + + return segments + + def _get_range(self, height: int) -> range: + """ + Get the range of lines to render. + """ + raise NotImplementedError + + def _render_line( + self, + *, + line_index: int, + width: int, + get_pixel: GetPixel + ) -> list[Segment]: + """ + Render a line of pixels. + """ + raise NotImplementedError + + +class HalfcellRenderer(Renderer): + """ + Render an image to half-height cells. + """ + + def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]: + # because each row is 2 lines high, so we need to make sure the height is even + target_height = resize[1] if resize else image.size[1] + if target_height % 2 != 0: + target_height += 1 + + if image.size[1] != target_height: + resize = (resize[0], target_height) if resize else ( + image.size[0], target_height) + + return super().render(image, resize) + + def _get_range(self, height: int) -> range: + return range(0, height, 2) + + def _render_line( + self, + *, + line_index: int, + width: int, + get_pixel: GetPixel + ) -> list[Segment]: + line = [] + for x in range(width): + line.append(self._render_halfcell(x=x, y=line_index, get_pixel=get_pixel)) + return line + + def _render_halfcell( + self, + *, + x: int, + y: int, + get_pixel: GetPixel + ) -> Segment: + colors = [] + + # get lower pixel, render lower pixel use foreground color, so it must be first + lower_color = _get_color(get_pixel((x, y + 1)), + default_color=self.default_color) + colors.append(lower_color or "") + # get upper pixel, render upper pixel use background color, it is optional + upper_color = _get_color(get_pixel((x, y)), default_color=self.default_color) + if upper_color: colors.append(upper_color or "") + + style = Style.parse(" on ".join(colors)) if colors else self.null_style + # use lower halfheight block to render if lower pixel is not transparent + return Segment("▄" if lower_color else " ", style) + + +class FullcellRenderer(Renderer): + """ + Render an image to full-height cells. + """ + + def _get_range(self, height: int) -> range: + return range(height) + + def _render_line( + self, + *, + line_index: int, + width: int, + get_pixel: GetPixel + ) -> list[Segment]: + line = [] + for x in range(width): + line.append(self._render_fullcell(x=x, y=line_index, get_pixel=get_pixel)) + return line + + def _render_fullcell( + self, + *, + x: int, + y: int, + get_pixel: GetPixel + ) -> Segment: + pixel = get_pixel((x, y)) + style = Style.parse( + f"on {_get_color(pixel, default_color=self.default_color)}") if pixel[ + 3] > 0 else self.null_style + return Segment(" ", style) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__snapshots__/test_pixel/test_png_image_path.svg b/tests/__snapshots__/test_pixel/test_png_image_path.svg index 9fdfb11..304c5eb 100644 --- a/tests/__snapshots__/test_pixel/test_png_image_path.svg +++ b/tests/__snapshots__/test_pixel/test_png_image_path.svg @@ -19,167 +19,167 @@ font-weight: 700; } - .terminal-2697279030-matrix { + .terminal-3148282356-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2697279030-title { + .terminal-3148282356-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2697279030-r1 { fill: #c5c8c6 } + .terminal-3148282356-r1 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Rich + Rich - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg b/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg new file mode 100644 index 0000000..c10a26d --- /dev/null +++ b/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rich + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_pixel.py b/tests/test_pixel.py index 9bef356..62e691a 100644 --- a/tests/test_pixel.py +++ b/tests/test_pixel.py @@ -7,7 +7,7 @@ from rich.style import Style from syrupy.extensions.image import SVGImageSnapshotExtension -from rich_pixels import Pixels +from rich_pixels import Pixels, FullcellRenderer SAMPLE_DATA_DIR = Path(__file__).parent / ".sample_data/" @@ -24,7 +24,7 @@ def get_console(): def test_png_image_path(svg_snapshot): console = get_console() - pixels = Pixels.from_image_path(SAMPLE_DATA_DIR / "images/bulbasaur.png") + pixels = Pixels.from_image_path(SAMPLE_DATA_DIR / "images/bulbasaur.png", renderer=FullcellRenderer()) console.print(pixels) svg = console.export_svg() assert svg == svg_snapshot @@ -42,3 +42,13 @@ def test_ascii_text(svg_snapshot): console.print(Align.center(pixels)) svg = console.export_svg(title="pixels in the terminal") assert svg == svg_snapshot + + +def test_png_image_path_with_halfpixels(svg_snapshot): + console = get_console() + pixels = Pixels.from_image_path( + SAMPLE_DATA_DIR / "images/bulbasaur.png", + ) + console.print(pixels) + svg = console.export_svg() + assert svg == svg_snapshot