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) 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`. diff --git a/nbread/__init__.py b/nbread/__init__.py index 4cdbef3..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: @@ -220,22 +221,40 @@ 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): + 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] + ) + 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: + # 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 @@ -322,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") @@ -353,6 +381,7 @@ def run(): guides=False, use_pager=use_pager, pager_cmd=pager_cmd, + enable_images=args.experimental_images, ) diff --git a/nbread/mime_image.py b/nbread/mime_image.py new file mode 100644 index 0000000..ab927ea --- /dev/null +++ b/nbread/mime_image.py @@ -0,0 +1,74 @@ +import os +import base64 +import tempfile +import subprocess +from enum import Enum + + +class SixelRenderer(Enum): + CHAFA = "chafa" + + +def check_terminal_supports_sixel() -> bool: + 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 | SixelRenderer: + # Checks for 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 + + return False + + +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: + 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.decode('latin-1') + else: + sixel_data = None + finally: + os.unlink(tmp_path) + + return sixel_data + + +def handle_image_output(image_data: str, suffix: str) -> str | None: + if not check_terminal_supports_sixel(): + return None + + renderer = check_sixel_renderer_available() + + if renderer is False: + return None + + else: + if renderer == SixelRenderer.CHAFA: + sixel_data = render_sixel_chafa(image_data, suffix) + return sixel_data + else: + # Not implemented yet! + return None