diff --git a/packages/markitdown/src/markitdown/converters/_xlsx_converter.py b/packages/markitdown/src/markitdown/converters/_xlsx_converter.py index 355dd8f8d..b42aff163 100644 --- a/packages/markitdown/src/markitdown/converters/_xlsx_converter.py +++ b/packages/markitdown/src/markitdown/converters/_xlsx_converter.py @@ -84,6 +84,32 @@ def _repair_sheetview_show_zeroes( return repaired_stream +def _format_integral_columns(sheet: "pd.DataFrame") -> "pd.DataFrame": + """Render whole numbers stored as floats without a trailing ``.0``. + + A single blank cell makes pandas read the column as float64, so every + whole number in it renders as ``1.0`` even though Excel shows ``1``. + Columns whose present values are all whole numbers are converted back to + integers for display; fractional values and missing cells are unchanged. + """ + for column in sheet.columns: + series = sheet[column] + values = series.dropna() + if ( + pd.api.types.is_float_dtype(series.dtype) + and not values.empty + and (values % 1 == 0).all() + ): + # The object dtype stops pandas from coercing the mixed ints and + # NaN values back into a float column on assignment. + sheet[column] = pd.Series( + [int(value) if pd.notna(value) else value for value in series], + dtype=object, + index=series.index, + ) + return sheet + + class XlsxConverter(DocumentConverter): """ Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table. @@ -135,7 +161,7 @@ def convert( md_content = "" for s in sheets: md_content += f"## {s}\n" - html_content = sheets[s].to_html(index=False) + html_content = _format_integral_columns(sheets[s]).to_html(index=False) md_content += ( self._html_converter.convert_string( html_content, **kwargs @@ -197,7 +223,7 @@ def convert( md_content = "" for s in sheets: md_content += f"## {s}\n" - html_content = sheets[s].to_html(index=False) + html_content = _format_integral_columns(sheets[s]).to_html(index=False) md_content += ( self._html_converter.convert_string( html_content, **kwargs diff --git a/packages/markitdown/tests/test_xlsx_number_formatting.py b/packages/markitdown/tests/test_xlsx_number_formatting.py new file mode 100644 index 000000000..f0be7eaaf --- /dev/null +++ b/packages/markitdown/tests/test_xlsx_number_formatting.py @@ -0,0 +1,80 @@ +from io import BytesIO + +import openpyxl + +from markitdown import MarkItDown, StreamInfo + + +def _convert_workbook(workbook: openpyxl.Workbook) -> str: + stream = BytesIO() + workbook.save(stream) + stream.seek(0) + return MarkItDown().convert( + stream, stream_info=StreamInfo(extension=".xlsx") + ).markdown + + +def test_integer_column_with_blank_cell_renders_without_trailing_point_zero() -> None: + # A blank cell makes pandas read the column as float64, which used to + # render every whole number in it as "1.0" even though Excel shows "1". + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Sheet" + sheet.append(["Qty", "Note"]) + sheet.append([1, "first"]) + sheet.append([None, "gap"]) + sheet.append([3, "last"]) + + markdown = _convert_workbook(workbook) + + assert "| 1 | first |" in markdown + assert "| 3 | last |" in markdown + assert "1.0" not in markdown + assert "3.0" not in markdown + + +def test_integer_column_with_numeric_header_and_blank_cell() -> None: + # A numeric header row (e.g. years) keeps the column numeric even before + # the header is split off, so the whole column is read as float64 and + # its data cells used to render as "1.0" beside clean "5" cells. + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Sheet" + sheet.append([2023, 2024]) + sheet.append([1, 5]) + sheet.append([None, 6]) + sheet.append([3, 7]) + + markdown = _convert_workbook(workbook) + + assert "| 1 | 5 |" in markdown + assert "| 3 | 7 |" in markdown + assert "1.0" not in markdown + assert "3.0" not in markdown + + +def test_fractional_numbers_keep_their_decimals() -> None: + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Sheet" + sheet.append(["Price"]) + sheet.append([2.5]) + sheet.append([None]) + + markdown = _convert_workbook(workbook) + + assert "2.5" in markdown + + +def test_fully_populated_integer_column_is_unchanged() -> None: + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Sheet" + sheet.append(["Qty"]) + sheet.append([2]) + sheet.append([4]) + + markdown = _convert_workbook(workbook) + + assert "| 2 |" in markdown + assert "| 4 |" in markdown