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 @@
+
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