From 12d0fb824f3c69de93ae86e9353083ee04a44aeb Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Wed, 17 Jan 2024 15:20:15 +0800 Subject: [PATCH 1/8] implements halfpixels mode. --- rich_pixels/_pixel.py | 65 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index 0293c0e..00c00d2 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -1,4 +1,5 @@ from __future__ import annotations +import math from pathlib import Path, PurePath from typing import Iterable, Mapping, Tuple, Union, Optional, List @@ -12,36 +13,55 @@ class Pixels: + DEFAULT_COLOR = "black" + def __init__(self) -> None: self._segments: Segments | None = None + @staticmethod + def _get_color(pixel: Tuple[int, int, int, int]) -> Style: + r, g, b, a = pixel + return f"rgb({r},{g},{b})" if a > 0 else Pixels.DEFAULT_COLOR + @staticmethod def from_image( image: Image, + use_halfpixels: bool = False, ): - segments = Pixels._segments_from_image(image) + segments = Pixels._segments_from_image(image, use_halfpixels=use_halfpixels) return Pixels.from_segments(segments) @staticmethod def from_image_path( path: Union[PurePath, str], resize: Optional[Tuple[int, int]] = None, + use_halfpixels: bool = False, ) -> 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. + use_halfpixels: Whether to use halfpixels or not. Defaults to False. """ with PILImageModule.open(Path(path)) as image: - segments = Pixels._segments_from_image(image, resize) + segments = Pixels._segments_from_image(image, resize, use_halfpixels) 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, use_halfpixels: bool = False ) -> list[Segment]: + if use_halfpixels: + # 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 not resize and image.size[1] != target_height: + resize = (image.size[0], target_height) + if resize: image = image.resize(resize, resample=Resampling.NEAREST) @@ -52,14 +72,39 @@ def _segments_from_image( null_style = Style.null() segments = [] - for y in range(height): + def render_halfpixels(x: int, y: int) -> None: + """ + Render 2 pixels per character. + """ + + # get upper pixel + upper_color = Pixels._get_color(get_pixel((x, y))) + # get lower pixel + lower_color = Pixels._get_color(get_pixel((x, y + 1))) + # render upper pixel use foreground color, lower pixel use background color + style = parse_style(f"{upper_color} on {lower_color}") + # use upper halfheight block to render + row_append(Segment("▀", style)) + + def render_fullpixels(x: int, y: int) -> None: + """ + Render 1 pixel per 2character. + """ + + color = Pixels._get_color(get_pixel((x, y))) + style = parse_style(f"on {color}") + row_append(Segment(" ", style)) + + render = render_halfpixels if use_halfpixels else render_fullpixels + # step=2 if use halfpixels, because each row is 2 lines high + seq = range(0, height, 2) if use_halfpixels else range(height) + + for y in seq: 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)) + render(x, y) row_append(Segment("\n", null_style)) @@ -115,6 +160,11 @@ def __rich_console__( console = Console() images_path = Path(__file__).parent / "../tests/.sample_data/images" pixels = Pixels.from_image_path(images_path / "bulbasaur.png") + console.print("\[case.1] print fullpixels") + console.print(pixels) + + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", use_halfpixels=True) + console.print("\[case.2] print halfpixels") console.print(pixels) grid = """\ @@ -131,4 +181,5 @@ def __rich_console__( "O": Segment("O", Style.parse("white on blue")), } pixels = Pixels.from_ascii(grid, mapping) + console.print("\[case.3] print ascii") console.print(pixels) From 9eadea11a3a0f77b48de617ec14d3b3300841bd8 Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Wed, 17 Jan 2024 15:27:31 +0800 Subject: [PATCH 2/8] when render fullpixels, if pixel's alpha == 0, use null_style for this pixel. --- rich_pixels/_pixel.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index 00c00d2..3fa4782 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -19,9 +19,9 @@ def __init__(self) -> None: self._segments: Segments | None = None @staticmethod - def _get_color(pixel: Tuple[int, int, int, int]) -> Style: + def _get_color(pixel: Tuple[int, int, int, int], default_color: str = None) -> Style: r, g, b, a = pixel - return f"rgb({r},{g},{b})" if a > 0 else Pixels.DEFAULT_COLOR + return f"rgb({r},{g},{b})" if a > 0 else default_color or Pixels.DEFAULT_COLOR @staticmethod def from_image( @@ -91,8 +91,8 @@ def render_fullpixels(x: int, y: int) -> None: Render 1 pixel per 2character. """ - color = Pixels._get_color(get_pixel((x, y))) - style = parse_style(f"on {color}") + pixel = get_pixel((x, y)) + style = parse_style(f"on {Pixels._get_color(pixel)}") if pixel[3] > 0 else null_style row_append(Segment(" ", style)) render = render_halfpixels if use_halfpixels else render_fullpixels From cfd0539b03442eb0e75b6c7f0476a8215feee59f Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Wed, 17 Jan 2024 15:51:42 +0800 Subject: [PATCH 3/8] add unit test for halfpixels mode. --- .../test_png_image_path_with_halfpixels.svg | 138 ++++++++++++++++++ tests/test_pixel.py | 11 ++ 2 files changed, 149 insertions(+) create mode 100644 tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg 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..bcaa85b --- /dev/null +++ b/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rich + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_pixel.py b/tests/test_pixel.py index 9bef356..9a1d3e7 100644 --- a/tests/test_pixel.py +++ b/tests/test_pixel.py @@ -42,3 +42,14 @@ 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", + use_halfpixels=True, + ) + console.print(pixels) + svg = console.export_svg() + assert svg == svg_snapshot From a14d2d2ee811fab230a300497d93c89bd343d596 Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Wed, 17 Jan 2024 16:34:16 +0800 Subject: [PATCH 4/8] use lower halfheight block to render, when pixel is transparent, use null_style. --- rich_pixels/_pixel.py | 21 ++-- .../test_png_image_path_with_halfpixels.svg | 113 +++++++++--------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index 3fa4782..980bbd7 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -13,15 +13,13 @@ class Pixels: - DEFAULT_COLOR = "black" - def __init__(self) -> None: self._segments: Segments | None = None @staticmethod def _get_color(pixel: Tuple[int, int, int, int], default_color: str = None) -> Style: r, g, b, a = pixel - return f"rgb({r},{g},{b})" if a > 0 else default_color or Pixels.DEFAULT_COLOR + return f"rgb({r},{g},{b})" if a > 0 else default_color @staticmethod def from_image( @@ -77,14 +75,17 @@ def render_halfpixels(x: int, y: int) -> None: Render 2 pixels per character. """ - # get upper pixel - upper_color = Pixels._get_color(get_pixel((x, y))) - # get lower pixel + colors = [] + # get lower pixel, render lower pixel use foreground color, so it must be first lower_color = Pixels._get_color(get_pixel((x, y + 1))) - # render upper pixel use foreground color, lower pixel use background color - style = parse_style(f"{upper_color} on {lower_color}") - # use upper halfheight block to render - row_append(Segment("▀", style)) + colors.append(lower_color or "") + # get upper pixel, render upper pixel use background color, it is optional + upper_color = Pixels._get_color(get_pixel((x, y))) + upper_color and colors.append(upper_color or "") + + style = parse_style(" on ".join(colors)) if colors else null_style + # use lower halfheight block to render if lower pixel is not transparent + row_append(Segment("▄" if lower_color else " ", style)) def render_fullpixels(x: int, y: int) -> None: """ 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 index bcaa85b..c10a26d 100644 --- a/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg +++ b/tests/__snapshots__/test_pixel/test_png_image_path_with_halfpixels.svg @@ -19,119 +19,118 @@ font-weight: 700; } - .terminal-1539593039-matrix { + .terminal-597517765-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1539593039-title { + .terminal-597517765-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1539593039-r1 { fill: #4b4e55 } -.terminal-1539593039-r2 { fill: #526229 } -.terminal-1539593039-r3 { fill: #c5c8c6 } -.terminal-1539593039-r4 { fill: #73ac31 } -.terminal-1539593039-r5 { fill: #a4d541 } -.terminal-1539593039-r6 { fill: #101010 } -.terminal-1539593039-r7 { fill: #bdff73 } -.terminal-1539593039-r8 { fill: #399494 } -.terminal-1539593039-r9 { fill: #83eec5 } -.terminal-1539593039-r10 { fill: #184a4a } -.terminal-1539593039-r11 { fill: #317373 } -.terminal-1539593039-r12 { fill: #62d5b4 } -.terminal-1539593039-r13 { fill: #cdcdcd } -.terminal-1539593039-r14 { fill: #ee2039 } -.terminal-1539593039-r15 { fill: #ac0031 } -.terminal-1539593039-r16 { fill: #ffffff } -.terminal-1539593039-r17 { fill: #ff6a62 } + .terminal-597517765-r1 { fill: #c5c8c6 } +.terminal-597517765-r2 { fill: #526229 } +.terminal-597517765-r3 { fill: #a4d541 } +.terminal-597517765-r4 { fill: #101010 } +.terminal-597517765-r5 { fill: #73ac31 } +.terminal-597517765-r6 { fill: #bdff73 } +.terminal-597517765-r7 { fill: #184a4a } +.terminal-597517765-r8 { fill: #399494 } +.terminal-597517765-r9 { fill: #83eec5 } +.terminal-597517765-r10 { fill: #317373 } +.terminal-597517765-r11 { fill: #62d5b4 } +.terminal-597517765-r12 { fill: #ee2039 } +.terminal-597517765-r13 { fill: #ac0031 } +.terminal-597517765-r14 { fill: #ffffff } +.terminal-597517765-r15 { fill: #cdcdcd } +.terminal-597517765-r16 { fill: #ff6a62 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Rich + Rich - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + From 347d3c62509e810a2ee0dc934fa9eef1991b6ae6 Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Thu, 18 Jan 2024 09:43:15 +0800 Subject: [PATCH 5/8] add `default_color` parameter for `Pixels.from_image` and `Pixels.from_image_path`; fix an error when use `use_halfpixels` and `resize` together. --- rich_pixels/_pixel.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index 980bbd7..fa7fec4 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -25,8 +25,9 @@ def _get_color(pixel: Tuple[int, int, int, int], default_color: str = None) -> S def from_image( image: Image, use_halfpixels: bool = False, + default_color: str = None, ): - segments = Pixels._segments_from_image(image, use_halfpixels=use_halfpixels) + segments = Pixels._segments_from_image(image, use_halfpixels=use_halfpixels, default_color=default_color) return Pixels.from_segments(segments) @staticmethod @@ -34,6 +35,7 @@ def from_image_path( path: Union[PurePath, str], resize: Optional[Tuple[int, int]] = None, use_halfpixels: bool = False, + default_color: str = None, ) -> Pixels: """Create a Pixels object from an image. Requires 'image' extra dependencies. @@ -41,15 +43,19 @@ def from_image_path( path: The path to the image file. resize: A tuple of (width, height) to resize the image to. use_halfpixels: Whether to use halfpixels or not. Defaults to False. + default_color: The default color to use for transparent pixels. Defaults to None. """ with PILImageModule.open(Path(path)) as image: - segments = Pixels._segments_from_image(image, resize, use_halfpixels) + segments = Pixels._segments_from_image(image, resize, use_halfpixels, default_color) return Pixels.from_segments(segments) @staticmethod def _segments_from_image( - image: Image, resize: Optional[Tuple[int, int]] = None, use_halfpixels: bool = False + image: Image, + resize: Optional[Tuple[int, int]] = None, + use_halfpixels: bool = False, + default_color: str = None ) -> list[Segment]: if use_halfpixels: # because each row is 2 lines high, so we need to make sure the height is even @@ -57,8 +63,8 @@ def _segments_from_image( if target_height % 2 != 0: target_height += 1 - if not resize and image.size[1] != target_height: - resize = (image.size[0], target_height) + if image.size[1] != target_height: + resize = (resize[0], target_height) if resize else (image.size[0], target_height) if resize: image = image.resize(resize, resample=Resampling.NEAREST) @@ -67,7 +73,7 @@ def _segments_from_image( rgba_image = image.convert("RGBA") get_pixel = rgba_image.getpixel parse_style = Style.parse - null_style = Style.null() + null_style = Style.null() if default_color is None else parse_style(f"on {default_color}") segments = [] def render_halfpixels(x: int, y: int) -> None: @@ -77,10 +83,10 @@ def render_halfpixels(x: int, y: int) -> None: colors = [] # get lower pixel, render lower pixel use foreground color, so it must be first - lower_color = Pixels._get_color(get_pixel((x, y + 1))) + lower_color = Pixels._get_color(get_pixel((x, y + 1)), default_color=default_color) colors.append(lower_color or "") # get upper pixel, render upper pixel use background color, it is optional - upper_color = Pixels._get_color(get_pixel((x, y))) + upper_color = Pixels._get_color(get_pixel((x, y)), default_color=default_color) upper_color and colors.append(upper_color or "") style = parse_style(" on ".join(colors)) if colors else null_style @@ -93,14 +99,14 @@ def render_fullpixels(x: int, y: int) -> None: """ pixel = get_pixel((x, y)) - style = parse_style(f"on {Pixels._get_color(pixel)}") if pixel[3] > 0 else null_style + style = parse_style(f"on {Pixels._get_color(pixel, default_color=default_color)}") if pixel[3] > 0 else null_style row_append(Segment(" ", style)) render = render_halfpixels if use_halfpixels else render_fullpixels # step=2 if use halfpixels, because each row is 2 lines high - seq = range(0, height, 2) if use_halfpixels else range(height) + lines = range(0, height, 2) if use_halfpixels else range(height) - for y in seq: + for y in lines: this_row: List[Segment] = [] row_append = this_row.append @@ -164,8 +170,16 @@ def __rich_console__( console.print("\[case.1] print fullpixels") console.print(pixels) + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", default_color="black") + console.print("\[case.2] print fullpixels with default_color") + console.print(pixels) + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", use_halfpixels=True) - console.print("\[case.2] print halfpixels") + console.print("\[case.3] print halfpixels") + console.print(pixels) + + pixels = Pixels.from_image_path(images_path / "bulbasaur.png", use_halfpixels=True, default_color="black") + console.print("\[case.4] print halfpixels with default_color") console.print(pixels) grid = """\ @@ -182,5 +196,5 @@ def __rich_console__( "O": Segment("O", Style.parse("white on blue")), } pixels = Pixels.from_ascii(grid, mapping) - console.print("\[case.3] print ascii") + console.print("\[case.5] print ascii") console.print(pixels) From 81d1cf0ec70913326c8bc92febfef3f42b494bfd Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Sat, 27 Jan 2024 13:43:44 +0800 Subject: [PATCH 6/8] use `Renderer` to manage render logic. --- .gitignore | 7 +- rich_pixels/__init__.py | 8 +- rich_pixels/_pixel.py | 113 ++++++---------------------- rich_pixels/_renderer.py | 157 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 93 deletions(-) create mode 100644 rich_pixels/_renderer.py 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 fa7fec4..c7b3f66 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -1,52 +1,46 @@ from __future__ import annotations -import math 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 + _segments: Segments | None - @staticmethod - def _get_color(pixel: Tuple[int, int, int, int], default_color: str = None) -> Style: - r, g, b, a = pixel - return f"rgb({r},{g},{b})" if a > 0 else default_color + def __init__(self) -> None: + self._segments = None @staticmethod def from_image( image: Image, - use_halfpixels: bool = False, - default_color: str = None, + renderer: Renderer = HalfcellRenderer(), ): - segments = Pixels._segments_from_image(image, use_halfpixels=use_halfpixels, default_color=default_color) + 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, - use_halfpixels: bool = False, - default_color: str = 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. - use_halfpixels: Whether to use halfpixels or not. Defaults to False. - default_color: The default color to use for transparent pixels. Defaults to None. + renderer: The renderer to use. Defaults to HalfcellRenderer. """ with PILImageModule.open(Path(path)) as image: - segments = Pixels._segments_from_image(image, resize, use_halfpixels, default_color) + segments = Pixels._segments_from_image(image, resize, renderer=renderer) return Pixels.from_segments(segments) @@ -54,72 +48,9 @@ def from_image_path( def _segments_from_image( image: Image, resize: Optional[Tuple[int, int]] = None, - use_halfpixels: bool = False, - default_color: str = None + renderer: Renderer = HalfcellRenderer(), ) -> list[Segment]: - if use_halfpixels: - # 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) - - 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() if default_color is None else parse_style(f"on {default_color}") - segments = [] - - def render_halfpixels(x: int, y: int) -> None: - """ - Render 2 pixels per character. - """ - - colors = [] - # get lower pixel, render lower pixel use foreground color, so it must be first - lower_color = Pixels._get_color(get_pixel((x, y + 1)), default_color=default_color) - colors.append(lower_color or "") - # get upper pixel, render upper pixel use background color, it is optional - upper_color = Pixels._get_color(get_pixel((x, y)), default_color=default_color) - upper_color and colors.append(upper_color or "") - - style = parse_style(" on ".join(colors)) if colors else null_style - # use lower halfheight block to render if lower pixel is not transparent - row_append(Segment("▄" if lower_color else " ", style)) - - def render_fullpixels(x: int, y: int) -> None: - """ - Render 1 pixel per 2character. - """ - - pixel = get_pixel((x, y)) - style = parse_style(f"on {Pixels._get_color(pixel, default_color=default_color)}") if pixel[3] > 0 else null_style - row_append(Segment(" ", style)) - - render = render_halfpixels if use_halfpixels else render_fullpixels - # step=2 if use halfpixels, because each row is 2 lines high - lines = range(0, height, 2) if use_halfpixels else range(height) - - for y in lines: - this_row: List[Segment] = [] - row_append = this_row.append - - for x in range(width): - render(x, y) - - 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( @@ -166,20 +97,20 @@ 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") - console.print("\[case.1] print fullpixels") + 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", default_color="black") - console.print("\[case.2] print fullpixels with default_color") + 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", use_halfpixels=True) - console.print("\[case.3] print halfpixels") + 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", use_halfpixels=True, default_color="black") - console.print("\[case.4] print halfpixels with default_color") + 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 = """\ @@ -196,5 +127,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("\\[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..d41323c --- /dev/null +++ b/rich_pixels/_renderer.py @@ -0,0 +1,157 @@ +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) + From f3809ded14e84cdd2fbb073c6e12f7bf28168f65 Mon Sep 17 00:00:00 2001 From: JuerGenie Date: Sat, 27 Jan 2024 14:06:37 +0800 Subject: [PATCH 7/8] use `renderer`. --- tests/test_pixel.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_pixel.py b/tests/test_pixel.py index 9a1d3e7..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 @@ -48,7 +48,6 @@ def test_png_image_path_with_halfpixels(svg_snapshot): console = get_console() pixels = Pixels.from_image_path( SAMPLE_DATA_DIR / "images/bulbasaur.png", - use_halfpixels=True, ) console.print(pixels) svg = console.export_svg() From a472e6451c478a35688fce39e1c3852492fdc437 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Sat, 10 Feb 2024 19:41:42 +0000 Subject: [PATCH 8/8] Fix 3.8 support, plus some minor changes. --- rich_pixels/_pixel.py | 15 +- rich_pixels/_renderer.py | 26 ++-- tests/__init__.py | 0 .../test_pixel/test_png_image_path.svg | 144 +++++++++--------- 4 files changed, 98 insertions(+), 87 deletions(-) create mode 100644 tests/__init__.py diff --git a/rich_pixels/_pixel.py b/rich_pixels/_pixel.py index c7b3f66..ac57494 100644 --- a/rich_pixels/_pixel.py +++ b/rich_pixels/_pixel.py @@ -13,10 +13,9 @@ class Pixels: - _segments: Segments | None def __init__(self) -> None: - self._segments = None + self._segments: Segments | None = None @staticmethod def from_image( @@ -97,19 +96,23 @@ 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", renderer=FullcellRenderer()) + 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")) + 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()) + 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")) + 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) diff --git a/rich_pixels/_renderer.py b/rich_pixels/_renderer.py index d41323c..d1c125f 100644 --- a/rich_pixels/_renderer.py +++ b/rich_pixels/_renderer.py @@ -1,10 +1,13 @@ +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] +GetPixel = Callable[[Tuple[int, int]], RGBA] + def _get_color(pixel: RGBA, default_color: str | None = None) -> str | None: r, g, b, a = pixel @@ -25,7 +28,8 @@ def __init__( 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}") + 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]: """ @@ -44,7 +48,8 @@ def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]: 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 += 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... @@ -52,13 +57,13 @@ def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]: 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, *, @@ -84,7 +89,8 @@ def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]: target_height += 1 if image.size[1] != target_height: - resize = (resize[0], target_height) if resize else (image.size[0], target_height) + resize = (resize[0], target_height) if resize else ( + image.size[0], target_height) return super().render(image, resize) @@ -113,7 +119,8 @@ def _render_halfcell( 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) + 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) @@ -152,6 +159,7 @@ def _render_fullcell( 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 + 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 - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +