From a634453407ad559c910920ae136129a316c51399 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:04:04 +0900 Subject: [PATCH] fix: keep blank lines inside fenced code blocks The output normalization collapsed every run of blank lines, including the ones inside a code fence, so a notebook cell or a
 block that
separated two definitions with two blank lines came back with one. The
collapsing now steps over closed fenced blocks.
---
 .../markitdown/src/markitdown/_markitdown.py  | 29 +++++-
 .../tests/test_code_block_blank_lines.py      | 98 +++++++++++++++++++
 2 files changed, 126 insertions(+), 1 deletion(-)
 create mode 100644 packages/markitdown/tests/test_code_block_blank_lines.py

diff --git a/packages/markitdown/src/markitdown/_markitdown.py b/packages/markitdown/src/markitdown/_markitdown.py
index 888d1e626..3a479b5a1 100644
--- a/packages/markitdown/src/markitdown/_markitdown.py
+++ b/packages/markitdown/src/markitdown/_markitdown.py
@@ -98,6 +98,33 @@ def _read_charset_sample(file_stream: BinaryIO) -> bytes:
     return sample
 
 
+# A fenced code block carries the document's own content, blank lines included:
+# a notebook cell, or a 
 block, that separates two definitions with two
+# blank lines has to come back with two. Only a closed fence is treated as a
+# block, so an unterminated one is still normalized.
+_FENCED_CODE_BLOCK = re.compile(
+    r"^(?P`{3,}|~{3,}).*?^(?P=fence)[`~]*[ \t]*$",
+    re.MULTILINE | re.DOTALL,
+)
+_BLANK_LINE_RUN = re.compile(r"\n{3,}")
+
+
+def _collapse_blank_lines(markdown: str) -> str:
+    """Collapse runs of blank lines, leaving fenced code blocks untouched."""
+    collapsed: List[str] = []
+    position = 0
+
+    for block in _FENCED_CODE_BLOCK.finditer(markdown):
+        collapsed.append(
+            _BLANK_LINE_RUN.sub("\n\n", markdown[position : block.start()])
+        )
+        collapsed.append(block.group(0))
+        position = block.end()
+
+    collapsed.append(_BLANK_LINE_RUN.sub("\n\n", markdown[position:]))
+    return "".join(collapsed)
+
+
 # Lower priority values are tried first.
 PRIORITY_SPECIFIC_FILE_FORMAT = (
     0.0  # e.g., .docx, .pdf, .xlsx, Or specific pages, e.g., wikipedia
@@ -686,7 +713,7 @@ def _convert(
                     res.text_content = "\n".join(
                         [line.rstrip() for line in re.split(r"\r?\n", res.text_content)]
                     )
-                    res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
+                    res.text_content = _collapse_blank_lines(res.text_content)
                     return res
 
         # If we got this far without success, report any exceptions
diff --git a/packages/markitdown/tests/test_code_block_blank_lines.py b/packages/markitdown/tests/test_code_block_blank_lines.py
new file mode 100644
index 000000000..43a689545
--- /dev/null
+++ b/packages/markitdown/tests/test_code_block_blank_lines.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3 -m pytest
+"""Blank lines inside a fenced code block are content, not layout.
+
+``MarkItDown._convert`` collapses runs of blank lines as it normalizes a
+converter's output. Applied inside a code fence that rewrites the document's
+own code -- PEP 8's two blank lines between top-level definitions came back
+as one.
+"""
+
+import io
+import json
+from typing import List
+
+from markitdown import MarkItDown, StreamInfo
+
+CODE = "def first():\n    return 1\n\n\ndef second():\n    return 2"
+
+
+def _convert(stream: io.BytesIO, extension: str, **kwargs: object) -> str:
+    return (
+        MarkItDown()
+        .convert_stream(stream, stream_info=StreamInfo(extension=extension, **kwargs))
+        .markdown
+    )
+
+
+def _notebook(source: str) -> io.BytesIO:
+    lines: List[str] = source.splitlines(keepends=True)
+    notebook = {
+        "cells": [
+            {
+                "cell_type": "code",
+                "execution_count": 1,
+                "metadata": {},
+                "outputs": [],
+                "source": lines,
+            }
+        ],
+        "metadata": {},
+        "nbformat": 4,
+        "nbformat_minor": 5,
+    }
+    return io.BytesIO(json.dumps(notebook).encode("utf-8"))
+
+
+def _html(body: str) -> io.BytesIO:
+    return io.BytesIO(f"{body}".encode("utf-8"))
+
+
+def test_notebook_code_cell_keeps_its_blank_lines() -> None:
+    markdown = _convert(_notebook(CODE), ".ipynb")
+
+    assert CODE in markdown
+
+
+def test_html_code_block_keeps_its_blank_lines() -> None:
+    markdown = _convert(
+        _html(f"
{CODE}\n
"), + ".html", + mimetype="text/html", + charset="utf-8", + ) + + assert CODE in markdown + + +def test_blank_lines_between_paragraphs_are_still_collapsed() -> None: + markdown = _convert( + _html("

First

Second

"), + ".html", + mimetype="text/html", + charset="utf-8", + ) + + assert markdown == "First\n\nSecond" + + +def test_blank_lines_around_a_code_block_are_still_collapsed() -> None: + markdown = _convert( + _html("

Before

x = 1\n

After

"), + ".html", + mimetype="text/html", + charset="utf-8", + ) + + assert markdown == "Before\n\n```\nx = 1\n```\n\nAfter" + + +def test_unterminated_fence_is_not_treated_as_a_code_block() -> None: + """Only a closed fence protects its content; an open one is normalized.""" + markdown = _convert( + io.BytesIO("```\nx = 1\n\n\n\ny = 2\n".encode("utf-8")), + ".md", + mimetype="text/markdown", + charset="utf-8", + ) + + assert markdown == "```\nx = 1\n\ny = 2\n"