358 lines
11 KiB
Python
358 lines
11 KiB
Python
|
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)):
|
||
|
# only add empty cells if the row is still empty
|
||
|
if not col_count[cell.row]:
|
||
|
for _ in range(startcol):
|
||
|
rows[cell.row].addElement(TableCell())
|
||
|
|
||
|
# fill with empty cells if needed
|
||
|
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)
|
||
|
|
||
|
# add all rows to the sheet
|
||
|
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):
|
||
|
# Fast formatting
|
||
|
value = val.isoformat()
|
||
|
# Slow but locale-dependent
|
||
|
pvalue = val.strftime("%c")
|
||
|
return (
|
||
|
pvalue,
|
||
|
TableCell(valuetype="date", datevalue=value, attributes=attributes),
|
||
|
)
|
||
|
elif isinstance(val, datetime.date):
|
||
|
# Fast formatting
|
||
|
value = f"{val.year}-{val.month:02d}-{val.day:02d}"
|
||
|
# Slow but locale-dependent
|
||
|
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]))
|
||
|
)
|