_odswriter.py 10 KB

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