From 86346b93589a4c0065ab96504ab4a64088ff4b5e Mon Sep 17 00:00:00 2001 From: zhao0335 <3797723137@qq.com> Date: Sat, 12 Sep 2026 03:48:30 +0800 Subject: [PATCH] Escape pipes in HTML/DOCX/XLSX table cells A literal | in an HTML table cell was emitted raw, so converted rows gained phantom columns. DOCX and XLSX/XLS are affected because both convert through the HTML path. Reuse the same escaping rule as the CSV converter (#2266 / #2464): double any run of backslashes immediately before the pipe, then escape the pipe. Fixes #2438 and #2436. --- .../src/markitdown/converters/_markdownify.py | 45 ++++++++++++ .../markitdown/tests/test_html_converter.py | 70 +++++++++++++++++++ packages/markitdown/tests/test_module_misc.py | 30 ++++++++ 3 files changed, 145 insertions(+) diff --git a/packages/markitdown/src/markitdown/converters/_markdownify.py b/packages/markitdown/src/markitdown/converters/_markdownify.py index e0bbcaf2a..0fafc63b5 100644 --- a/packages/markitdown/src/markitdown/converters/_markdownify.py +++ b/packages/markitdown/src/markitdown/converters/_markdownify.py @@ -7,6 +7,22 @@ _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, so that run can be doubled before the pipe is escaped. Same rule as the +# CSV converter (#2266 / #2464). The lookbehind avoids retrying from each +# position inside a backslash run. +_PIPE_ESCAPE_RE = re.compile(r"(? str: + r"""Escape cell text so it is safe inside a Markdown table cell. + + A pipe is a column separator, so it must be escaped. + Line breaks would end the row early, so they collapse to a single space. + """ + value = _PIPE_ESCAPE_RE.sub(lambda m: m.group(1) * 2 + r"\|", value) + return value.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") + def _quote_path_preserving_percent_encoded_octets(path: str) -> str: """Quote a URL path while preserving existing %HH byte encodings.""" @@ -30,6 +46,7 @@ class _CustomMarkdownify(markdownify.MarkdownConverter): - Removing javascript hyperlinks. - Truncating images with large data:uri sources. - Ensuring URIs are properly escaped, and do not conflict with Markdown syntax + - Escaping pipes (and collapsing newlines) in table cells """ def __init__(self, **options: Any): @@ -143,6 +160,34 @@ def convert_img( return "![%s](%s%s)" % (alt, src, title_part) + def convert_td( + self, + el: Any, + text: str, + parent_tags: Any = None, + **kwargs, + ) -> str: + """Escape pipes so cell data cannot split the Markdown table row.""" + colspan = 1 + colspan_attr = el.attrs.get("colspan") if el.attrs else None + if isinstance(colspan_attr, str) and colspan_attr.isdigit(): + colspan = max(1, min(1000, int(colspan_attr))) + return " " + _escape_table_cell(text.strip()) + " |" * colspan + + def convert_th( + self, + el: Any, + text: str, + parent_tags: Any = None, + **kwargs, + ) -> str: + """Escape pipes so header cell data cannot split the Markdown table row.""" + colspan = 1 + colspan_attr = el.attrs.get("colspan") if el.attrs else None + if isinstance(colspan_attr, str) and colspan_attr.isdigit(): + colspan = max(1, min(1000, int(colspan_attr))) + return " " + _escape_table_cell(text.strip()) + " |" * colspan + def convert_input( self, el: Any, diff --git a/packages/markitdown/tests/test_html_converter.py b/packages/markitdown/tests/test_html_converter.py index e56435196..67b23a6ef 100644 --- a/packages/markitdown/tests/test_html_converter.py +++ b/packages/markitdown/tests/test_html_converter.py @@ -122,3 +122,73 @@ def test_img_keeps_embedded_data_uri_over_data_src_when_keeping_data_uris() -> N assert f"![A photo]({embedded})" in markdown assert other_src not in markdown + + +def test_html_table_pipe_in_cell_is_escaped() -> None: + # Issue #2438: a literal | in a cell is data, not a column separator. + 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_pipe_preceded_by_backslash_is_still_escaped() -> None: + # Same rule as CSV: double the backslash run so `\|` survives as data. + html = ( + "" + "
namedescription
Widgetleft\\|right
" + ) + + markdown = _convert_html(html) + + assert r"| Widget | left\\\|right |" in markdown + + +def test_html_table_plain_cells_are_unchanged() -> None: + html = ( + "" + "
namedescription
Widgetcheap and fast
" + ) + + markdown = _convert_html(html) + + assert "| Widget | cheap and fast |" in markdown + assert "\\" not in markdown + + +def test_html_table_newline_in_cell_collapses_to_space() -> None: + html = ( + "" + "
namenotes
Widgetline one
line two
" + ) + + markdown = _convert_html(html) + + assert len([line for line in markdown.splitlines() if line.strip()]) == 3 + assert "| Widget | line one line two |" in markdown + + +def test_html_table_colspan_still_expands_after_escaping() -> None: + html = ( + "" + "
a | bc
123
" + ) + + markdown = _convert_html(html) + + assert "| a \\| b | c | |" in markdown diff --git a/packages/markitdown/tests/test_module_misc.py b/packages/markitdown/tests/test_module_misc.py index bc0312460..4dc0b0cd9 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -1814,6 +1814,36 @@ def test_csv_long_backslash_runs(suffix: str, escaped_suffix: str) -> None: assert result == f"| {expected} |\n| --- |\n| {expected} |" +def _convert_xlsx_dataframe(df) -> str: + pytest.importorskip("openpyxl") + pytest.importorskip("pandas") + import pandas as pd + + buf = io.BytesIO() + with pd.ExcelWriter(buf, engine="openpyxl") as writer: + df.to_excel(writer, index=False) + buf.seek(0) + result = MarkItDown().convert_stream(buf, file_extension=".xlsx") + return result.markdown + + +def test_xlsx_pipe_in_cell_is_escaped() -> None: + # Issue #2436: XLSX/XLS convert via HTML, so pipe escaping must apply there too. + pd = pytest.importorskip("pandas") + result = _convert_xlsx_dataframe(pd.DataFrame({"a": ["x|y"], "b": ["2"]})) + + assert "| x\\|y | 2 |" in result + + +def test_xlsx_pipe_in_header_is_escaped() -> None: + pd = pytest.importorskip("pandas") + result = _convert_xlsx_dataframe( + pd.DataFrame({"a | b": [1], "c": [2]}) + ) + + assert "| a \\| b | c |" in result + + # --------------------------------------------------------------------------- # Regression test for issue #1960: # exiftool_path pointing to a nonexistent binary used to leak a raw