_odswriter.py 11 KB

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