1+ import re
12import sys
2- from typing import BinaryIO , Any
3+ from dataclasses import dataclass
4+ from datetime import datetime
5+ from typing import BinaryIO , Any , Optional
36from ._html_converter import HtmlConverter
47from .._base_converter import DocumentConverter , DocumentConverterResult
58from .._exceptions import MissingDependencyException , MISSING_DEPENDENCY_MESSAGE
3336ACCEPTED_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+
3653class 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
98259class XlsConverter (DocumentConverter ):
99260 """
0 commit comments