From bdaaf243c2ff9c48bc669d3a3bb5528e5aa9e257 Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:00:46 +0530 Subject: [PATCH 1/2] feat(ipynb): render code cell outputs (streams, errors, text results) Notebooks carry their recorded results in cell outputs, but the converter dropped them entirely: only sources survived. Render the text-bearing outputs after each code cell - stdout/stderr streams and plain-text results as fenced blocks, error outputs with their traceback (or ename/evalue when no traceback was recorded). Image/HTML outputs are left out. Fixes #2285 Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../markitdown/converters/_ipynb_converter.py | 32 +++++++++++++- .../markitdown/tests/test_ipynb_outputs.py | 44 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/markitdown/tests/test_ipynb_outputs.py diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index 6bc1c1673f..9bf4d1f01b 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -61,6 +61,32 @@ 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 "" + parts.append(f"```{lang}\n{text.rstrip()}\n```") + 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 + parts.append(f"```\n{body}\n```") + elif out_type in ("execute_result", "display_data"): + data = out.get("data", {}) or {} + text = "".join(data.get("text/plain", []) or []) + if text.strip(): + parts.append(f"```\n{text.rstrip()}\n```") + return "\n\n".join(parts) + def _convert(self, notebook_content: dict) -> DocumentConverterResult: """Helper function that converts notebook JSON content to Markdown.""" try: @@ -84,10 +110,14 @@ 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```") + # 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```") - 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) diff --git a/packages/markitdown/tests/test_ipynb_outputs.py b/packages/markitdown/tests/test_ipynb_outputs.py new file mode 100644 index 0000000000..e6f09433ca --- /dev/null +++ b/packages/markitdown/tests/test_ipynb_outputs.py @@ -0,0 +1,44 @@ +"""Code cell outputs (streams, errors, text results) survive conversion.""" +import io +import json + +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```" From b24f82b7e895d5d63a26b630ab6160f55e1fc96d Mon Sep 17 00:00:00 2001 From: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:11:50 +0530 Subject: [PATCH 2/2] fix(ipynb): pick fence length longer than any backtick run in the content Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> --- .../markitdown/converters/_ipynb_converter.py | 36 ++++++++++++++++--- .../markitdown/tests/test_ipynb_outputs.py | 27 ++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index 9bf4d1f01b..fc5848c48c 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -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.""" @@ -62,6 +80,7 @@ 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.""" @@ -74,17 +93,20 @@ def _render_outputs(outputs: list) -> str: text = "".join(out.get("text", []) or []) if text.strip(): lang = "text" if out.get("name", "stdout") != "stderr" else "" - parts.append(f"```{lang}\n{text.rstrip()}\n```") + 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 - parts.append(f"```\n{body}\n```") + 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(): - parts.append(f"```\n{text.rstrip()}\n```") + 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: @@ -109,13 +131,17 @@ 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(part for part in md_output if part.strip()) diff --git a/packages/markitdown/tests/test_ipynb_outputs.py b/packages/markitdown/tests/test_ipynb_outputs.py index e6f09433ca..105d9cea64 100644 --- a/packages/markitdown/tests/test_ipynb_outputs.py +++ b/packages/markitdown/tests/test_ipynb_outputs.py @@ -1,6 +1,7 @@ """Code cell outputs (streams, errors, text results) survive conversion.""" import io import json +import re from markitdown import MarkItDown, StreamInfo @@ -42,3 +43,29 @@ def test_no_outputs_leaves_cell_unchanged(): ]) 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