├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock ├── rich_pixels ├── __init__.py ├── _pixel.py ├── _renderer.py └── py.typed └── tests ├── .sample_data ├── ascii │ └── rich_pixels.txt └── images │ └── bulbasaur.png ├── __init__.py ├── __snapshots__ └── test_pixel │ ├── test_ascii_text.svg │ ├── test_png_image_path.svg │ └── test_png_image_path_with_halfpixels.svg └── test_pixel.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # Mac 163 | .DS_Store 164 | .AppleDouble 165 | .LSOverride 166 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Darren Burns 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rich Pixels 2 | 3 | A Rich-compatible library for writing pixel images and other colourful grids to the 4 | terminal. 5 | 6 |

7 | 8 |

9 | 10 | ## Installation 11 | 12 | Get `rich-pixels` from PyPI. 13 | 14 | ``` 15 | pip install rich-pixels 16 | ``` 17 | 18 | ## Basic Usage 19 | 20 | ### Images 21 | 22 | #### Image from a file 23 | 24 | You can load an image file from a path using `from_image_path`: 25 | 26 | ```python 27 | from rich_pixels import Pixels 28 | from rich.console import Console 29 | 30 | console = Console() 31 | pixels = Pixels.from_image_path("pokemon/bulbasaur.png") 32 | console.print(pixels) 33 | ``` 34 | 35 | #### Pillow image object 36 | 37 | You can create a PIL image object yourself and pass it in to `from_image`. 38 | 39 | ```python 40 | from rich_pixels import Pixels 41 | from rich.console import Console 42 | from PIL import Image 43 | 44 | console = Console() 45 | 46 | with Image.open("path/to/image.png") as image: 47 | pixels = Pixels.from_image(image) 48 | 49 | console.print(pixels) 50 | ``` 51 | 52 | Using this approach means you can modify your PIL `Image` beforehard. 53 | 54 | #### ASCII Art 55 | 56 | You can quickly build shapes using a tool like [asciiflow](https://asciiflow.com), and 57 | apply styles the ASCII characters. This provides a quick way of sketching out shapes. 58 | 59 | ```python 60 | from rich_pixels import Pixels 61 | from rich.console import Console 62 | from rich.segment import Segment 63 | from rich.style import Style 64 | 65 | console = Console() 66 | 67 | # Draw your shapes using any character you want 68 | grid = """\ 69 | xx xx 70 | ox ox 71 | Ox Ox 72 | xx xx 73 | xxxxxxxxxxxxxxxxx 74 | """ 75 | 76 | # Map characters to different characters/styles 77 | mapping = { 78 | "x": Segment(" ", Style.parse("yellow on yellow")), 79 | "o": Segment(" ", Style.parse("on white")), 80 | "O": Segment(" ", Style.parse("on blue")), 81 | } 82 | 83 | pixels = Pixels.from_ascii(grid, mapping) 84 | console.print(pixels) 85 | ``` 86 | 87 | ### Using with Textual 88 | 89 | `Pixels` can be integrated into [Textual](https://github.com/Textualize/textual) 90 | applications just like any other Rich renderable. 91 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "rich-pixels" 3 | version = "3.0.1" 4 | description = "A Rich-compatible library for writing pixel images and ASCII art to the terminal." 5 | authors = [ 6 | { name = "Darren Burns", email = "darrenb900@gmail.com" } 7 | ] 8 | dependencies = [ 9 | "rich>=12.0.0", 10 | "pillow>=10.0.0", 11 | ] 12 | readme = "README.md" 13 | requires-python = ">= 3.8" 14 | 15 | [build-system] 16 | requires = ["hatchling"] 17 | build-backend = "hatchling.build" 18 | 19 | [tool.rye] 20 | managed = true 21 | dev-dependencies = [ 22 | "black>=24.1.1", 23 | "pytest>=8.0.0", 24 | "mypy>=1.8.0", 25 | "syrupy>=3.0.6", 26 | "types-pillow>=10.2.0.20240206", 27 | ] 28 | 29 | [tool.hatch.metadata] 30 | allow-direct-references = true 31 | 32 | [tool.hatch.build.targets.wheel] 33 | packages = ["rich_pixels"] 34 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | 10 | -e file:. 11 | black==24.1.1 12 | click==8.1.7 13 | exceptiongroup==1.2.0 14 | iniconfig==2.0.0 15 | markdown-it-py==3.0.0 16 | mdurl==0.1.2 17 | mypy==1.8.0 18 | mypy-extensions==1.0.0 19 | packaging==23.2 20 | pathspec==0.12.1 21 | pillow==10.2.0 22 | platformdirs==4.2.0 23 | pluggy==1.4.0 24 | pygments==2.17.2 25 | pytest==8.0.0 26 | rich==13.7.0 27 | syrupy==4.6.1 28 | tomli==2.0.1 29 | types-pillow==10.2.0.20240206 30 | typing-extensions==4.9.0 31 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | 10 | -e file:. 11 | markdown-it-py==3.0.0 12 | mdurl==0.1.2 13 | pillow==10.2.0 14 | pygments==2.17.2 15 | rich==13.7.0 16 | typing-extensions==4.9.0 17 | -------------------------------------------------------------------------------- /rich_pixels/__init__.py: -------------------------------------------------------------------------------- 1 | from rich_pixels._pixel import Pixels 2 | from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer 3 | 4 | __all__ = [ 5 | "Pixels", 6 | "Renderer", 7 | "HalfcellRenderer", 8 | "FullcellRenderer", 9 | ] 10 | -------------------------------------------------------------------------------- /rich_pixels/_pixel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path, PurePath 4 | from typing import Iterable, Mapping, Tuple, Union, Optional 5 | 6 | from PIL import Image as PILImageModule 7 | from PIL.Image import Image 8 | from rich.console import Console, ConsoleOptions, RenderResult 9 | from rich.segment import Segment, Segments 10 | from rich.style import Style 11 | 12 | from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer 13 | 14 | 15 | class Pixels: 16 | def __init__(self) -> None: 17 | self._segments: Segments | None = None 18 | 19 | @staticmethod 20 | def from_image( 21 | image: Image, 22 | resize: Optional[Tuple[int, int]] = None, 23 | renderer: Renderer | None = None, 24 | ): 25 | """Create a Pixels object from a PIL Image. 26 | Requires 'image' extra dependencies. 27 | 28 | Args: 29 | image: The PIL Image 30 | resize: A tuple of (width, height) to resize the image to. 31 | renderer: The renderer to use. If None, the default half-cell renderer will 32 | be used. 33 | """ 34 | segments = Pixels._segments_from_image(image, resize, renderer=renderer) 35 | return Pixels.from_segments(segments) 36 | 37 | @staticmethod 38 | def from_image_path( 39 | path: Union[PurePath, str], 40 | resize: Optional[Tuple[int, int]] = None, 41 | renderer: Renderer | None = None, 42 | ) -> Pixels: 43 | """Create a Pixels object from an image path. 44 | Requires 'image' extra dependencies. 45 | 46 | Args: 47 | path: The path to the image file. 48 | resize: A tuple of (width, height) to resize the image to. 49 | renderer: The renderer to use. If None, the default half-cell renderer will 50 | be used. 51 | """ 52 | with PILImageModule.open(Path(path)) as image: 53 | segments = Pixels._segments_from_image(image, resize, renderer=renderer) 54 | 55 | return Pixels.from_segments(segments) 56 | 57 | @staticmethod 58 | def _segments_from_image( 59 | image: Image, 60 | resize: Optional[Tuple[int, int]] = None, 61 | renderer: Renderer | None = None, 62 | ) -> list[Segment]: 63 | if renderer is None: 64 | renderer = HalfcellRenderer() 65 | return renderer.render(image, resize) 66 | 67 | @staticmethod 68 | def from_segments( 69 | segments: Iterable[Segment], 70 | ) -> Pixels: 71 | """Create a Pixels object from an Iterable of Segments instance.""" 72 | pixels = Pixels() 73 | pixels._segments = Segments(segments) 74 | return pixels 75 | 76 | @staticmethod 77 | def from_ascii( 78 | grid: str, mapping: Optional[Mapping[str, Segment]] = None 79 | ) -> Pixels: 80 | """ 81 | Create a Pixels object from a 2D-grid of ASCII characters. 82 | Each ASCII character can be mapped to a Segment (a character and style combo), 83 | allowing you to add a splash of colour to your grid. 84 | 85 | Args: 86 | grid: A 2D grid of characters (a multi-line string). 87 | mapping: Maps ASCII characters to Segments. Occurrences of a character 88 | will be replaced with the corresponding Segment. 89 | """ 90 | if mapping is None: 91 | mapping = {} 92 | 93 | if not grid: 94 | return Pixels.from_segments([]) 95 | 96 | segments = [] 97 | for character in grid: 98 | segment = mapping.get(character, Segment(character)) 99 | segments.append(segment) 100 | 101 | return Pixels.from_segments(segments) 102 | 103 | def __rich_console__( 104 | self, console: Console, options: ConsoleOptions 105 | ) -> RenderResult: 106 | yield self._segments or "" 107 | 108 | 109 | if __name__ == "__main__": 110 | console = Console() 111 | images_path = Path(__file__).parent / "../tests/.sample_data/images" 112 | pixels = Pixels.from_image_path( 113 | images_path / "bulbasaur.png", renderer=FullcellRenderer() 114 | ) 115 | console.print("\\[case.1] print with fullpixels renderer") 116 | console.print(pixels) 117 | 118 | pixels = Pixels.from_image_path( 119 | images_path / "bulbasaur.png", renderer=FullcellRenderer(default_color="black") 120 | ) 121 | console.print("\\[case.2] print with fullpixels renderer and default_color") 122 | console.print(pixels) 123 | 124 | pixels = Pixels.from_image_path( 125 | images_path / "bulbasaur.png", renderer=HalfcellRenderer() 126 | ) 127 | console.print("\\[case.3] print with halfpixels renderer") 128 | console.print(pixels) 129 | 130 | pixels = Pixels.from_image_path( 131 | images_path / "bulbasaur.png", renderer=HalfcellRenderer(default_color="black") 132 | ) 133 | console.print("\\[case.4] print with halfpixels renderer and default_color") 134 | console.print(pixels) 135 | 136 | grid = """\ 137 | xx xx 138 | ox ox 139 | Ox Ox 140 | xx xx 141 | xxxxxxxxxxxxxxxxx 142 | """ 143 | 144 | mapping = { 145 | "x": Segment(" ", Style.parse("yellow on yellow")), 146 | "o": Segment(" ", Style.parse("on white")), 147 | "O": Segment("O", Style.parse("white on blue")), 148 | } 149 | pixels = Pixels.from_ascii(grid, mapping) 150 | console.print("\\[case.5] print ascii") 151 | console.print(pixels) 152 | -------------------------------------------------------------------------------- /rich_pixels/_renderer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, Tuple 4 | 5 | from PIL.Image import Image, Resampling 6 | from rich.segment import Segment 7 | from rich.style import Style 8 | 9 | RGBA = Tuple[int, int, int, int] 10 | GetPixel = Callable[[Tuple[int, int]], RGBA] 11 | 12 | 13 | def _get_color(pixel: RGBA, default_color: str | None = None) -> str | None: 14 | r, g, b, a = pixel 15 | return f"rgb({r},{g},{b})" if a > 0 else default_color 16 | 17 | 18 | class Renderer: 19 | """ 20 | Base class for renderers. 21 | """ 22 | 23 | default_color: str | None 24 | null_style: Style | None 25 | 26 | def __init__( 27 | self, 28 | *, 29 | default_color: str | None = None, 30 | ) -> None: 31 | self.default_color = default_color 32 | self.null_style = ( 33 | None if default_color is None else Style.parse(f"on {default_color}") 34 | ) 35 | 36 | def render(self, image: Image, resize: tuple[int, int] | None) -> list[Segment]: 37 | """ 38 | Render an image to Segments. 39 | """ 40 | 41 | rgba_image = image.convert("RGBA") 42 | if resize: 43 | rgba_image = rgba_image.resize(resize, resample=Resampling.NEAREST) 44 | 45 | get_pixel = rgba_image.getpixel 46 | width, height = rgba_image.width, rgba_image.height 47 | 48 | segments = [] 49 | 50 | for y in self._get_range(height): 51 | this_row: list[Segment] = [] 52 | 53 | this_row += self._render_line( 54 | line_index=y, width=width, get_pixel=get_pixel 55 | ) 56 | this_row.append(Segment("\n", self.null_style)) 57 | 58 | # TODO: Double-check if this is required - I've forgotten... 59 | if not all(t[1] == "" for t in this_row[:-1]): 60 | segments += this_row 61 | 62 | return segments 63 | 64 | def _get_range(self, height: int) -> range: 65 | """ 66 | Get the range of lines to render. 67 | """ 68 | raise NotImplementedError 69 | 70 | def _render_line( 71 | self, *, line_index: int, width: int, get_pixel: GetPixel 72 | ) -> list[Segment]: 73 | """ 74 | Render a line of pixels. 75 | """ 76 | raise NotImplementedError 77 | 78 | 79 | class HalfcellRenderer(Renderer): 80 | """ 81 | Render an image to half-height cells. 82 | """ 83 | 84 | def render(self, image: Image, resize: tuple[int, int] | None) -> list[Segment]: 85 | # because each row is 2 lines high, so we need to make sure the height is even 86 | target_height = resize[1] if resize else image.size[1] 87 | if target_height % 2 != 0: 88 | target_height += 1 89 | 90 | if image.size[1] != target_height: 91 | resize = ( 92 | (resize[0], target_height) if resize else (image.size[0], target_height) 93 | ) 94 | 95 | return super().render(image, resize) 96 | 97 | def _get_range(self, height: int) -> range: 98 | return range(0, height, 2) 99 | 100 | def _render_line( 101 | self, *, line_index: int, width: int, get_pixel: GetPixel 102 | ) -> list[Segment]: 103 | line = [] 104 | for x in range(width): 105 | line.append(self._render_halfcell(x=x, y=line_index, get_pixel=get_pixel)) 106 | return line 107 | 108 | def _render_halfcell(self, *, x: int, y: int, get_pixel: GetPixel) -> Segment: 109 | colors = [] 110 | 111 | # get lower pixel, render lower pixel use foreground color, so it must be first 112 | lower_color = _get_color( 113 | get_pixel((x, y + 1)), default_color=self.default_color 114 | ) 115 | colors.append(lower_color or "") 116 | # get upper pixel, render upper pixel use background color, it is optional 117 | upper_color = _get_color(get_pixel((x, y)), default_color=self.default_color) 118 | if upper_color: 119 | colors.append(upper_color or "") 120 | 121 | style = Style.parse(" on ".join(colors)) if colors else self.null_style 122 | # use lower halfheight block to render if lower pixel is not transparent 123 | return Segment("▄" if lower_color else " ", style) 124 | 125 | 126 | class FullcellRenderer(Renderer): 127 | """ 128 | Render an image to full-height cells. 129 | """ 130 | 131 | def _get_range(self, height: int) -> range: 132 | return range(height) 133 | 134 | def _render_line( 135 | self, *, line_index: int, width: int, get_pixel: GetPixel 136 | ) -> list[Segment]: 137 | line = [] 138 | for x in range(width): 139 | line.append(self._render_fullcell(x=x, y=line_index, get_pixel=get_pixel)) 140 | return line 141 | 142 | def _render_fullcell(self, *, x: int, y: int, get_pixel: GetPixel) -> Segment: 143 | pixel = get_pixel((x, y)) 144 | style = ( 145 | Style.parse(f"on {_get_color(pixel, default_color=self.default_color)}") 146 | if pixel[3] > 0 147 | else self.null_style 148 | ) 149 | return Segment(" ", style) 150 | -------------------------------------------------------------------------------- /rich_pixels/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/rich-pixels/a0745ebcc26b966d9dbac5875720364ee5c6a1d3/rich_pixels/py.typed -------------------------------------------------------------------------------- /tests/.sample_data/ascii/rich_pixels.txt: -------------------------------------------------------------------------------- 1 | ###### ###### 2 | # # # #### # # # # # # # ###### # #### 3 | # # # # # # # # # # # # # # # 4 | ###### # # ###### ###### # ## ##### # #### 5 | # # # # # # # # ## # # # 6 | # # # # # # # # # # # # # # # 7 | # # # #### # # # # # # ###### ###### #### 8 | 9 | ================================================================ -------------------------------------------------------------------------------- /tests/.sample_data/images/bulbasaur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/rich-pixels/a0745ebcc26b966d9dbac5875720364ee5c6a1d3/tests/.sample_data/images/bulbasaur.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrenburns/rich-pixels/a0745ebcc26b966d9dbac5875720364ee5c6a1d3/tests/__init__.py -------------------------------------------------------------------------------- /tests/__snapshots__/test_pixel/test_ascii_text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | pixels in the terminal 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /tests/__snapshots__/test_pixel/test_png_image_path.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | Rich 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /tests/test_pixel.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from rich.align import Align 5 | from rich.console import Console 6 | from rich.segment import Segment 7 | from rich.style import Style 8 | from syrupy.extensions.image import SVGImageSnapshotExtension 9 | 10 | from rich_pixels import Pixels, FullcellRenderer 11 | 12 | SAMPLE_DATA_DIR = Path(__file__).parent / ".sample_data/" 13 | 14 | 15 | @pytest.fixture 16 | def svg_snapshot(snapshot): 17 | return snapshot.use_extension(SVGImageSnapshotExtension) 18 | 19 | 20 | def get_console(): 21 | console = Console(record=True) 22 | return console 23 | 24 | 25 | def test_png_image_path(svg_snapshot): 26 | console = get_console() 27 | pixels = Pixels.from_image_path( 28 | SAMPLE_DATA_DIR / "images/bulbasaur.png", renderer=FullcellRenderer() 29 | ) 30 | console.print(pixels) 31 | svg = console.export_svg() 32 | assert svg == svg_snapshot 33 | 34 | 35 | def test_ascii_text(svg_snapshot): 36 | console = get_console() 37 | ascii = (SAMPLE_DATA_DIR / "ascii/rich_pixels.txt").read_text(encoding="utf-8") 38 | mapping = { 39 | "#": Segment(" ", Style.parse("on #50b332")), 40 | "=": Segment(" ", Style.parse("on #10ada3")), 41 | } 42 | pixels = Pixels.from_ascii( 43 | ascii, 44 | mapping=mapping, 45 | ) 46 | console.print(Align.center(pixels)) 47 | svg = console.export_svg(title="pixels in the terminal") 48 | assert svg == svg_snapshot 49 | 50 | 51 | def test_png_image_path_with_halfpixels(svg_snapshot): 52 | console = get_console() 53 | pixels = Pixels.from_image_path( 54 | SAMPLE_DATA_DIR / "images/bulbasaur.png", 55 | ) 56 | console.print(pixels) 57 | svg = console.export_svg() 58 | assert svg == svg_snapshot 59 | --------------------------------------------------------------------------------