diff --git a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py index b15e77aa2..3ec423080 100644 --- a/packages/markitdown/src/markitdown/converters/_ipynb_converter.py +++ b/packages/markitdown/src/markitdown/converters/_ipynb_converter.py @@ -1,10 +1,24 @@ from typing import BinaryIO, Any import json +import re from .._base_converter import DocumentConverter, DocumentConverterResult from .._exceptions import FileConversionException from .._stream_info import StreamInfo + +def _fenced_code_block(content: str, info_string: str = "") -> str: + """Wrap content in a Markdown code fence that is guaranteed not to be + closed prematurely by backticks inside the content. Per CommonMark, the + fence must be longer than the longest run of backticks it contains, so a + cell that itself prints ``` (common in notebooks demoing Markdown) does not + leak out as prose.""" + longest_backtick_run = max( + (len(m) for m in re.findall(r"`+", content)), default=0 + ) + fence = "`" * max(3, longest_backtick_run + 1) + return f"{fence}{info_string}\n{content}\n{fence}" + CANDIDATE_MIME_TYPE_PREFIXES = [ "application/json", ] @@ -76,9 +90,11 @@ 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```") + md_output.append( + _fenced_code_block("".join(source_lines), "python") + ) elif cell_type == "raw": - md_output.append(f"```\n{''.join(source_lines)}\n```") + md_output.append(_fenced_code_block("".join(source_lines))) 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 4d62e4919..ffacdffce 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -532,9 +532,46 @@ def test_markitdown_llm() -> None: validate_strings(result, PPTX_TEST_STRINGS) +def test_ipynb_code_cell_with_backtick_fence() -> None: + """A notebook code/raw cell whose source itself contains a ``` fence must + be wrapped in a longer fence, otherwise the inner backticks close the code + block early and the cell's content leaks out as prose. The cell source must + survive verbatim as a single fenced block.""" + import json + + inner = 'print("""\n```\nnot python\n```\n""")' + notebook = { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {}, + "cells": [ + { + "cell_type": "code", + "source": [inner], + "metadata": {}, + "outputs": [], + "execution_count": None, + } + ], + } + result = MarkItDown().convert_stream( + io.BytesIO(json.dumps(notebook).encode("utf-8")), file_extension=".ipynb" + ) + + # The opening fence must be longer than the 3-backtick run inside the cell. + fence_match = re.match(r"(`{4,})python\n", result.markdown) + assert fence_match, ( + f"code cell was not wrapped in a long-enough fence: {result.markdown!r}" + ) + fence = fence_match.group(1) + # The cell's source must appear intact, enclosed by the longer fence. + assert f"{fence}python\n{inner}\n{fence}" in result.markdown + + if __name__ == "__main__": """Runs this file's tests from the command line.""" for test in [ + test_ipynb_code_cell_with_backtick_fence, test_stream_info_operations, test_data_uris, test_file_uris,