|
from __future__ import annotations |
|
|
|
from collections import defaultdict |
|
import datetime |
|
import json |
|
from typing import ( |
|
TYPE_CHECKING, |
|
Any, |
|
DefaultDict, |
|
cast, |
|
overload, |
|
) |
|
|
|
from pandas.io.excel._base import ExcelWriter |
|
from pandas.io.excel._util import ( |
|
combine_kwargs, |
|
validate_freeze_panes, |
|
) |
|
|
|
if TYPE_CHECKING: |
|
from pandas._typing import ( |
|
ExcelWriterIfSheetExists, |
|
FilePath, |
|
StorageOptions, |
|
WriteExcelBuffer, |
|
) |
|
|
|
from pandas.io.formats.excel import ExcelCell |
|
|
|
|
|
class ODSWriter(ExcelWriter): |
|
_engine = "odf" |
|
_supported_extensions = (".ods",) |
|
|
|
def __init__( |
|
self, |
|
path: FilePath | WriteExcelBuffer | ExcelWriter, |
|
engine: str | None = None, |
|
date_format: str | None = None, |
|
datetime_format=None, |
|
mode: str = "w", |
|
storage_options: StorageOptions | None = None, |
|
if_sheet_exists: ExcelWriterIfSheetExists | None = None, |
|
engine_kwargs: dict[str, Any] | None = None, |
|
**kwargs, |
|
) -> None: |
|
from odf.opendocument import OpenDocumentSpreadsheet |
|
|
|
if mode == "a": |
|
raise ValueError("Append mode is not supported with odf!") |
|
|
|
engine_kwargs = combine_kwargs(engine_kwargs, kwargs) |
|
self._book = OpenDocumentSpreadsheet(**engine_kwargs) |
|
|
|
super().__init__( |
|
path, |
|
mode=mode, |
|
storage_options=storage_options, |
|
if_sheet_exists=if_sheet_exists, |
|
engine_kwargs=engine_kwargs, |
|
) |
|
|
|
self._style_dict: dict[str, str] = {} |
|
|
|
@property |
|
def book(self): |
|
""" |
|
Book instance of class odf.opendocument.OpenDocumentSpreadsheet. |
|
|
|
This attribute can be used to access engine-specific features. |
|
""" |
|
return self._book |
|
|
|
@property |
|
def sheets(self) -> dict[str, Any]: |
|
"""Mapping of sheet names to sheet objects.""" |
|
from odf.table import Table |
|
|
|
result = { |
|
sheet.getAttribute("name"): sheet |
|
for sheet in self.book.getElementsByType(Table) |
|
} |
|
return result |
|
|
|
def _save(self) -> None: |
|
""" |
|
Save workbook to disk. |
|
""" |
|
for sheet in self.sheets.values(): |
|
self.book.spreadsheet.addElement(sheet) |
|
self.book.save(self._handles.handle) |
|
|
|
def _write_cells( |
|
self, |
|
cells: list[ExcelCell], |
|
sheet_name: str | None = None, |
|
startrow: int = 0, |
|
startcol: int = 0, |
|
freeze_panes: tuple[int, int] | None = None, |
|
) -> None: |
|
""" |
|
Write the frame cells using odf |
|
""" |
|
from odf.table import ( |
|
Table, |
|
TableCell, |
|
TableRow, |
|
) |
|
from odf.text import P |
|
|
|
sheet_name = self._get_sheet_name(sheet_name) |
|
assert sheet_name is not None |
|
|
|
if sheet_name in self.sheets: |
|
wks = self.sheets[sheet_name] |
|
else: |
|
wks = Table(name=sheet_name) |
|
self.book.spreadsheet.addElement(wks) |
|
|
|
if validate_freeze_panes(freeze_panes): |
|
freeze_panes = cast(tuple[int, int], freeze_panes) |
|
self._create_freeze_panes(sheet_name, freeze_panes) |
|
|
|
for _ in range(startrow): |
|
wks.addElement(TableRow()) |
|
|
|
rows: DefaultDict = defaultdict(TableRow) |
|
col_count: DefaultDict = defaultdict(int) |
|
|
|
for cell in sorted(cells, key=lambda cell: (cell.row, cell.col)): |
|
|
|
if not col_count[cell.row]: |
|
for _ in range(startcol): |
|
rows[cell.row].addElement(TableCell()) |
|
|
|
|
|
for _ in range(cell.col - col_count[cell.row]): |
|
rows[cell.row].addElement(TableCell()) |
|
col_count[cell.row] += 1 |
|
|
|
pvalue, tc = self._make_table_cell(cell) |
|
rows[cell.row].addElement(tc) |
|
col_count[cell.row] += 1 |
|
p = P(text=pvalue) |
|
tc.addElement(p) |
|
|
|
|
|
if len(rows) > 0: |
|
for row_nr in range(max(rows.keys()) + 1): |
|
wks.addElement(rows[row_nr]) |
|
|
|
def _make_table_cell_attributes(self, cell) -> dict[str, int | str]: |
|
"""Convert cell attributes to OpenDocument attributes |
|
|
|
Parameters |
|
---------- |
|
cell : ExcelCell |
|
Spreadsheet cell data |
|
|
|
Returns |
|
------- |
|
attributes : Dict[str, Union[int, str]] |
|
Dictionary with attributes and attribute values |
|
""" |
|
attributes: dict[str, int | str] = {} |
|
style_name = self._process_style(cell.style) |
|
if style_name is not None: |
|
attributes["stylename"] = style_name |
|
if cell.mergestart is not None and cell.mergeend is not None: |
|
attributes["numberrowsspanned"] = max(1, cell.mergestart) |
|
attributes["numbercolumnsspanned"] = cell.mergeend |
|
return attributes |
|
|
|
def _make_table_cell(self, cell) -> tuple[object, Any]: |
|
"""Convert cell data to an OpenDocument spreadsheet cell |
|
|
|
Parameters |
|
---------- |
|
cell : ExcelCell |
|
Spreadsheet cell data |
|
|
|
Returns |
|
------- |
|
pvalue, cell : Tuple[str, TableCell] |
|
Display value, Cell value |
|
""" |
|
from odf.table import TableCell |
|
|
|
attributes = self._make_table_cell_attributes(cell) |
|
val, fmt = self._value_with_fmt(cell.val) |
|
pvalue = value = val |
|
if isinstance(val, bool): |
|
value = str(val).lower() |
|
pvalue = str(val).upper() |
|
return ( |
|
pvalue, |
|
TableCell( |
|
valuetype="boolean", |
|
booleanvalue=value, |
|
attributes=attributes, |
|
), |
|
) |
|
elif isinstance(val, datetime.datetime): |
|
|
|
value = val.isoformat() |
|
|
|
pvalue = val.strftime("%c") |
|
return ( |
|
pvalue, |
|
TableCell(valuetype="date", datevalue=value, attributes=attributes), |
|
) |
|
elif isinstance(val, datetime.date): |
|
|
|
value = f"{val.year}-{val.month:02d}-{val.day:02d}" |
|
|
|
pvalue = val.strftime("%x") |
|
return ( |
|
pvalue, |
|
TableCell(valuetype="date", datevalue=value, attributes=attributes), |
|
) |
|
elif isinstance(val, str): |
|
return ( |
|
pvalue, |
|
TableCell( |
|
valuetype="string", |
|
stringvalue=value, |
|
attributes=attributes, |
|
), |
|
) |
|
else: |
|
return ( |
|
pvalue, |
|
TableCell( |
|
valuetype="float", |
|
value=value, |
|
attributes=attributes, |
|
), |
|
) |
|
|
|
@overload |
|
def _process_style(self, style: dict[str, Any]) -> str: |
|
... |
|
|
|
@overload |
|
def _process_style(self, style: None) -> None: |
|
... |
|
|
|
def _process_style(self, style: dict[str, Any] | None) -> str | None: |
|
"""Convert a style dictionary to a OpenDocument style sheet |
|
|
|
Parameters |
|
---------- |
|
style : Dict |
|
Style dictionary |
|
|
|
Returns |
|
------- |
|
style_key : str |
|
Unique style key for later reference in sheet |
|
""" |
|
from odf.style import ( |
|
ParagraphProperties, |
|
Style, |
|
TableCellProperties, |
|
TextProperties, |
|
) |
|
|
|
if style is None: |
|
return None |
|
style_key = json.dumps(style) |
|
if style_key in self._style_dict: |
|
return self._style_dict[style_key] |
|
name = f"pd{len(self._style_dict)+1}" |
|
self._style_dict[style_key] = name |
|
odf_style = Style(name=name, family="table-cell") |
|
if "font" in style: |
|
font = style["font"] |
|
if font.get("bold", False): |
|
odf_style.addElement(TextProperties(fontweight="bold")) |
|
if "borders" in style: |
|
borders = style["borders"] |
|
for side, thickness in borders.items(): |
|
thickness_translation = {"thin": "0.75pt solid #000000"} |
|
odf_style.addElement( |
|
TableCellProperties( |
|
attributes={f"border{side}": thickness_translation[thickness]} |
|
) |
|
) |
|
if "alignment" in style: |
|
alignment = style["alignment"] |
|
horizontal = alignment.get("horizontal") |
|
if horizontal: |
|
odf_style.addElement(ParagraphProperties(textalign=horizontal)) |
|
vertical = alignment.get("vertical") |
|
if vertical: |
|
odf_style.addElement(TableCellProperties(verticalalign=vertical)) |
|
self.book.styles.addElement(odf_style) |
|
return name |
|
|
|
def _create_freeze_panes( |
|
self, sheet_name: str, freeze_panes: tuple[int, int] |
|
) -> None: |
|
""" |
|
Create freeze panes in the sheet. |
|
|
|
Parameters |
|
---------- |
|
sheet_name : str |
|
Name of the spreadsheet |
|
freeze_panes : tuple of (int, int) |
|
Freeze pane location x and y |
|
""" |
|
from odf.config import ( |
|
ConfigItem, |
|
ConfigItemMapEntry, |
|
ConfigItemMapIndexed, |
|
ConfigItemMapNamed, |
|
ConfigItemSet, |
|
) |
|
|
|
config_item_set = ConfigItemSet(name="ooo:view-settings") |
|
self.book.settings.addElement(config_item_set) |
|
|
|
config_item_map_indexed = ConfigItemMapIndexed(name="Views") |
|
config_item_set.addElement(config_item_map_indexed) |
|
|
|
config_item_map_entry = ConfigItemMapEntry() |
|
config_item_map_indexed.addElement(config_item_map_entry) |
|
|
|
config_item_map_named = ConfigItemMapNamed(name="Tables") |
|
config_item_map_entry.addElement(config_item_map_named) |
|
|
|
config_item_map_entry = ConfigItemMapEntry(name=sheet_name) |
|
config_item_map_named.addElement(config_item_map_entry) |
|
|
|
config_item_map_entry.addElement( |
|
ConfigItem(name="HorizontalSplitMode", type="short", text="2") |
|
) |
|
config_item_map_entry.addElement( |
|
ConfigItem(name="VerticalSplitMode", type="short", text="2") |
|
) |
|
config_item_map_entry.addElement( |
|
ConfigItem( |
|
name="HorizontalSplitPosition", type="int", text=str(freeze_panes[0]) |
|
) |
|
) |
|
config_item_map_entry.addElement( |
|
ConfigItem( |
|
name="VerticalSplitPosition", type="int", text=str(freeze_panes[1]) |
|
) |
|
) |
|
config_item_map_entry.addElement( |
|
ConfigItem(name="PositionRight", type="int", text=str(freeze_panes[0])) |
|
) |
|
config_item_map_entry.addElement( |
|
ConfigItem(name="PositionBottom", type="int", text=str(freeze_panes[1])) |
|
) |
|
|