diff --git a/packages/markitdown/src/markitdown/converters/_docx_converter.py b/packages/markitdown/src/markitdown/converters/_docx_converter.py index 3975107b1..19b5342a6 100644 --- a/packages/markitdown/src/markitdown/converters/_docx_converter.py +++ b/packages/markitdown/src/markitdown/converters/_docx_converter.py @@ -77,6 +77,7 @@ def convert( style_map = kwargs.get("style_map", None) pre_process_stream = pre_process_docx(file_stream) + kwargs.setdefault("preserve_complex_tables", True) return self._html_converter.convert_string( mammoth.convert_to_html(pre_process_stream, style_map=style_map).value, **kwargs, diff --git a/packages/markitdown/src/markitdown/converters/_markdownify.py b/packages/markitdown/src/markitdown/converters/_markdownify.py index 19e8a2984..a1c46b694 100644 --- a/packages/markitdown/src/markitdown/converters/_markdownify.py +++ b/packages/markitdown/src/markitdown/converters/_markdownify.py @@ -16,11 +16,23 @@ class _CustomMarkdownify(markdownify.MarkdownConverter): """ def __init__(self, **options: Any): + self._preserve_complex_tables = options.pop("preserve_complex_tables", False) options["heading_style"] = options.get("heading_style", markdownify.ATX) options["keep_data_uris"] = options.get("keep_data_uris", False) # Explicitly cast options to the expected type if necessary super().__init__(**options) + def convert_table(self, el: Any, text: str, parent_tags: set[str]) -> str: + """Preserve tables with merged cells when Markdown cannot represent them.""" + if self._preserve_complex_tables: + for cell in el.find_all(["td", "th"]): + for attribute in ("rowspan", "colspan"): + span = cell.get(attribute) + if span is not None and span.isdigit() and int(span) > 1: + return f"\n\n{el}\n\n" + + return super().convert_table(el, text, parent_tags) + def convert_hn( self, n: int, diff --git a/packages/markitdown/tests/test_docx_merged_tables.py b/packages/markitdown/tests/test_docx_merged_tables.py new file mode 100644 index 000000000..33c014644 --- /dev/null +++ b/packages/markitdown/tests/test_docx_merged_tables.py @@ -0,0 +1,85 @@ +import io +from types import SimpleNamespace + +import pytest + +import markitdown.converters._docx_converter as docx_converter_module +from markitdown import StreamInfo +from markitdown.converters import DocxConverter, HtmlConverter + + +@pytest.fixture +def mock_docx_html(monkeypatch: pytest.MonkeyPatch): + def _convert(html: str): + monkeypatch.setattr( + docx_converter_module, + "pre_process_docx", + lambda file_stream: file_stream, + ) + monkeypatch.setattr( + docx_converter_module.mammoth, + "convert_to_html", + lambda *args, **kwargs: SimpleNamespace(value=html), + ) + return ( + DocxConverter() + .convert( + io.BytesIO(b"mock docx"), + StreamInfo(extension=".docx"), + ) + .markdown + ) + + return _convert + + +@pytest.mark.parametrize("attribute", ["rowspan", "colspan"]) +def test_docx_preserves_tables_with_merged_cells_as_html( + mock_docx_html, + attribute: str, +) -> None: + html = f""" +

Before table

+ + + +
Merged headingSecond heading
First valueSecond value
+

After table

+ """ + + result = mock_docx_html(html) + + assert "Before table" in result + assert "After table" in result + assert "" in result + assert f'{attribute}="2"' in result + assert "Merged heading" in result + + +def test_docx_keeps_simple_tables_as_markdown(mock_docx_html) -> None: + html = """ +
+ + +
First headingSecond heading
First valueSecond value
+ """ + + result = mock_docx_html(html) + + assert "" not in result + assert "| First heading | Second heading |" in result + assert "| First value | Second value |" in result + + +def test_html_converter_does_not_preserve_complex_tables_by_default() -> None: + html = """ +
+ + +
Merged headingSecond heading
First value
+ """ + + result = HtmlConverter().convert_string(html).markdown + + assert "" not in result + assert "Merged heading" in result