diff --git a/packages/markitdown/src/markitdown/converters/_markdownify.py b/packages/markitdown/src/markitdown/converters/_markdownify.py index e0bbcaf2a..f9aa7cd32 100644 --- a/packages/markitdown/src/markitdown/converters/_markdownify.py +++ b/packages/markitdown/src/markitdown/converters/_markdownify.py @@ -7,6 +7,21 @@ _PERCENT_ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}") +# Matches a pipe together with the (possibly empty) run of backslashes in +# front of it, anchored so the run starts after a non-backslash (or at the +# start of the string). The run therefore holds *every* backslash in front +# of the pipe, which makes the substitution parity-correct: a pipe preceded +# by an even run (including zero) is unescaped and gains one backslash, +# while an already-escaped pipe (odd run) matches nothing and is left alone. +# Idempotency matters because some converters (e.g. XLSX via _escape_sheet, +# PPTX via html-escaped cells) pre-escape pipes before the HTML reaches us. +_TABLE_PIPE_RE = re.compile(r"(? str: + """Escape unescaped pipes so cell text cannot split a Markdown table.""" + return _TABLE_PIPE_RE.sub(r"\1\\|", text) + def _quote_path_preserving_percent_encoded_octets(path: str) -> str: """Quote a URL path while preserving existing %HH byte encodings.""" @@ -172,5 +187,19 @@ def convert_strike(self, el: Any, text: str, *args, **kwargs) -> str: """Obsolete is still in the wild; treat it like /.""" return self.convert_s(el, text, *args, **kwargs) # type: ignore + def convert_td(self, el: Any, text: str, parent_tags: Any) -> str: + """Same as usual, but escape pipes so cell text cannot split the table.""" + colspan = 1 + if "colspan" in el.attrs and el["colspan"].isdigit(): + colspan = max(1, min(1000, int(el["colspan"]))) + return " " + _escape_table_cell_pipes(text.strip().replace("\n", " ")) + " |" * colspan + + def convert_th(self, el: Any, text: str, parent_tags: Any) -> str: + """Same as usual, but escape pipes so header text cannot split the table.""" + colspan = 1 + if "colspan" in el.attrs and el["colspan"].isdigit(): + colspan = max(1, min(1000, int(el["colspan"]))) + return " " + _escape_table_cell_pipes(text.strip().replace("\n", " ")) + " |" * colspan + def convert_soup(self, soup: Any) -> str: return super().convert_soup(soup) # type: ignore diff --git a/packages/markitdown/tests/test_html_converter.py b/packages/markitdown/tests/test_html_converter.py index e56435196..8e5a79a34 100644 --- a/packages/markitdown/tests/test_html_converter.py +++ b/packages/markitdown/tests/test_html_converter.py @@ -66,6 +66,52 @@ def test_html_href_does_not_quote_query_or_fragment() -> None: assert f"[example]({expected_href})" in markdown +def test_html_table_pipe_in_cell_is_escaped() -> None: + html = ( + "" + "
NameNote
AliceHas a | pipe
" + ) + + markdown = _convert_html(html) + + assert "| Alice | Has a \\| pipe |" in markdown + + +def test_html_table_pipe_in_header_is_escaped() -> None: + html = ( + "" + "
A|BC
12
" + ) + + markdown = _convert_html(html) + + assert "| A\\|B | C |" in markdown + + +def test_html_table_already_escaped_pipe_is_not_doubled() -> None: + html = ( + "" + "
H1H2
a\\|bc
" + ) + + markdown = _convert_html(html) + + assert "| a\\|b | c |" in markdown + assert "\\\\|" not in markdown + + +def test_html_table_pipe_in_inline_code_is_escaped() -> None: + html = ( + "" + "
ValueControl
a|bend
" + ) + + markdown = _convert_html(html) + + assert "`a\\|b`" in markdown + assert "| end |" in markdown + + def test_img_prefers_data_src_over_placeholder_data_uri() -> None: placeholder = ( "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7"