From 98e3a775121c7f184826f3ce71621aeee94caa2d Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 01:45:09 +0800 Subject: [PATCH 01/10] refactor: consolidated image type handling in __init__ --- nbread/__init__.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/nbread/__init__.py b/nbread/__init__.py index 4cdbef3..0db1bc5 100644 --- a/nbread/__init__.py +++ b/nbread/__init__.py @@ -220,22 +220,22 @@ def wrapped_print(text): renderable = None # Handle different MIME types in priority order - if "image/png" in data: - # TODO: Implement image rendering for terminals - # Placeholder for now - renderable = Text("[Image: PNG]", style="dim cyan") - - elif "image/jpeg" in data: - # TODO: Implement image rendering for terminals - renderable = Text("[Image: JPEG]", style="dim cyan") - - elif "image/svg+xml" in data: - # TODO: Implement SVG rendering - renderable = Text("[Image: SVG]", style="dim cyan") - - elif "image/gif" in data: - # TODO: Implement GIF rendering - renderable = Text("[Image: GIF]", style="dim cyan") + if any(mime.startswith("image/") for mime in data): + for image_type in [ + "image/png", + "image/jpeg", + "image/svg+xml", + "image/gif", + ]: + if image_type in data: + # TODO: Implement image rendering for terminals + # Placeholder for now + renderable = Text(f"[{image_type}]", style="dim cyan") + break + else: + renderable = Text( + f"[Unsupported: {image_type}]", style="dim cyan" + ) elif "text/html" in data: from .mime_text import handle_html_output From e414978c7c4929f08cfb470902af8c3fff3773e0 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:05:30 +0800 Subject: [PATCH 02/10] chore: setup functions for handle image output --- nbread/__init__.py | 17 ++++++++++++++--- nbread/mime_image.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 nbread/mime_image.py diff --git a/nbread/__init__.py b/nbread/__init__.py index 0db1bc5..c7c58d8 100644 --- a/nbread/__init__.py +++ b/nbread/__init__.py @@ -228,10 +228,21 @@ def wrapped_print(text): "image/gif", ]: if image_type in data: - # TODO: Implement image rendering for terminals - # Placeholder for now - renderable = Text(f"[{image_type}]", style="dim cyan") + from .mime_image import handle_image_output + + sixel_data = handle_image_output(data[image_type]) + if sixel_data is None: + # Print placeholder to acknowledge text + renderable = Text( + f"[{image_type}]", style="dim cyan" + ) + else: + # Successfully obtained sixel payload, print it + print(sixel_data, end="") + renderable = Text("\n") + break + else: renderable = Text( f"[Unsupported: {image_type}]", style="dim cyan" diff --git a/nbread/mime_image.py b/nbread/mime_image.py new file mode 100644 index 0000000..f06c4ba --- /dev/null +++ b/nbread/mime_image.py @@ -0,0 +1,25 @@ +def check_terminal_supports_sixel() -> bool: + # TODO + return False + + +def check_sixel_renderer_available() -> bool: + # TODO + return False + + +def check_supports_sixel() -> bool: + if not check_terminal_supports_sixel(): + return False + if not check_sixel_renderer_available(): + return False + + return True + + +def handle_image_output(image_data: str) -> str | None: + if not check_supports_sixel(): + return None + else: + # TODO: Sixel rendering code + return "placeholder-text-to-print" From e86052ac332c71b818fe0e6868e04570adae0041 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:18:09 +0800 Subject: [PATCH 03/10] feat: added term and chafa check to mime_image --- nbread/mime_image.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/nbread/mime_image.py b/nbread/mime_image.py index f06c4ba..d8f9666 100644 --- a/nbread/mime_image.py +++ b/nbread/mime_image.py @@ -1,25 +1,40 @@ +import os +import subprocess + + def check_terminal_supports_sixel() -> bool: - # TODO + term = os.environ.get("TERM", "") + term_program = os.environ.get("TERM_PROGRAM", "") + + # Known sixel-capable terminals + sixel_terms = ["xterm", "mlterm", "foot", "wezterm", "konsole", "contour"] + + for sixel_term in sixel_terms: + if sixel_term in term.lower() or sixel_term in term_program.lower(): + return True + return False -def check_sixel_renderer_available() -> bool: - # TODO +def check_sixel_renderer_available() -> bool | str: + # Checks for chafa + result = subprocess.run(["which", "chafa"]) + if result.returncode == 0: + fpath = result.stdout.decode("ascii").strip() + return fpath + return False -def check_supports_sixel() -> bool: +def handle_image_output(image_data: str) -> bytes | None: if not check_terminal_supports_sixel(): - return False - if not check_sixel_renderer_available(): - return False - - return True + return None + sixel_exec_path = check_sixel_renderer_available() -def handle_image_output(image_data: str) -> str | None: - if not check_supports_sixel(): + if bool(sixel_exec_path) is False: return None + else: # TODO: Sixel rendering code - return "placeholder-text-to-print" + return b"placeholder-text-to-print" From a3b01b0623cb18d6551b922a988f3a28fbc632e3 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:24:27 +0800 Subject: [PATCH 04/10] refactor: set up render_sixel_chafa --- nbread/mime_image.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/nbread/mime_image.py b/nbread/mime_image.py index d8f9666..152febf 100644 --- a/nbread/mime_image.py +++ b/nbread/mime_image.py @@ -1,5 +1,10 @@ import os import subprocess +from enum import Enum + + +class SixelRenderer(Enum): + CHAFA = "chafa" def check_terminal_supports_sixel() -> bool: @@ -16,25 +21,31 @@ def check_terminal_supports_sixel() -> bool: return False -def check_sixel_renderer_available() -> bool | str: +def check_sixel_renderer_available() -> bool | SixelRenderer: # Checks for chafa result = subprocess.run(["which", "chafa"]) if result.returncode == 0: - fpath = result.stdout.decode("ascii").strip() - return fpath + return SixelRenderer.CHAFA return False +def render_sixel_chafa(image_data: str) -> bytes: + return b"placeholder-text-to-print" + + def handle_image_output(image_data: str) -> bytes | None: if not check_terminal_supports_sixel(): return None - sixel_exec_path = check_sixel_renderer_available() + renderer = check_sixel_renderer_available() - if bool(sixel_exec_path) is False: + if renderer is False: return None else: - # TODO: Sixel rendering code - return b"placeholder-text-to-print" + if renderer == SixelRenderer.CHAFA: + return render_sixel_chafa(image_data) + else: + # Not implemented yet! + return None From 2b7222c9af55e0a5948ef121c67e007f75175a5b Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:34:51 +0800 Subject: [PATCH 05/10] feat: enabled chafa for rendering images --- nbread/__init__.py | 4 +++- nbread/mime_image.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/nbread/__init__.py b/nbread/__init__.py index c7c58d8..ddec65a 100644 --- a/nbread/__init__.py +++ b/nbread/__init__.py @@ -230,7 +230,9 @@ def wrapped_print(text): if image_type in data: from .mime_image import handle_image_output - sixel_data = handle_image_output(data[image_type]) + sixel_data = handle_image_output( + data[image_type], suffix=image_type.split("/")[1] + ) if sixel_data is None: # Print placeholder to acknowledge text renderable = Text( diff --git a/nbread/mime_image.py b/nbread/mime_image.py index 152febf..10390ee 100644 --- a/nbread/mime_image.py +++ b/nbread/mime_image.py @@ -1,4 +1,6 @@ import os +import base64 +import tempfile import subprocess from enum import Enum @@ -30,11 +32,30 @@ def check_sixel_renderer_available() -> bool | SixelRenderer: return False -def render_sixel_chafa(image_data: str) -> bytes: - return b"placeholder-text-to-print" +def render_sixel_chafa(image_data: str, suffix: str) -> bytes | None: + # Dump to tempfile + decoded = base64.b64decode(image_data) + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(decoded) + tmp_path = tmp.name + + # Specify sixel_data in advance incase try blocks fails + sixel_data = None + try: + result = subprocess.run( + ["chafa", "--format=sixel", tmp_path], capture_output=True + ) + if result.returncode == 0: + sixel_data = result.stdout + else: + sixel_data = None + finally: + os.unlink(tmp_path) + + return sixel_data -def handle_image_output(image_data: str) -> bytes | None: +def handle_image_output(image_data: str, suffix: str) -> bytes | None: if not check_terminal_supports_sixel(): return None @@ -45,7 +66,8 @@ def handle_image_output(image_data: str) -> bytes | None: else: if renderer == SixelRenderer.CHAFA: - return render_sixel_chafa(image_data) + sixel_data = render_sixel_chafa(image_data, suffix) + return sixel_data else: # Not implemented yet! return None From 1d0dfd79c5087f1afd0204d218ed2606461c2e20 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:39:45 +0800 Subject: [PATCH 06/10] fix: properly pass string to show sixel escape codes --- nbread/mime_image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nbread/mime_image.py b/nbread/mime_image.py index 10390ee..26ba63a 100644 --- a/nbread/mime_image.py +++ b/nbread/mime_image.py @@ -32,7 +32,7 @@ def check_sixel_renderer_available() -> bool | SixelRenderer: return False -def render_sixel_chafa(image_data: str, suffix: str) -> bytes | None: +def render_sixel_chafa(image_data: str, suffix: str) -> str | None: # Dump to tempfile decoded = base64.b64decode(image_data) with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: @@ -46,7 +46,7 @@ def render_sixel_chafa(image_data: str, suffix: str) -> bytes | None: ["chafa", "--format=sixel", tmp_path], capture_output=True ) if result.returncode == 0: - sixel_data = result.stdout + sixel_data = result.stdout.decode('latin-1') else: sixel_data = None finally: @@ -55,7 +55,7 @@ def render_sixel_chafa(image_data: str, suffix: str) -> bytes | None: return sixel_data -def handle_image_output(image_data: str, suffix: str) -> bytes | None: +def handle_image_output(image_data: str, suffix: str) -> str | None: if not check_terminal_supports_sixel(): return None From 85f299dbd109c1418a4b678d6b1d1298582de956 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:46:52 +0800 Subject: [PATCH 07/10] fix: redir chafa availability check stdout --- nbread/mime_image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nbread/mime_image.py b/nbread/mime_image.py index 26ba63a..ab927ea 100644 --- a/nbread/mime_image.py +++ b/nbread/mime_image.py @@ -25,7 +25,8 @@ def check_terminal_supports_sixel() -> bool: def check_sixel_renderer_available() -> bool | SixelRenderer: # Checks for chafa - result = subprocess.run(["which", "chafa"]) + # capture_output=True to redir away from stdout when rendering notebook + result = subprocess.run(["which", "chafa"], capture_output=True) if result.returncode == 0: return SixelRenderer.CHAFA From d0f99f054358461e38db60e76e0d7c25f0f6be54 Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:49:14 +0800 Subject: [PATCH 08/10] feat: added feature flag for experimental sixel images --- nbread/__init__.py | 76 ++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/nbread/__init__.py b/nbread/__init__.py index ddec65a..e41e9e1 100644 --- a/nbread/__init__.py +++ b/nbread/__init__.py @@ -74,6 +74,7 @@ def render_ipynb_jit( guides: bool, use_pager: bool, pager_cmd: Optional[str] = None, + enable_images: bool = False, ) -> RenderableType: try: if use_pager: @@ -221,34 +222,39 @@ def wrapped_print(text): # Handle different MIME types in priority order if any(mime.startswith("image/") for mime in data): - for image_type in [ - "image/png", - "image/jpeg", - "image/svg+xml", - "image/gif", - ]: - if image_type in data: - from .mime_image import handle_image_output - - sixel_data = handle_image_output( - data[image_type], suffix=image_type.split("/")[1] - ) - if sixel_data is None: - # Print placeholder to acknowledge text - renderable = Text( - f"[{image_type}]", style="dim cyan" + if enable_images: + for image_type in [ + "image/png", + "image/jpeg", + "image/svg+xml", + "image/gif", + ]: + if image_type in data: + from .mime_image import handle_image_output + + sixel_data = handle_image_output( + data[image_type], suffix=image_type.split("/")[1] ) - else: - # Successfully obtained sixel payload, print it - print(sixel_data, end="") - renderable = Text("\n") - - break + if sixel_data is None: + # Print placeholder to acknowledge image + renderable = Text( + f"[{image_type}]", style="dim cyan" + ) + else: + # Successfully obtained sixel payload, print it + print(sixel_data, end="") + renderable = Text("\n") + + break - else: - renderable = Text( - f"[Unsupported: {image_type}]", style="dim cyan" - ) + else: + renderable = Text( + f"[Unsupported: {image_type}]", style="dim cyan" + ) + else: + # Images disabled by default, show placeholder + image_type = next(mime for mime in data.keys() if mime.startswith("image/")) + renderable = Text(f"[{image_type}]", style="dim cyan") elif "text/html" in data: from .mime_text import handle_html_output @@ -335,16 +341,25 @@ def run(): action="store_true", help="Disable pager and print directly to stdout", ) + parser.add_argument( + "--experimental-images", + action="store_true", + help="Enable experimental Sixel image rendering (disables pager, requires compatible terminal and chafa)", + ) args = parser.parse_args() # Determine paging behavior following Git's approach: - # 1. --no-pager flag takes precedence - # 2. $PAGER environment variable (empty string means no paging) - # 3. Default to auto with less + # 1. --experimental-images flag disables pager (Sixel doesn't work in pagers) + # 2. --no-pager flag takes precedence + # 3. $PAGER environment variable (empty string means no paging) + # 4. Default to auto with less pager_cmd = None use_pager = True - if args.no_pager: + if args.experimental_images: + # Images require direct output + use_pager = False + elif args.no_pager: use_pager = False else: env_pager = os.environ.get("PAGER") @@ -366,6 +381,7 @@ def run(): guides=False, use_pager=use_pager, pager_cmd=pager_cmd, + enable_images=args.experimental_images, ) From e6e42dc7898816129130ac26b330ab9ea180e17d Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:50:18 +0800 Subject: [PATCH 09/10] docs: updated README on --experimental-images --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d809cea..b9d1d5c 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,36 @@ Run `nbread notebook.ipynb` for notebook preview in terminal. ```bash $ nbread --help -usage: nbread [-h] [--no-pager] filename +usage: nbread [-h] [--no-pager] [--experimental-images] filename positional arguments: filename optional arguments: - -h, --help show this help message and exit - --no-pager Disable pager and print directly to stdout + -h, --help show this help message and exit + --no-pager Disable pager and print directly to stdout + --experimental-images Enable experimental Sixel image rendering (disables pager, + requires compatible terminal and chafa) Paging: Defaults to 'less' with auto-exit. Override with $PAGER env var or disable with --no-pager. Set PAGER='' to disable paging via environment. ``` +### Experimental: Image Support + +nbread supports rendering images in notebooks using Sixel graphics. This feature is experimental and requires: + +- A Sixel-compatible terminal (e.g., `xterm`, `mlterm`, `foot`, `wezterm`, `konsole`, `contour`) +- `chafa` installed (install via your package manager: `sudo apt install chafa`, `brew install chafa`, etc.) + +To enable image rendering: + +```bash +nbread --experimental-images notebook.ipynb +``` + +**Note:** The `--experimental-images` flag automatically disables the pager since Sixel graphics don't work correctly in pagers. For notebooks with images, you'll see the full output printed directly to your terminal. + ## Setup Installation: `pipx install git+https://github.com/tnwei/nbread`. From fa5abb7fb2da8382f8776e2e2702e960c04c001f Mon Sep 17 00:00:00 2001 From: tnwei Date: Wed, 31 Dec 2025 02:51:24 +0800 Subject: [PATCH 10/10] docs: updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e9b1f3..60ec9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ + Update `--paging` option to `--no-pager` and honor $PAGER to be more Unix-like + Print placeholders for non-text cell output (images and javascript), pretty prints specific text types (latex, json, html) + Remove cell borders to be copy-paste friendly ++ Show images with sixel using --experimental-images ## 0.1.2dev1 (Mar 7, 2024)