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
45 changes: 45 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,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"(?<!\\)(\\*)\|")


def _escape_table_cell(value: str) -> 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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions packages/markitdown/tests/test_html_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
"<table><thead><tr><th>Name</th><th>Note</th></tr></thead>"
"<tbody><tr><td>Alice</td><td>Has a | pipe</td></tr></tbody></table>"
)

markdown = _convert_html(html)

assert "| Alice | Has a \\| pipe |" in markdown


def test_html_table_pipe_in_header_is_escaped() -> None:
html = (
"<table><thead><tr><th>a | b</th><th>c</th></tr></thead>"
"<tbody><tr><td>1</td><td>2</td></tr></tbody></table>"
)

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 = (
"<table><tr><th>name</th><th>description</th></tr>"
"<tr><td>Widget</td><td>left\\|right</td></tr></table>"
)

markdown = _convert_html(html)

assert r"| Widget | left\\\|right |" in markdown


def test_html_table_plain_cells_are_unchanged() -> None:
html = (
"<table><tr><th>name</th><th>description</th></tr>"
"<tr><td>Widget</td><td>cheap and fast</td></tr></table>"
)

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 = (
"<table><tr><th>name</th><th>notes</th></tr>"
"<tr><td>Widget</td><td>line one<br/>line two</td></tr></table>"
)

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 = (
"<table><tr><th>a | b</th><th colspan=\"2\">c</th></tr>"
"<tr><td>1</td><td>2</td><td>3</td></tr></table>"
)

markdown = _convert_html(html)

assert "| a \\| b | c | |" in markdown
30 changes: 30 additions & 0 deletions packages/markitdown/tests/test_module_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down