#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright Martin Manns
# Distributed under the terms of the GNU General Public License
# --------------------------------------------------------------------
# pyspread is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pyspread is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pyspread. If not, see <http://www.gnu.org/licenses/>.
# --------------------------------------------------------------------
"""
This file contains interfaces to the native pys file format.
PysReader and PysWriter classed are structured into the following sections:
* shape
* code
* attributes
* row_heights
* col_widths
* macros
**Provides**
* :func:`wxcolor2rgb`
* :func:`qt52qt6_fontweights`
* :func:`qt62qt5_fontweights`
* :dict:`wx2qt_fontweights`
* :dict:`wx2qt_fontstyles`
* :class:`PysReader`
* :class:`PysWriter`
"""
from builtins import str, map, object
import ast
from base64 import b64decode, b85encode
from collections import OrderedDict
from typing import Any, BinaryIO, Callable, Iterable, Tuple
try:
from pyspread.lib.attrdict import AttrDict
from pyspread.lib.selection import Selection
from pyspread.model.model import CellAttribute, CodeArray
except ImportError:
from lib.attrdict import AttrDict
from lib.selection import Selection
from model.model import CellAttribute, CodeArray
[docs]
def wxcolor2rgb(wxcolor: int) -> Tuple[int, int, int]:
"""Returns red, green, blue for given wxPython binary color value
:param wxcolor: Color value from wx.Color
"""
red = wxcolor >> 16
green = wxcolor - (red << 16) >> 8
blue = wxcolor - (red << 16) - (green << 8)
return red, green, blue
[docs]
def qt52qt6_fontweights(qt5_weight):
"""Approximates the mapping from Qt5 to Qt6 font weight"""
return int((qt5_weight - 20) * 13.5)
[docs]
def qt62qt5_fontweights(qt6_weight):
"""Approximates the mapping from Qt6 to Qt5 font weight"""
return int(qt6_weight / 13.5 + 20)
wx2qt_fontweights = {
90: 50, # wx.FONTWEIGHT_NORMAL
91: 40, # wx.FONTWEIGHT_LIGHT
92: 75, # wx.FONTWEIGHT_BOLD
93: 87, # wx.FONTWEIGHT_MAX
}
wx2qt_fontstyles = {
90: 0, # wx.FONTSTYLE_NORMAL
93: 1, # wx.FONTSTYLE_ITALIC
94: 1, # wx.FONTSTYLE_SLANT
95: 2, # wx.FONTSTYLE_MAX
}
[docs]
class PysReader:
"""Reads pys v2.0 file into a code_array"""
def __init__(self, pys_file: BinaryIO, code_array: CodeArray):
"""
:param pys_file: The pys or pysu file to be read
:param code_array: Target code_array
"""
self.pys_file = pys_file
self.code_array = code_array
self._section2reader = {
"[Pyspread save file version]\n": self._pys_version,
"[shape]\n": self._pys2shape,
"[grid]\n": self._pys2code,
"[attributes]\n": self._pys2attributes,
"[row_heights]\n": self._pys2row_heights,
"[col_widths]\n": self._pys2col_widths,
"[macros]\n": self._pys2macros,
}
# When converting old versions, cell attributes are required that
# take place after the cell attribute readout
self.cell_attributes_postfixes = []
def __iter__(self):
"""Iterates over self.pys_file, replacing everything in code_array"""
state = None
# Reset pys_file to start to enable multiple calls of this method
self.pys_file.seek(0)
for line in self.pys_file:
line = line.decode("utf8")
if line in self._section2reader:
state = line
elif state is not None:
self._section2reader[state](line)
yield line
# Apply cell attributes post fixes
for cell_attribute in self.cell_attributes_postfixes:
self.code_array.cell_attributes.append(cell_attribute)
# Decorators
[docs]
def version_handler(method: Callable) -> Callable:
"""Chooses method`_10` of method if version < 2.0
:param method: Method to be replaced in case of old pys file version
"""
def new_method(self, *args, **kwargs):
if self.version <= 1.0:
method10 = getattr(self, method.__name__+"_10")
method10(*args, **kwargs)
else:
method(self, *args, **kwargs)
return new_method
# Helpers
def _split_tidy(self, string: str, maxsplit: int = None) -> str:
"""Rstrips string for \n and splits string for \t
:param string: String to be rstripped and split
:param maxsplit: Maximum number of splits
"""
if maxsplit is None:
return string.rstrip("\n").split("\t")
else:
return string.rstrip("\n").split("\t", maxsplit)
def _get_key(self, *keystrings: str) -> Tuple[int, ...]:
"""Returns int key tuple from key string list
:param keystrings: Strings that contain integers that are key elements
"""
return tuple(map(int, keystrings))
# Sections
def _pys_version(self, line: str):
"""pys file version including assertion
:param line: Pys file line to be parsed
"""
self.version = float(line.strip())
if self.version > 2.0:
# Abort if file version not supported
msg = "File version {version} unsupported (> 2.0)."
raise ValueError(msg.format(version=line.strip()))
def _pys2shape(self, line: str):
"""Updates shape in code_array
:param line: Pys file line to be parsed
"""
shape = self._get_key(*self._split_tidy(line))
if any(dim <= 0 for dim in shape):
# Abort if any axis is 0 or less
msg = "Code array has invalid shape {shape}."
raise ValueError(msg.format(shape=shape))
self.code_array.shape = shape
def _code_convert_1_2(self, key: Tuple[int, int, int], code: str) -> str:
"""Converts chart and image code from v1.0 to v2.0
:param key: Key of cell with code
:param code: Code in cell to be converted
"""
def get_image_code(image_data: str, width: int, height: int) -> str:
"""Returns code string for v2.0
:param image_data: b85encoded image data
:param width: Image width
:param height: Image height
"""
image_buffer_tpl = 'bz2.decompress(base64.b85decode({data}))'
image_array_tpl = 'numpy.frombuffer({buffer}, dtype="uint8")'
image_matrix_tpl = '{array}.reshape({height}, {width}, 3)'
image_buffer = image_buffer_tpl.format(data=image_data)
image_array = image_array_tpl.format(buffer=image_buffer)
image_matrix = image_matrix_tpl.format(array=image_array,
height=height, width=width)
return image_matrix
start_str = "bz2.decompress(base64.b64decode('"
size_start_str = "wx.ImageFromData("
if size_start_str in code and start_str in code:
size_start = code.index(size_start_str) + len(size_start_str)
size_str_list = code[size_start:].split(",")[:2]
width, height = tuple(map(int, size_str_list))
# We have a cell that displays a bitmap
data_start = code.index(start_str) + len(start_str)
data_stop = code.find("'", data_start)
enc_data = bytes(code[data_start:data_stop], encoding='utf-8')
compressed_image_data = b64decode(enc_data)
reenc_data = b85encode(compressed_image_data)
code = get_image_code(repr(reenc_data), width, height)
selection = Selection([], [], [], [], [(key[0], key[1])])
tab = key[2]
attr_dict = AttrDict([("renderer", "image")])
attr = CellAttribute(selection, tab, attr_dict)
self.cell_attributes_postfixes.append(attr)
elif "charts.ChartFigure(" in code:
# We have a matplotlib figure
selection = Selection([], [], [], [], [(key[0], key[1])])
tab = key[2]
attr_dict = AttrDict([("renderer", "matplotlib")])
attr = CellAttribute(selection, tab, attr_dict)
self.cell_attributes_postfixes.append(attr)
return code
def _pys2code_10(self, line: str):
"""Updates code in pys code_array - for save file version 1.0
:param line: Pys file line to be parsed
"""
row, col, tab, code = self._split_tidy(line, maxsplit=3)
key = self._get_key(row, col, tab)
if all(0 <= key[i] < self.code_array.shape[i] for i in range(3)):
self.code_array.dict_grid[key] = str(self._code_convert_1_2(key,
code))
@version_handler
def _pys2code(self, line: str):
"""Updates code in pys code_array
:param line: Pys file line to be parsed
"""
row, col, tab, code = self._split_tidy(line, maxsplit=3)
key = self._get_key(row, col, tab)
if all(0 <= key[i] < self.code_array.shape[i] for i in range(3)):
self.code_array.dict_grid[key] = ast.literal_eval(code)
def _attr_convert_1to2(self, key: str, value: Any) -> Tuple[str, Any]:
"""Converts key, value attribute pair from v1.0 to v2.0
:param key: AttrDict key
:param value: AttrDict value for key
"""
color_attrs = ["bordercolor_bottom", "bordercolor_right", "bgcolor",
"textcolor"]
if key in color_attrs:
return key, wxcolor2rgb(value)
elif key == "fontweight":
return key, wx2qt_fontweights[value]
elif key == "fontstyle":
return key, wx2qt_fontstyles[value]
elif key == "markup" and value:
return "renderer", "markup"
elif key == "angle" and value < 0:
return "angle", 360 + value
elif key == "merge_area":
# Value in v1.0 None if the cell was merged
# In v 2.0 this is no longer necessary
return None, value
# Update justifiaction and alignment values
elif key in ["vertical_align", "justification"]:
just_align_value_tansitions = {
"left": "justify_left",
"center": "justify_center",
"right": "justify_right",
"top": "align_top",
"middle": "align_center",
"bottom": "align_bottom",
}
return key, just_align_value_tansitions[value]
return key, value
def _pys2attributes_10(self, line: str):
"""Updates attributes in code_array - for save file version 1.0
:param line: Pys file line to be parsed
"""
splitline = self._split_tidy(line)
selection_data = list(map(ast.literal_eval, splitline[:5]))
selection = Selection(*selection_data)
tab = int(splitline[5])
attr_dict = AttrDict()
old_merged_cells = {}
for col, ele in enumerate(splitline[6:]):
if not (col % 2):
# Odd entries are keys
key = ast.literal_eval(ele)
else:
# Even cols are values
value = ast.literal_eval(ele)
# Convert old wx color values and merged cells
key_, value_ = self._attr_convert_1to2(key, value)
if key_ is None and value_ is not None:
# We have a merged cell
old_merged_cells[value_[:2]] = value_
try:
attr_dict.pop("merge_area")
except KeyError:
pass
attr_dict[key_] = value_
attr = CellAttribute(selection, tab, attr_dict)
self.code_array.cell_attributes.append(attr)
for key in old_merged_cells:
selection = Selection([], [], [], [], [key])
attr_dict = AttrDict([("merge_area", old_merged_cells[key])])
attr = CellAttribute(selection, tab, attr_dict)
self.code_array.cell_attributes.append(attr)
old_merged_cells.clear()
@version_handler
def _pys2attributes(self, line: str):
"""Updates attributes in code_array
:param line: Pys file line to be parsed
"""
splitline = self._split_tidy(line)
selection_data = list(map(ast.literal_eval, splitline[:5]))
selection = Selection(*selection_data)
tab = int(splitline[5])
attr_dict = AttrDict()
for col, ele in enumerate(splitline[6:]):
if not (col % 2):
# Odd entries are keys
key = ast.literal_eval(ele)
else:
# Even cols are values
value = ast.literal_eval(ele)
attr_dict[key] = value
if attr_dict: # Ignore empty attribute settings
attr = CellAttribute(selection, tab, attr_dict)
self.code_array.cell_attributes.append(attr)
def _pys2row_heights(self, line: str):
"""Updates row_heights in code_array
:param line: Pys file line to be parsed
"""
# Split with maxsplit 3
split_line = self._split_tidy(line)
key = row, tab = self._get_key(*split_line[:2])
height = float(split_line[2])
shape = self.code_array.shape
try:
if row < shape[0] and tab < shape[2]:
self.code_array.row_heights[key] = height
except ValueError:
pass
def _pys2col_widths(self, line: str):
"""Updates col_widths in code_array
:param line: Pys file line to be parsed
"""
# Split with maxsplit 3
split_line = self._split_tidy(line)
key = col, tab = self._get_key(*split_line[:2])
width = float(split_line[2])
shape = self.code_array.shape
try:
if col < shape[1] and tab < shape[2]:
self.code_array.col_widths[key] = width
except ValueError:
pass
def _pys2macros(self, line: str):
"""Updates macros in code_array
:param line: Pys file line to be parsed
"""
self.code_array.macros += line
[docs]
class PysWriter(object):
"""Interface between code_array and pys file data
Iterating over it yields pys file lines
"""
def __init__(self, code_array: CodeArray):
"""
:param code_array: The code_array object data structure
"""
self.code_array = code_array
self.version = 2.0
self._section2writer = OrderedDict([
("[Pyspread save file version]\n", self._version2pys),
("[shape]\n", self._shape2pys),
("[grid]\n", self._code2pys),
("[attributes]\n", self._attributes2pys),
("[row_heights]\n", self._row_heights2pys),
("[col_widths]\n", self._col_widths2pys),
("[macros]\n", self._macros2pys),
])
def __iter__(self) -> Iterable[str]:
"""Yields a pys_file line wise from code_array"""
for key in self._section2writer:
yield key
for line in self._section2writer[key]():
yield line
def __len__(self) -> int:
"""Returns how many lines will be written when saving the code_array"""
lines = 9 # Headers + 1 line version + 1 line shape
lines += len(self.code_array.dict_grid)
lines += len(self.code_array.cell_attributes)
lines += len(self.code_array.dict_grid.row_heights)
lines += len(self.code_array.dict_grid.col_widths)
lines += self.code_array.dict_grid.macros.count('\n')
return lines
def _version2pys(self) -> Iterable[str]:
"""Returns pys file version information in pys format
Format: <version>\n
"""
yield repr(self.version) + "\n"
def _shape2pys(self) -> Iterable[str]:
"""Returns shape information in pys format
Format: <rows>\t<cols>\t<tabs>\n
"""
yield u"\t".join(map(str, self.code_array.shape)) + u"\n"
def _code2pys(self) -> Iterable[str]:
"""Returns cell code information in pys format
Format: <row>\t<col>\t<tab>\t<code>\n
"""
for key in self.code_array:
key_str = u"\t".join(repr(ele) for ele in key)
if self.version <= 1.0:
code_str = self.code_array(key)
else:
code_str = repr(self.code_array(key))
out_str = key_str + u"\t" + code_str + u"\n"
yield out_str
def _attributes2pys(self) -> Iterable[str]:
"""Returns cell attributes information in pys format
Format:
<selection[0]>\t[...]\t<tab>\t<key>\t<value>\t[...]\n
"""
# Remove doublettes
purged_cell_attributes = []
purged_cell_attributes_keys = []
for selection, tab, attr_dict in self.code_array.cell_attributes:
if purged_cell_attributes_keys and \
(selection, tab) == purged_cell_attributes_keys[-1]:
purged_cell_attributes[-1][2].update(attr_dict)
else:
purged_cell_attributes_keys.append((selection, tab))
purged_cell_attributes.append([selection, tab, attr_dict])
for selection, tab, attr_dict in purged_cell_attributes:
if not attr_dict:
continue
sel_list = [selection.block_tl, selection.block_br,
selection.rows, selection.columns, selection.cells]
tab_list = [tab]
attr_dict_list = []
for key in attr_dict:
if key is not None:
attr_dict_list.append(key)
attr_dict_list.append(attr_dict[key])
line_list = list(map(repr, sel_list + tab_list + attr_dict_list))
yield u"\t".join(line_list) + u"\n"
def _row_heights2pys(self) -> Iterable[str]:
"""Returns row height information in pys format
Format: <row>\t<tab>\t<value>\n
"""
for row, tab in self.code_array.dict_grid.row_heights:
if row < self.code_array.shape[0] and \
tab < self.code_array.shape[2]:
height = self.code_array.dict_grid.row_heights[(row, tab)]
height_strings = list(map(repr, [row, tab, height]))
yield u"\t".join(height_strings) + u"\n"
def _col_widths2pys(self) -> Iterable[str]:
"""Returns column width information in pys format
Format: <col>\t<tab>\t<value>\n
"""
for col, tab in self.code_array.dict_grid.col_widths:
if col < self.code_array.shape[1] and \
tab < self.code_array.shape[2]:
width = self.code_array.dict_grid.col_widths[(col, tab)]
width_strings = list(map(repr, [col, tab, width]))
yield u"\t".join(width_strings) + u"\n"
def _macros2pys(self) -> Iterable[str]:
"""Returns macros information in pys format
Format: <macro code line>\n
"""
macros = self.code_array.dict_grid.macros
yield macros