Skip to content
Closed
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
2 changes: 1 addition & 1 deletion packages/markitdown/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies = [
"beautifulsoup4",
"requests",
"markdownify",
"magika~=0.6.1",
"magika>=0.6.1,<2",
"charset-normalizer",
"defusedxml",
]
Expand Down
40 changes: 31 additions & 9 deletions packages/markitdown/src/markitdown/converters/_xlsx_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,42 @@ def convert(
images = _XlsxImages(workbook_stream)

for s in sheets:
md_content += f"## {s}\n"
html_content = sheets[s].to_html(index=False)
md_content += (
self._html_converter.convert_string(
html_content, **kwargs
).markdown.strip()
+ "\n\n"
)
# Build the sheet's two content channels separately: the cell table and
# any embedded images. A worksheet with no columns has no header row, so
# rendering its table would emit a heading followed by a one-column
# "table" with no delimiter row (`|\n| |`), which is not valid Markdown.
# But that must not skip the image hook: an image-only worksheet (an
# embedded image and no cell values) still produces content through
# `_image_to_html`, and dropping it would lose that content silently.
table_md = ""
if not sheets[s].columns.empty:
html_content = sheets[s].to_html(index=False)
table_md = (
self._html_converter.convert_string(
html_content, **kwargs
).markdown.strip()
+ "\n\n"
)
image_md = ""
if images is not None:
image_content = images.to_html(s, self._image_to_html, kwargs)
if image_content:
md_content += (
image_md = (
self._html_converter.convert_string(
image_content, **kwargs
).markdown.strip()
+ "\n\n"
)

# Emit the heading only when the sheet actually produces content, so a
# genuinely empty sheet stays entirely absent rather than leaving a bare
# `## name` with nothing under it.
if not table_md and not image_md:
continue
md_content += f"## {s}\n"
md_content += table_md
md_content += image_md

return DocumentConverterResult(markdown=md_content.strip())

def _image_to_html(
Expand Down Expand Up @@ -240,6 +258,10 @@ def convert(
sheets = pd.read_excel(file_stream, sheet_name=None, engine="xlrd")
md_content = ""
for s in sheets:
# Same rule as the XLSX path: a sheet with no columns has no header row, so
# rendering it would emit a heading followed by broken table syntax.
if sheets[s].columns.empty:
continue
md_content += f"## {s}\n"
html_content = sheets[s].to_html(index=False)
md_content += (
Expand Down
74 changes: 74 additions & 0 deletions packages/markitdown/tests/test_xlsx.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,77 @@ def test_xlsx_show_zeroes_rename_is_scoped_to_sheet_view_tags() -> None:

if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))


# Worksheets with no columns


def _workbook_with(sheets: list[tuple[str, list[list[Any]]]]) -> bytes:
workbook = openpyxl.Workbook()
workbook.remove(workbook.active)
for name, rows in sheets:
sheet = workbook.create_sheet(name)
for row in rows:
sheet.append(row)
stream = io.BytesIO()
workbook.save(stream)
return stream.getvalue()


def test_completely_empty_sheet_is_skipped_instead_of_emitting_broken_table() -> None:
data = _workbook_with([("Empty", [])])

markdown = _convert(XlsxConverter(), data)

assert markdown == ""
assert "Empty" not in markdown
assert "|" not in markdown


def test_sheet_with_a_header_but_no_data_rows_keeps_its_heading() -> None:
data = _workbook_with([("HeaderOnly", [["col"]])])

markdown = _convert(XlsxConverter(), data)

assert markdown == "## HeaderOnly\n| col |\n| --- |"


def test_data_sheet_followed_by_an_empty_sheet() -> None:
data = _workbook_with([("HasData", [["col"], ["v"]]), ("Empty", [])])

markdown = _convert(XlsxConverter(), data)

assert markdown == "## HasData\n| col |\n| --- |\n| v |"
assert "Empty" not in markdown


def test_empty_sheet_does_not_hide_the_next_sheet() -> None:
data = _workbook_with([("Empty", []), ("HasData", [["col"], ["v"]])])

markdown = _convert(XlsxConverter(), data)

assert markdown == "## HasData\n| col |\n| --- |\n| v |"


def test_image_only_sheet_is_preserved_via_the_hook() -> None:
"""An image-only worksheet (no cell values) must still reach the image hook.

`columns.empty` describes only the cell table; a sheet carrying an embedded
image and no data must not be skipped, or the `_image_to_html` output is lost.
"""

def render(stream: BinaryIO, info: StreamInfo, **kwargs: Any) -> str:
return "<p>Quarterly revenue chart</p>"

workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.title = "ChartOnly"
sheet.add_image(SheetImage(io.BytesIO(_RED)), "A1")
stream = io.BytesIO()
workbook.save(stream)
workbook.close()

markdown = _convert(_ImageConverter(render), stream.getvalue())

assert "Quarterly revenue chart" in markdown
assert "ChartOnly" in markdown