From 3db16bb1e8fa3e4f407974113ffff0538e8e62cf Mon Sep 17 00:00:00 2001 From: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:28:42 +0000 Subject: [PATCH] fix(xlsx): render whole numbers as ints when blanks promote float64 A single blank cell makes pandas read a numeric column as float64, so every whole number rendered as "1.0". Convert integral float columns back to ints before to_html for both .xlsx and .xls. Fixes #2484. --- .../markitdown/converters/_xlsx_converter.py | 28 +++++- .../tests/test_xlsx_number_formatting.py | 99 +++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 packages/markitdown/tests/test_xlsx_number_formatting.py diff --git a/packages/markitdown/src/markitdown/converters/_xlsx_converter.py b/packages/markitdown/src/markitdown/converters/_xlsx_converter.py index 9f794a3b7..07a0fdd2a 100644 --- a/packages/markitdown/src/markitdown/converters/_xlsx_converter.py +++ b/packages/markitdown/src/markitdown/converters/_xlsx_converter.py @@ -93,6 +93,30 @@ def _repair_sheetview_show_zeroes( return repaired_stream +def _format_integral_columns(sheet: Any) -> Any: + """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] + if not pd.api.types.is_float_dtype(series.dtype): + continue + values = series.dropna() + if values.empty or not (values % 1 == 0).all(): + continue + # Object dtype keeps mixed ints and NaN from being coerced back to float. + 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. @@ -150,7 +174,7 @@ def convert( 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 @@ -241,7 +265,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..65d980674 --- /dev/null +++ b/packages/markitdown/tests/test_xlsx_number_formatting.py @@ -0,0 +1,99 @@ +"""Whole numbers must not render as ``1.0`` when a blank promotes the column to float.""" + +from io import BytesIO + +import openpyxl +import pandas as pd + +from markitdown import MarkItDown, StreamInfo +from markitdown.converters._xlsx_converter import _format_integral_columns + + +def _convert_workbook(workbook: openpyxl.Workbook) -> str: + stream = BytesIO() + workbook.save(stream) + stream.seek(0) + return ( + MarkItDown(enable_plugins=False) + .convert_stream(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 + assert sheet is not None + sheet.title = "Sheet" + sheet.append(["count"]) + sheet.append([1]) + sheet.append([None]) + sheet.append([2]) + + markdown = _convert_workbook(workbook) + + assert "| 1 |" in markdown + assert "| 2 |" in markdown + assert "1.0" not in markdown + assert "2.0" not in markdown + + +def test_integer_column_with_blank_beside_clean_integer_column() -> None: + workbook = openpyxl.Workbook() + sheet = workbook.active + assert sheet is not None + 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_fractional_numbers_keep_their_decimals() -> None: + workbook = openpyxl.Workbook() + sheet = workbook.active + assert sheet is not None + 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 + assert sheet is not None + sheet.title = "Sheet" + sheet.append(["Qty"]) + sheet.append([2]) + sheet.append([4]) + + markdown = _convert_workbook(workbook) + + assert "| 2 |" in markdown + assert "| 4 |" in markdown + + +def test_format_integral_columns_helper_preserves_fractions_and_empties() -> None: + # Shared by both the .xlsx and .xls converters before to_html. + sheet = pd.DataFrame({"a": [1.0, None, 2.0], "b": [1.5, None, 2.0]}) + formatted = _format_integral_columns(sheet.copy()) + + assert formatted["a"].tolist()[0] == 1 + assert formatted["a"].tolist()[2] == 2 + assert pd.isna(formatted["a"].tolist()[1]) + assert formatted["b"].tolist()[0] == 1.5 + assert isinstance(formatted["a"].tolist()[0], int)