Skip to content

Commit ddc34ab

Browse files
committed
feat: preserve Excel cell formatting via XlsxConfig (fix #53)
1 parent 9dc0d65 commit ddc34ab

4 files changed

Lines changed: 294 additions & 3 deletions

File tree

packages/markitdown/src/markitdown/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FileConversionException,
1818
UnsupportedFormatException,
1919
)
20+
from .converters._xlsx_converter import XlsxConfig
2021

2122
__all__ = [
2223
"__version__",
@@ -29,6 +30,7 @@
2930
"FileConversionException",
3031
"UnsupportedFormatException",
3132
"StreamInfo",
33+
"XlsxConfig",
3234
"PRIORITY_SPECIFIC_FILE_FORMAT",
3335
"PRIORITY_GENERIC_FILE_FORMAT",
3436
]

packages/markitdown/src/markitdown/_markitdown.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ def enable_builtins(self, **kwargs) -> None:
193193
self.register_converter(YouTubeConverter())
194194
self.register_converter(BingSerpConverter())
195195
self.register_converter(DocxConverter())
196-
self.register_converter(XlsxConverter())
196+
self.register_converter(
197+
XlsxConverter(config=kwargs.get("xlsx_config"))
198+
)
197199
self.register_converter(XlsConverter())
198200
self.register_converter(PptxConverter())
199201
self.register_converter(AudioConverter())

packages/markitdown/src/markitdown/converters/_xlsx_converter.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import re
12
import sys
2-
from typing import BinaryIO, Any
3+
from dataclasses import dataclass
4+
from datetime import datetime
5+
from typing import BinaryIO, Any, Optional
36
from ._html_converter import HtmlConverter
47
from .._base_converter import DocumentConverter, DocumentConverterResult
58
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
@@ -33,14 +36,29 @@
3336
ACCEPTED_XLS_FILE_EXTENSIONS = [".xls"]
3437

3538

39+
@dataclass
40+
class XlsxConfig:
41+
"""Configuration options for :class:`XlsxConverter`.
42+
43+
Attributes:
44+
preserve_formatting: When ``True``, cell number formats (currency,
45+
percentage, thousands separators, dates, etc.) are preserved in the
46+
Markdown output. Defaults to ``False`` to keep the existing behavior
47+
of emitting raw cell values.
48+
"""
49+
50+
preserve_formatting: bool = False
51+
52+
3653
class XlsxConverter(DocumentConverter):
3754
"""
3855
Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table.
3956
"""
4057

41-
def __init__(self):
58+
def __init__(self, config: Optional[XlsxConfig] = None):
4259
super().__init__()
4360
self._html_converter = HtmlConverter()
61+
self._config = config or XlsxConfig()
4462

4563
def accepts(
4664
self,
@@ -80,6 +98,9 @@ def convert(
8098
_xlsx_dependency_exc_info[2]
8199
)
82100

101+
if self._config.preserve_formatting:
102+
return self._convert_with_formatting(file_stream, **kwargs)
103+
83104
sheets = pd.read_excel(file_stream, sheet_name=None, engine="openpyxl")
84105
md_content = ""
85106
for s in sheets:
@@ -94,6 +115,146 @@ def convert(
94115

95116
return DocumentConverterResult(markdown=md_content.strip())
96117

118+
def _convert_with_formatting(
119+
self,
120+
file_stream: BinaryIO,
121+
**kwargs: Any,
122+
) -> DocumentConverterResult:
123+
"""Convert an XLSX file to Markdown while preserving cell number formats."""
124+
wb = openpyxl.load_workbook(file_stream, data_only=True)
125+
md_content = ""
126+
for sheet_name in wb.sheetnames:
127+
sheet = wb[sheet_name]
128+
if sheet.max_row is None or sheet.max_column is None:
129+
continue
130+
131+
md_content += f"## {sheet_name}\n"
132+
data = [
133+
[self._format_cell_value(cell) for cell in row]
134+
for row in sheet.iter_rows()
135+
]
136+
if not data:
137+
continue
138+
139+
df = pd.DataFrame(data)
140+
# Use the first row as the header, matching pandas.read_excel defaults
141+
header = [
142+
str(c) if c is not None and c != "" else f"Unnamed: {i}"
143+
for i, c in enumerate(df.iloc[0])
144+
]
145+
df = df.iloc[1:]
146+
df.columns = header
147+
html_content = df.to_html(index=False)
148+
md_content += (
149+
self._html_converter.convert_string(
150+
html_content, **kwargs
151+
).markdown.strip()
152+
+ "\n\n"
153+
)
154+
155+
return DocumentConverterResult(markdown=md_content.strip())
156+
157+
def _format_cell_value(self, cell) -> str:
158+
"""Render a cell value using its Excel number format."""
159+
value = cell.value
160+
if value is None:
161+
return ""
162+
if isinstance(value, bool):
163+
return str(value)
164+
if isinstance(value, datetime):
165+
return self._format_datetime(value, cell.number_format)
166+
if not isinstance(value, (int, float)):
167+
return str(value)
168+
169+
number_format = cell.number_format
170+
if not number_format or number_format == "General":
171+
return str(value)
172+
173+
if "%" in number_format:
174+
return self._format_percentage(value, number_format)
175+
176+
currency_symbol = self._extract_currency_symbol(number_format)
177+
if currency_symbol is not None:
178+
return self._format_currency(value, number_format, currency_symbol)
179+
180+
if "#,##" in number_format or re.search(r"0+\.0+", number_format):
181+
return self._format_number(value, number_format)
182+
183+
return str(value)
184+
185+
def _extract_currency_symbol(self, number_format: str) -> Optional[str]:
186+
"""Return the currency symbol embedded in an Excel number format, if any."""
187+
# Quoted literal, e.g. "$"#,##0.00 or #,##0.00"€"
188+
for match in re.finditer(r'"([^"]*)"', number_format):
189+
symbol = match.group(1).strip()
190+
if symbol and not symbol.replace(".", "").isdigit():
191+
return symbol
192+
# Currency code, e.g. [$USD] or [$¥-411]
193+
for match in re.finditer(r"\[\$([^\]]*)\]", number_format):
194+
code = re.sub(r"-\d+$", "", match.group(1)).strip()
195+
if code:
196+
return code
197+
return None
198+
199+
def _format_number(self, value, number_format: str) -> str:
200+
decimal_places = self._decimal_places(number_format)
201+
if "#,##" in number_format:
202+
return f"{value:,.{decimal_places}f}"
203+
return f"{value:.{decimal_places}f}"
204+
205+
def _format_percentage(self, value, number_format: str) -> str:
206+
decimal_places = self._decimal_places(number_format)
207+
return f"{value * 100:.{decimal_places}f}%"
208+
209+
def _format_currency(
210+
self, value, number_format: str, symbol: str
211+
) -> str:
212+
decimal_places = self._decimal_places(number_format)
213+
if "#,##" in number_format:
214+
formatted = f"{value:,.{decimal_places}f}"
215+
else:
216+
formatted = f"{value:.{decimal_places}f}"
217+
218+
# Place the symbol before or after the number to match the format
219+
num_pos = len(number_format)
220+
for ch in "0#":
221+
idx = number_format.find(ch)
222+
if idx != -1:
223+
num_pos = min(num_pos, idx)
224+
symbol_pos = number_format.find(symbol)
225+
prefix = symbol_pos != -1 and symbol_pos < num_pos
226+
227+
if value < 0:
228+
abs_formatted = formatted[1:]
229+
if "(" in number_format:
230+
return f"({symbol}{abs_formatted})"
231+
return (
232+
f"-{symbol}{abs_formatted}"
233+
if prefix
234+
else f"-{abs_formatted}{symbol}"
235+
)
236+
return f"{symbol}{formatted}" if prefix else f"{formatted}{symbol}"
237+
238+
def _decimal_places(self, number_format: str) -> int:
239+
"""Count the digit placeholders after the decimal point in a format."""
240+
if "." not in number_format:
241+
return 0
242+
decimal_places = 0
243+
for ch in number_format.split(".", 1)[1]:
244+
if ch == "0":
245+
decimal_places += 1
246+
elif ch in "#?":
247+
continue
248+
else:
249+
break
250+
return decimal_places
251+
252+
def _format_datetime(self, value: datetime, number_format: str) -> str:
253+
fmt = number_format.lower()
254+
if any(tok in fmt for tok in ("hh", "h:", "am/pm")):
255+
return value.strftime("%Y-%m-%d %H:%M")
256+
return value.strftime("%Y-%m-%d")
257+
97258

98259
class XlsConverter(DocumentConverter):
99260
"""
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3 -m pytest
2+
import io
3+
4+
import pytest
5+
from openpyxl import Workbook
6+
7+
from markitdown import MarkItDown, XlsxConfig
8+
9+
10+
def _convert(data: bytes, *, preserve_formatting: bool = False) -> str:
11+
md = MarkItDown(
12+
xlsx_config=XlsxConfig(preserve_formatting=preserve_formatting)
13+
)
14+
return md.convert(io.BytesIO(data), file_extension=".xlsx").text_content
15+
16+
17+
def test_xlsx_default_does_not_preserve_formatting() -> None:
18+
wb = Workbook()
19+
ws = wb.active
20+
ws.title = "Sheet1"
21+
ws.append(["Item", "Cost"])
22+
ws.append(["Breakfast", 5])
23+
ws.cell(row=2, column=2).number_format = '"$"#,##0.00'
24+
buf = io.BytesIO()
25+
wb.save(buf)
26+
27+
result = _convert(buf.getvalue())
28+
assert "5" in result
29+
assert "$" not in result
30+
31+
32+
def test_xlsx_preserve_currency() -> None:
33+
wb = Workbook()
34+
ws = wb.active
35+
ws.title = "Sheet1"
36+
ws.append(["Item", "Cost", "Total"])
37+
ws.append(["Breakfast", 5, 100])
38+
ws.append(["Laptops", 1199, 5995])
39+
for row in (2, 3):
40+
ws.cell(row=row, column=2).number_format = '"$"#,##0.00'
41+
ws.cell(row=row, column=3).number_format = '"$"#,##0.00'
42+
buf = io.BytesIO()
43+
wb.save(buf)
44+
45+
result = _convert(buf.getvalue(), preserve_formatting=True)
46+
assert "$5.00" in result
47+
assert "$100.00" in result
48+
assert "$1,199.00" in result
49+
assert "$5,995.00" in result
50+
51+
52+
def test_xlsx_preserve_percentage() -> None:
53+
wb = Workbook()
54+
ws = wb.active
55+
ws.title = "Sheet1"
56+
ws.append(["Name", "Rate"])
57+
ws.append(["A", 0.255])
58+
ws.cell(row=2, column=2).number_format = "0.0%"
59+
buf = io.BytesIO()
60+
wb.save(buf)
61+
62+
result = _convert(buf.getvalue(), preserve_formatting=True)
63+
assert "25.5%" in result
64+
65+
66+
def test_xlsx_preserve_thousands_separator() -> None:
67+
wb = Workbook()
68+
ws = wb.active
69+
ws.title = "Sheet1"
70+
ws.append(["Name", "Qty"])
71+
ws.append(["A", 1234567])
72+
ws.cell(row=2, column=2).number_format = "#,##0"
73+
buf = io.BytesIO()
74+
wb.save(buf)
75+
76+
result = _convert(buf.getvalue(), preserve_formatting=True)
77+
assert "1,234,567" in result
78+
79+
80+
def test_xlsx_preserve_other_currency_symbol() -> None:
81+
wb = Workbook()
82+
ws = wb.active
83+
ws.title = "Sheet1"
84+
ws.append(["Name", "Price"])
85+
ws.append(["A", 9.5])
86+
ws.cell(row=2, column=2).number_format = '"€"#,##0.00'
87+
buf = io.BytesIO()
88+
wb.save(buf)
89+
90+
result = _convert(buf.getvalue(), preserve_formatting=True)
91+
assert "€9.50" in result
92+
93+
94+
def test_xlsx_preserve_negative_currency() -> None:
95+
wb = Workbook()
96+
ws = wb.active
97+
ws.title = "Sheet1"
98+
ws.append(["Name", "Balance"])
99+
ws.append(["A", -1234.5])
100+
ws.cell(row=2, column=2).number_format = '"$"#,##0.00'
101+
buf = io.BytesIO()
102+
wb.save(buf)
103+
104+
result = _convert(buf.getvalue(), preserve_formatting=True)
105+
assert "-$1,234.50" in result
106+
107+
108+
def test_xlsx_preserve_dates() -> None:
109+
from datetime import datetime
110+
111+
wb = Workbook()
112+
ws = wb.active
113+
ws.title = "Sheet1"
114+
ws.append(["Name", "Date"])
115+
ws.append(["A", datetime(2024, 1, 15)])
116+
ws.cell(row=2, column=2).number_format = "yyyy-mm-dd"
117+
buf = io.BytesIO()
118+
wb.save(buf)
119+
120+
result = _convert(buf.getvalue(), preserve_formatting=True)
121+
assert "2024-01-15" in result
122+
123+
124+
def test_xlsx_config_exported() -> None:
125+
assert XlsxConfig(preserve_formatting=True).preserve_formatting is True
126+
assert XlsxConfig().preserve_formatting is False

0 commit comments

Comments
 (0)