From 4fd8baa5988dd93e12eaedf013e53c5c8d62b952 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 17:17:56 +0200 Subject: [PATCH] fix(html): keep a pipe in a table cell from adding a column A Markdown table row is split on every unescaped pipe, and markdownify writes a cell's text through unchanged. A part number, a shell command or a regex alternation in a cell therefore pushes the rest of the row into columns the header does not have: | Product | Spec | | --- | --- | | Cable | USB-A|USB-C | Escape the pipes in a cell before markdownify lays the row out. A backslash already in the cell is doubled first, so it cannot consume the escape. This runs for every format that reaches Markdown through the HTML converter, including .html, .docx (mammoth) and .xlsx (sheet_to_html). --- .../src/markitdown/converters/_markdownify.py | 23 +++++ .../markitdown/tests/test_table_cell_pipe.py | 85 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/markitdown/tests/test_table_cell_pipe.py diff --git a/packages/markitdown/src/markitdown/converters/_markdownify.py b/packages/markitdown/src/markitdown/converters/_markdownify.py index ed3414486..525ac41c7 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}") +# A run of backslashes that is not itself escaped, followed by the pipe it would +# otherwise escape. Matching the run is what keeps a cell's own backslash from +# consuming the escape we add. +_TABLE_CELL_PIPE = re.compile(r"(? str: + """Escape the pipes in a table cell so the cell cannot add a column. + + A Markdown table row is split on every unescaped pipe, so a cell holding one + -- `USB-A|USB-C`, a shell command, a regex alternation -- pushes the rest of + the row into columns the header does not have. + """ + return _TABLE_CELL_PIPE.sub(lambda match: match.group(1) * 2 + r"\|", text) + def _quote_path_preserving_percent_encoded_octets(path: str) -> str: """Quote a URL path while preserving existing %HH byte encodings.""" @@ -143,6 +158,14 @@ def convert_img( return "![%s](%s%s)" % (alt, src, title_part) + def convert_td(self, el: Any, text: str, *args: Any, **kwargs: Any) -> str: + """Same as usual converter, but a pipe in the cell stays inside the cell.""" + return super().convert_td(el, _escape_table_cell(text), *args, **kwargs) # type: ignore + + def convert_th(self, el: Any, text: str, *args: Any, **kwargs: Any) -> str: + """Same as usual converter, but a pipe in the cell stays inside the cell.""" + return super().convert_th(el, _escape_table_cell(text), *args, **kwargs) # type: ignore + def convert_input( self, el: Any, diff --git a/packages/markitdown/tests/test_table_cell_pipe.py b/packages/markitdown/tests/test_table_cell_pipe.py new file mode 100644 index 000000000..a6b46a850 --- /dev/null +++ b/packages/markitdown/tests/test_table_cell_pipe.py @@ -0,0 +1,85 @@ +import io +import re + +import pytest + +from markitdown import MarkItDown + +_UNESCAPED_PIPE = re.compile(r"(? str: + return ( + MarkItDown().convert_stream(io.BytesIO(data), file_extension=extension).markdown + ) + + +def _rows(markdown: str) -> list[list[str]]: + """Read the markdown table back the way a reader does. + + A row is split on every pipe that is not escaped, and each cell then has its + backslash escapes resolved -- so a cell comes back as the text it held. + """ + rows = [] + for line in markdown.splitlines(): + line = line.strip() + if not line.startswith("|"): + continue + cells = _UNESCAPED_PIPE.split(line.strip("|"))[::2] + rows.append([_ESCAPE.sub(r"\1", cell).strip() for cell in cells]) + return rows + + +@pytest.mark.parametrize( + ("cell", "expected"), + [ + # A part number, a shell command and a regex alternation all carry one. + ("USB-A|USB-C", "USB-A|USB-C"), + ("grep -E 'a|b'", "grep -E 'a|b'"), + ("a||b", "a||b"), + # A backslash already in the cell must not consume the escape. + (r"a\|b", r"a\|b"), + (r"C:\path", r"C:\path"), + ("plain", "plain"), + ], +) +def test_a_pipe_in_a_cell_stays_inside_the_cell(cell: str, expected: str) -> None: + html = f"
ProductSpec
Cable{cell}
" + + rows = _rows(_convert(html.encode("utf-8"), ".html")) + + assert rows[0] == ["Product", "Spec"] + assert rows[-1] == ["Cable", expected] + + +def test_a_pipe_in_a_header_cell_stays_inside_the_cell() -> None: + html = "
Cable a|bSpec
12
" + + rows = _rows(_convert(html.encode("utf-8"), ".html")) + + assert rows[0] == ["Cable a|b", "Spec"] + assert rows[-1] == ["1", "2"] + + +def test_a_pipe_outside_a_table_is_left_alone() -> None: + assert _convert(b"

stdin | stdout

", ".html").strip() == "stdin | stdout" + + +def test_a_spreadsheet_cell_holding_a_pipe_keeps_its_column() -> None: + openpyxl = pytest.importorskip("openpyxl") + + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.append(["Product", "Spec"]) + sheet.append(["Cable", "USB-A|USB-C"]) + sheet.append(["Hub", "4 ports"]) + buffer = io.BytesIO() + workbook.save(buffer) + + rows = _rows(_convert(buffer.getvalue(), ".xlsx")) + + assert rows[0] == ["Product", "Spec"] + assert rows[-2] == ["Cable", "USB-A|USB-C"] + assert rows[-1] == ["Hub", "4 ports"]