Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions packages/markitdown/src/markitdown/converters/_ipynb_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@
ACCEPTED_FILE_EXTENSIONS = [".ipynb"]


def _fence_for(text: str) -> str:
"""Pick a backtick fence longer than any backtick run in text.

A fixed triple-backtick fence breaks when the content itself contains
a ``` run (a printed markdown example, say): it closes early and the
rest of the output leaks out as prose.
"""
longest = 0
run = 0
for ch in text:
if ch == "`":
run += 1
longest = max(longest, run)
else:
run = 0
return "`" * max(3, longest + 1)


class IpynbConverter(DocumentConverter):
"""Converts Jupyter Notebook (.ipynb) files to Markdown."""

Expand Down Expand Up @@ -61,6 +79,36 @@ def convert(

return self._convert(json.loads(notebook_content))



@staticmethod
def _render_outputs(outputs: list) -> str:
"""Render a code cell's outputs as markdown, text-bearing ones only."""
parts: list[str] = []
for out in outputs:
if not isinstance(out, dict):
continue
out_type = out.get("output_type", "")
if out_type == "stream":
text = "".join(out.get("text", []) or [])
if text.strip():
lang = "text" if out.get("name", "stdout") != "stderr" else ""
fence = _fence_for(text)
parts.append(f"{fence}{lang}\n{text.rstrip()}\n{fence}")
elif out_type == "error":
lines = out.get("traceback") or []
header = f"{out.get('ename', 'Error')}: {out.get('evalue', '')}".rstrip(": ")
body = "\n".join(lines) if lines else header
fence = _fence_for(body)
parts.append(f"{fence}\n{body}\n{fence}")
elif out_type in ("execute_result", "display_data"):
data = out.get("data", {}) or {}
text = "".join(data.get("text/plain", []) or [])
if text.strip():
fence = _fence_for(text)
parts.append(f"{fence}\n{text.rstrip()}\n{fence}")
return "\n\n".join(parts)

def _convert(self, notebook_content: dict) -> DocumentConverterResult:
"""Helper function that converts notebook JSON content to Markdown."""
try:
Expand All @@ -83,11 +131,19 @@ def _convert(self, notebook_content: dict) -> DocumentConverterResult:

elif cell_type == "code":
# Code cells are wrapped in Markdown code blocks
md_output.append(f"```python\n{''.join(source_lines)}\n```")
src = ''.join(source_lines)
fence = _fence_for(src)
md_output.append(f"{fence}python\n{src}\n{fence}")
# Text-bearing outputs (stdout/stderr streams, error
# tracebacks, text results) follow their cell so the
# notebook's recorded results survive conversion (#2285).
md_output.append(self._render_outputs(cell.get("outputs", [])))
elif cell_type == "raw":
md_output.append(f"```\n{''.join(source_lines)}\n```")
src = ''.join(source_lines)
fence = _fence_for(src)
md_output.append(f"{fence}\n{src}\n{fence}")

md_text = "\n\n".join(md_output)
md_text = "\n\n".join(part for part in md_output if part.strip())

# Check for title in notebook metadata
title = notebook_content.get("metadata", {}).get("title", title)
Expand Down
71 changes: 71 additions & 0 deletions packages/markitdown/tests/test_ipynb_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Code cell outputs (streams, errors, text results) survive conversion."""
import io
import json
import re

from markitdown import MarkItDown, StreamInfo


def _notebook_bytes(cells):
nb = {"cells": cells, "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
return io.BytesIO(json.dumps(nb).encode())


def test_stream_and_error_outputs_rendered():
buf = _notebook_bytes([
{"cell_type": "code", "execution_count": 1, "metadata": {}, "source": ["print('hi')"],
"outputs": [
{"output_type": "stream", "name": "stdout", "text": ["hi\n"]},
{"output_type": "error", "ename": "ValueError", "evalue": "bad",
"traceback": ["Traceback (most recent call last):", "ValueError: bad"]},
]},
])
result = MarkItDown().convert_stream(buf, stream_info=StreamInfo(extension=".ipynb"))
assert "```python\nprint('hi')\n```" in result.markdown
assert "hi" in result.markdown
assert "ValueError: bad" in result.markdown


def test_execute_result_text_rendered():
buf = _notebook_bytes([
{"cell_type": "code", "execution_count": 2, "metadata": {}, "source": ["1+1"],
"outputs": [{"output_type": "execute_result", "execution_count": 2,
"data": {"text/plain": ["2"]}, "metadata": {}}]},
])
result = MarkItDown().convert_stream(buf, stream_info=StreamInfo(extension=".ipynb"))
assert "| 2 |" not in result.markdown
assert "```" in result.markdown and "\n2\n" in result.markdown


def test_no_outputs_leaves_cell_unchanged():
buf = _notebook_bytes([
{"cell_type": "code", "execution_count": None, "metadata": {}, "source": ["x = 1"], "outputs": []},
])
result = MarkItDown().convert_stream(buf, stream_info=StreamInfo(extension=".ipynb"))
assert result.markdown.strip() == "```python\nx = 1\n```"

def test_output_containing_backticks_gets_longer_fence():
"""A printed ``` run must not close the output fence early."""
nb = {
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"source": ["print(markdown_example)"],
"outputs": [
{"output_type": "stream", "name": "stdout", "text": ["before\n```\nafter\n"]}
],
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5,
}
buf = io.BytesIO(json.dumps(nb).encode())
result = MarkItDown().convert_stream(buf, stream_info=StreamInfo(extension=".ipynb"))
md = result.markdown
assert "````text\nbefore\n```\nafter" in md
fences = [len(run) for run in re.findall(r"^`+", md, re.MULTILINE)]
# source fence stays 3 (no backticks in it), output fence grows to 4
assert 4 in fences