1from __future__ import annotations
2
3from collections import defaultdict
4import datetime
5import json
6from typing import (
7 TYPE_CHECKING,
8 Any,
9 DefaultDict,
10 cast,
11 overload,
12)
13
14from pandas.io.excel._base import ExcelWriter
15from pandas.io.excel._util import (
16 combine_kwargs,
17 validate_freeze_panes,
18)
19
20if TYPE_CHECKING:
21 from odf.opendocument import OpenDocumentSpreadsheet
22
23 from pandas._typing import (
24 ExcelWriterIfSheetExists,
25 FilePath,
26 StorageOptions,
27 WriteExcelBuffer,
28 )
29
30 from pandas.io.formats.excel import ExcelCell
31
32
33class ODSWriter(ExcelWriter):
34 _engine = "odf"
35 _supported_extensions = (".ods",)
36
37 def __init__( # pyright: ignore[reportInconsistentConstructor]
38 self,
39 path: FilePath | WriteExcelBuffer | ExcelWriter,
40 engine: str | None = None,
41 date_format: str | None = None,
42 datetime_format: str | None = None,
43 mode: str = "w",
44 storage_options: StorageOptions | None = None,
45 if_sheet_exists: ExcelWriterIfSheetExists | None = None,
46 engine_kwargs: dict[str, Any] | None = None,
47 **kwargs: Any,
48 ) -> None:
49 from odf.opendocument import OpenDocumentSpreadsheet
50
51 if mode == "a":
52 raise ValueError("Append mode is not supported with odf!")
53
54 engine_kwargs = combine_kwargs(engine_kwargs, kwargs)
55 self._book = OpenDocumentSpreadsheet(**engine_kwargs)
56
57 super().__init__(
58 path,
59 mode=mode,
60 storage_options=storage_options,
61 if_sheet_exists=if_sheet_exists,
62 engine_kwargs=engine_kwargs,
63 )
64
65 self._style_dict: dict[str, str] = {}
66
67 @property
68 def book(self) -> OpenDocumentSpreadsheet:
69 """
70 Book instance of class odf.opendocument.OpenDocumentSpreadsheet.
71
72 This attribute can be used to access engine-specific features.
73 """
74 return self._book
75
76 @property
77 def sheets(self) -> dict[str, Any]:
78 """Mapping of sheet names to sheet objects."""
79 from odf.table import Table
80
81 result = {
82 sheet.getAttribute("name"): sheet
83 for sheet in self.book.getElementsByType(Table)
84 }
85 return result
86
87 def _save(self) -> None:
88 """
89 Save workbook to disk.
90 """
91 for sheet in self.sheets.values():
92 self.book.spreadsheet.addElement(sheet)
93 self.book.save(self._handles.handle)
94
95 def _write_cells(
96 self,
97 cells: list[ExcelCell],
98 sheet_name: str | None = None,
99 startrow: int = 0,
100 startcol: int = 0,
101 freeze_panes: tuple[int, int] | None = None,
102 autofilter_range: str | None = None,
103 ) -> None:
104 """
105 Write the frame cells using odf
106 """
107
108 if autofilter_range:
109 raise ValueError("Autofilter is not supported with odf!")
110
111 from odf.table import (
112 Table,
113 TableCell,
114 TableRow,
115 )
116 from odf.text import P
117
118 sheet_name = self._get_sheet_name(sheet_name)
119 assert sheet_name is not None
120
121 if sheet_name in self.sheets:
122 wks = self.sheets[sheet_name]
123 else:
124 wks = Table(name=sheet_name)
125 self.book.spreadsheet.addElement(wks)
126
127 if validate_freeze_panes(freeze_panes):
128 freeze_panes = cast(tuple[int, int], freeze_panes)
129 self._create_freeze_panes(sheet_name, freeze_panes)
130
131 for _ in range(startrow):
132 wks.addElement(TableRow())
133
134 rows: DefaultDict = defaultdict(TableRow)
135 col_count: DefaultDict = defaultdict(int)
136
137 for cell in sorted(cells, key=lambda cell: (cell.row, cell.col)):
138 # only add empty cells if the row is still empty
139 if not col_count[cell.row]:
140 for _ in range(startcol):
141 rows[cell.row].addElement(TableCell())
142
143 # fill with empty cells if needed
144 for _ in range(cell.col - col_count[cell.row]):
145 rows[cell.row].addElement(TableCell())
146 col_count[cell.row] += 1
147
148 pvalue, tc = self._make_table_cell(cell)
149 rows[cell.row].addElement(tc)
150 col_count[cell.row] += 1
151 p = P(text=pvalue)
152 tc.addElement(p)
153
154 # add all rows to the sheet
155 if len(rows) > 0:
156 for row_nr in range(max(rows.keys()) + 1):
157 wks.addElement(rows[row_nr])
158
159 def _make_table_cell_attributes(self, cell: ExcelCell) -> dict[str, int | str]:
160 """Convert cell attributes to OpenDocument attributes
161
162 Parameters
163 ----------
164 cell : ExcelCell
165 Spreadsheet cell data
166
167 Returns
168 -------
169 attributes : Dict[str, Union[int, str]]
170 Dictionary with attributes and attribute values
171 """
172 attributes: dict[str, int | str] = {}
173 style_name = self._process_style(cell.style)
174 if style_name is not None:
175 attributes["stylename"] = style_name
176 if cell.mergestart is not None and cell.mergeend is not None:
177 attributes["numberrowsspanned"] = max(1, cell.mergestart)
178 attributes["numbercolumnsspanned"] = cell.mergeend
179 return attributes
180
181 def _make_table_cell(self, cell: ExcelCell) -> tuple[object, Any]:
182 """Convert cell data to an OpenDocument spreadsheet cell
183
184 Parameters
185 ----------
186 cell : ExcelCell
187 Spreadsheet cell data
188
189 Returns
190 -------
191 pvalue, cell : Tuple[str, TableCell]
192 Display value, Cell value
193 """
194 from odf.table import TableCell
195
196 attributes = self._make_table_cell_attributes(cell)
197 val, fmt = self._value_with_fmt(cell.val)
198 pvalue = value = val
199 if isinstance(val, bool):
200 value = str(val).lower()
201 pvalue = str(val).upper()
202 return (
203 pvalue,
204 TableCell(
205 valuetype="boolean",
206 booleanvalue=value,
207 attributes=attributes,
208 ),
209 )
210 elif isinstance(val, datetime.datetime):
211 # Fast formatting
212 value = val.isoformat()
213 # Slow but locale-dependent
214 pvalue = val.strftime("%c")
215 return (
216 pvalue,
217 TableCell(valuetype="date", datevalue=value, attributes=attributes),
218 )
219 elif isinstance(val, datetime.date):
220 # Fast formatting
221 value = f"{val.year}-{val.month:02d}-{val.day:02d}"
222 # Slow but locale-dependent
223 pvalue = val.strftime("%x")
224 return (
225 pvalue,
226 TableCell(valuetype="date", datevalue=value, attributes=attributes),
227 )
228 elif isinstance(val, str):
229 return (
230 pvalue,
231 TableCell(
232 valuetype="string",
233 stringvalue=value,
234 attributes=attributes,
235 ),
236 )
237 else:
238 return (
239 pvalue,
240 TableCell(
241 valuetype="float",
242 value=value,
243 attributes=attributes,
244 ),
245 )
246
247 @overload
248 def _process_style(self, style: dict[str, Any]) -> str: ...
249
250 @overload
251 def _process_style(self, style: None) -> None: ...
252
253 def _process_style(self, style: dict[str, Any] | None) -> str | None:
254 """Convert a style dictionary to an OpenDocument style sheet
255
256 Parameters
257 ----------
258 style : Dict
259 Style dictionary
260
261 Returns
262 -------
263 style_key : str
264 Unique style key for later reference in sheet
265 """
266 from odf.style import (
267 ParagraphProperties,
268 Style,
269 TableCellProperties,
270 TextProperties,
271 )
272
273 if style is None:
274 return None
275 style_key = json.dumps(style)
276 if style_key in self._style_dict:
277 return self._style_dict[style_key]
278 name = f"pd{len(self._style_dict) + 1}"
279 self._style_dict[style_key] = name
280 odf_style = Style(name=name, family="table-cell")
281 if "font" in style:
282 font = style["font"]
283 if font.get("bold", False):
284 odf_style.addElement(TextProperties(fontweight="bold"))
285 if "borders" in style:
286 borders = style["borders"]
287 for side, thickness in borders.items():
288 thickness_translation = {"thin": "0.75pt solid #000000"}
289 odf_style.addElement(
290 TableCellProperties(
291 attributes={f"border{side}": thickness_translation[thickness]}
292 )
293 )
294 if "alignment" in style:
295 alignment = style["alignment"]
296 horizontal = alignment.get("horizontal")
297 if horizontal:
298 odf_style.addElement(ParagraphProperties(textalign=horizontal))
299 vertical = alignment.get("vertical")
300 if vertical:
301 odf_style.addElement(TableCellProperties(verticalalign=vertical))
302 self.book.styles.addElement(odf_style)
303 return name
304
305 def _create_freeze_panes(
306 self, sheet_name: str, freeze_panes: tuple[int, int]
307 ) -> None:
308 """
309 Create freeze panes in the sheet.
310
311 Parameters
312 ----------
313 sheet_name : str
314 Name of the spreadsheet
315 freeze_panes : tuple of (int, int)
316 Freeze pane location x and y
317 """
318 from odf.config import (
319 ConfigItem,
320 ConfigItemMapEntry,
321 ConfigItemMapIndexed,
322 ConfigItemMapNamed,
323 ConfigItemSet,
324 )
325
326 config_item_set = ConfigItemSet(name="ooo:view-settings")
327 self.book.settings.addElement(config_item_set)
328
329 config_item_map_indexed = ConfigItemMapIndexed(name="Views")
330 config_item_set.addElement(config_item_map_indexed)
331
332 config_item_map_entry = ConfigItemMapEntry()
333 config_item_map_indexed.addElement(config_item_map_entry)
334
335 config_item_map_named = ConfigItemMapNamed(name="Tables")
336 config_item_map_entry.addElement(config_item_map_named)
337
338 config_item_map_entry = ConfigItemMapEntry(name=sheet_name)
339 config_item_map_named.addElement(config_item_map_entry)
340
341 config_item_map_entry.addElement(
342 ConfigItem(name="HorizontalSplitMode", type="short", text="2")
343 )
344 config_item_map_entry.addElement(
345 ConfigItem(name="VerticalSplitMode", type="short", text="2")
346 )
347 config_item_map_entry.addElement(
348 ConfigItem(
349 name="HorizontalSplitPosition", type="int", text=str(freeze_panes[0])
350 )
351 )
352 config_item_map_entry.addElement(
353 ConfigItem(
354 name="VerticalSplitPosition", type="int", text=str(freeze_panes[1])
355 )
356 )
357 config_item_map_entry.addElement(
358 ConfigItem(name="PositionRight", type="int", text=str(freeze_panes[0]))
359 )
360 config_item_map_entry.addElement(
361 ConfigItem(name="PositionBottom", type="int", text=str(freeze_panes[1]))
362 )