Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/markitdown/src/markitdown/converters/_markdownify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\\)(\\*)\|")


def _escape_table_cell(text: str) -> 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."""
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions packages/markitdown/tests/test_table_cell_pipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import io
import re

import pytest

from markitdown import MarkItDown

_UNESCAPED_PIPE = re.compile(r"(?<!\\)((?:\\\\)*)\|")
# CommonMark: a backslash escapes ASCII punctuation, and nothing else.
_ESCAPE = re.compile(r"\\([!-/:-@\[-`{-~])")


def _convert(data: bytes, extension: str) -> 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"<table><tr><th>Product</th><th>Spec</th></tr><tr><td>Cable</td><td>{cell}</td></tr></table>"

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 = "<table><tr><th>Cable a|b</th><th>Spec</th></tr><tr><td>1</td><td>2</td></tr></table>"

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"<p>stdin | stdout</p>", ".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"]