1from __future__ import annotations
2
3from typing import (
4 TYPE_CHECKING,
5 Any,
6 cast,
7)
8
9import numpy as np
10
11from pandas.compat._optional import import_optional_dependency
12
13import pandas as pd
14
15from pandas.io.excel._base import BaseExcelReader
16
17if TYPE_CHECKING:
18 from odf.opendocument import OpenDocument
19
20 from pandas._libs.tslibs.nattype import NaTType
21 from pandas._typing import (
22 FilePath,
23 ReadBuffer,
24 Scalar,
25 StorageOptions,
26 )
27
28
29class ODFReader(BaseExcelReader["OpenDocument"]):
30 def __init__(
31 self,
32 filepath_or_buffer: FilePath | ReadBuffer[bytes],
33 storage_options: StorageOptions | None = None,
34 engine_kwargs: dict[str, Any] | None = None,
35 ) -> None:
36 """
37 Read tables out of OpenDocument formatted files.
38
39 Parameters
40 ----------
41 filepath_or_buffer : str, path to be parsed or
42 an open readable stream.
43 storage_options : dict, optional
44 Extra options that make sense for a particular storage connection,
45 e.g. host, port, username, password, etc. For HTTP(S) URLs the
46 key-value pairs are forwarded to ``urllib.request.Request`` as
47 header options. For other URLs (e.g. starting with "s3://", and
48 "gcs://") the key-value pairs are forwarded to ``fsspec.open``.
49 Please see ``fsspec`` and ``urllib`` for more details, and for
50 more examples on storage options refer `here
51 <https://pandas.pydata.org/docs/user_guide/io.html?#reading-writing-remote-files>`__.
52 engine_kwargs : dict, optional
53 Arbitrary keyword arguments passed to excel engine.
54 """
55 import_optional_dependency("odf")
56 super().__init__(
57 filepath_or_buffer,
58 storage_options=storage_options,
59 engine_kwargs=engine_kwargs,
60 )
61
62 @property
63 def _workbook_class(self) -> type[OpenDocument]:
64 from odf.opendocument import OpenDocument
65
66 return OpenDocument
67
68 def load_workbook(
69 self, filepath_or_buffer: FilePath | ReadBuffer[bytes], engine_kwargs
70 ) -> OpenDocument:
71 from odf.opendocument import load
72
73 return load(filepath_or_buffer, **engine_kwargs)
74
75 @property
76 def empty_value(self) -> str:
77 """Property for compat with other readers."""
78 return ""
79
80 @property
81 def sheet_names(self) -> list[str]:
82 """Return a list of sheet names present in the document"""
83 from odf.table import Table
84
85 tables = self.book.getElementsByType(Table)
86 return [t.getAttribute("name") for t in tables]
87
88 def get_sheet_by_index(self, index: int):
89 from odf.table import Table
90
91 self.raise_if_bad_sheet_by_index(index)
92 tables = self.book.getElementsByType(Table)
93 return tables[index]
94
95 def get_sheet_by_name(self, name: str):
96 from odf.table import Table
97
98 self.raise_if_bad_sheet_by_name(name)
99 tables = self.book.getElementsByType(Table)
100
101 for table in tables:
102 if table.getAttribute("name") == name:
103 return table
104
105 self.close()
106 raise ValueError(f"sheet {name} not found")
107
108 def get_sheet_data(
109 self, sheet, file_rows_needed: int | None = None
110 ) -> list[list[Scalar | NaTType]]:
111 """
112 Parse an ODF Table into a list of lists
113 """
114 from odf.namespaces import OFFICENS
115 from odf.table import (
116 CoveredTableCell,
117 TableCell,
118 TableRow,
119 )
120
121 covered_cell_name = CoveredTableCell().qname
122 table_cell_name = TableCell().qname
123 cell_names = {covered_cell_name, table_cell_name}
124
125 sheet_rows = sheet.getElementsByType(TableRow)
126 empty_rows = 0
127 max_row_len = 0
128
129 table: list[list[Scalar | NaTType]] = []
130
131 for sheet_row in sheet_rows:
132 empty_cells = 0
133 table_row: list[Scalar | NaTType] = []
134
135 for sheet_cell in sheet_row.childNodes:
136 if hasattr(sheet_cell, "qname") and sheet_cell.qname in cell_names:
137 value: Scalar | NaTType
138 if (
139 sheet_cell.qname == covered_cell_name
140 and (OFFICENS, "value") not in sheet_cell.attributes
141 ):
142 value = self.empty_value
143 else:
144 value = self._get_cell_value(sheet_cell)
145
146 column_repeat = self._get_column_repeat(sheet_cell)
147
148 # Queue up empty values, writing only if content succeeds them
149 if value == self.empty_value:
150 empty_cells += column_repeat
151 else:
152 table_row.extend([self.empty_value] * empty_cells)
153 empty_cells = 0
154 table_row.extend([value] * column_repeat)
155
156 if max_row_len < len(table_row):
157 max_row_len = len(table_row)
158
159 row_repeat = self._get_row_repeat(sheet_row)
160 if len(table_row) == 0:
161 empty_rows += row_repeat
162 else:
163 # add blank rows to our table
164 table.extend([[self.empty_value]] * empty_rows)
165 empty_rows = 0
166 table.extend(table_row for _ in range(row_repeat))
167 if file_rows_needed is not None and len(table) >= file_rows_needed:
168 break
169
170 # Make our table square
171 for row in table:
172 if len(row) < max_row_len:
173 row.extend([self.empty_value] * (max_row_len - len(row)))
174
175 return table
176
177 def _get_row_repeat(self, row) -> int:
178 """
179 Return number of times this row was repeated
180 Repeating an empty row appeared to be a common way
181 of representing sparse rows in the table.
182 """
183 from odf.namespaces import TABLENS
184
185 return int(row.attributes.get((TABLENS, "number-rows-repeated"), 1))
186
187 def _get_column_repeat(self, cell) -> int:
188 from odf.namespaces import TABLENS
189
190 return int(cell.attributes.get((TABLENS, "number-columns-repeated"), 1))
191
192 def _get_cell_value(self, cell) -> Scalar | NaTType:
193 from odf.namespaces import OFFICENS
194
195 if str(cell) == "#N/A":
196 return np.nan
197
198 cell_type = cell.attributes.get((OFFICENS, "value-type"))
199 if cell_type == "boolean":
200 if str(cell) == "TRUE":
201 return True
202 return False
203 if cell_type is None:
204 return self.empty_value
205 elif cell_type == "float":
206 # GH5394
207 cell_value = float(cell.attributes.get((OFFICENS, "value")))
208 val = int(cell_value)
209 if val == cell_value:
210 return val
211 return cell_value
212 elif cell_type == "percentage":
213 cell_value = cell.attributes.get((OFFICENS, "value"))
214 return float(cell_value)
215 elif cell_type == "string":
216 return self._get_cell_string_value(cell)
217 elif cell_type == "currency":
218 cell_value = cell.attributes.get((OFFICENS, "value"))
219 return float(cell_value)
220 elif cell_type == "date":
221 cell_value = cell.attributes.get((OFFICENS, "date-value"))
222 return pd.Timestamp(cell_value)
223 elif cell_type == "time":
224 stamp = pd.Timestamp(str(cell))
225 # cast needed here because Scalar doesn't include datetime.time
226 return cast("Scalar", stamp.time())
227 else:
228 self.close()
229 raise ValueError(f"Unrecognized type {cell_type}")
230
231 def _get_cell_string_value(self, cell) -> str:
232 """
233 Find and decode OpenDocument text:s tags that represent
234 a run length encoded sequence of space characters.
235 """
236 from odf.element import Element
237 from odf.namespaces import (
238 OFFICENS,
239 TEXTNS,
240 )
241
242 # NB: built as plain tuples rather than from odf element instances,
243 # since this runs once per cell.
244 office_annotation = (OFFICENS, "annotation")
245 text_s = (TEXTNS, "s")
246 text_p = (TEXTNS, "p")
247 text_line_break = (TEXTNS, "line-break")
248
249 value = []
250 seen_paragraph = False
251
252 for fragment in cell.childNodes:
253 if isinstance(fragment, Element):
254 if fragment.qname == text_s:
255 spaces = int(fragment.attributes.get((TEXTNS, "c"), 1))
256 value.append(" " * spaces)
257 elif fragment.qname == text_line_break:
258 # GH#55728: an explicit line break within a paragraph.
259 value.append("\n")
260 elif fragment.qname == office_annotation:
261 continue
262 else:
263 if fragment.qname == text_p:
264 # GH#53924: a cell holding several paragraphs shows them
265 # on separate lines, which is how LibreOffice and Excel
266 # store a multi-line cell.
267 if seen_paragraph:
268 value.append("\n")
269 seen_paragraph = True
270 # recursive impl needed in case of nested fragments
271 # with multiple spaces
272 # https://github.com/pandas-dev/pandas/pull/36175#discussion_r484639704
273 value.append(self._get_cell_string_value(fragment))
274 else:
275 value.append(str(fragment).strip("\n"))
276 return "".join(value)