From d47bdd809802856c09317ff1007787729ab695be Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Thu, 24 Sep 2026 03:38:10 +0530 Subject: [PATCH 1/3] Read a notebook cell's source as text, not as characters nbformat's `multiline_string` is a string *or* a list of lines. Both validate, and `nbformat.reads` keeps whichever the file used. The converter walked the raw value looking for a heading: for line in source_lines: if line.startswith("# "): With a list that iterates lines, which is what was intended. With a string it iterates characters, and no single character starts with "# ", so a notebook written that way came out with no title at all. The markdown body was fine, because "".join() over a string reassembles it. Take the cell's text once, then split it for the heading scan. That also picks up a heading in a list entry that holds several lines, which the per-entry check could not see. --- .../markitdown/converters/_ipynb_converter.py | 20 +++-- packages/markitdown/tests/test_module_misc.py | 79 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index 6bc1c1673f..c5f0f77849 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -69,23 +69,31 @@ def _convert(self, notebook_content: dict) -> DocumentConverterResult: for cell in notebook_content.get("cells", []): cell_type = cell.get("cell_type", "") - source_lines = cell.get("source", []) + # nbformat's `multiline_string` is a string *or* a list of lines, and + # both are valid; `nbformat.reads` keeps whichever the file used. + # Joining the list and taking the string as-is give the same text. + source = cell.get("source", []) + source_text = source if isinstance(source, str) else "".join(source) if cell_type == "markdown": - md_output.append("".join(source_lines)) + md_output.append(source_text) - # Extract the first # heading as title if not already found + # Extract the first # heading as title if not already found. + # Split the text rather than walking the raw value: a string + # source would otherwise be iterated one character at a time, + # and a list entry holding several lines would hide a heading + # that is not on its first one. if title is None: - for line in source_lines: + for line in source_text.splitlines(): if line.startswith("# "): title = line.removeprefix("# ").strip() break elif cell_type == "code": # Code cells are wrapped in Markdown code blocks - md_output.append(f"```python\n{''.join(source_lines)}\n```") + md_output.append(f"```python\n{source_text}\n```") elif cell_type == "raw": - md_output.append(f"```\n{''.join(source_lines)}\n```") + md_output.append(f"```\n{source_text}\n```") md_text = "\n\n".join(md_output) diff --git a/packages/markitdown/tests/test_module_misc.py b/packages/markitdown/tests/test_module_misc.py index 434cd3c5bf..cc75bd091b 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -1927,3 +1927,82 @@ def fake_run(args, **kwargs): test() print("OK") print("All tests passed!") + + +def test_ipynb_string_source_is_read_as_text_not_characters() -> None: + """`source` may be a single string, and the title must still be found. + + nbformat's `multiline_string` is a string or a list of lines. Both validate, + and `nbformat.reads` keeps whichever the file used. Walking the raw value + iterated a string one character at a time, so no character ever started with + "# " and the notebook came out titleless. + """ + from markitdown.converters._ipynb_converter import IpynbConverter + + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "markdown", + "source": "# My Notebook\nintro text\n", + "metadata": {}, + }, + {"cell_type": "code", "source": "print('hello')\n", "metadata": {}}, + ], + } + + result = IpynbConverter()._convert(notebook) + + assert result.title == "My Notebook" + assert "# My Notebook" in result.markdown + assert "```python\nprint('hello')" in result.markdown + + +def test_ipynb_string_and_list_sources_agree() -> None: + """The two spellings of the same notebook convert to the same thing.""" + from markitdown.converters._ipynb_converter import IpynbConverter + + def notebook(markdown_source, code_source): + return { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "markdown", + "source": markdown_source, + "metadata": {}, + }, + {"cell_type": "code", "source": code_source, "metadata": {}}, + ], + } + + as_list = IpynbConverter()._convert( + notebook(["# Title\n", "body\n"], ["print(1)\n"]) + ) + as_string = IpynbConverter()._convert(notebook("# Title\nbody\n", "print(1)\n")) + + assert as_list.title == as_string.title == "Title" + assert as_list.markdown == as_string.markdown + + +def test_ipynb_heading_below_the_first_line_of_a_source_entry() -> None: + """A list entry can hold several lines; a heading on a later one still counts.""" + from markitdown.converters._ipynb_converter import IpynbConverter + + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "markdown", + "source": ["intro paragraph\n# My Notebook\n"], + "metadata": {}, + } + ], + } + + assert IpynbConverter()._convert(notebook).title == "My Notebook" From da81da6ececab6c09ffcdee6732b4d0f5bd2abdf Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Sun, 4 Oct 2026 19:54:43 +0530 Subject: [PATCH 2/3] Only treat "# " after a newline as a heading Co-Authored-By: Claude Opus 5.5 (1M context) --- .../markitdown/converters/_ipynb_converter.py | 2 +- packages/markitdown/tests/test_module_misc.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index c5f0f77849..c5836051b7 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -84,7 +84,7 @@ def _convert(self, notebook_content: dict) -> DocumentConverterResult: # and a list entry holding several lines would hide a heading # that is not on its first one. if title is None: - for line in source_text.splitlines(): + for line in source_text.split("\n"): if line.startswith("# "): title = line.removeprefix("# ").strip() break diff --git a/packages/markitdown/tests/test_module_misc.py b/packages/markitdown/tests/test_module_misc.py index cc75bd091b..6ece5f5dea 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -2006,3 +2006,23 @@ def test_ipynb_heading_below_the_first_line_of_a_source_entry() -> None: } assert IpynbConverter()._convert(notebook).title == "My Notebook" + + +def test_ipynb_heading_only_counts_at_the_start_of_a_line() -> None: + """A "# " after a line separator other than a newline is not a heading.""" + from markitdown.converters._ipynb_converter import IpynbConverter + + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "markdown", + "source": ["pasted text
# not a title\n"], + "metadata": {}, + } + ], + } + + assert IpynbConverter()._convert(notebook).title is None From 19ee1b6af83aab5aec12c939c920f0a46abb178d Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Mon, 5 Oct 2026 22:41:55 +0530 Subject: [PATCH 3/3] Split heading lines on \r and \r\n too --- .../markitdown/converters/_ipynb_converter.py | 7 +++-- packages/markitdown/tests/test_module_misc.py | 31 +++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index c5836051b7..817f457bca 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -1,5 +1,6 @@ from typing import BinaryIO, Any import json +import re from .._base_converter import DocumentConverter, DocumentConverterResult from .._exceptions import FileConversionException @@ -70,8 +71,8 @@ def _convert(self, notebook_content: dict) -> DocumentConverterResult: for cell in notebook_content.get("cells", []): cell_type = cell.get("cell_type", "") # nbformat's `multiline_string` is a string *or* a list of lines, and - # both are valid; `nbformat.reads` keeps whichever the file used. - # Joining the list and taking the string as-is give the same text. + # both are valid. Joining the list and taking the string as-is give + # the same text. source = cell.get("source", []) source_text = source if isinstance(source, str) else "".join(source) @@ -84,7 +85,7 @@ def _convert(self, notebook_content: dict) -> DocumentConverterResult: # and a list entry holding several lines would hide a heading # that is not on its first one. if title is None: - for line in source_text.split("\n"): + for line in re.split(r"\r\n|\r|\n", source_text): if line.startswith("# "): title = line.removeprefix("# ").strip() break diff --git a/packages/markitdown/tests/test_module_misc.py b/packages/markitdown/tests/test_module_misc.py index dd98e6111a..044445ab62 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -555,10 +555,9 @@ def test_ipynb_accepts_non_ascii() -> None: def test_ipynb_string_source_is_read_as_text_not_characters() -> None: """`source` may be a single string, and the title must still be found. - nbformat's `multiline_string` is a string or a list of lines. Both validate, - and `nbformat.reads` keeps whichever the file used. Walking the raw value - iterated a string one character at a time, so no character ever started with - "# " and the notebook came out titleless. + nbformat's `multiline_string` is a string or a list of lines, and both + validate. Walking the raw value iterated a string one character at a time, + so no character ever started with "# " and the notebook came out titleless. """ from markitdown.converters._ipynb_converter import IpynbConverter @@ -632,7 +631,7 @@ def test_ipynb_heading_below_the_first_line_of_a_source_entry() -> None: def test_ipynb_heading_only_counts_at_the_start_of_a_line() -> None: - """A "# " after a line separator other than a newline is not a heading.""" + """Only \\n, \\r\\n and \\r end a line, so "# " after U+2028 is not a heading.""" from markitdown.converters._ipynb_converter import IpynbConverter notebook = { @@ -642,7 +641,7 @@ def test_ipynb_heading_only_counts_at_the_start_of_a_line() -> None: "cells": [ { "cell_type": "markdown", - "source": ["pasted text
# not a title\n"], + "source": ["pasted text\u2028# not a title\n"], "metadata": {}, } ], @@ -651,6 +650,26 @@ def test_ipynb_heading_only_counts_at_the_start_of_a_line() -> None: assert IpynbConverter()._convert(notebook).title is None +def test_ipynb_heading_with_cr_line_endings() -> None: + """A source written with \\r line endings still yields just the heading.""" + from markitdown.converters._ipynb_converter import IpynbConverter + + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "markdown", + "source": ["# My Notebook\r", "intro text\r"], + "metadata": {}, + } + ], + } + + assert IpynbConverter()._convert(notebook).title == "My Notebook" + + # File URI validation UNC_URI_PATHS = [