Skip to content

Commit 3faa1f0

Browse files
committed
fix: preserve Markdown hard line breaks
1 parent 4cc9fa1 commit 3faa1f0

4 files changed

Lines changed: 219 additions & 4 deletions

File tree

‎packages/markitdown/src/markitdown/_markitdown.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ def _read_charset_sample(file_stream: BinaryIO) -> bytes:
9898
return sample
9999

100100

101+
def _rstrip_preserving_hard_break(line: str) -> str:
102+
"""Drop insignificant trailing whitespace, but keep a CommonMark hard break.
103+
104+
Two or more trailing spaces before a newline are a hard line break. A line
105+
that contains only whitespace is not one. Collapsing runs of blank lines is
106+
unchanged.
107+
"""
108+
if line.endswith(" ") and line.strip():
109+
return line
110+
return line.rstrip()
111+
112+
101113
# Lower priority values are tried first.
102114
PRIORITY_SPECIFIC_FILE_FORMAT = (
103115
0.0 # e.g., .docx, .pdf, .xlsx, Or specific pages, e.g., wikipedia
@@ -680,7 +692,10 @@ def _convert(
680692
if res is not None:
681693
# Normalize the content
682694
res.text_content = "\n".join(
683-
[line.rstrip() for line in re.split(r"\r?\n", res.text_content)]
695+
[
696+
_rstrip_preserving_hard_break(line)
697+
for line in re.split(r"\r?\n", res.text_content)
698+
]
684699
)
685700
res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
686701
return res

‎packages/markitdown/tests/test_docx.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1134,7 +1134,7 @@ def test_docx_underlined_text_is_preserved(tmp_path) -> None:
11341134
('<w:t xml:space="preserve"> </w:t>', "First Last"),
11351135
("<w:tab/>", "First Last"),
11361136
("<w:t>&#160;</w:t>", "First\u00a0Last"),
1137-
("<w:br/>", "First\nLast"),
1137+
("<w:br/>", "First \nLast"),
11381138
],
11391139
)
11401140
def test_docx_underlined_whitespace_is_preserved(

‎packages/markitdown/tests/test_html.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_underline_preserves_whitespace_verbatim(whitespace: str) -> None:
3838
(" ", "First Last"),
3939
("\t", "First Last"),
4040
("&#160;", "First\u00a0Last"),
41-
("<br>", "First\nLast"),
41+
("<br>", "First \nLast"),
4242
("word", "First<u>word</u>Last"),
4343
(" word ", "First <u>word</u> Last"),
4444
],
@@ -289,7 +289,7 @@ def test_html_strikethrough_variants(tmp_path) -> None:
289289
# A line break inside the element is kept, and the markup
290290
# survives it because strikethrough may span a single newline
291291
"Newline I~~J\nK~~L.",
292-
"Break M~~N\nO~~P.",
292+
"Break M~~N \nO~~P.",
293293
]
294294
)
295295

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Trailing spaces that encode a hard break must survive normalization.
2+
3+
``MarkItDown._convert`` strips trailing whitespace from every converter's
4+
Markdown. Two or more trailing spaces are a CommonMark hard line break, so
5+
those are kept. A single trailing space, a trailing tab, and a line that is
6+
only whitespace are still removed. Runs of blank lines are still collapsed.
7+
"""
8+
9+
import io
10+
from typing import Any, BinaryIO
11+
12+
import pytest
13+
14+
from markitdown import (
15+
DocumentConverter,
16+
DocumentConverterResult,
17+
MarkItDown,
18+
StreamInfo,
19+
)
20+
from markitdown._markitdown import _rstrip_preserving_hard_break
21+
22+
23+
class _PassthroughConverter(DocumentConverter):
24+
def accepts(
25+
self,
26+
file_stream: BinaryIO,
27+
stream_info: StreamInfo,
28+
**kwargs: Any,
29+
) -> bool:
30+
return (stream_info.extension or "").lower() == ".fixture"
31+
32+
def convert(
33+
self,
34+
file_stream: BinaryIO,
35+
stream_info: StreamInfo,
36+
**kwargs: Any,
37+
) -> DocumentConverterResult:
38+
return DocumentConverterResult(markdown=file_stream.read().decode("utf-8"))
39+
40+
41+
def _convert(source: str) -> str:
42+
markitdown = MarkItDown(enable_builtins=False, enable_plugins=False)
43+
markitdown.register_converter(_PassthroughConverter(), priority=-1.0)
44+
return markitdown.convert_stream(
45+
io.BytesIO(source.encode("utf-8")),
46+
stream_info=StreamInfo(extension=".fixture", charset="utf-8"),
47+
).markdown
48+
49+
50+
def _convert_builtin(source: str, *, extension: str, mimetype: str) -> str:
51+
return (
52+
MarkItDown(enable_plugins=False)
53+
.convert_stream(
54+
io.BytesIO(source.encode("utf-8")),
55+
stream_info=StreamInfo(
56+
extension=extension, mimetype=mimetype, charset="utf-8"
57+
),
58+
)
59+
.markdown
60+
)
61+
62+
63+
@pytest.mark.parametrize(
64+
("line", "expected"),
65+
[
66+
("First line ", "First line "),
67+
("First line ", "First line "),
68+
("First line ", "First line"),
69+
("First line\t", "First line"),
70+
("First line \t", "First line"),
71+
("First line \t", "First line"),
72+
("First line\t ", "First line\t "),
73+
(" ", ""),
74+
("", ""),
75+
("First line\u00a0\u00a0", "First line"),
76+
],
77+
)
78+
def test_rstrip_keeps_only_a_hard_break(line: str, expected: str) -> None:
79+
assert _rstrip_preserving_hard_break(line) == expected
80+
81+
82+
def test_hard_line_breaks_survive_normalization() -> None:
83+
source = "First line \nSecond line\n"
84+
85+
assert _convert(source) == source
86+
87+
88+
def test_single_trailing_space_is_still_trimmed() -> None:
89+
source = "First line \nSecond line\n"
90+
91+
assert _convert(source) == "First line\nSecond line\n"
92+
93+
94+
def test_hard_line_breaks_with_more_than_two_spaces_are_preserved() -> None:
95+
source = "First line \nSecond line\n"
96+
97+
assert _convert(source) == source
98+
99+
100+
def test_several_hard_breaks_in_one_document_are_preserved() -> None:
101+
source = "One \nTwo \nThree\n"
102+
103+
assert _convert(source) == source
104+
105+
106+
def test_crlf_hard_break_becomes_lf_and_keeps_the_spaces() -> None:
107+
source = "First line \r\nSecond line\r\n"
108+
109+
assert _convert(source) == "First line \nSecond line\n"
110+
111+
112+
def test_backslash_hard_break_is_unchanged() -> None:
113+
source = "First line\\\nSecond line\n"
114+
115+
assert _convert(source) == source
116+
117+
118+
def test_trailing_tab_is_still_trimmed() -> None:
119+
source = "First line\t\nSecond line\n"
120+
121+
assert _convert(source) == "First line\nSecond line\n"
122+
123+
124+
def test_spaces_followed_by_a_tab_are_not_a_hard_break() -> None:
125+
source = "First line \t\nSecond line\n"
126+
127+
assert _convert(source) == "First line\nSecond line\n"
128+
129+
130+
def test_whitespace_only_line_is_still_trimmed() -> None:
131+
source = "First line\n \nSecond line\n"
132+
133+
assert _convert(source) == "First line\n\nSecond line\n"
134+
135+
136+
def test_blank_line_runs_are_still_collapsed() -> None:
137+
source = "First line\n\n\n\nSecond line\n"
138+
139+
assert _convert(source) == "First line\n\nSecond line\n"
140+
141+
142+
def test_hard_break_survives_beside_a_collapsed_blank_run() -> None:
143+
source = "First line \n\n\n\nSecond line \nThird\n"
144+
145+
assert _convert(source) == "First line \n\nSecond line \nThird\n"
146+
147+
148+
def test_two_trailing_spaces_inside_a_fence_are_kept() -> None:
149+
source = "```\ncode \n```\n"
150+
151+
assert _convert(source) == source
152+
153+
154+
def test_one_trailing_space_inside_a_fence_is_still_trimmed() -> None:
155+
source = "```\ncode \n```\n"
156+
157+
assert _convert(source) == "```\ncode\n```\n"
158+
159+
160+
def test_empty_output_stays_empty() -> None:
161+
assert _convert("") == ""
162+
163+
164+
def test_hard_break_without_a_final_newline_is_kept() -> None:
165+
source = "First line "
166+
167+
assert _convert(source) == source
168+
169+
170+
def test_markdown_files_keep_hard_line_breaks() -> None:
171+
source = "First line \nSecond line\n"
172+
173+
assert _convert_builtin(source, extension=".md", mimetype="text/markdown") == source
174+
175+
176+
def test_markdown_crlf_file_keeps_the_hard_break() -> None:
177+
source = "- item one \r\n continuation\r\n"
178+
179+
assert (
180+
_convert_builtin(source, extension=".markdown", mimetype="text/markdown")
181+
== "- item one \n continuation\n"
182+
)
183+
184+
185+
def test_html_br_keeps_a_hard_line_break() -> None:
186+
html = "<p>First<br>Second</p><p>Third<br/>Fourth</p>"
187+
188+
assert (
189+
_convert_builtin(html, extension=".html", mimetype="text/html")
190+
== "First \nSecond\n\nThird \nFourth"
191+
)
192+
193+
194+
def test_html_paragraphs_do_not_gain_trailing_spaces() -> None:
195+
html = "<p>First </p><p>Second</p>"
196+
197+
assert (
198+
_convert_builtin(html, extension=".html", mimetype="text/html")
199+
== "First\n\nSecond"
200+
)

0 commit comments

Comments
 (0)