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"