Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
69 changes: 49 additions & 20 deletions nbread/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -353,6 +381,7 @@ def run():
guides=False,
use_pager=use_pager,
pager_cmd=pager_cmd,
enable_images=args.experimental_images,
)


Expand Down
74 changes: 74 additions & 0 deletions nbread/mime_image.py
Original file line number Diff line number Diff line change
@@ -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