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