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 "" % (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"
| Product | Spec |
|---|---|
| Cable | {cell} |
| Cable a|b | Spec |
|---|---|
| 1 | 2 |
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"]