diff options
author | S. Solomon Darnell | 2025-03-28 21:52:21 -0500 |
---|---|---|
committer | S. Solomon Darnell | 2025-03-28 21:52:21 -0500 |
commit | 4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch) | |
tree | ee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py | |
parent | cc961e04ba734dd72309fb548a2f97d67d578813 (diff) | |
download | gn-ai-master.tar.gz |
Diffstat (limited to '.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py | 8554 |
1 files changed, 8554 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py b/.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py new file mode 100644 index 00000000..08b66032 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py @@ -0,0 +1,8554 @@ +############################################################################### +# +# Worksheet - A class for writing the Excel XLSX Worksheet file. +# +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org +# + +# pylint: disable=too-many-return-statements + +# Standard packages. +import datetime +import math +import os +import re +import tempfile +from collections import defaultdict, namedtuple +from decimal import Decimal +from fractions import Fraction +from functools import wraps +from io import StringIO +from math import isinf, isnan +from warnings import warn + +# Package imports. +from . import xmlwriter +from .drawing import Drawing +from .exceptions import DuplicateTableName, OverlappingRange +from .format import Format +from .shape import Shape +from .utility import ( + _datetime_to_excel_datetime, + _get_image_properties, + _get_sparkline_style, + _preserve_whitespace, + _supported_datetime, + _xl_color, + quote_sheetname, + xl_cell_to_rowcol, + xl_col_to_name, + xl_pixel_width, + xl_range, + xl_rowcol_to_cell, + xl_rowcol_to_cell_fast, +) +from .xmlwriter import XMLwriter + +re_dynamic_function = re.compile( + r""" + \bANCHORARRAY\( | + \bBYCOL\( | + \bBYROW\( | + \bCHOOSECOLS\( | + \bCHOOSEROWS\( | + \bDROP\( | + \bEXPAND\( | + \bFILTER\( | + \bHSTACK\( | + \bLAMBDA\( | + \bMAKEARRAY\( | + \bMAP\( | + \bRANDARRAY\( | + \bREDUCE\( | + \bSCAN\( | + \bSEQUENCE\( | + \bSINGLE\( | + \bSORT\( | + \bSORTBY\( | + \bSWITCH\( | + \bTAKE\( | + \bTEXTSPLIT\( | + \bTOCOL\( | + \bTOROW\( | + \bUNIQUE\( | + \bVSTACK\( | + \bWRAPCOLS\( | + \bWRAPROWS\( | + \bXLOOKUP\(""", + re.VERBOSE, +) + + +############################################################################### +# +# Decorator functions. +# +############################################################################### +def convert_cell_args(method): + """ + Decorator function to convert A1 notation in cell method calls + to the default row/col notation. + + """ + + @wraps(method) + def cell_wrapper(self, *args, **kwargs): + try: + # First arg is an int, default to row/col notation. + if args: + first_arg = args[0] + int(first_arg) + except ValueError: + # First arg isn't an int, convert to A1 notation. + new_args = xl_cell_to_rowcol(first_arg) + args = new_args + args[1:] + + return method(self, *args, **kwargs) + + return cell_wrapper + + +def convert_range_args(method): + """ + Decorator function to convert A1 notation in range method calls + to the default row/col notation. + + """ + + @wraps(method) + def cell_wrapper(self, *args, **kwargs): + try: + # First arg is an int, default to row/col notation. + if args: + int(args[0]) + except ValueError: + # First arg isn't an int, convert to A1 notation. + if ":" in args[0]: + cell_1, cell_2 = args[0].split(":") + row_1, col_1 = xl_cell_to_rowcol(cell_1) + row_2, col_2 = xl_cell_to_rowcol(cell_2) + else: + row_1, col_1 = xl_cell_to_rowcol(args[0]) + row_2, col_2 = row_1, col_1 + + new_args = [row_1, col_1, row_2, col_2] + new_args.extend(args[1:]) + args = new_args + + return method(self, *args, **kwargs) + + return cell_wrapper + + +def convert_column_args(method): + """ + Decorator function to convert A1 notation in columns method calls + to the default row/col notation. + + """ + + @wraps(method) + def column_wrapper(self, *args, **kwargs): + try: + # First arg is an int, default to row/col notation. + if args: + int(args[0]) + except ValueError: + # First arg isn't an int, convert to A1 notation. + cell_1, cell_2 = [col + "1" for col in args[0].split(":")] + _, col_1 = xl_cell_to_rowcol(cell_1) + _, col_2 = xl_cell_to_rowcol(cell_2) + new_args = [col_1, col_2] + new_args.extend(args[1:]) + args = new_args + + return method(self, *args, **kwargs) + + return column_wrapper + + +############################################################################### +# +# Named tuples used for cell types. +# +############################################################################### +CellBlankTuple = namedtuple("Blank", "format") +CellErrorTuple = namedtuple("Error", "error, format, value") +CellNumberTuple = namedtuple("Number", "number, format") +CellStringTuple = namedtuple("String", "string, format") +CellBooleanTuple = namedtuple("Boolean", "boolean, format") +CellFormulaTuple = namedtuple("Formula", "formula, format, value") +CellDatetimeTuple = namedtuple("Datetime", "number, format") +CellRichStringTuple = namedtuple("RichString", "string, format, raw_string") +CellArrayFormulaTuple = namedtuple( + "ArrayFormula", "formula, format, value, range, atype" +) + + +############################################################################### +# +# Worksheet Class definition. +# +############################################################################### +class Worksheet(xmlwriter.XMLwriter): + """ + A class for writing the Excel XLSX Worksheet file. + + """ + + ########################################################################### + # + # Public API. + # + ########################################################################### + + def __init__(self): + """ + Constructor. + + """ + + super().__init__() + + self.name = None + self.index = None + self.str_table = None + self.palette = None + self.constant_memory = 0 + self.tmpdir = None + self.is_chartsheet = False + + self.ext_sheets = [] + self.fileclosed = 0 + self.excel_version = 2007 + self.excel2003_style = False + + self.xls_rowmax = 1048576 + self.xls_colmax = 16384 + self.xls_strmax = 32767 + self.dim_rowmin = None + self.dim_rowmax = None + self.dim_colmin = None + self.dim_colmax = None + + self.col_info = {} + self.selections = [] + self.hidden = 0 + self.active = 0 + self.tab_color = 0 + self.top_left_cell = "" + + self.panes = [] + self.active_pane = 3 + self.selected = 0 + + self.page_setup_changed = False + self.paper_size = 0 + self.orientation = 1 + + self.print_options_changed = False + self.hcenter = False + self.vcenter = False + self.print_gridlines = False + self.screen_gridlines = True + self.print_headers = False + self.row_col_headers = False + + self.header_footer_changed = False + self.header = "" + self.footer = "" + self.header_footer_aligns = True + self.header_footer_scales = True + self.header_images = [] + self.footer_images = [] + self.header_images_list = [] + + self.margin_left = 0.7 + self.margin_right = 0.7 + self.margin_top = 0.75 + self.margin_bottom = 0.75 + self.margin_header = 0.3 + self.margin_footer = 0.3 + + self.repeat_row_range = "" + self.repeat_col_range = "" + self.print_area_range = "" + + self.page_order = 0 + self.black_white = 0 + self.draft_quality = 0 + self.print_comments = 0 + self.page_start = 0 + + self.fit_page = 0 + self.fit_width = 0 + self.fit_height = 0 + + self.hbreaks = [] + self.vbreaks = [] + + self.protect_options = {} + self.protected_ranges = [] + self.num_protected_ranges = 0 + self.set_cols = {} + self.set_rows = defaultdict(dict) + + self.zoom = 100 + self.zoom_scale_normal = 1 + self.print_scale = 100 + self.is_right_to_left = 0 + self.show_zeros = 1 + self.leading_zeros = 0 + + self.outline_row_level = 0 + self.outline_col_level = 0 + self.outline_style = 0 + self.outline_below = 1 + self.outline_right = 1 + self.outline_on = 1 + self.outline_changed = False + + self.original_row_height = 15 + self.default_row_height = 15 + self.default_row_pixels = 20 + self.default_col_width = 8.43 + self.default_col_pixels = 64 + self.default_date_pixels = 68 + self.default_row_zeroed = 0 + + self.names = {} + self.write_match = [] + self.table = defaultdict(dict) + self.merge = [] + self.merged_cells = {} + self.table_cells = {} + self.row_spans = {} + + self.has_vml = False + self.has_header_vml = False + self.has_comments = False + self.comments = defaultdict(dict) + self.comments_list = [] + self.comments_author = "" + self.comments_visible = 0 + self.vml_shape_id = 1024 + self.buttons_list = [] + self.vml_header_id = 0 + + self.autofilter_area = "" + self.autofilter_ref = None + self.filter_range = [0, 9] + self.filter_on = 0 + self.filter_cols = {} + self.filter_type = {} + self.filter_cells = {} + + self.row_sizes = {} + self.col_size_changed = False + self.row_size_changed = False + + self.last_shape_id = 1 + self.rel_count = 0 + self.hlink_count = 0 + self.hlink_refs = [] + self.external_hyper_links = [] + self.external_drawing_links = [] + self.external_comment_links = [] + self.external_vml_links = [] + self.external_table_links = [] + self.external_background_links = [] + self.drawing_links = [] + self.vml_drawing_links = [] + self.charts = [] + self.images = [] + self.tables = [] + self.sparklines = [] + self.shapes = [] + self.shape_hash = {} + self.drawing = 0 + self.drawing_rels = {} + self.drawing_rels_id = 0 + self.vml_drawing_rels = {} + self.vml_drawing_rels_id = 0 + self.background_image = None + self.background_bytes = False + + self.rstring = "" + self.previous_row = 0 + + self.validations = [] + self.cond_formats = {} + self.data_bars_2010 = [] + self.use_data_bars_2010 = False + self.dxf_priority = 1 + self.page_view = 0 + + self.vba_codename = None + + self.date_1904 = False + self.hyperlinks = defaultdict(dict) + + self.strings_to_numbers = False + self.strings_to_urls = True + self.nan_inf_to_errors = False + self.strings_to_formulas = True + + self.default_date_format = None + self.default_url_format = None + self.default_checkbox_format = None + self.workbook_add_format = None + self.remove_timezone = False + self.max_url_length = 2079 + + self.row_data_filename = None + self.row_data_fh = None + self.worksheet_meta = None + self.vml_data_id = None + self.vml_shape_id = None + + self.row_data_filename = None + self.row_data_fh = None + self.row_data_fh_closed = False + + self.vertical_dpi = 0 + self.horizontal_dpi = 0 + + self.write_handlers = {} + + self.ignored_errors = None + + self.has_dynamic_arrays = False + self.use_future_functions = False + self.ignore_write_string = False + self.embedded_images = None + + # Utility function for writing different types of strings. + def _write_token_as_string(self, token, row, col, *args): + # Map the data to the appropriate write_*() method. + if token == "": + return self._write_blank(row, col, *args) + + if self.strings_to_formulas and token.startswith("="): + return self._write_formula(row, col, *args) + + if token.startswith("{=") and token.endswith("}"): + return self._write_formula(row, col, *args) + + if ( + ":" in token + and self.strings_to_urls + and ( + re.match("(ftp|http)s?://", token) + or re.match("mailto:", token) + or re.match("(in|ex)ternal:", token) + ) + ): + return self._write_url(row, col, *args) + + if self.strings_to_numbers: + try: + f = float(token) + if self.nan_inf_to_errors or (not isnan(f) and not isinf(f)): + return self._write_number(row, col, f, *args[1:]) + except ValueError: + # Not a number, write as a string. + pass + + return self._write_string(row, col, *args) + + # We have a plain string. + return self._write_string(row, col, *args) + + @convert_cell_args + def write(self, row, col, *args): + """ + Write data to a worksheet cell by calling the appropriate write_*() + method based on the type of data being passed. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + *args: Args to pass to sub functions. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + other: Return value of called method. + + """ + return self._write(row, col, *args) + + # Undecorated version of write(). + def _write(self, row, col, *args): + # pylint: disable=raise-missing-from + # Check the number of args passed. + if not args: + raise TypeError("write() takes at least 4 arguments (3 given)") + + # The first arg should be the token for all write calls. + token = args[0] + + # Avoid isinstance() for better performance. + token_type = token.__class__ + + # Check for any user defined type handlers with callback functions. + if token_type in self.write_handlers: + write_handler = self.write_handlers[token_type] + function_return = write_handler(self, row, col, *args) + + # If the return value is None then the callback has returned + # control to this function and we should continue as + # normal. Otherwise we return the value to the caller and exit. + if function_return is None: + pass + else: + return function_return + + # Write None as a blank cell. + if token is None: + return self._write_blank(row, col, *args) + + # Check for standard Python types. + if token_type is bool: + return self._write_boolean(row, col, *args) + + if token_type in (float, int, Decimal, Fraction): + return self._write_number(row, col, *args) + + if token_type is str: + return self._write_token_as_string(token, row, col, *args) + + if token_type in ( + datetime.datetime, + datetime.date, + datetime.time, + datetime.timedelta, + ): + return self._write_datetime(row, col, *args) + + # Resort to isinstance() for subclassed primitives. + + # Write number types. + if isinstance(token, (float, int, Decimal, Fraction)): + return self._write_number(row, col, *args) + + # Write string types. + if isinstance(token, str): + return self._write_token_as_string(token, row, col, *args) + + # Write boolean types. + if isinstance(token, bool): + return self._write_boolean(row, col, *args) + + # Write datetime objects. + if _supported_datetime(token): + return self._write_datetime(row, col, *args) + + # We haven't matched a supported type. Try float. + try: + f = float(token) + return self._write_number(row, col, f, *args[1:]) + except ValueError: + pass + except TypeError: + raise TypeError(f"Unsupported type {type(token)} in write()") + + # Finally try string. + try: + str(token) + return self._write_string(row, col, *args) + except ValueError: + raise TypeError(f"Unsupported type {type(token)} in write()") + + @convert_cell_args + def write_string(self, row, col, string, cell_format=None): + """ + Write a string to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + string: Cell data. Str. + format: An optional cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: String truncated to 32k characters. + + """ + return self._write_string(row, col, string, cell_format) + + # Undecorated version of write_string(). + def _write_string(self, row, col, string, cell_format=None): + str_error = 0 + + # Check that row and col are valid and store max and min values. + if self._check_dimensions(row, col): + return -1 + + # Check that the string is < 32767 chars. + if len(string) > self.xls_strmax: + string = string[: self.xls_strmax] + str_error = -2 + + # Write a shared string or an in-line string in constant_memory mode. + if not self.constant_memory: + string_index = self.str_table._get_shared_string_index(string) + else: + string_index = string + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellStringTuple(string_index, cell_format) + + return str_error + + @convert_cell_args + def write_number(self, row, col, number, cell_format=None): + """ + Write a number to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + number: Cell data. Int or float. + cell_format: An optional cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + return self._write_number(row, col, number, cell_format) + + # Undecorated version of write_number(). + def _write_number(self, row, col, number, cell_format=None): + if isnan(number) or isinf(number): + if self.nan_inf_to_errors: + if isnan(number): + return self._write_formula(row, col, "#NUM!", cell_format, "#NUM!") + + if number == math.inf: + return self._write_formula(row, col, "1/0", cell_format, "#DIV/0!") + + if number == -math.inf: + return self._write_formula(row, col, "-1/0", cell_format, "#DIV/0!") + else: + raise TypeError( + "NAN/INF not supported in write_number() " + "without 'nan_inf_to_errors' Workbook() option" + ) + + if number.__class__ is Fraction: + number = float(number) + + # Check that row and col are valid and store max and min values. + if self._check_dimensions(row, col): + return -1 + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellNumberTuple(number, cell_format) + + return 0 + + @convert_cell_args + def write_blank(self, row, col, blank, cell_format=None): + """ + Write a blank cell with formatting to a worksheet cell. The blank + token is ignored and the format only is written to the cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + blank: Any value. It is ignored. + cell_format: An optional cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + return self._write_blank(row, col, blank, cell_format) + + # Undecorated version of write_blank(). + def _write_blank(self, row, col, _, cell_format=None): + # Don't write a blank cell unless it has a format. + if cell_format is None: + return 0 + + # Check that row and col are valid and store max and min values. + if self._check_dimensions(row, col): + return -1 + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellBlankTuple(cell_format) + + return 0 + + @convert_cell_args + def write_formula(self, row, col, formula, cell_format=None, value=0): + """ + Write a formula to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + formula: Cell formula. + cell_format: An optional cell Format object. + value: An optional value for the formula. Default is 0. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: Formula can't be None or empty. + + """ + # Check that row and col are valid and store max and min values. + return self._write_formula(row, col, formula, cell_format, value) + + # Undecorated version of write_formula(). + def _write_formula(self, row, col, formula, cell_format=None, value=0): + if self._check_dimensions(row, col): + return -1 + + if formula is None or formula == "": + warn("Formula can't be None or empty") + return -1 + + # Check for dynamic array functions. + if re_dynamic_function.search(formula): + return self.write_dynamic_array_formula( + row, col, row, col, formula, cell_format, value + ) + + # Hand off array formulas. + if formula.startswith("{") and formula.endswith("}"): + return self._write_array_formula( + row, col, row, col, formula, cell_format, value + ) + + # Modify the formula string, as needed. + formula = self._prepare_formula(formula) + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellFormulaTuple(formula, cell_format, value) + + return 0 + + @convert_range_args + def write_array_formula( + self, + first_row, + first_col, + last_row, + last_col, + formula, + cell_format=None, + value=0, + ): + """ + Write a formula to a worksheet cell/range. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + formula: Cell formula. + cell_format: An optional cell Format object. + value: An optional value for the formula. Default is 0. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check for dynamic array functions. + if re_dynamic_function.search(formula): + return self.write_dynamic_array_formula( + first_row, first_col, last_row, last_col, formula, cell_format, value + ) + + return self._write_array_formula( + first_row, + first_col, + last_row, + last_col, + formula, + cell_format, + value, + "static", + ) + + @convert_range_args + def write_dynamic_array_formula( + self, + first_row, + first_col, + last_row, + last_col, + formula, + cell_format=None, + value=0, + ): + """ + Write a dynamic array formula to a worksheet cell/range. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + formula: Cell formula. + cell_format: An optional cell Format object. + value: An optional value for the formula. Default is 0. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + error = self._write_array_formula( + first_row, + first_col, + last_row, + last_col, + formula, + cell_format, + value, + "dynamic", + ) + + if error == 0: + self.has_dynamic_arrays = True + + return error + + # Utility method to strip equal sign and array braces from a formula and + # also expand out future and dynamic array formulas. + def _prepare_formula(self, formula, expand_future_functions=False): + # Remove array formula braces and the leading =. + if formula.startswith("{"): + formula = formula[1:] + if formula.startswith("="): + formula = formula[1:] + if formula.endswith("}"): + formula = formula[:-1] + + # Check if formula is already expanded by the user. + if "_xlfn." in formula: + return formula + + # Expand dynamic formulas. + formula = re.sub(r"\bANCHORARRAY\(", "_xlfn.ANCHORARRAY(", formula) + formula = re.sub(r"\bBYCOL\(", "_xlfn.BYCOL(", formula) + formula = re.sub(r"\bBYROW\(", "_xlfn.BYROW(", formula) + formula = re.sub(r"\bCHOOSECOLS\(", "_xlfn.CHOOSECOLS(", formula) + formula = re.sub(r"\bCHOOSEROWS\(", "_xlfn.CHOOSEROWS(", formula) + formula = re.sub(r"\bDROP\(", "_xlfn.DROP(", formula) + formula = re.sub(r"\bEXPAND\(", "_xlfn.EXPAND(", formula) + formula = re.sub(r"\bFILTER\(", "_xlfn._xlws.FILTER(", formula) + formula = re.sub(r"\bHSTACK\(", "_xlfn.HSTACK(", formula) + formula = re.sub(r"\bLAMBDA\(", "_xlfn.LAMBDA(", formula) + formula = re.sub(r"\bMAKEARRAY\(", "_xlfn.MAKEARRAY(", formula) + formula = re.sub(r"\bMAP\(", "_xlfn.MAP(", formula) + formula = re.sub(r"\bRANDARRAY\(", "_xlfn.RANDARRAY(", formula) + formula = re.sub(r"\bREDUCE\(", "_xlfn.REDUCE(", formula) + formula = re.sub(r"\bSCAN\(", "_xlfn.SCAN(", formula) + formula = re.sub(r"\SINGLE\(", "_xlfn.SINGLE(", formula) + formula = re.sub(r"\bSEQUENCE\(", "_xlfn.SEQUENCE(", formula) + formula = re.sub(r"\bSORT\(", "_xlfn._xlws.SORT(", formula) + formula = re.sub(r"\bSORTBY\(", "_xlfn.SORTBY(", formula) + formula = re.sub(r"\bSWITCH\(", "_xlfn.SWITCH(", formula) + formula = re.sub(r"\bTAKE\(", "_xlfn.TAKE(", formula) + formula = re.sub(r"\bTEXTSPLIT\(", "_xlfn.TEXTSPLIT(", formula) + formula = re.sub(r"\bTOCOL\(", "_xlfn.TOCOL(", formula) + formula = re.sub(r"\bTOROW\(", "_xlfn.TOROW(", formula) + formula = re.sub(r"\bUNIQUE\(", "_xlfn.UNIQUE(", formula) + formula = re.sub(r"\bVSTACK\(", "_xlfn.VSTACK(", formula) + formula = re.sub(r"\bWRAPCOLS\(", "_xlfn.WRAPCOLS(", formula) + formula = re.sub(r"\bWRAPROWS\(", "_xlfn.WRAPROWS(", formula) + formula = re.sub(r"\bXLOOKUP\(", "_xlfn.XLOOKUP(", formula) + + if not self.use_future_functions and not expand_future_functions: + return formula + + formula = re.sub(r"\bACOTH\(", "_xlfn.ACOTH(", formula) + formula = re.sub(r"\bACOT\(", "_xlfn.ACOT(", formula) + formula = re.sub(r"\bAGGREGATE\(", "_xlfn.AGGREGATE(", formula) + formula = re.sub(r"\bARABIC\(", "_xlfn.ARABIC(", formula) + formula = re.sub(r"\bARRAYTOTEXT\(", "_xlfn.ARRAYTOTEXT(", formula) + formula = re.sub(r"\bBASE\(", "_xlfn.BASE(", formula) + formula = re.sub(r"\bBETA.DIST\(", "_xlfn.BETA.DIST(", formula) + formula = re.sub(r"\bBETA.INV\(", "_xlfn.BETA.INV(", formula) + formula = re.sub(r"\bBINOM.DIST.RANGE\(", "_xlfn.BINOM.DIST.RANGE(", formula) + formula = re.sub(r"\bBINOM.DIST\(", "_xlfn.BINOM.DIST(", formula) + formula = re.sub(r"\bBINOM.INV\(", "_xlfn.BINOM.INV(", formula) + formula = re.sub(r"\bBITAND\(", "_xlfn.BITAND(", formula) + formula = re.sub(r"\bBITLSHIFT\(", "_xlfn.BITLSHIFT(", formula) + formula = re.sub(r"\bBITOR\(", "_xlfn.BITOR(", formula) + formula = re.sub(r"\bBITRSHIFT\(", "_xlfn.BITRSHIFT(", formula) + formula = re.sub(r"\bBITXOR\(", "_xlfn.BITXOR(", formula) + formula = re.sub(r"\bCEILING.MATH\(", "_xlfn.CEILING.MATH(", formula) + formula = re.sub(r"\bCEILING.PRECISE\(", "_xlfn.CEILING.PRECISE(", formula) + formula = re.sub(r"\bCHISQ.DIST.RT\(", "_xlfn.CHISQ.DIST.RT(", formula) + formula = re.sub(r"\bCHISQ.DIST\(", "_xlfn.CHISQ.DIST(", formula) + formula = re.sub(r"\bCHISQ.INV.RT\(", "_xlfn.CHISQ.INV.RT(", formula) + formula = re.sub(r"\bCHISQ.INV\(", "_xlfn.CHISQ.INV(", formula) + formula = re.sub(r"\bCHISQ.TEST\(", "_xlfn.CHISQ.TEST(", formula) + formula = re.sub(r"\bCOMBINA\(", "_xlfn.COMBINA(", formula) + formula = re.sub(r"\bCONCAT\(", "_xlfn.CONCAT(", formula) + formula = re.sub(r"\bCONFIDENCE.NORM\(", "_xlfn.CONFIDENCE.NORM(", formula) + formula = re.sub(r"\bCONFIDENCE.T\(", "_xlfn.CONFIDENCE.T(", formula) + formula = re.sub(r"\bCOTH\(", "_xlfn.COTH(", formula) + formula = re.sub(r"\bCOT\(", "_xlfn.COT(", formula) + formula = re.sub(r"\bCOVARIANCE.P\(", "_xlfn.COVARIANCE.P(", formula) + formula = re.sub(r"\bCOVARIANCE.S\(", "_xlfn.COVARIANCE.S(", formula) + formula = re.sub(r"\bCSCH\(", "_xlfn.CSCH(", formula) + formula = re.sub(r"\bCSC\(", "_xlfn.CSC(", formula) + formula = re.sub(r"\bDAYS\(", "_xlfn.DAYS(", formula) + formula = re.sub(r"\bDECIMAL\(", "_xlfn.DECIMAL(", formula) + formula = re.sub(r"\bERF.PRECISE\(", "_xlfn.ERF.PRECISE(", formula) + formula = re.sub(r"\bERFC.PRECISE\(", "_xlfn.ERFC.PRECISE(", formula) + formula = re.sub(r"\bEXPON.DIST\(", "_xlfn.EXPON.DIST(", formula) + formula = re.sub(r"\bF.DIST.RT\(", "_xlfn.F.DIST.RT(", formula) + formula = re.sub(r"\bF.DIST\(", "_xlfn.F.DIST(", formula) + formula = re.sub(r"\bF.INV.RT\(", "_xlfn.F.INV.RT(", formula) + formula = re.sub(r"\bF.INV\(", "_xlfn.F.INV(", formula) + formula = re.sub(r"\bF.TEST\(", "_xlfn.F.TEST(", formula) + formula = re.sub(r"\bFILTERXML\(", "_xlfn.FILTERXML(", formula) + formula = re.sub(r"\bFLOOR.MATH\(", "_xlfn.FLOOR.MATH(", formula) + formula = re.sub(r"\bFLOOR.PRECISE\(", "_xlfn.FLOOR.PRECISE(", formula) + formula = re.sub( + r"\bFORECAST.ETS.CONFINT\(", "_xlfn.FORECAST.ETS.CONFINT(", formula + ) + formula = re.sub( + r"\bFORECAST.ETS.SEASONALITY\(", "_xlfn.FORECAST.ETS.SEASONALITY(", formula + ) + formula = re.sub(r"\bFORECAST.ETS.STAT\(", "_xlfn.FORECAST.ETS.STAT(", formula) + formula = re.sub(r"\bFORECAST.ETS\(", "_xlfn.FORECAST.ETS(", formula) + formula = re.sub(r"\bFORECAST.LINEAR\(", "_xlfn.FORECAST.LINEAR(", formula) + formula = re.sub(r"\bFORMULATEXT\(", "_xlfn.FORMULATEXT(", formula) + formula = re.sub(r"\bGAMMA.DIST\(", "_xlfn.GAMMA.DIST(", formula) + formula = re.sub(r"\bGAMMA.INV\(", "_xlfn.GAMMA.INV(", formula) + formula = re.sub(r"\bGAMMALN.PRECISE\(", "_xlfn.GAMMALN.PRECISE(", formula) + formula = re.sub(r"\bGAMMA\(", "_xlfn.GAMMA(", formula) + formula = re.sub(r"\bGAUSS\(", "_xlfn.GAUSS(", formula) + formula = re.sub(r"\bHYPGEOM.DIST\(", "_xlfn.HYPGEOM.DIST(", formula) + formula = re.sub(r"\bIFNA\(", "_xlfn.IFNA(", formula) + formula = re.sub(r"\bIFS\(", "_xlfn.IFS(", formula) + formula = re.sub(r"\bIMAGE\(", "_xlfn.IMAGE(", formula) + formula = re.sub(r"\bIMCOSH\(", "_xlfn.IMCOSH(", formula) + formula = re.sub(r"\bIMCOT\(", "_xlfn.IMCOT(", formula) + formula = re.sub(r"\bIMCSCH\(", "_xlfn.IMCSCH(", formula) + formula = re.sub(r"\bIMCSC\(", "_xlfn.IMCSC(", formula) + formula = re.sub(r"\bIMSECH\(", "_xlfn.IMSECH(", formula) + formula = re.sub(r"\bIMSEC\(", "_xlfn.IMSEC(", formula) + formula = re.sub(r"\bIMSINH\(", "_xlfn.IMSINH(", formula) + formula = re.sub(r"\bIMTAN\(", "_xlfn.IMTAN(", formula) + formula = re.sub(r"\bISFORMULA\(", "_xlfn.ISFORMULA(", formula) + formula = re.sub(r"\bISOMITTED\(", "_xlfn.ISOMITTED(", formula) + formula = re.sub(r"\bISOWEEKNUM\(", "_xlfn.ISOWEEKNUM(", formula) + formula = re.sub(r"\bLET\(", "_xlfn.LET(", formula) + formula = re.sub(r"\bLOGNORM.DIST\(", "_xlfn.LOGNORM.DIST(", formula) + formula = re.sub(r"\bLOGNORM.INV\(", "_xlfn.LOGNORM.INV(", formula) + formula = re.sub(r"\bMAXIFS\(", "_xlfn.MAXIFS(", formula) + formula = re.sub(r"\bMINIFS\(", "_xlfn.MINIFS(", formula) + formula = re.sub(r"\bMODE.MULT\(", "_xlfn.MODE.MULT(", formula) + formula = re.sub(r"\bMODE.SNGL\(", "_xlfn.MODE.SNGL(", formula) + formula = re.sub(r"\bMUNIT\(", "_xlfn.MUNIT(", formula) + formula = re.sub(r"\bNEGBINOM.DIST\(", "_xlfn.NEGBINOM.DIST(", formula) + formula = re.sub(r"\bNORM.DIST\(", "_xlfn.NORM.DIST(", formula) + formula = re.sub(r"\bNORM.INV\(", "_xlfn.NORM.INV(", formula) + formula = re.sub(r"\bNORM.S.DIST\(", "_xlfn.NORM.S.DIST(", formula) + formula = re.sub(r"\bNORM.S.INV\(", "_xlfn.NORM.S.INV(", formula) + formula = re.sub(r"\bNUMBERVALUE\(", "_xlfn.NUMBERVALUE(", formula) + formula = re.sub(r"\bPDURATION\(", "_xlfn.PDURATION(", formula) + formula = re.sub(r"\bPERCENTILE.EXC\(", "_xlfn.PERCENTILE.EXC(", formula) + formula = re.sub(r"\bPERCENTILE.INC\(", "_xlfn.PERCENTILE.INC(", formula) + formula = re.sub(r"\bPERCENTRANK.EXC\(", "_xlfn.PERCENTRANK.EXC(", formula) + formula = re.sub(r"\bPERCENTRANK.INC\(", "_xlfn.PERCENTRANK.INC(", formula) + formula = re.sub(r"\bPERMUTATIONA\(", "_xlfn.PERMUTATIONA(", formula) + formula = re.sub(r"\bPHI\(", "_xlfn.PHI(", formula) + formula = re.sub(r"\bPOISSON.DIST\(", "_xlfn.POISSON.DIST(", formula) + formula = re.sub(r"\bQUARTILE.EXC\(", "_xlfn.QUARTILE.EXC(", formula) + formula = re.sub(r"\bQUARTILE.INC\(", "_xlfn.QUARTILE.INC(", formula) + formula = re.sub(r"\bQUERYSTRING\(", "_xlfn.QUERYSTRING(", formula) + formula = re.sub(r"\bRANK.AVG\(", "_xlfn.RANK.AVG(", formula) + formula = re.sub(r"\bRANK.EQ\(", "_xlfn.RANK.EQ(", formula) + formula = re.sub(r"\bRRI\(", "_xlfn.RRI(", formula) + formula = re.sub(r"\bSECH\(", "_xlfn.SECH(", formula) + formula = re.sub(r"\bSEC\(", "_xlfn.SEC(", formula) + formula = re.sub(r"\bSHEETS\(", "_xlfn.SHEETS(", formula) + formula = re.sub(r"\bSHEET\(", "_xlfn.SHEET(", formula) + formula = re.sub(r"\bSKEW.P\(", "_xlfn.SKEW.P(", formula) + formula = re.sub(r"\bSTDEV.P\(", "_xlfn.STDEV.P(", formula) + formula = re.sub(r"\bSTDEV.S\(", "_xlfn.STDEV.S(", formula) + formula = re.sub(r"\bT.DIST.2T\(", "_xlfn.T.DIST.2T(", formula) + formula = re.sub(r"\bT.DIST.RT\(", "_xlfn.T.DIST.RT(", formula) + formula = re.sub(r"\bT.DIST\(", "_xlfn.T.DIST(", formula) + formula = re.sub(r"\bT.INV.2T\(", "_xlfn.T.INV.2T(", formula) + formula = re.sub(r"\bT.INV\(", "_xlfn.T.INV(", formula) + formula = re.sub(r"\bT.TEST\(", "_xlfn.T.TEST(", formula) + formula = re.sub(r"\bTEXTAFTER\(", "_xlfn.TEXTAFTER(", formula) + formula = re.sub(r"\bTEXTBEFORE\(", "_xlfn.TEXTBEFORE(", formula) + formula = re.sub(r"\bTEXTJOIN\(", "_xlfn.TEXTJOIN(", formula) + formula = re.sub(r"\bUNICHAR\(", "_xlfn.UNICHAR(", formula) + formula = re.sub(r"\bUNICODE\(", "_xlfn.UNICODE(", formula) + formula = re.sub(r"\bVALUETOTEXT\(", "_xlfn.VALUETOTEXT(", formula) + formula = re.sub(r"\bVAR.P\(", "_xlfn.VAR.P(", formula) + formula = re.sub(r"\bVAR.S\(", "_xlfn.VAR.S(", formula) + formula = re.sub(r"\bWEBSERVICE\(", "_xlfn.WEBSERVICE(", formula) + formula = re.sub(r"\bWEIBULL.DIST\(", "_xlfn.WEIBULL.DIST(", formula) + formula = re.sub(r"\bXMATCH\(", "_xlfn.XMATCH(", formula) + formula = re.sub(r"\bXOR\(", "_xlfn.XOR(", formula) + formula = re.sub(r"\bZ.TEST\(", "_xlfn.Z.TEST(", formula) + + return formula + + # Escape/expand table functions. This mainly involves converting Excel 2010 + # "@" table ref to 2007 "[#This Row],". We parse the string to avoid + # replacements in string literals within the formula. + @staticmethod + def _prepare_table_formula(formula): + if "@" not in formula: + # No escaping required. + return formula + + escaped_formula = [] + in_string_literal = False + + for char in formula: + # Match the start/end of string literals to avoid escaping + # references in strings. + if char == '"': + in_string_literal = not in_string_literal + + # Copy the string literal. + if in_string_literal: + escaped_formula.append(char) + continue + + # Replace table reference. + if char == "@": + escaped_formula.append("[#This Row],") + else: + escaped_formula.append(char) + + return ("").join(escaped_formula) + + # Undecorated version of write_array_formula() and + # write_dynamic_array_formula(). + def _write_array_formula( + self, + first_row, + first_col, + last_row, + last_col, + formula, + cell_format=None, + value=0, + atype="static", + ): + # Swap last row/col with first row/col as necessary. + if first_row > last_row: + first_row, last_row = last_row, first_row + if first_col > last_col: + first_col, last_col = last_col, first_col + + # Check that row and col are valid and store max and min values. + if self._check_dimensions(first_row, first_col): + return -1 + if self._check_dimensions(last_row, last_col): + return -1 + + # Define array range + if first_row == last_row and first_col == last_col: + cell_range = xl_rowcol_to_cell(first_row, first_col) + else: + cell_range = ( + xl_rowcol_to_cell(first_row, first_col) + + ":" + + xl_rowcol_to_cell(last_row, last_col) + ) + + # Modify the formula string, as needed. + formula = self._prepare_formula(formula) + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and first_row > self.previous_row: + self._write_single_row(first_row) + + # Store the cell data in the worksheet data table. + self.table[first_row][first_col] = CellArrayFormulaTuple( + formula, cell_format, value, cell_range, atype + ) + + # Pad out the rest of the area with formatted zeroes. + if not self.constant_memory: + for row in range(first_row, last_row + 1): + for col in range(first_col, last_col + 1): + if row != first_row or col != first_col: + self._write_number(row, col, 0, cell_format) + + return 0 + + @convert_cell_args + def write_datetime(self, row, col, date, cell_format=None): + """ + Write a date or time to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + date: Date and/or time as a datetime object. + cell_format: A cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + return self._write_datetime(row, col, date, cell_format) + + # Undecorated version of write_datetime(). + def _write_datetime(self, row, col, date, cell_format=None): + # Check that row and col are valid and store max and min values. + if self._check_dimensions(row, col): + return -1 + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Convert datetime to an Excel date. + number = self._convert_date_time(date) + + # Add the default date format. + if cell_format is None: + cell_format = self.default_date_format + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellDatetimeTuple(number, cell_format) + + return 0 + + @convert_cell_args + def write_boolean(self, row, col, boolean, cell_format=None): + """ + Write a boolean value to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + boolean: Cell data. bool type. + cell_format: An optional cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + return self._write_boolean(row, col, boolean, cell_format) + + # Undecorated version of write_boolean(). + def _write_boolean(self, row, col, boolean, cell_format=None): + # Check that row and col are valid and store max and min values. + if self._check_dimensions(row, col): + return -1 + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + if boolean: + value = 1 + else: + value = 0 + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellBooleanTuple(value, cell_format) + + return 0 + + # Write a hyperlink. This is comprised of two elements: the displayed + # string and the non-displayed link. The displayed string is the same as + # the link unless an alternative string is specified. The display string + # is written using the write_string() method. Therefore the max characters + # string limit applies. + # + # The hyperlink can be to a http, ftp, mail, internal sheet, or external + # directory urls. + @convert_cell_args + def write_url(self, row, col, url, cell_format=None, string=None, tip=None): + """ + Write a hyperlink to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + url: Hyperlink url. + format: An optional cell Format object. + string: An optional display string for the hyperlink. + tip: An optional tooltip. + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: String longer than 32767 characters. + -3: URL longer than Excel limit of 255 characters. + -4: Exceeds Excel limit of 65,530 urls per worksheet. + """ + return self._write_url(row, col, url, cell_format, string, tip) + + # Undecorated version of write_url(). + def _write_url(self, row, col, url, cell_format=None, string=None, tip=None): + # Check that row and col are valid and store max and min values + if self._check_dimensions(row, col): + return -1 + + # Set the displayed string to the URL unless defined by the user. + if string is None: + string = url + + # Default to external link type such as 'http://' or 'external:'. + link_type = 1 + + # Remove the URI scheme from internal links. + if url.startswith("internal:"): + url = url.replace("internal:", "") + string = string.replace("internal:", "") + link_type = 2 + + # Remove the URI scheme from external links and change the directory + # separator from Unix to Dos. + external = False + if url.startswith("external:"): + url = url.replace("external:", "") + url = url.replace("/", "\\") + string = string.replace("external:", "") + string = string.replace("/", "\\") + external = True + + # Strip the mailto header. + string = string.replace("mailto:", "") + + # Check that the string is < 32767 chars + str_error = 0 + if len(string) > self.xls_strmax: + warn( + "Ignoring URL since it exceeds Excel's string limit of " + "32767 characters" + ) + return -2 + + # Copy string for use in hyperlink elements. + url_str = string + + # External links to URLs and to other Excel workbooks have slightly + # different characteristics that we have to account for. + if link_type == 1: + # Split url into the link and optional anchor/location. + if "#" in url: + url, url_str = url.split("#", 1) + else: + url_str = None + + url = self._escape_url(url) + + if url_str is not None and not external: + url_str = self._escape_url(url_str) + + # Add the file:/// URI to the url for Windows style "C:/" link and + # Network shares. + if re.match(r"\w:", url) or re.match(r"\\", url): + url = "file:///" + url + + # Convert a .\dir\file.xlsx link to dir\file.xlsx. + url = re.sub(r"^\.\\", "", url) + + # Excel limits the escaped URL and location/anchor to 255 characters. + tmp_url_str = url_str or "" + max_url = self.max_url_length + if len(url) > max_url or len(tmp_url_str) > max_url: + warn( + f"Ignoring URL '{url}' with link or location/anchor > {max_url} " + f"characters since it exceeds Excel's limit for URLs." + ) + return -3 + + # Check the limit of URLs per worksheet. + self.hlink_count += 1 + + if self.hlink_count > 65530: + warn( + f"Ignoring URL '{url}' since it exceeds Excel's limit of " + f"65,530 URLs per worksheet." + ) + return -4 + + # Add the default URL format. + if cell_format is None: + cell_format = self.default_url_format + + if not self.ignore_write_string: + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Write the hyperlink string. + self._write_string(row, col, string, cell_format) + + # Store the hyperlink data in a separate structure. + self.hyperlinks[row][col] = { + "link_type": link_type, + "url": url, + "str": url_str, + "tip": tip, + } + + return str_error + + @convert_cell_args + def write_rich_string(self, row, col, *args): + """ + Write a "rich" string with multiple formats to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + string_parts: String and format pairs. + cell_format: Optional Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: String truncated to 32k characters. + -3: 2 consecutive formats used. + -4: Empty string used. + -5: Insufficient parameters. + + """ + + return self._write_rich_string(row, col, *args) + + # Undecorated version of write_rich_string(). + def _write_rich_string(self, row, col, *args): + tokens = list(args) + cell_format = None + string_index = 0 + raw_string = "" + + # Check that row and col are valid and store max and min values + if self._check_dimensions(row, col): + return -1 + + # If the last arg is a format we use it as the cell format. + if isinstance(tokens[-1], Format): + cell_format = tokens.pop() + + # Create a temp XMLWriter object and use it to write the rich string + # XML to a string. + fh = StringIO() + self.rstring = XMLwriter() + self.rstring._set_filehandle(fh) + + # Create a temp format with the default font for unformatted fragments. + default = Format() + + # Convert list of format, string tokens to pairs of (format, string) + # except for the first string fragment which doesn't require a default + # formatting run. Use the default for strings without a leading format. + fragments = [] + previous = "format" + pos = 0 + + if len(tokens) <= 2: + warn( + "You must specify more than 2 format/fragments for rich " + "strings. Ignoring input in write_rich_string()." + ) + return -5 + + for token in tokens: + if not isinstance(token, Format): + # Token is a string. + if previous != "format": + # If previous token wasn't a format add one before string. + fragments.append(default) + fragments.append(token) + else: + # If previous token was a format just add the string. + fragments.append(token) + + if token == "": + warn( + "Excel doesn't allow empty strings in rich strings. " + "Ignoring input in write_rich_string()." + ) + return -4 + + # Keep track of unformatted string. + raw_string += token + previous = "string" + else: + # Can't allow 2 formats in a row. + if previous == "format" and pos > 0: + warn( + "Excel doesn't allow 2 consecutive formats in rich " + "strings. Ignoring input in write_rich_string()." + ) + return -3 + + # Token is a format object. Add it to the fragment list. + fragments.append(token) + previous = "format" + + pos += 1 + + # If the first token is a string start the <r> element. + if not isinstance(fragments[0], Format): + self.rstring._xml_start_tag("r") + + # Write the XML elements for the $format $string fragments. + for token in fragments: + if isinstance(token, Format): + # Write the font run. + self.rstring._xml_start_tag("r") + self._write_font(token) + else: + # Write the string fragment part, with whitespace handling. + attributes = [] + + if _preserve_whitespace(token): + attributes.append(("xml:space", "preserve")) + + self.rstring._xml_data_element("t", token, attributes) + self.rstring._xml_end_tag("r") + + # Read the in-memory string. + string = self.rstring.fh.getvalue() + + # Check that the string is < 32767 chars. + if len(raw_string) > self.xls_strmax: + warn( + "String length must be less than or equal to Excel's limit " + "of 32,767 characters in write_rich_string()." + ) + return -2 + + # Write a shared string or an in-line string in constant_memory mode. + if not self.constant_memory: + string_index = self.str_table._get_shared_string_index(string) + else: + string_index = string + + # Write previous row if in in-line string constant_memory mode. + if self.constant_memory and row > self.previous_row: + self._write_single_row(row) + + # Store the cell data in the worksheet data table. + self.table[row][col] = CellRichStringTuple( + string_index, cell_format, raw_string + ) + + return 0 + + def add_write_handler(self, user_type, user_function): + """ + Add a callback function to the write() method to handle user defined + types. + + Args: + user_type: The user type() to match on. + user_function: The user defined function to write the type data. + Returns: + Nothing. + + """ + + self.write_handlers[user_type] = user_function + + @convert_cell_args + def write_row(self, row, col, data, cell_format=None): + """ + Write a row of data starting from (row, col). + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + data: A list of tokens to be written with write(). + format: An optional cell Format object. + Returns: + 0: Success. + other: Return value of write() method. + + """ + for token in data: + error = self._write(row, col, token, cell_format) + if error: + return error + col += 1 + + return 0 + + @convert_cell_args + def write_column(self, row, col, data, cell_format=None): + """ + Write a column of data starting from (row, col). + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + data: A list of tokens to be written with write(). + format: An optional cell Format object. + Returns: + 0: Success. + other: Return value of write() method. + + """ + for token in data: + error = self._write(row, col, token, cell_format) + if error: + return error + row += 1 + + return 0 + + @convert_cell_args + def insert_image(self, row, col, filename, options=None): + """ + Insert an image with its top-left corner in a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + filename: Path and filename for in supported formats. + options: Position, scale, url and data stream of the image. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check insert (row, col) without storing. + if self._check_dimensions(row, col, True, True): + warn(f"Cannot insert image at ({row}, {col}).") + return -1 + + if options is None: + options = {} + + x_offset = options.get("x_offset", 0) + y_offset = options.get("y_offset", 0) + x_scale = options.get("x_scale", 1) + y_scale = options.get("y_scale", 1) + url = options.get("url", None) + tip = options.get("tip", None) + anchor = options.get("object_position", 2) + image_data = options.get("image_data", None) + description = options.get("description", None) + decorative = options.get("decorative", False) + + # For backward compatibility with older parameter name. + anchor = options.get("positioning", anchor) + + if not image_data and not os.path.exists(filename): + warn(f"Image file '{filename}' not found.") + return -1 + + self.images.append( + [ + row, + col, + filename, + x_offset, + y_offset, + x_scale, + y_scale, + url, + tip, + anchor, + image_data, + description, + decorative, + ] + ) + return 0 + + @convert_cell_args + def embed_image(self, row, col, filename, options=None): + """ + Embed an image in a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + filename: Path and filename for in supported formats. + options: Url and data stream of the image. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check insert (row, col) without storing. + if self._check_dimensions(row, col): + warn(f"Cannot embed image at ({row}, {col}).") + return -1 + + if options is None: + options = {} + + url = options.get("url", None) + tip = options.get("tip", None) + cell_format = options.get("cell_format", None) + image_data = options.get("image_data", None) + description = options.get("description", None) + decorative = options.get("decorative", False) + + if not image_data and not os.path.exists(filename): + warn(f"Image file '{filename}' not found.") + return -1 + + if url: + if cell_format is None: + cell_format = self.default_url_format + + self.ignore_write_string = True + self.write_url(row, col, url, cell_format, None, tip) + self.ignore_write_string = False + + # Get the image properties, for the type and checksum. + ( + image_type, + _, + _, + _, + _, + _, + digest, + ) = _get_image_properties(filename, image_data) + + image = [filename, image_type, image_data, description, decorative] + image_index = self.embedded_images.get_image_index(image, digest) + + # Store the cell error and image index in the worksheet data table. + self.table[row][col] = CellErrorTuple("#VALUE!", cell_format, image_index) + + return 0 + + @convert_cell_args + def insert_textbox(self, row, col, text, options=None): + """ + Insert an textbox with its top-left corner in a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + text: The text for the textbox. + options: Textbox options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check insert (row, col) without storing. + if self._check_dimensions(row, col, True, True): + warn(f"Cannot insert textbox at ({row}, {col}).") + return -1 + + if text is None: + text = "" + + if options is None: + options = {} + + x_offset = options.get("x_offset", 0) + y_offset = options.get("y_offset", 0) + x_scale = options.get("x_scale", 1) + y_scale = options.get("y_scale", 1) + anchor = options.get("object_position", 1) + description = options.get("description", None) + decorative = options.get("decorative", False) + + self.shapes.append( + [ + row, + col, + x_offset, + y_offset, + x_scale, + y_scale, + text, + anchor, + options, + description, + decorative, + ] + ) + return 0 + + @convert_cell_args + def insert_chart(self, row, col, chart, options=None): + """ + Insert an chart with its top-left corner in a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + chart: Chart object. + options: Position and scale of the chart. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check insert (row, col) without storing. + if self._check_dimensions(row, col, True, True): + warn(f"Cannot insert chart at ({row}, {col}).") + return -1 + + if options is None: + options = {} + + # Ensure a chart isn't inserted more than once. + if chart.already_inserted or chart.combined and chart.combined.already_inserted: + warn("Chart cannot be inserted in a worksheet more than once.") + return -2 + + chart.already_inserted = True + + if chart.combined: + chart.combined.already_inserted = True + + x_offset = options.get("x_offset", 0) + y_offset = options.get("y_offset", 0) + x_scale = options.get("x_scale", 1) + y_scale = options.get("y_scale", 1) + anchor = options.get("object_position", 1) + description = options.get("description", None) + decorative = options.get("decorative", False) + + # Allow Chart to override the scale and offset. + if chart.x_scale != 1: + x_scale = chart.x_scale + + if chart.y_scale != 1: + y_scale = chart.y_scale + + if chart.x_offset: + x_offset = chart.x_offset + + if chart.y_offset: + y_offset = chart.y_offset + + self.charts.append( + [ + row, + col, + chart, + x_offset, + y_offset, + x_scale, + y_scale, + anchor, + description, + decorative, + ] + ) + return 0 + + @convert_cell_args + def write_comment(self, row, col, comment, options=None): + """ + Write a comment to a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + comment: Cell comment. Str. + options: Comment formatting options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: String longer than 32k characters. + + """ + if options is None: + options = {} + + # Check that row and col are valid and store max and min values + if self._check_dimensions(row, col): + return -1 + + # Check that the comment string is < 32767 chars. + if len(comment) > self.xls_strmax: + return -2 + + self.has_vml = 1 + self.has_comments = 1 + + # Store the options of the cell comment, to process on file close. + self.comments[row][col] = [row, col, comment, options] + + return 0 + + def show_comments(self): + """ + Make any comments in the worksheet visible. + + Args: + None. + + Returns: + Nothing. + + """ + self.comments_visible = 1 + + def set_background(self, filename, is_byte_stream=False): + """ + Set a background image for a worksheet. + + Args: + filename: Path and filename for in supported formats. + is_byte_stream: File is a stream of bytes. + + Returns: + 0: Success. + -1: Image file not found. + + """ + + if not is_byte_stream and not os.path.exists(filename): + warn(f"Image file '{filename}' not found.") + return -1 + + self.background_bytes = is_byte_stream + self.background_image = filename + + return 0 + + def set_comments_author(self, author): + """ + Set the default author of the cell comments. + + Args: + author: Comment author name. String. + + Returns: + Nothing. + + """ + self.comments_author = author + + def get_name(self): + """ + Retrieve the worksheet name. + + Args: + None. + + Returns: + Nothing. + + """ + # There is no set_name() method. Name must be set in add_worksheet(). + return self.name + + def activate(self): + """ + Set this worksheet as the active worksheet, i.e. the worksheet that is + displayed when the workbook is opened. Also set it as selected. + + Note: An active worksheet cannot be hidden. + + Args: + None. + + Returns: + Nothing. + + """ + self.hidden = 0 + self.selected = 1 + self.worksheet_meta.activesheet = self.index + + def select(self): + """ + Set current worksheet as a selected worksheet, i.e. the worksheet + has its tab highlighted. + + Note: A selected worksheet cannot be hidden. + + Args: + None. + + Returns: + Nothing. + + """ + self.selected = 1 + self.hidden = 0 + + def hide(self): + """ + Hide the current worksheet. + + Args: + None. + + Returns: + Nothing. + + """ + self.hidden = 1 + + # A hidden worksheet shouldn't be active or selected. + self.selected = 0 + + def very_hidden(self): + """ + Hide the current worksheet. This can only be unhidden by VBA. + + Args: + None. + + Returns: + Nothing. + + """ + self.hidden = 2 + + # A hidden worksheet shouldn't be active or selected. + self.selected = 0 + + def set_first_sheet(self): + """ + Set current worksheet as the first visible sheet. This is necessary + when there are a large number of worksheets and the activated + worksheet is not visible on the screen. + + Note: A selected worksheet cannot be hidden. + + Args: + None. + + Returns: + Nothing. + + """ + self.hidden = 0 # Active worksheet can't be hidden. + self.worksheet_meta.firstsheet = self.index + + @convert_column_args + def set_column( + self, first_col, last_col, width=None, cell_format=None, options=None + ): + """ + Set the width, and other properties of a single column or a + range of columns. + + Args: + first_col: First column (zero-indexed). + last_col: Last column (zero-indexed). Can be same as first_col. + width: Column width. (optional). + cell_format: Column cell_format. (optional). + options: Dict of options such as hidden and level. + + Returns: + 0: Success. + -1: Column number is out of worksheet bounds. + + """ + if options is None: + options = {} + + # Ensure 2nd col is larger than first. + if first_col > last_col: + (first_col, last_col) = (last_col, first_col) + + # Don't modify the row dimensions when checking the columns. + ignore_row = True + + # Set optional column values. + hidden = options.get("hidden", False) + collapsed = options.get("collapsed", False) + level = options.get("level", 0) + + # Store the column dimension only in some conditions. + if cell_format or (width and hidden): + ignore_col = False + else: + ignore_col = True + + # Check that each column is valid and store the max and min values. + if self._check_dimensions(0, last_col, ignore_row, ignore_col): + return -1 + if self._check_dimensions(0, first_col, ignore_row, ignore_col): + return -1 + + # Set the limits for the outline levels (0 <= x <= 7). + level = max(level, 0) + level = min(level, 7) + + self.outline_col_level = max(self.outline_col_level, level) + + # Store the column data. + for col in range(first_col, last_col + 1): + self.col_info[col] = [width, cell_format, hidden, level, collapsed, False] + + # Store the column change to allow optimizations. + self.col_size_changed = True + + return 0 + + @convert_column_args + def set_column_pixels( + self, first_col, last_col, width=None, cell_format=None, options=None + ): + """ + Set the width, and other properties of a single column or a + range of columns, where column width is in pixels. + + Args: + first_col: First column (zero-indexed). + last_col: Last column (zero-indexed). Can be same as first_col. + width: Column width in pixels. (optional). + cell_format: Column cell_format. (optional). + options: Dict of options such as hidden and level. + + Returns: + 0: Success. + -1: Column number is out of worksheet bounds. + + """ + if width is not None: + width = self._pixels_to_width(width) + + return self.set_column(first_col, last_col, width, cell_format, options) + + def autofit(self, max_width=1790): + """ + Simulate autofit based on the data, and datatypes in each column. + + Args: + max_width (optional): max column width to autofit, in pixels. + + Returns: + Nothing. + + """ + # pylint: disable=too-many-nested-blocks + if self.constant_memory: + warn("Autofit is not supported in constant_memory mode.") + return + + # No data written to the target sheet; nothing to autofit + if self.dim_rowmax is None: + return + + # Store the max pixel width for each column. + col_width_max = {} + + # Convert the autofit maximum pixel width to a column/character width, + # but limit it to the Excel max limit. + max_width = min(self._pixels_to_width(max_width), 255.0) + + # Create a reverse lookup for the share strings table so we can convert + # the string id back to the original string. + strings = sorted( + self.str_table.string_table, key=self.str_table.string_table.__getitem__ + ) + + for row_num in range(self.dim_rowmin, self.dim_rowmax + 1): + if not self.table.get(row_num): + continue + + for col_num in range(self.dim_colmin, self.dim_colmax + 1): + if col_num in self.table[row_num]: + cell = self.table[row_num][col_num] + cell_type = cell.__class__.__name__ + length = 0 + + if cell_type in ("String", "RichString"): + # Handle strings and rich strings. + # + # For standard shared strings we do a reverse lookup + # from the shared string id to the actual string. For + # rich strings we use the unformatted string. We also + # split multi-line strings and handle each part + # separately. + if cell_type == "String": + string_id = cell.string + string = strings[string_id] + else: + string = cell.raw_string + + if "\n" not in string: + # Single line string. + length = xl_pixel_width(string) + else: + # Handle multi-line strings. + for string in string.split("\n"): + seg_length = xl_pixel_width(string) + length = max(length, seg_length) + + elif cell_type == "Number": + # Handle numbers. + # + # We use a workaround/optimization for numbers since + # digits all have a pixel width of 7. This gives a + # slightly greater width for the decimal place and + # minus sign but only by a few pixels and + # over-estimation is okay. + length = 7 * len(str(cell.number)) + + elif cell_type == "Datetime": + # Handle dates. + # + # The following uses the default width for mm/dd/yyyy + # dates. It isn't feasible to parse the number format + # to get the actual string width for all format types. + length = self.default_date_pixels + + elif cell_type == "Boolean": + # Handle boolean values. + # + # Use the Excel standard widths for TRUE and FALSE. + if cell.boolean: + length = 31 + else: + length = 36 + + elif cell_type in ("Formula", "ArrayFormula"): + # Handle formulas. + # + # We only try to autofit a formula if it has a + # non-zero value. + if isinstance(cell.value, (float, int)): + if cell.value > 0: + length = 7 * len(str(cell.value)) + + elif isinstance(cell.value, str): + length = xl_pixel_width(cell.value) + + elif isinstance(cell.value, bool): + if cell.value: + length = 31 + else: + length = 36 + + # If the cell is in an autofilter header we add an + # additional 16 pixels for the dropdown arrow. + if self.filter_cells.get((row_num, col_num)) and length > 0: + length += 16 + + # Add the string length to the lookup table. + width_max = col_width_max.get(col_num, 0) + if length > width_max: + col_width_max[col_num] = length + + # Apply the width to the column. + for col_num, pixel_width in col_width_max.items(): + # Convert the string pixel width to a character width using an + # additional padding of 7 pixels, like Excel. + width = self._pixels_to_width(pixel_width + 7) + + # Limit the width to the maximum user or Excel value. + width = min(width, max_width) + + # Add the width to an existing col info structure or add a new one. + if self.col_info.get(col_num): + # We only update the width for an existing column if it is + # greater than the user defined value. This allows the user + # to pre-load a minimum col width. + col_info = self.col_info.get(col_num) + user_width = col_info[0] + hidden = col_info[5] + if user_width is not None and not hidden: + # Col info is user defined. + if width > user_width: + self.col_info[col_num][0] = width + self.col_info[col_num][5] = True + else: + self.col_info[col_num][0] = width + self.col_info[col_num][5] = True + else: + self.col_info[col_num] = [width, None, False, 0, False, True] + + def set_row(self, row, height=None, cell_format=None, options=None): + """ + Set the width, and other properties of a row. + + Args: + row: Row number (zero-indexed). + height: Row height. (optional). + cell_format: Row cell_format. (optional). + options: Dict of options such as hidden, level and collapsed. + + Returns: + 0: Success. + -1: Row number is out of worksheet bounds. + + """ + if options is None: + options = {} + + # Use minimum col in _check_dimensions(). + if self.dim_colmin is not None: + min_col = self.dim_colmin + else: + min_col = 0 + + # Check that row is valid. + if self._check_dimensions(row, min_col): + return -1 + + if height is None: + height = self.default_row_height + + # Set optional row values. + hidden = options.get("hidden", False) + collapsed = options.get("collapsed", False) + level = options.get("level", 0) + + # If the height is 0 the row is hidden and the height is the default. + if height == 0: + hidden = 1 + height = self.default_row_height + + # Set the limits for the outline levels (0 <= x <= 7). + level = max(level, 0) + level = min(level, 7) + + self.outline_row_level = max(self.outline_row_level, level) + + # Store the row properties. + self.set_rows[row] = [height, cell_format, hidden, level, collapsed] + + # Store the row change to allow optimizations. + self.row_size_changed = True + + # Store the row sizes for use when calculating image vertices. + self.row_sizes[row] = [height, hidden] + + return 0 + + def set_row_pixels(self, row, height=None, cell_format=None, options=None): + """ + Set the width (in pixels), and other properties of a row. + + Args: + row: Row number (zero-indexed). + height: Row height in pixels. (optional). + cell_format: Row cell_format. (optional). + options: Dict of options such as hidden, level and collapsed. + + Returns: + 0: Success. + -1: Row number is out of worksheet bounds. + + """ + if height is not None: + height = self._pixels_to_height(height) + + return self.set_row(row, height, cell_format, options) + + def set_default_row(self, height=None, hide_unused_rows=False): + """ + Set the default row properties. + + Args: + height: Default height. Optional, defaults to 15. + hide_unused_rows: Hide unused rows. Optional, defaults to False. + + Returns: + Nothing. + + """ + if height is None: + height = self.default_row_height + + if height != self.original_row_height: + # Store the row change to allow optimizations. + self.row_size_changed = True + self.default_row_height = height + + if hide_unused_rows: + self.default_row_zeroed = 1 + + @convert_range_args + def merge_range( + self, first_row, first_col, last_row, last_col, data, cell_format=None + ): + """ + Merge a range of cells. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + data: Cell data. + cell_format: Cell Format object. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + other: Return value of write(). + + """ + # Merge a range of cells. The first cell should contain the data and + # the others should be blank. All cells should have the same format. + + # Excel doesn't allow a single cell to be merged + if first_row == last_row and first_col == last_col: + warn("Can't merge single cell") + return -1 + + # Swap last row/col with first row/col as necessary + if first_row > last_row: + (first_row, last_row) = (last_row, first_row) + if first_col > last_col: + (first_col, last_col) = (last_col, first_col) + + # Check that row and col are valid and store max and min values. + if self._check_dimensions(first_row, first_col): + return -1 + if self._check_dimensions(last_row, last_col): + return -1 + + # Check if the merge range overlaps a previous merged or table range. + # This is a critical file corruption error in Excel. + cell_range = xl_range(first_row, first_col, last_row, last_col) + for row in range(first_row, last_row + 1): + for col in range(first_col, last_col + 1): + if self.merged_cells.get((row, col)): + previous_range = self.merged_cells.get((row, col)) + raise OverlappingRange( + f"Merge range '{cell_range}' overlaps previous merge " + f"range '{previous_range}'." + ) + + if self.table_cells.get((row, col)): + previous_range = self.table_cells.get((row, col)) + raise OverlappingRange( + f"Merge range '{cell_range}' overlaps previous table " + f"range '{previous_range}'." + ) + + self.merged_cells[(row, col)] = cell_range + + # Store the merge range. + self.merge.append([first_row, first_col, last_row, last_col]) + + # Write the first cell + self._write(first_row, first_col, data, cell_format) + + # Pad out the rest of the area with formatted blank cells. + for row in range(first_row, last_row + 1): + for col in range(first_col, last_col + 1): + if row == first_row and col == first_col: + continue + self._write_blank(row, col, "", cell_format) + + return 0 + + @convert_range_args + def autofilter(self, first_row, first_col, last_row, last_col): + """ + Set the autofilter area in the worksheet. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + + Returns: + Nothing. + + """ + # Reverse max and min values if necessary. + if last_row < first_row: + (first_row, last_row) = (last_row, first_row) + if last_col < first_col: + (first_col, last_col) = (last_col, first_col) + + # Build up the autofilter area range "Sheet1!$A$1:$C$13". + area = self._convert_name_area(first_row, first_col, last_row, last_col) + ref = xl_range(first_row, first_col, last_row, last_col) + + self.autofilter_area = area + self.autofilter_ref = ref + self.filter_range = [first_col, last_col] + + # Store the filter cell positions for use in the autofit calculation. + for col in range(first_col, last_col + 1): + # Check that the autofilter doesn't overlap a table filter. + if self.filter_cells.get((first_row, col)): + filter_type, filter_range = self.filter_cells.get((first_row, col)) + if filter_type == "table": + raise OverlappingRange( + f"Worksheet autofilter range '{ref}' overlaps previous " + f"Table autofilter range '{filter_range}'." + ) + + self.filter_cells[(first_row, col)] = ("worksheet", ref) + + def filter_column(self, col, criteria): + """ + Set the column filter criteria. + + Args: + col: Filter column (zero-indexed). + criteria: Filter criteria. + + Returns: + Nothing. + + """ + if not self.autofilter_area: + warn("Must call autofilter() before filter_column()") + return + + # Check for a column reference in A1 notation and substitute. + try: + int(col) + except ValueError: + # Convert col ref to a cell ref and then to a col number. + col_letter = col + (_, col) = xl_cell_to_rowcol(col + "1") + + if col >= self.xls_colmax: + warn(f"Invalid column '{col_letter}'") + return + + (col_first, col_last) = self.filter_range + + # Reject column if it is outside filter range. + if col < col_first or col > col_last: + warn( + f"Column '{col}' outside autofilter() column " + f"range ({col_first}, {col_last})" + ) + return + + tokens = self._extract_filter_tokens(criteria) + + if len(tokens) not in (3, 7): + warn(f"Incorrect number of tokens in criteria '{criteria}'") + + tokens = self._parse_filter_expression(criteria, tokens) + + # Excel handles single or double custom filters as default filters. + # We need to check for them and handle them accordingly. + if len(tokens) == 2 and tokens[0] == 2: + # Single equality. + self.filter_column_list(col, [tokens[1]]) + elif len(tokens) == 5 and tokens[0] == 2 and tokens[2] == 1 and tokens[3] == 2: + # Double equality with "or" operator. + self.filter_column_list(col, [tokens[1], tokens[4]]) + else: + # Non default custom filter. + self.filter_cols[col] = tokens + self.filter_type[col] = 0 + + self.filter_on = 1 + + def filter_column_list(self, col, filters): + """ + Set the column filter criteria in Excel 2007 list style. + + Args: + col: Filter column (zero-indexed). + filters: List of filter criteria to match. + + Returns: + Nothing. + + """ + if not self.autofilter_area: + warn("Must call autofilter() before filter_column()") + return + + # Check for a column reference in A1 notation and substitute. + try: + int(col) + except ValueError: + # Convert col ref to a cell ref and then to a col number. + col_letter = col + (_, col) = xl_cell_to_rowcol(col + "1") + + if col >= self.xls_colmax: + warn(f"Invalid column '{col_letter}'") + return + + (col_first, col_last) = self.filter_range + + # Reject column if it is outside filter range. + if col < col_first or col > col_last: + warn( + f"Column '{col}' outside autofilter() column range " + f"({col_first},{col_last})" + ) + return + + self.filter_cols[col] = filters + self.filter_type[col] = 1 + self.filter_on = 1 + + @convert_range_args + def data_validation(self, first_row, first_col, last_row, last_col, options=None): + """ + Add a data validation to a worksheet. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + options: Data validation options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: Incorrect parameter or option. + """ + # Check that row and col are valid without storing the values. + if self._check_dimensions(first_row, first_col, True, True): + return -1 + if self._check_dimensions(last_row, last_col, True, True): + return -1 + + if options is None: + options = {} + else: + # Copy the user defined options so they aren't modified. + options = options.copy() + + # Valid input parameters. + valid_parameters = { + "validate", + "criteria", + "value", + "source", + "minimum", + "maximum", + "ignore_blank", + "dropdown", + "show_input", + "input_title", + "input_message", + "show_error", + "error_title", + "error_message", + "error_type", + "other_cells", + "multi_range", + } + + # Check for valid input parameters. + for param_key in options.keys(): + if param_key not in valid_parameters: + warn(f"Unknown parameter '{param_key}' in data_validation()") + return -2 + + # Map alternative parameter names 'source' or 'minimum' to 'value'. + if "source" in options: + options["value"] = options["source"] + if "minimum" in options: + options["value"] = options["minimum"] + + # 'validate' is a required parameter. + if "validate" not in options: + warn("Parameter 'validate' is required in data_validation()") + return -2 + + # List of valid validation types. + valid_types = { + "any": "none", + "any value": "none", + "whole number": "whole", + "whole": "whole", + "integer": "whole", + "decimal": "decimal", + "list": "list", + "date": "date", + "time": "time", + "text length": "textLength", + "length": "textLength", + "custom": "custom", + } + + # Check for valid validation types. + if options["validate"] not in valid_types: + warn( + f"Unknown validation type '{options['validate']}' for parameter " + f"'validate' in data_validation()" + ) + return -2 + + options["validate"] = valid_types[options["validate"]] + + # No action is required for validation type 'any' if there are no + # input messages to display. + if ( + options["validate"] == "none" + and options.get("input_title") is None + and options.get("input_message") is None + ): + return -2 + + # The any, list and custom validations don't have a criteria so we use + # a default of 'between'. + if ( + options["validate"] == "none" + or options["validate"] == "list" + or options["validate"] == "custom" + ): + options["criteria"] = "between" + options["maximum"] = None + + # 'criteria' is a required parameter. + if "criteria" not in options: + warn("Parameter 'criteria' is required in data_validation()") + return -2 + + # Valid criteria types. + criteria_types = { + "between": "between", + "not between": "notBetween", + "equal to": "equal", + "=": "equal", + "==": "equal", + "not equal to": "notEqual", + "!=": "notEqual", + "<>": "notEqual", + "greater than": "greaterThan", + ">": "greaterThan", + "less than": "lessThan", + "<": "lessThan", + "greater than or equal to": "greaterThanOrEqual", + ">=": "greaterThanOrEqual", + "less than or equal to": "lessThanOrEqual", + "<=": "lessThanOrEqual", + } + + # Check for valid criteria types. + if options["criteria"] not in criteria_types: + warn( + f"Unknown criteria type '{options['criteria']}' for parameter " + f"'criteria' in data_validation()" + ) + return -2 + + options["criteria"] = criteria_types[options["criteria"]] + + # 'Between' and 'Not between' criteria require 2 values. + if options["criteria"] == "between" or options["criteria"] == "notBetween": + if "maximum" not in options: + warn( + "Parameter 'maximum' is required in data_validation() " + "when using 'between' or 'not between' criteria" + ) + return -2 + else: + options["maximum"] = None + + # Valid error dialog types. + error_types = { + "stop": 0, + "warning": 1, + "information": 2, + } + + # Check for valid error dialog types. + if "error_type" not in options: + options["error_type"] = 0 + elif options["error_type"] not in error_types: + warn( + f"Unknown criteria type '{options['error_type']}' " + f"for parameter 'error_type'." + ) + return -2 + else: + options["error_type"] = error_types[options["error_type"]] + + # Convert date/times value if required. + if ( + options["validate"] in ("date", "time") + and options["value"] + and _supported_datetime(options["value"]) + ): + date_time = self._convert_date_time(options["value"]) + # Format date number to the same precision as Excel. + options["value"] = f"{date_time:.16g}" + + if options["maximum"] and _supported_datetime(options["maximum"]): + date_time = self._convert_date_time(options["maximum"]) + options["maximum"] = f"{date_time:.16g}" + + # Check that the input title doesn't exceed the maximum length. + if options.get("input_title") and len(options["input_title"]) > 32: + warn( + f"Length of input title '{options['input_title']}' " + f"exceeds Excel's limit of 32" + ) + return -2 + + # Check that the error title doesn't exceed the maximum length. + if options.get("error_title") and len(options["error_title"]) > 32: + warn( + f"Length of error title '{options['error_title']}' " + f"exceeds Excel's limit of 32" + ) + return -2 + + # Check that the input message doesn't exceed the maximum length. + if options.get("input_message") and len(options["input_message"]) > 255: + warn( + f"Length of input message '{options['input_message']}' " + f"exceeds Excel's limit of 255" + ) + return -2 + + # Check that the error message doesn't exceed the maximum length. + if options.get("error_message") and len(options["error_message"]) > 255: + warn( + f"Length of error message '{options['error_message']}' " + f"exceeds Excel's limit of 255" + ) + return -2 + + # Check that the input list doesn't exceed the maximum length. + if options["validate"] == "list" and isinstance(options["value"], list): + formula = self._csv_join(*options["value"]) + if len(formula) > 255: + warn( + f"Length of list items '{formula}' exceeds Excel's limit of " + f"255, use a formula range instead" + ) + return -2 + + # Set some defaults if they haven't been defined by the user. + if "ignore_blank" not in options: + options["ignore_blank"] = 1 + if "dropdown" not in options: + options["dropdown"] = 1 + if "show_input" not in options: + options["show_input"] = 1 + if "show_error" not in options: + options["show_error"] = 1 + + # These are the cells to which the validation is applied. + options["cells"] = [[first_row, first_col, last_row, last_col]] + + # A (for now) undocumented parameter to pass additional cell ranges. + if "other_cells" in options: + options["cells"].extend(options["other_cells"]) + + # Override with user defined multiple range if provided. + if "multi_range" in options: + options["multi_range"] = options["multi_range"].replace("$", "") + + # Store the validation information until we close the worksheet. + self.validations.append(options) + + return 0 + + @convert_range_args + def conditional_format( + self, first_row, first_col, last_row, last_col, options=None + ): + """ + Add a conditional format to a worksheet. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + options: Conditional format options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: Incorrect parameter or option. + """ + # Check that row and col are valid without storing the values. + if self._check_dimensions(first_row, first_col, True, True): + return -1 + if self._check_dimensions(last_row, last_col, True, True): + return -1 + + if options is None: + options = {} + else: + # Copy the user defined options so they aren't modified. + options = options.copy() + + # Valid input parameters. + valid_parameter = { + "type", + "format", + "criteria", + "value", + "minimum", + "maximum", + "stop_if_true", + "min_type", + "mid_type", + "max_type", + "min_value", + "mid_value", + "max_value", + "min_color", + "mid_color", + "max_color", + "min_length", + "max_length", + "multi_range", + "bar_color", + "bar_negative_color", + "bar_negative_color_same", + "bar_solid", + "bar_border_color", + "bar_negative_border_color", + "bar_negative_border_color_same", + "bar_no_border", + "bar_direction", + "bar_axis_position", + "bar_axis_color", + "bar_only", + "data_bar_2010", + "icon_style", + "reverse_icons", + "icons_only", + "icons", + } + + # Check for valid input parameters. + for param_key in options.keys(): + if param_key not in valid_parameter: + warn(f"Unknown parameter '{param_key}' in conditional_format()") + return -2 + + # 'type' is a required parameter. + if "type" not in options: + warn("Parameter 'type' is required in conditional_format()") + return -2 + + # Valid types. + valid_type = { + "cell": "cellIs", + "date": "date", + "time": "time", + "average": "aboveAverage", + "duplicate": "duplicateValues", + "unique": "uniqueValues", + "top": "top10", + "bottom": "top10", + "text": "text", + "time_period": "timePeriod", + "blanks": "containsBlanks", + "no_blanks": "notContainsBlanks", + "errors": "containsErrors", + "no_errors": "notContainsErrors", + "2_color_scale": "2_color_scale", + "3_color_scale": "3_color_scale", + "data_bar": "dataBar", + "formula": "expression", + "icon_set": "iconSet", + } + + # Check for valid types. + if options["type"] not in valid_type: + warn( + f"Unknown value '{options['type']}' for parameter 'type' " + f"in conditional_format()" + ) + return -2 + + if options["type"] == "bottom": + options["direction"] = "bottom" + options["type"] = valid_type[options["type"]] + + # Valid criteria types. + criteria_type = { + "between": "between", + "not between": "notBetween", + "equal to": "equal", + "=": "equal", + "==": "equal", + "not equal to": "notEqual", + "!=": "notEqual", + "<>": "notEqual", + "greater than": "greaterThan", + ">": "greaterThan", + "less than": "lessThan", + "<": "lessThan", + "greater than or equal to": "greaterThanOrEqual", + ">=": "greaterThanOrEqual", + "less than or equal to": "lessThanOrEqual", + "<=": "lessThanOrEqual", + "containing": "containsText", + "not containing": "notContains", + "begins with": "beginsWith", + "ends with": "endsWith", + "yesterday": "yesterday", + "today": "today", + "last 7 days": "last7Days", + "last week": "lastWeek", + "this week": "thisWeek", + "next week": "nextWeek", + "last month": "lastMonth", + "this month": "thisMonth", + "next month": "nextMonth", + # For legacy, but incorrect, support. + "continue week": "nextWeek", + "continue month": "nextMonth", + } + + # Check for valid criteria types. + if "criteria" in options and options["criteria"] in criteria_type: + options["criteria"] = criteria_type[options["criteria"]] + + # Convert boolean values if required. + if "value" in options and isinstance(options["value"], bool): + options["value"] = str(options["value"]).upper() + + # Convert date/times value if required. + if options["type"] in ("date", "time"): + options["type"] = "cellIs" + + if "value" in options: + if not _supported_datetime(options["value"]): + warn("Conditional format 'value' must be a datetime object.") + return -2 + + date_time = self._convert_date_time(options["value"]) + # Format date number to the same precision as Excel. + options["value"] = f"{date_time:.16g}" + + if "minimum" in options: + if not _supported_datetime(options["minimum"]): + warn("Conditional format 'minimum' must be a datetime object.") + return -2 + + date_time = self._convert_date_time(options["minimum"]) + options["minimum"] = f"{date_time:.16g}" + + if "maximum" in options: + if not _supported_datetime(options["maximum"]): + warn("Conditional format 'maximum' must be a datetime object.") + return -2 + + date_time = self._convert_date_time(options["maximum"]) + options["maximum"] = f"{date_time:.16g}" + + # Valid icon styles. + valid_icons = { + "3_arrows": "3Arrows", # 1 + "3_flags": "3Flags", # 2 + "3_traffic_lights_rimmed": "3TrafficLights2", # 3 + "3_symbols_circled": "3Symbols", # 4 + "4_arrows": "4Arrows", # 5 + "4_red_to_black": "4RedToBlack", # 6 + "4_traffic_lights": "4TrafficLights", # 7 + "5_arrows_gray": "5ArrowsGray", # 8 + "5_quarters": "5Quarters", # 9 + "3_arrows_gray": "3ArrowsGray", # 10 + "3_traffic_lights": "3TrafficLights", # 11 + "3_signs": "3Signs", # 12 + "3_symbols": "3Symbols2", # 13 + "4_arrows_gray": "4ArrowsGray", # 14 + "4_ratings": "4Rating", # 15 + "5_arrows": "5Arrows", # 16 + "5_ratings": "5Rating", + } # 17 + + # Set the icon set properties. + if options["type"] == "iconSet": + # An icon_set must have an icon style. + if not options.get("icon_style"): + warn( + "The 'icon_style' parameter must be specified when " + "'type' == 'icon_set' in conditional_format()." + ) + return -3 + + # Check for valid icon styles. + if options["icon_style"] not in valid_icons: + warn( + f"Unknown icon_style '{options['icon_style']}' " + f"in conditional_format()." + ) + return -2 + + options["icon_style"] = valid_icons[options["icon_style"]] + + # Set the number of icons for the icon style. + options["total_icons"] = 3 + if options["icon_style"].startswith("4"): + options["total_icons"] = 4 + elif options["icon_style"].startswith("5"): + options["total_icons"] = 5 + + options["icons"] = self._set_icon_props( + options.get("total_icons"), options.get("icons") + ) + + # Swap last row/col for first row/col as necessary + if first_row > last_row: + first_row, last_row = last_row, first_row + + if first_col > last_col: + first_col, last_col = last_col, first_col + + # Set the formatting range. + cell_range = xl_range(first_row, first_col, last_row, last_col) + start_cell = xl_rowcol_to_cell(first_row, first_col) + + # Override with user defined multiple range if provided. + if "multi_range" in options: + cell_range = options["multi_range"] + cell_range = cell_range.replace("$", "") + + # Get the dxf format index. + if "format" in options and options["format"]: + options["format"] = options["format"]._get_dxf_index() + + # Set the priority based on the order of adding. + options["priority"] = self.dxf_priority + self.dxf_priority += 1 + + # Check for 2010 style data_bar parameters. + # pylint: disable=too-many-boolean-expressions + if ( + self.use_data_bars_2010 + or options.get("data_bar_2010") + or options.get("bar_solid") + or options.get("bar_border_color") + or options.get("bar_negative_color") + or options.get("bar_negative_color_same") + or options.get("bar_negative_border_color") + or options.get("bar_negative_border_color_same") + or options.get("bar_no_border") + or options.get("bar_axis_position") + or options.get("bar_axis_color") + or options.get("bar_direction") + ): + options["is_data_bar_2010"] = True + + # Special handling of text criteria. + if options["type"] == "text": + value = options["value"] + length = len(value) + criteria = options["criteria"] + + if options["criteria"] == "containsText": + options["type"] = "containsText" + options["formula"] = f'NOT(ISERROR(SEARCH("{value}",{start_cell})))' + elif options["criteria"] == "notContains": + options["type"] = "notContainsText" + options["formula"] = f'ISERROR(SEARCH("{value}",{start_cell}))' + elif options["criteria"] == "beginsWith": + options["type"] = "beginsWith" + options["formula"] = f'LEFT({start_cell},{length})="{value}"' + elif options["criteria"] == "endsWith": + options["type"] = "endsWith" + options["formula"] = f'RIGHT({start_cell},{length})="{value}"' + else: + warn(f"Invalid text criteria '{criteria}' in conditional_format()") + + # Special handling of time time_period criteria. + if options["type"] == "timePeriod": + if options["criteria"] == "yesterday": + options["formula"] = f"FLOOR({start_cell},1)=TODAY()-1" + + elif options["criteria"] == "today": + options["formula"] = f"FLOOR({start_cell},1)=TODAY()" + + elif options["criteria"] == "tomorrow": + options["formula"] = f"FLOOR({start_cell},1)=TODAY()+1" + + # fmt: off + elif options["criteria"] == "last7Days": + options["formula"] = ( + f"AND(TODAY()-FLOOR({start_cell},1)<=6," + f"FLOOR({start_cell},1)<=TODAY())" + ) + # fmt: on + + elif options["criteria"] == "lastWeek": + options["formula"] = ( + f"AND(TODAY()-ROUNDDOWN({start_cell},0)>=(WEEKDAY(TODAY()))," + f"TODAY()-ROUNDDOWN({start_cell},0)<(WEEKDAY(TODAY())+7))" + ) + + elif options["criteria"] == "thisWeek": + options["formula"] = ( + f"AND(TODAY()-ROUNDDOWN({start_cell},0)<=WEEKDAY(TODAY())-1," + f"ROUNDDOWN({start_cell},0)-TODAY()<=7-WEEKDAY(TODAY()))" + ) + + elif options["criteria"] == "nextWeek": + options["formula"] = ( + f"AND(ROUNDDOWN({start_cell},0)-TODAY()>(7-WEEKDAY(TODAY()))," + f"ROUNDDOWN({start_cell},0)-TODAY()<(15-WEEKDAY(TODAY())))" + ) + + elif options["criteria"] == "lastMonth": + options["formula"] = ( + f"AND(MONTH({start_cell})=MONTH(TODAY())-1," + f"OR(YEAR({start_cell})=YEAR(" + f"TODAY()),AND(MONTH({start_cell})=1,YEAR(A1)=YEAR(TODAY())-1)))" + ) + + # fmt: off + elif options["criteria"] == "thisMonth": + options["formula"] = ( + f"AND(MONTH({start_cell})=MONTH(TODAY())," + f"YEAR({start_cell})=YEAR(TODAY()))" + ) + # fmt: on + + elif options["criteria"] == "nextMonth": + options["formula"] = ( + f"AND(MONTH({start_cell})=MONTH(TODAY())+1," + f"OR(YEAR({start_cell})=YEAR(" + f"TODAY()),AND(MONTH({start_cell})=12," + f"YEAR({start_cell})=YEAR(TODAY())+1)))" + ) + + else: + warn( + f"Invalid time_period criteria '{options['criteria']}' " + f"in conditional_format()" + ) + + # Special handling of blanks/error types. + if options["type"] == "containsBlanks": + options["formula"] = f"LEN(TRIM({start_cell}))=0" + + if options["type"] == "notContainsBlanks": + options["formula"] = f"LEN(TRIM({start_cell}))>0" + + if options["type"] == "containsErrors": + options["formula"] = f"ISERROR({start_cell})" + + if options["type"] == "notContainsErrors": + options["formula"] = f"NOT(ISERROR({start_cell}))" + + # Special handling for 2 color scale. + if options["type"] == "2_color_scale": + options["type"] = "colorScale" + + # Color scales don't use any additional formatting. + options["format"] = None + + # Turn off 3 color parameters. + options["mid_type"] = None + options["mid_color"] = None + + options.setdefault("min_type", "min") + options.setdefault("max_type", "max") + options.setdefault("min_value", 0) + options.setdefault("max_value", 0) + options.setdefault("min_color", "#FF7128") + options.setdefault("max_color", "#FFEF9C") + + options["min_color"] = _xl_color(options["min_color"]) + options["max_color"] = _xl_color(options["max_color"]) + + # Special handling for 3 color scale. + if options["type"] == "3_color_scale": + options["type"] = "colorScale" + + # Color scales don't use any additional formatting. + options["format"] = None + + options.setdefault("min_type", "min") + options.setdefault("mid_type", "percentile") + options.setdefault("max_type", "max") + options.setdefault("min_value", 0) + options.setdefault("max_value", 0) + options.setdefault("min_color", "#F8696B") + options.setdefault("mid_color", "#FFEB84") + options.setdefault("max_color", "#63BE7B") + + options["min_color"] = _xl_color(options["min_color"]) + options["mid_color"] = _xl_color(options["mid_color"]) + options["max_color"] = _xl_color(options["max_color"]) + + # Set a default mid value. + if "mid_value" not in options: + options["mid_value"] = 50 + + # Special handling for data bar. + if options["type"] == "dataBar": + # Color scales don't use any additional formatting. + options["format"] = None + + if not options.get("min_type"): + options["min_type"] = "min" + options["x14_min_type"] = "autoMin" + else: + options["x14_min_type"] = options["min_type"] + + if not options.get("max_type"): + options["max_type"] = "max" + options["x14_max_type"] = "autoMax" + else: + options["x14_max_type"] = options["max_type"] + + options.setdefault("min_value", 0) + options.setdefault("max_value", 0) + options.setdefault("bar_color", "#638EC6") + options.setdefault("bar_border_color", options["bar_color"]) + options.setdefault("bar_only", False) + options.setdefault("bar_no_border", False) + options.setdefault("bar_solid", False) + options.setdefault("bar_direction", "") + options.setdefault("bar_negative_color", "#FF0000") + options.setdefault("bar_negative_border_color", "#FF0000") + options.setdefault("bar_negative_color_same", False) + options.setdefault("bar_negative_border_color_same", False) + options.setdefault("bar_axis_position", "") + options.setdefault("bar_axis_color", "#000000") + + options["bar_color"] = _xl_color(options["bar_color"]) + options["bar_border_color"] = _xl_color(options["bar_border_color"]) + options["bar_axis_color"] = _xl_color(options["bar_axis_color"]) + options["bar_negative_color"] = _xl_color(options["bar_negative_color"]) + options["bar_negative_border_color"] = _xl_color( + options["bar_negative_border_color"] + ) + + # Adjust for 2010 style data_bar parameters. + if options.get("is_data_bar_2010"): + self.excel_version = 2010 + + if options["min_type"] == "min" and options["min_value"] == 0: + options["min_value"] = None + + if options["max_type"] == "max" and options["max_value"] == 0: + options["max_value"] = None + + options["range"] = cell_range + + # Strip the leading = from formulas. + try: + options["min_value"] = options["min_value"].lstrip("=") + except (KeyError, AttributeError): + pass + try: + options["mid_value"] = options["mid_value"].lstrip("=") + except (KeyError, AttributeError): + pass + try: + options["max_value"] = options["max_value"].lstrip("=") + except (KeyError, AttributeError): + pass + + # Store the conditional format until we close the worksheet. + if cell_range in self.cond_formats: + self.cond_formats[cell_range].append(options) + else: + self.cond_formats[cell_range] = [options] + + return 0 + + @convert_range_args + def add_table(self, first_row, first_col, last_row, last_col, options=None): + """ + Add an Excel table to a worksheet. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + options: Table format options. (Optional) + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: Incorrect parameter or option. + -3: Not supported in constant_memory mode. + """ + table = {} + col_formats = {} + + if options is None: + options = {} + else: + # Copy the user defined options so they aren't modified. + options = options.copy() + + if self.constant_memory: + warn("add_table() isn't supported in 'constant_memory' mode") + return -3 + + # Check that row and col are valid without storing the values. + if self._check_dimensions(first_row, first_col, True, True): + return -1 + if self._check_dimensions(last_row, last_col, True, True): + return -1 + + # Swap last row/col for first row/col as necessary. + if first_row > last_row: + (first_row, last_row) = (last_row, first_row) + if first_col > last_col: + (first_col, last_col) = (last_col, first_col) + + # Check if the table range overlaps a previous merged or table range. + # This is a critical file corruption error in Excel. + cell_range = xl_range(first_row, first_col, last_row, last_col) + for row in range(first_row, last_row + 1): + for col in range(first_col, last_col + 1): + if self.table_cells.get((row, col)): + previous_range = self.table_cells.get((row, col)) + raise OverlappingRange( + f"Table range '{cell_range}' overlaps previous " + f"table range '{previous_range}'." + ) + + if self.merged_cells.get((row, col)): + previous_range = self.merged_cells.get((row, col)) + raise OverlappingRange( + f"Table range '{cell_range}' overlaps previous " + f"merge range '{previous_range}'." + ) + + self.table_cells[(row, col)] = cell_range + + # Valid input parameters. + valid_parameter = { + "autofilter", + "banded_columns", + "banded_rows", + "columns", + "data", + "first_column", + "header_row", + "last_column", + "name", + "style", + "total_row", + } + + # Check for valid input parameters. + for param_key in options.keys(): + if param_key not in valid_parameter: + warn(f"Unknown parameter '{param_key}' in add_table()") + return -2 + + # Turn on Excel's defaults. + options["banded_rows"] = options.get("banded_rows", True) + options["header_row"] = options.get("header_row", True) + options["autofilter"] = options.get("autofilter", True) + + # Check that there are enough rows. + num_rows = last_row - first_row + if options["header_row"]: + num_rows -= 1 + + if num_rows < 0: + warn("Must have at least one data row in in add_table()") + return -2 + + # Set the table options. + table["show_first_col"] = options.get("first_column", False) + table["show_last_col"] = options.get("last_column", False) + table["show_row_stripes"] = options.get("banded_rows", False) + table["show_col_stripes"] = options.get("banded_columns", False) + table["header_row_count"] = options.get("header_row", 0) + table["totals_row_shown"] = options.get("total_row", False) + + # Set the table name. + if "name" in options: + name = options["name"] + table["name"] = name + + if " " in name: + warn(f"Name '{name}' in add_table() cannot contain spaces") + return -2 + + # Warn if the name contains invalid chars as defined by Excel. + if not re.match(r"^[\w\\][\w\\.]*$", name, re.UNICODE) or re.match( + r"^\d", name + ): + warn(f"Invalid Excel characters in add_table(): '{name}'") + return -2 + + # Warn if the name looks like a cell name. + if re.match(r"^[a-zA-Z][a-zA-Z]?[a-dA-D]?\d+$", name): + warn(f"Name looks like a cell name in add_table(): '{name}'") + return -2 + + # Warn if the name looks like a R1C1 cell reference. + if re.match(r"^[rcRC]$", name) or re.match(r"^[rcRC]\d+[rcRC]\d+$", name): + warn(f"Invalid name '{name}' like a RC cell ref in add_table()") + return -2 + + # Set the table style. + if "style" in options: + table["style"] = options["style"] + + if table["style"] is None: + table["style"] = "" + + # Remove whitespace from style name. + table["style"] = table["style"].replace(" ", "") + else: + table["style"] = "TableStyleMedium9" + + # Set the data range rows (without the header and footer). + first_data_row = first_row + last_data_row = last_row + + if options.get("header_row"): + first_data_row += 1 + + if options.get("total_row"): + last_data_row -= 1 + + # Set the table and autofilter ranges. + table["range"] = xl_range(first_row, first_col, last_row, last_col) + + table["a_range"] = xl_range(first_row, first_col, last_data_row, last_col) + + # If the header row if off the default is to turn autofilter off. + if not options["header_row"]: + options["autofilter"] = 0 + + # Set the autofilter range. + if options["autofilter"]: + table["autofilter"] = table["a_range"] + + # Add the table columns. + col_id = 1 + table["columns"] = [] + seen_names = {} + + for col_num in range(first_col, last_col + 1): + # Set up the default column data. + col_data = { + "id": col_id, + "name": "Column" + str(col_id), + "total_string": "", + "total_function": "", + "custom_total": "", + "total_value": 0, + "formula": "", + "format": None, + "name_format": None, + } + + # Overwrite the defaults with any user defined values. + if "columns" in options: + # Check if there are user defined values for this column. + if col_id <= len(options["columns"]): + user_data = options["columns"][col_id - 1] + else: + user_data = None + + if user_data: + # Get the column format. + xformat = user_data.get("format", None) + + # Map user defined values to internal values. + if user_data.get("header"): + col_data["name"] = user_data["header"] + + # Excel requires unique case insensitive header names. + header_name = col_data["name"] + name = header_name.lower() + if name in seen_names: + warn(f"Duplicate header name in add_table(): '{name}'") + return -2 + + seen_names[name] = True + + col_data["name_format"] = user_data.get("header_format") + + # Handle the column formula. + if "formula" in user_data and user_data["formula"]: + formula = user_data["formula"] + + # Remove the formula '=' sign if it exists. + if formula.startswith("="): + formula = formula.lstrip("=") + + # Convert Excel 2010 "@" ref to 2007 "#This Row". + formula = self._prepare_table_formula(formula) + + # Escape any future functions. + formula = self._prepare_formula(formula, True) + + col_data["formula"] = formula + # We write the formulas below after the table data. + + # Handle the function for the total row. + if user_data.get("total_function"): + function = user_data["total_function"] + if function == "count_nums": + function = "countNums" + if function == "std_dev": + function = "stdDev" + + subtotals = set( + [ + "average", + "countNums", + "count", + "max", + "min", + "stdDev", + "sum", + "var", + ] + ) + + if function in subtotals: + formula = self._table_function_to_formula( + function, col_data["name"] + ) + else: + formula = self._prepare_formula(function, True) + col_data["custom_total"] = formula + function = "custom" + + col_data["total_function"] = function + + value = user_data.get("total_value", 0) + + self._write_formula(last_row, col_num, formula, xformat, value) + + elif user_data.get("total_string"): + # Total label only (not a function). + total_string = user_data["total_string"] + col_data["total_string"] = total_string + + self._write_string( + last_row, col_num, total_string, user_data.get("format") + ) + + # Get the dxf format index. + if xformat is not None: + col_data["format"] = xformat._get_dxf_index() + + # Store the column format for writing the cell data. + # It doesn't matter if it is undefined. + col_formats[col_id - 1] = xformat + + # Store the column data. + table["columns"].append(col_data) + + # Write the column headers to the worksheet. + if options["header_row"]: + self._write_string( + first_row, col_num, col_data["name"], col_data["name_format"] + ) + + col_id += 1 + + # Write the cell data if supplied. + if "data" in options: + data = options["data"] + + i = 0 # For indexing the row data. + for row in range(first_data_row, last_data_row + 1): + j = 0 # For indexing the col data. + for col in range(first_col, last_col + 1): + if i < len(data) and j < len(data[i]): + token = data[i][j] + if j in col_formats: + self._write(row, col, token, col_formats[j]) + else: + self._write(row, col, token, None) + j += 1 + i += 1 + + # Write any columns formulas after the user supplied table data to + # overwrite it if required. + for col_id, col_num in enumerate(range(first_col, last_col + 1)): + column_data = table["columns"][col_id] + if column_data and column_data["formula"]: + formula_format = col_formats.get(col_id) + formula = column_data["formula"] + + for row in range(first_data_row, last_data_row + 1): + self._write_formula(row, col_num, formula, formula_format) + + # Store the table data. + self.tables.append(table) + + # Store the filter cell positions for use in the autofit calculation. + if options["autofilter"]: + for col in range(first_col, last_col + 1): + # Check that the table autofilter doesn't overlap a worksheet filter. + if self.filter_cells.get((first_row, col)): + filter_type, filter_range = self.filter_cells.get((first_row, col)) + if filter_type == "worksheet": + raise OverlappingRange( + f"Table autofilter range '{cell_range}' overlaps previous " + f"Worksheet autofilter range '{filter_range}'." + ) + + self.filter_cells[(first_row, col)] = ("table", cell_range) + + return 0 + + @convert_cell_args + def add_sparkline(self, row, col, options=None): + """ + Add sparklines to the worksheet. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + options: Sparkline formatting options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + -2: Incorrect parameter or option. + + """ + + # Check that row and col are valid without storing the values. + if self._check_dimensions(row, col, True, True): + return -1 + + sparkline = {"locations": [xl_rowcol_to_cell(row, col)]} + + if options is None: + options = {} + + # Valid input parameters. + valid_parameters = { + "location", + "range", + "type", + "high_point", + "low_point", + "negative_points", + "first_point", + "last_point", + "markers", + "style", + "series_color", + "negative_color", + "markers_color", + "first_color", + "last_color", + "high_color", + "low_color", + "max", + "min", + "axis", + "reverse", + "empty_cells", + "show_hidden", + "plot_hidden", + "date_axis", + "weight", + } + + # Check for valid input parameters. + for param_key in options.keys(): + if param_key not in valid_parameters: + warn(f"Unknown parameter '{param_key}' in add_sparkline()") + return -1 + + # 'range' is a required parameter. + if "range" not in options: + warn("Parameter 'range' is required in add_sparkline()") + return -2 + + # Handle the sparkline type. + spark_type = options.get("type", "line") + + if spark_type not in ("line", "column", "win_loss"): + warn( + "Parameter 'type' must be 'line', 'column' " + "or 'win_loss' in add_sparkline()" + ) + return -2 + + if spark_type == "win_loss": + spark_type = "stacked" + sparkline["type"] = spark_type + + # We handle single location/range values or list of values. + if "location" in options: + if isinstance(options["location"], list): + sparkline["locations"] = options["location"] + else: + sparkline["locations"] = [options["location"]] + + if isinstance(options["range"], list): + sparkline["ranges"] = options["range"] + else: + sparkline["ranges"] = [options["range"]] + + range_count = len(sparkline["ranges"]) + location_count = len(sparkline["locations"]) + + # The ranges and locations must match. + if range_count != location_count: + warn( + "Must have the same number of location and range " + "parameters in add_sparkline()" + ) + return -2 + + # Store the count. + sparkline["count"] = len(sparkline["locations"]) + + # Get the worksheet name for the range conversion below. + sheetname = quote_sheetname(self.name) + + # Cleanup the input ranges. + new_ranges = [] + for spark_range in sparkline["ranges"]: + # Remove the absolute reference $ symbols. + spark_range = spark_range.replace("$", "") + + # Remove the = from formula. + spark_range = spark_range.lstrip("=") + + # Convert a simple range into a full Sheet1!A1:D1 range. + if "!" not in spark_range: + spark_range = sheetname + "!" + spark_range + + new_ranges.append(spark_range) + + sparkline["ranges"] = new_ranges + + # Cleanup the input locations. + new_locations = [] + for location in sparkline["locations"]: + location = location.replace("$", "") + new_locations.append(location) + + sparkline["locations"] = new_locations + + # Map options. + sparkline["high"] = options.get("high_point") + sparkline["low"] = options.get("low_point") + sparkline["negative"] = options.get("negative_points") + sparkline["first"] = options.get("first_point") + sparkline["last"] = options.get("last_point") + sparkline["markers"] = options.get("markers") + sparkline["min"] = options.get("min") + sparkline["max"] = options.get("max") + sparkline["axis"] = options.get("axis") + sparkline["reverse"] = options.get("reverse") + sparkline["hidden"] = options.get("show_hidden") + sparkline["weight"] = options.get("weight") + + # Map empty cells options. + empty = options.get("empty_cells", "") + + if empty == "zero": + sparkline["empty"] = 0 + elif empty == "connect": + sparkline["empty"] = "span" + else: + sparkline["empty"] = "gap" + + # Map the date axis range. + date_range = options.get("date_axis") + + if date_range and "!" not in date_range: + date_range = sheetname + "!" + date_range + + sparkline["date_axis"] = date_range + + # Set the sparkline styles. + style_id = options.get("style", 0) + style = _get_sparkline_style(style_id) + + sparkline["series_color"] = style["series"] + sparkline["negative_color"] = style["negative"] + sparkline["markers_color"] = style["markers"] + sparkline["first_color"] = style["first"] + sparkline["last_color"] = style["last"] + sparkline["high_color"] = style["high"] + sparkline["low_color"] = style["low"] + + # Override the style colors with user defined colors. + self._set_spark_color(sparkline, options, "series_color") + self._set_spark_color(sparkline, options, "negative_color") + self._set_spark_color(sparkline, options, "markers_color") + self._set_spark_color(sparkline, options, "first_color") + self._set_spark_color(sparkline, options, "last_color") + self._set_spark_color(sparkline, options, "high_color") + self._set_spark_color(sparkline, options, "low_color") + + self.sparklines.append(sparkline) + + return 0 + + @convert_range_args + def set_selection(self, first_row, first_col, last_row, last_col): + """ + Set the selected cell or cells in a worksheet + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + + Returns: + 0: Nothing. + """ + pane = None + + # Range selection. Do this before swapping max/min to allow the + # selection direction to be reversed. + active_cell = xl_rowcol_to_cell(first_row, first_col) + + # Swap last row/col for first row/col if necessary + if first_row > last_row: + (first_row, last_row) = (last_row, first_row) + + if first_col > last_col: + (first_col, last_col) = (last_col, first_col) + + sqref = xl_range(first_row, first_col, last_row, last_col) + + # Selection isn't set for cell A1. + if sqref == "A1": + return + + self.selections = [[pane, active_cell, sqref]] + + @convert_cell_args + def set_top_left_cell(self, row=0, col=0): + """ + Set the first visible cell at the top left of a worksheet. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + + Returns: + 0: Nothing. + """ + + if row == 0 and col == 0: + return + + self.top_left_cell = xl_rowcol_to_cell(row, col) + + def outline_settings( + self, visible=1, symbols_below=1, symbols_right=1, auto_style=0 + ): + """ + Control outline settings. + + Args: + visible: Outlines are visible. Optional, defaults to True. + symbols_below: Show row outline symbols below the outline bar. + Optional, defaults to True. + symbols_right: Show column outline symbols to the right of the + outline bar. Optional, defaults to True. + auto_style: Use Automatic style. Optional, defaults to False. + + Returns: + 0: Nothing. + """ + self.outline_on = visible + self.outline_below = symbols_below + self.outline_right = symbols_right + self.outline_style = auto_style + + self.outline_changed = True + + @convert_cell_args + def freeze_panes(self, row, col, top_row=None, left_col=None, pane_type=0): + """ + Create worksheet panes and mark them as frozen. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + top_row: Topmost visible row in scrolling region of pane. + left_col: Leftmost visible row in scrolling region of pane. + + Returns: + 0: Nothing. + + """ + if top_row is None: + top_row = row + + if left_col is None: + left_col = col + + self.panes = [row, col, top_row, left_col, pane_type] + + @convert_cell_args + def split_panes(self, x, y, top_row=None, left_col=None): + """ + Create worksheet panes and mark them as split. + + Args: + x: The position for the vertical split. + y: The position for the horizontal split. + top_row: Topmost visible row in scrolling region of pane. + left_col: Leftmost visible row in scrolling region of pane. + + Returns: + 0: Nothing. + + """ + # Same as freeze panes with a different pane type. + self.freeze_panes(x, y, top_row, left_col, 2) + + def set_zoom(self, zoom=100): + """ + Set the worksheet zoom factor. + + Args: + zoom: Scale factor: 10 <= zoom <= 400. + + Returns: + Nothing. + + """ + # Ensure the zoom scale is in Excel's range. + if zoom < 10 or zoom > 400: + warn(f"Zoom factor '{zoom}' outside range: 10 <= zoom <= 400") + zoom = 100 + + self.zoom = int(zoom) + + def right_to_left(self): + """ + Display the worksheet right to left for some versions of Excel. + + Args: + None. + + Returns: + Nothing. + + """ + self.is_right_to_left = 1 + + def hide_zero(self): + """ + Hide zero values in worksheet cells. + + Args: + None. + + Returns: + Nothing. + + """ + self.show_zeros = 0 + + def set_tab_color(self, color): + """ + Set the color of the worksheet tab. + + Args: + color: A #RGB color index. + + Returns: + Nothing. + + """ + self.tab_color = _xl_color(color) + + def protect(self, password="", options=None): + """ + Set the password and protection options of the worksheet. + + Args: + password: An optional password string. + options: A dictionary of worksheet objects to protect. + + Returns: + Nothing. + + """ + if password != "": + password = self._encode_password(password) + + if not options: + options = {} + + # Default values for objects that can be protected. + defaults = { + "sheet": True, + "content": False, + "objects": False, + "scenarios": False, + "format_cells": False, + "format_columns": False, + "format_rows": False, + "insert_columns": False, + "insert_rows": False, + "insert_hyperlinks": False, + "delete_columns": False, + "delete_rows": False, + "select_locked_cells": True, + "sort": False, + "autofilter": False, + "pivot_tables": False, + "select_unlocked_cells": True, + } + + # Overwrite the defaults with user specified values. + for key in options.keys(): + if key in defaults: + defaults[key] = options[key] + else: + warn(f"Unknown protection object: '{key}'") + + # Set the password after the user defined values. + defaults["password"] = password + + self.protect_options = defaults + + def unprotect_range(self, cell_range, range_name=None, password=None): + """ + Unprotect ranges within a protected worksheet. + + Args: + cell_range: The cell or cell range to unprotect. + range_name: An optional name for the range. + password: An optional password string. (undocumented) + + Returns: + 0: Success. + -1: Parameter error. + + """ + if cell_range is None: + warn("Cell range must be specified in unprotect_range()") + return -1 + + # Sanitize the cell range. + cell_range = cell_range.lstrip("=") + cell_range = cell_range.replace("$", "") + + self.num_protected_ranges += 1 + + if range_name is None: + range_name = "Range" + str(self.num_protected_ranges) + + if password: + password = self._encode_password(password) + + self.protected_ranges.append((cell_range, range_name, password)) + + return 0 + + @convert_cell_args + def insert_button(self, row, col, options=None): + """ + Insert a button form object into the worksheet. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + options: Button formatting options. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Check insert (row, col) without storing. + if self._check_dimensions(row, col, True, True): + warn(f"Cannot insert button at ({row}, {col}).") + return -1 + + if options is None: + options = {} + + button = self._button_params(row, col, options) + + self.buttons_list.append(button) + + self.has_vml = 1 + + return 0 + + @convert_cell_args + def insert_checkbox(self, row, col, boolean, cell_format=None): + """ + Insert a boolean checkbox in a worksheet cell. + + Args: + row: The cell row (zero indexed). + col: The cell column (zero indexed). + boolean: The boolean value to display as a checkbox. + cell_format: Cell Format object. (optional) + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Ensure that the checkbox property is set in the user defined format. + if cell_format and not cell_format.checkbox: + # This needs to be fixed with a clone. + cell_format.set_checkbox() + + # If no format is supplied create and/or use the default checkbox format. + if not cell_format: + if not self.default_checkbox_format: + self.default_checkbox_format = self.workbook_add_format() + self.default_checkbox_format.set_checkbox() + + cell_format = self.default_checkbox_format + + return self._write_boolean(row, col, boolean, cell_format) + + ########################################################################### + # + # Public API. Page Setup methods. + # + ########################################################################### + def set_landscape(self): + """ + Set the page orientation as landscape. + + Args: + None. + + Returns: + Nothing. + + """ + self.orientation = 0 + self.page_setup_changed = True + + def set_portrait(self): + """ + Set the page orientation as portrait. + + Args: + None. + + Returns: + Nothing. + + """ + self.orientation = 1 + self.page_setup_changed = True + + def set_page_view(self, view=1): + """ + Set the page view mode. + + Args: + 0: Normal view mode + 1: Page view mode (the default) + 2: Page break view mode + + Returns: + Nothing. + + """ + self.page_view = view + + def set_pagebreak_view(self): + """ + Set the page view mode. + + Args: + None. + + Returns: + Nothing. + + """ + self.page_view = 2 + + def set_paper(self, paper_size): + """ + Set the paper type. US Letter = 1, A4 = 9. + + Args: + paper_size: Paper index. + + Returns: + Nothing. + + """ + if paper_size: + self.paper_size = paper_size + self.page_setup_changed = True + + def center_horizontally(self): + """ + Center the page horizontally. + + Args: + None. + + Returns: + Nothing. + + """ + self.print_options_changed = True + self.hcenter = 1 + + def center_vertically(self): + """ + Center the page vertically. + + Args: + None. + + Returns: + Nothing. + + """ + self.print_options_changed = True + self.vcenter = 1 + + def set_margins(self, left=0.7, right=0.7, top=0.75, bottom=0.75): + """ + Set all the page margins in inches. + + Args: + left: Left margin. + right: Right margin. + top: Top margin. + bottom: Bottom margin. + + Returns: + Nothing. + + """ + self.margin_left = left + self.margin_right = right + self.margin_top = top + self.margin_bottom = bottom + + def set_header(self, header="", options=None, margin=None): + """ + Set the page header caption and optional margin. + + Args: + header: Header string. + margin: Header margin. + options: Header options, mainly for images. + + Returns: + Nothing. + + """ + header_orig = header + header = header.replace("&[Picture]", "&G") + + if len(header) > 255: + warn("Header string cannot be longer than Excel's limit of 255 characters") + return + + if options is not None: + # For backward compatibility allow options to be the margin. + if not isinstance(options, dict): + options = {"margin": options} + else: + options = {} + + # Copy the user defined options so they aren't modified. + options = options.copy() + + # For backward compatibility. + if margin is not None: + options["margin"] = margin + + # Reset the list in case the function is called more than once. + self.header_images = [] + + if options.get("image_left"): + self.header_images.append( + [options.get("image_left"), options.get("image_data_left"), "LH"] + ) + + if options.get("image_center"): + self.header_images.append( + [options.get("image_center"), options.get("image_data_center"), "CH"] + ) + + if options.get("image_right"): + self.header_images.append( + [options.get("image_right"), options.get("image_data_right"), "RH"] + ) + + placeholder_count = header.count("&G") + image_count = len(self.header_images) + + if placeholder_count != image_count: + warn( + f"Number of footer images '{image_count}' doesn't match placeholder " + f"count '{placeholder_count}' in string: {header_orig}" + ) + self.header_images = [] + return + + if "align_with_margins" in options: + self.header_footer_aligns = options["align_with_margins"] + + if "scale_with_doc" in options: + self.header_footer_scales = options["scale_with_doc"] + + self.header = header + self.margin_header = options.get("margin", 0.3) + self.header_footer_changed = True + + if image_count: + self.has_header_vml = True + + def set_footer(self, footer="", options=None, margin=None): + """ + Set the page footer caption and optional margin. + + Args: + footer: Footer string. + margin: Footer margin. + options: Footer options, mainly for images. + + Returns: + Nothing. + + """ + footer_orig = footer + footer = footer.replace("&[Picture]", "&G") + + if len(footer) > 255: + warn("Footer string cannot be longer than Excel's limit of 255 characters") + return + + if options is not None: + # For backward compatibility allow options to be the margin. + if not isinstance(options, dict): + options = {"margin": options} + else: + options = {} + + # Copy the user defined options so they aren't modified. + options = options.copy() + + # For backward compatibility. + if margin is not None: + options["margin"] = margin + + # Reset the list in case the function is called more than once. + self.footer_images = [] + + if options.get("image_left"): + self.footer_images.append( + [options.get("image_left"), options.get("image_data_left"), "LF"] + ) + + if options.get("image_center"): + self.footer_images.append( + [options.get("image_center"), options.get("image_data_center"), "CF"] + ) + + if options.get("image_right"): + self.footer_images.append( + [options.get("image_right"), options.get("image_data_right"), "RF"] + ) + + placeholder_count = footer.count("&G") + image_count = len(self.footer_images) + + if placeholder_count != image_count: + warn( + f"Number of footer images '{image_count}' doesn't match placeholder " + f"count '{placeholder_count}' in string: {footer_orig}" + ) + self.footer_images = [] + return + + if "align_with_margins" in options: + self.header_footer_aligns = options["align_with_margins"] + + if "scale_with_doc" in options: + self.header_footer_scales = options["scale_with_doc"] + + self.footer = footer + self.margin_footer = options.get("margin", 0.3) + self.header_footer_changed = True + + if image_count: + self.has_header_vml = True + + def repeat_rows(self, first_row, last_row=None): + """ + Set the rows to repeat at the top of each printed page. + + Args: + first_row: Start row for range. + last_row: End row for range. + + Returns: + Nothing. + + """ + if last_row is None: + last_row = first_row + + # Convert rows to 1 based. + first_row += 1 + last_row += 1 + + # Create the row range area like: $1:$2. + area = f"${first_row}:${last_row}" + + # Build up the print titles area "Sheet1!$1:$2" + sheetname = quote_sheetname(self.name) + self.repeat_row_range = sheetname + "!" + area + + @convert_column_args + def repeat_columns(self, first_col, last_col=None): + """ + Set the columns to repeat at the left hand side of each printed page. + + Args: + first_col: Start column for range. + last_col: End column for range. + + Returns: + Nothing. + + """ + if last_col is None: + last_col = first_col + + # Convert to A notation. + first_col = xl_col_to_name(first_col, 1) + last_col = xl_col_to_name(last_col, 1) + + # Create a column range like $C:$D. + area = first_col + ":" + last_col + + # Build up the print area range "=Sheet2!$C:$D" + sheetname = quote_sheetname(self.name) + self.repeat_col_range = sheetname + "!" + area + + def hide_gridlines(self, option=1): + """ + Set the option to hide gridlines on the screen and the printed page. + + Args: + option: 0 : Don't hide gridlines + 1 : Hide printed gridlines only + 2 : Hide screen and printed gridlines + + Returns: + Nothing. + + """ + if option == 0: + self.print_gridlines = 1 + self.screen_gridlines = 1 + self.print_options_changed = True + elif option == 1: + self.print_gridlines = 0 + self.screen_gridlines = 1 + else: + self.print_gridlines = 0 + self.screen_gridlines = 0 + + def print_row_col_headers(self): + """ + Set the option to print the row and column headers on the printed page. + + Args: + None. + + Returns: + Nothing. + + """ + self.print_headers = True + self.print_options_changed = True + + def hide_row_col_headers(self): + """ + Set the option to hide the row and column headers on the worksheet. + + Args: + None. + + Returns: + Nothing. + + """ + self.row_col_headers = True + + @convert_range_args + def print_area(self, first_row, first_col, last_row, last_col): + """ + Set the print area in the current worksheet. + + Args: + first_row: The first row of the cell range. (zero indexed). + first_col: The first column of the cell range. + last_row: The last row of the cell range. (zero indexed). + last_col: The last column of the cell range. + + Returns: + 0: Success. + -1: Row or column is out of worksheet bounds. + + """ + # Set the print area in the current worksheet. + + # Ignore max print area since it is the same as no area for Excel. + if ( + first_row == 0 + and first_col == 0 + and last_row == self.xls_rowmax - 1 + and last_col == self.xls_colmax - 1 + ): + return -1 + + # Build up the print area range "Sheet1!$A$1:$C$13". + area = self._convert_name_area(first_row, first_col, last_row, last_col) + self.print_area_range = area + + return 0 + + def print_across(self): + """ + Set the order in which pages are printed. + + Args: + None. + + Returns: + Nothing. + + """ + self.page_order = 1 + self.page_setup_changed = True + + def fit_to_pages(self, width, height): + """ + Fit the printed area to a specific number of pages both vertically and + horizontally. + + Args: + width: Number of pages horizontally. + height: Number of pages vertically. + + Returns: + Nothing. + + """ + self.fit_page = 1 + self.fit_width = width + self.fit_height = height + self.page_setup_changed = True + + def set_start_page(self, start_page): + """ + Set the start page number when printing. + + Args: + start_page: Start page number. + + Returns: + Nothing. + + """ + self.page_start = start_page + + def set_print_scale(self, scale): + """ + Set the scale factor for the printed page. + + Args: + scale: Print scale. 10 <= scale <= 400. + + Returns: + Nothing. + + """ + # Confine the scale to Excel's range. + if scale < 10 or scale > 400: + warn(f"Print scale '{scale}' outside range: 10 <= scale <= 400") + return + + # Turn off "fit to page" option when print scale is on. + self.fit_page = 0 + + self.print_scale = int(scale) + self.page_setup_changed = True + + def print_black_and_white(self): + """ + Set the option to print the worksheet in black and white. + + Args: + None. + + Returns: + Nothing. + + """ + self.black_white = True + self.page_setup_changed = True + + def set_h_pagebreaks(self, breaks): + """ + Set the horizontal page breaks on a worksheet. + + Args: + breaks: List of rows where the page breaks should be added. + + Returns: + Nothing. + + """ + self.hbreaks = breaks + + def set_v_pagebreaks(self, breaks): + """ + Set the horizontal page breaks on a worksheet. + + Args: + breaks: List of columns where the page breaks should be added. + + Returns: + Nothing. + + """ + self.vbreaks = breaks + + def set_vba_name(self, name=None): + """ + Set the VBA name for the worksheet. By default this is the + same as the sheet name: i.e., Sheet1 etc. + + Args: + name: The VBA name for the worksheet. + + Returns: + Nothing. + + """ + if name is not None: + self.vba_codename = name + else: + self.vba_codename = "Sheet" + str(self.index + 1) + + def ignore_errors(self, options=None): + """ + Ignore various Excel errors/warnings in a worksheet for user defined + ranges. + + Args: + options: A dict of ignore errors keys with cell range values. + + Returns: + 0: Success. + -1: Incorrect parameter or option. + + """ + if options is None: + return -1 + + # Copy the user defined options so they aren't modified. + options = options.copy() + + # Valid input parameters. + valid_parameters = { + "number_stored_as_text", + "eval_error", + "formula_differs", + "formula_range", + "formula_unlocked", + "empty_cell_reference", + "list_data_validation", + "calculated_column", + "two_digit_text_year", + } + + # Check for valid input parameters. + for param_key in options.keys(): + if param_key not in valid_parameters: + warn(f"Unknown parameter '{param_key}' in ignore_errors()") + return -1 + + self.ignored_errors = options + + return 0 + + ########################################################################### + # + # Private API. + # + ########################################################################### + def _initialize(self, init_data): + self.name = init_data["name"] + self.index = init_data["index"] + self.str_table = init_data["str_table"] + self.worksheet_meta = init_data["worksheet_meta"] + self.constant_memory = init_data["constant_memory"] + self.tmpdir = init_data["tmpdir"] + self.date_1904 = init_data["date_1904"] + self.strings_to_numbers = init_data["strings_to_numbers"] + self.strings_to_formulas = init_data["strings_to_formulas"] + self.strings_to_urls = init_data["strings_to_urls"] + self.nan_inf_to_errors = init_data["nan_inf_to_errors"] + self.default_date_format = init_data["default_date_format"] + self.default_url_format = init_data["default_url_format"] + self.workbook_add_format = init_data["workbook_add_format"] + self.excel2003_style = init_data["excel2003_style"] + self.remove_timezone = init_data["remove_timezone"] + self.max_url_length = init_data["max_url_length"] + self.use_future_functions = init_data["use_future_functions"] + self.embedded_images = init_data["embedded_images"] + + if self.excel2003_style: + self.original_row_height = 12.75 + self.default_row_height = 12.75 + self.default_row_pixels = 17 + self.margin_left = 0.75 + self.margin_right = 0.75 + self.margin_top = 1 + self.margin_bottom = 1 + self.margin_header = 0.5 + self.margin_footer = 0.5 + self.header_footer_aligns = False + + # Open a temp filehandle to store row data in constant_memory mode. + if self.constant_memory: + # This is sub-optimal but we need to create a temp file + # with utf8 encoding in Python < 3. + (fd, filename) = tempfile.mkstemp(dir=self.tmpdir) + os.close(fd) + self.row_data_filename = filename + # pylint: disable=consider-using-with + self.row_data_fh = open(filename, mode="w+", encoding="utf-8") + + # Set as the worksheet filehandle until the file is assembled. + self.fh = self.row_data_fh + + def _assemble_xml_file(self): + # Assemble and write the XML file. + + # Write the XML declaration. + self._xml_declaration() + + # Write the root worksheet element. + self._write_worksheet() + + # Write the worksheet properties. + self._write_sheet_pr() + + # Write the worksheet dimensions. + self._write_dimension() + + # Write the sheet view properties. + self._write_sheet_views() + + # Write the sheet format properties. + self._write_sheet_format_pr() + + # Write the sheet column info. + self._write_cols() + + # Write the worksheet data such as rows columns and cells. + if not self.constant_memory: + self._write_sheet_data() + else: + self._write_optimized_sheet_data() + + # Write the sheetProtection element. + self._write_sheet_protection() + + # Write the protectedRanges element. + self._write_protected_ranges() + + # Write the phoneticPr element. + if self.excel2003_style: + self._write_phonetic_pr() + + # Write the autoFilter element. + self._write_auto_filter() + + # Write the mergeCells element. + self._write_merge_cells() + + # Write the conditional formats. + self._write_conditional_formats() + + # Write the dataValidations element. + self._write_data_validations() + + # Write the hyperlink element. + self._write_hyperlinks() + + # Write the printOptions element. + self._write_print_options() + + # Write the worksheet page_margins. + self._write_page_margins() + + # Write the worksheet page setup. + self._write_page_setup() + + # Write the headerFooter element. + self._write_header_footer() + + # Write the rowBreaks element. + self._write_row_breaks() + + # Write the colBreaks element. + self._write_col_breaks() + + # Write the ignoredErrors element. + self._write_ignored_errors() + + # Write the drawing element. + self._write_drawings() + + # Write the legacyDrawing element. + self._write_legacy_drawing() + + # Write the legacyDrawingHF element. + self._write_legacy_drawing_hf() + + # Write the picture element, for the background. + self._write_picture() + + # Write the tableParts element. + self._write_table_parts() + + # Write the extLst elements. + self._write_ext_list() + + # Close the worksheet tag. + self._xml_end_tag("worksheet") + + # Close the file. + self._xml_close() + + def _check_dimensions(self, row, col, ignore_row=False, ignore_col=False): + # Check that row and col are valid and store the max and min + # values for use in other methods/elements. The ignore_row / + # ignore_col flags is used to indicate that we wish to perform + # the dimension check without storing the value. The ignore + # flags are use by set_row() and data_validate. + + # Check that the row/col are within the worksheet bounds. + if row < 0 or col < 0: + return -1 + if row >= self.xls_rowmax or col >= self.xls_colmax: + return -1 + + # In constant_memory mode we don't change dimensions for rows + # that are already written. + if not ignore_row and not ignore_col and self.constant_memory: + if row < self.previous_row: + return -2 + + if not ignore_row: + if self.dim_rowmin is None or row < self.dim_rowmin: + self.dim_rowmin = row + if self.dim_rowmax is None or row > self.dim_rowmax: + self.dim_rowmax = row + + if not ignore_col: + if self.dim_colmin is None or col < self.dim_colmin: + self.dim_colmin = col + if self.dim_colmax is None or col > self.dim_colmax: + self.dim_colmax = col + + return 0 + + def _convert_date_time(self, dt_obj): + # Convert a datetime object to an Excel serial date and time. + return _datetime_to_excel_datetime(dt_obj, self.date_1904, self.remove_timezone) + + def _convert_name_area(self, row_num_1, col_num_1, row_num_2, col_num_2): + # Convert zero indexed rows and columns to the format required by + # worksheet named ranges, eg, "Sheet1!$A$1:$C$13". + + range1 = "" + range2 = "" + area = "" + row_col_only = 0 + + # Convert to A1 notation. + col_char_1 = xl_col_to_name(col_num_1, 1) + col_char_2 = xl_col_to_name(col_num_2, 1) + row_char_1 = "$" + str(row_num_1 + 1) + row_char_2 = "$" + str(row_num_2 + 1) + + # We need to handle special cases that refer to rows or columns only. + if row_num_1 == 0 and row_num_2 == self.xls_rowmax - 1: + range1 = col_char_1 + range2 = col_char_2 + row_col_only = 1 + elif col_num_1 == 0 and col_num_2 == self.xls_colmax - 1: + range1 = row_char_1 + range2 = row_char_2 + row_col_only = 1 + else: + range1 = col_char_1 + row_char_1 + range2 = col_char_2 + row_char_2 + + # A repeated range is only written once (if it isn't a special case). + if range1 == range2 and not row_col_only: + area = range1 + else: + area = range1 + ":" + range2 + + # Build up the print area range "Sheet1!$A$1:$C$13". + sheetname = quote_sheetname(self.name) + area = sheetname + "!" + area + + return area + + def _sort_pagebreaks(self, breaks): + # This is an internal method used to filter elements of a list of + # pagebreaks used in the _store_hbreak() and _store_vbreak() methods. + # It: + # 1. Removes duplicate entries from the list. + # 2. Sorts the list. + # 3. Removes 0 from the list if present. + if not breaks: + return [] + + breaks_set = set(breaks) + + if 0 in breaks_set: + breaks_set.remove(0) + + breaks_list = list(breaks_set) + breaks_list.sort() + + # The Excel 2007 specification says that the maximum number of page + # breaks is 1026. However, in practice it is actually 1023. + max_num_breaks = 1023 + if len(breaks_list) > max_num_breaks: + breaks_list = breaks_list[:max_num_breaks] + + return breaks_list + + def _extract_filter_tokens(self, expression): + # Extract the tokens from the filter expression. The tokens are mainly + # non-whitespace groups. The only tricky part is to extract string + # tokens that contain whitespace and/or quoted double quotes (Excel's + # escaped quotes). + # + # Examples: 'x < 2000' + # 'x > 2000 and x < 5000' + # 'x = "foo"' + # 'x = "foo bar"' + # 'x = "foo "" bar"' + # + if not expression: + return [] + + token_re = re.compile(r'"(?:[^"]|"")*"|\S+') + tokens = token_re.findall(expression) + + new_tokens = [] + # Remove single leading and trailing quotes and un-escape other quotes. + for token in tokens: + if token.startswith('"'): + token = token[1:] + + if token.endswith('"'): + token = token[:-1] + + token = token.replace('""', '"') + + new_tokens.append(token) + + return new_tokens + + def _parse_filter_expression(self, expression, tokens): + # Converts the tokens of a possibly conditional expression into 1 or 2 + # sub expressions for further parsing. + # + # Examples: + # ('x', '==', 2000) -> exp1 + # ('x', '>', 2000, 'and', 'x', '<', 5000) -> exp1 and exp2 + + if len(tokens) == 7: + # The number of tokens will be either 3 (for 1 expression) + # or 7 (for 2 expressions). + conditional = tokens[3] + + if re.match("(and|&&)", conditional): + conditional = 0 + elif re.match(r"(or|\|\|)", conditional): + conditional = 1 + else: + warn( + f"Token '{conditional}' is not a valid conditional " + f"in filter expression '{expression}'" + ) + + expression_1 = self._parse_filter_tokens(expression, tokens[0:3]) + expression_2 = self._parse_filter_tokens(expression, tokens[4:7]) + return expression_1 + [conditional] + expression_2 + + return self._parse_filter_tokens(expression, tokens) + + def _parse_filter_tokens(self, expression, tokens): + # Parse the 3 tokens of a filter expression and return the operator + # and token. The use of numbers instead of operators is a legacy of + # Spreadsheet::WriteExcel. + operators = { + "==": 2, + "=": 2, + "=~": 2, + "eq": 2, + "!=": 5, + "!~": 5, + "ne": 5, + "<>": 5, + "<": 1, + "<=": 3, + ">": 4, + ">=": 6, + } + + operator = operators.get(tokens[1], None) + token = tokens[2] + + # Special handling of "Top" filter expressions. + if re.match("top|bottom", tokens[0].lower()): + value = int(tokens[1]) + + if value < 1 or value > 500: + warn( + f"The value '{token}' in expression '{expression}' " + f"must be in the range 1 to 500" + ) + + token = token.lower() + + if token not in ("items", "%"): + warn( + f"The type '{token}' in expression '{expression}' " + f"must be either 'items' or '%%'" + ) + + if tokens[0].lower() == "top": + operator = 30 + else: + operator = 32 + + if tokens[2] == "%": + operator += 1 + + token = str(value) + + if not operator and tokens[0]: + warn( + f"Token '{token[0]}' is not a valid operator " + f"in filter expression '{expression}'." + ) + + # Special handling for Blanks/NonBlanks. + if re.match("blanks|nonblanks", token.lower()): + # Only allow Equals or NotEqual in this context. + if operator not in (2, 5): + warn( + f"The operator '{tokens[1]}' in expression '{expression}' " + f"is not valid in relation to Blanks/NonBlanks'." + ) + + token = token.lower() + + # The operator should always be 2 (=) to flag a "simple" equality + # in the binary record. Therefore we convert <> to =. + if token == "blanks": + if operator == 5: + token = " " + else: + if operator == 5: + operator = 2 + token = "blanks" + else: + operator = 5 + token = " " + + # if the string token contains an Excel match character then change the + # operator type to indicate a non "simple" equality. + if operator == 2 and re.search("[*?]", token): + operator = 22 + + return [operator, token] + + def _encode_password(self, password): + # Hash a worksheet password. Based on the algorithm in + # ECMA-376-4:2016, Office Open XML File Formats — Transitional + # Migration Features, Additional attributes for workbookProtection + # element (Part 1, §18.2.29). + digest = 0x0000 + + for char in password[::-1]: + digest = ((digest >> 14) & 0x01) | ((digest << 1) & 0x7FFF) + digest ^= ord(char) + + digest = ((digest >> 14) & 0x01) | ((digest << 1) & 0x7FFF) + digest ^= len(password) + digest ^= 0xCE4B + + return f"{digest:X}" + + def _prepare_image( + self, + index, + image_id, + drawing_id, + width, + height, + name, + image_type, + x_dpi, + y_dpi, + digest, + ): + # Set up images/drawings. + drawing_type = 2 + ( + row, + col, + _, + x_offset, + y_offset, + x_scale, + y_scale, + url, + tip, + anchor, + _, + description, + decorative, + ) = self.images[index] + + width *= x_scale + height *= y_scale + + # Scale by non 96dpi resolutions. + width *= 96.0 / x_dpi + height *= 96.0 / y_dpi + + dimensions = self._position_object_emus( + col, row, x_offset, y_offset, width, height, anchor + ) + # Convert from pixels to emus. + width = int(0.5 + (width * 9525)) + height = int(0.5 + (height * 9525)) + + # Create a Drawing obj to use with worksheet unless one already exists. + if not self.drawing: + drawing = Drawing() + drawing.embedded = 1 + self.drawing = drawing + + self.external_drawing_links.append( + ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml", None] + ) + else: + drawing = self.drawing + + drawing_object = drawing._add_drawing_object() + drawing_object["type"] = drawing_type + drawing_object["dimensions"] = dimensions + drawing_object["width"] = width + drawing_object["height"] = height + drawing_object["description"] = name + drawing_object["shape"] = None + drawing_object["anchor"] = anchor + drawing_object["rel_index"] = 0 + drawing_object["url_rel_index"] = 0 + drawing_object["tip"] = tip + drawing_object["decorative"] = decorative + + if description is not None: + drawing_object["description"] = description + + if url: + target = None + rel_type = "/hyperlink" + target_mode = "External" + + if re.match("(ftp|http)s?://", url): + target = self._escape_url(url) + + if re.match("^mailto:", url): + target = self._escape_url(url) + + if re.match("external:", url): + target = url.replace("external:", "") + target = self._escape_url(target) + # Additional escape not required in worksheet hyperlinks. + target = target.replace("#", "%23") + + if re.match(r"\w:", target) or re.match(r"\\", target): + target = "file:///" + target + else: + target = target.replace("\\", "/") + + if re.match("internal:", url): + target = url.replace("internal:", "#") + target_mode = None + + if target is not None: + if len(target) > self.max_url_length: + warn( + f"Ignoring URL '{url}' with link and/or anchor > " + f"{self.max_url_length} characters since it exceeds " + f"Excel's limit for URLs." + ) + else: + if not self.drawing_rels.get(url): + self.drawing_links.append([rel_type, target, target_mode]) + + drawing_object["url_rel_index"] = self._get_drawing_rel_index(url) + + if not self.drawing_rels.get(digest): + self.drawing_links.append( + ["/image", "../media/image" + str(image_id) + "." + image_type] + ) + + drawing_object["rel_index"] = self._get_drawing_rel_index(digest) + + def _prepare_shape(self, index, drawing_id): + # Set up shapes/drawings. + drawing_type = 3 + + ( + row, + col, + x_offset, + y_offset, + x_scale, + y_scale, + text, + anchor, + options, + description, + decorative, + ) = self.shapes[index] + + width = options.get("width", self.default_col_pixels * 3) + height = options.get("height", self.default_row_pixels * 6) + + width *= x_scale + height *= y_scale + + dimensions = self._position_object_emus( + col, row, x_offset, y_offset, width, height, anchor + ) + + # Convert from pixels to emus. + width = int(0.5 + (width * 9525)) + height = int(0.5 + (height * 9525)) + + # Create a Drawing obj to use with worksheet unless one already exists. + if not self.drawing: + drawing = Drawing() + drawing.embedded = 1 + self.drawing = drawing + + self.external_drawing_links.append( + ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml", None] + ) + else: + drawing = self.drawing + + shape = Shape("rect", "TextBox", options) + shape.text = text + + drawing_object = drawing._add_drawing_object() + drawing_object["type"] = drawing_type + drawing_object["dimensions"] = dimensions + drawing_object["width"] = width + drawing_object["height"] = height + drawing_object["description"] = description + drawing_object["shape"] = shape + drawing_object["anchor"] = anchor + drawing_object["rel_index"] = 0 + drawing_object["url_rel_index"] = 0 + drawing_object["tip"] = options.get("tip") + drawing_object["decorative"] = decorative + + url = options.get("url", None) + if url: + target = None + rel_type = "/hyperlink" + target_mode = "External" + + if re.match("(ftp|http)s?://", url): + target = self._escape_url(url) + + if re.match("^mailto:", url): + target = self._escape_url(url) + + if re.match("external:", url): + target = url.replace("external:", "file:///") + target = self._escape_url(target) + # Additional escape not required in worksheet hyperlinks. + target = target.replace("#", "%23") + + if re.match("internal:", url): + target = url.replace("internal:", "#") + target_mode = None + + if target is not None: + if len(target) > self.max_url_length: + warn( + f"Ignoring URL '{url}' with link and/or anchor > " + f"{self.max_url_length} characters since it exceeds " + f"Excel's limit for URLs." + ) + else: + if not self.drawing_rels.get(url): + self.drawing_links.append([rel_type, target, target_mode]) + + drawing_object["url_rel_index"] = self._get_drawing_rel_index(url) + + def _prepare_header_image( + self, image_id, width, height, name, image_type, position, x_dpi, y_dpi, digest + ): + # Set up an image without a drawing object for header/footer images. + + # Strip the extension from the filename. + name = re.sub(r"\..*$", "", name) + + if not self.vml_drawing_rels.get(digest): + self.vml_drawing_links.append( + ["/image", "../media/image" + str(image_id) + "." + image_type] + ) + + ref_id = self._get_vml_drawing_rel_index(digest) + + self.header_images_list.append( + [width, height, name, position, x_dpi, y_dpi, ref_id] + ) + + def _prepare_background(self, image_id, image_type): + # Set up an image without a drawing object for backgrounds. + self.external_background_links.append( + ["/image", "../media/image" + str(image_id) + "." + image_type] + ) + + def _prepare_chart(self, index, chart_id, drawing_id): + # Set up chart/drawings. + drawing_type = 1 + + ( + row, + col, + chart, + x_offset, + y_offset, + x_scale, + y_scale, + anchor, + description, + decorative, + ) = self.charts[index] + + chart.id = chart_id - 1 + + # Use user specified dimensions, if any. + width = int(0.5 + (chart.width * x_scale)) + height = int(0.5 + (chart.height * y_scale)) + + dimensions = self._position_object_emus( + col, row, x_offset, y_offset, width, height, anchor + ) + + # Set the chart name for the embedded object if it has been specified. + name = chart.chart_name + + # Create a Drawing obj to use with worksheet unless one already exists. + if not self.drawing: + drawing = Drawing() + drawing.embedded = 1 + self.drawing = drawing + + self.external_drawing_links.append( + ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml"] + ) + else: + drawing = self.drawing + + drawing_object = drawing._add_drawing_object() + drawing_object["type"] = drawing_type + drawing_object["dimensions"] = dimensions + drawing_object["width"] = width + drawing_object["height"] = height + drawing_object["name"] = name + drawing_object["shape"] = None + drawing_object["anchor"] = anchor + drawing_object["rel_index"] = self._get_drawing_rel_index() + drawing_object["url_rel_index"] = 0 + drawing_object["tip"] = None + drawing_object["description"] = description + drawing_object["decorative"] = decorative + + self.drawing_links.append( + ["/chart", "../charts/chart" + str(chart_id) + ".xml"] + ) + + def _position_object_emus( + self, col_start, row_start, x1, y1, width, height, anchor + ): + # Calculate the vertices that define the position of a graphical + # object within the worksheet in EMUs. + # + # The vertices are expressed as English Metric Units (EMUs). There are + # 12,700 EMUs per point. Therefore, 12,700 * 3 /4 = 9,525 EMUs per + # pixel + ( + col_start, + row_start, + x1, + y1, + col_end, + row_end, + x2, + y2, + x_abs, + y_abs, + ) = self._position_object_pixels( + col_start, row_start, x1, y1, width, height, anchor + ) + + # Convert the pixel values to EMUs. See above. + x1 = int(0.5 + 9525 * x1) + y1 = int(0.5 + 9525 * y1) + x2 = int(0.5 + 9525 * x2) + y2 = int(0.5 + 9525 * y2) + x_abs = int(0.5 + 9525 * x_abs) + y_abs = int(0.5 + 9525 * y_abs) + + return (col_start, row_start, x1, y1, col_end, row_end, x2, y2, x_abs, y_abs) + + # Calculate the vertices that define the position of a graphical object + # within the worksheet in pixels. + # + # +------------+------------+ + # | A | B | + # +-----+------------+------------+ + # | |(x1,y1) | | + # | 1 |(A1)._______|______ | + # | | | | | + # | | | | | + # +-----+----| OBJECT |-----+ + # | | | | | + # | 2 | |______________. | + # | | | (B2)| + # | | | (x2,y2)| + # +---- +------------+------------+ + # + # Example of an object that covers some of the area from cell A1 to B2. + # + # Based on the width and height of the object we need to calculate 8 vars: + # + # col_start, row_start, col_end, row_end, x1, y1, x2, y2. + # + # We also calculate the absolute x and y position of the top left vertex of + # the object. This is required for images. + # + # The width and height of the cells that the object occupies can be + # variable and have to be taken into account. + # + # The values of col_start and row_start are passed in from the calling + # function. The values of col_end and row_end are calculated by + # subtracting the width and height of the object from the width and + # height of the underlying cells. + # + def _position_object_pixels( + self, col_start, row_start, x1, y1, width, height, anchor + ): + # col_start # Col containing upper left corner of object. + # x1 # Distance to left side of object. + # + # row_start # Row containing top left corner of object. + # y1 # Distance to top of object. + # + # col_end # Col containing lower right corner of object. + # x2 # Distance to right side of object. + # + # row_end # Row containing bottom right corner of object. + # y2 # Distance to bottom of object. + # + # width # Width of object frame. + # height # Height of object frame. + # + # x_abs # Absolute distance to left side of object. + # y_abs # Absolute distance to top side of object. + x_abs = 0 + y_abs = 0 + + # Adjust start column for negative offsets. + # pylint: disable=chained-comparison + while x1 < 0 and col_start > 0: + x1 += self._size_col(col_start - 1) + col_start -= 1 + + # Adjust start row for negative offsets. + while y1 < 0 and row_start > 0: + y1 += self._size_row(row_start - 1) + row_start -= 1 + + # Ensure that the image isn't shifted off the page at top left. + x1 = max(0, x1) + y1 = max(0, y1) + + # Calculate the absolute x offset of the top-left vertex. + if self.col_size_changed: + for col_id in range(col_start): + x_abs += self._size_col(col_id) + else: + # Optimization for when the column widths haven't changed. + x_abs += self.default_col_pixels * col_start + + x_abs += x1 + + # Calculate the absolute y offset of the top-left vertex. + if self.row_size_changed: + for row_id in range(row_start): + y_abs += self._size_row(row_id) + else: + # Optimization for when the row heights haven't changed. + y_abs += self.default_row_pixels * row_start + + y_abs += y1 + + # Adjust start column for offsets that are greater than the col width. + while x1 >= self._size_col(col_start, anchor): + x1 -= self._size_col(col_start) + col_start += 1 + + # Adjust start row for offsets that are greater than the row height. + while y1 >= self._size_row(row_start, anchor): + y1 -= self._size_row(row_start) + row_start += 1 + + # Initialize end cell to the same as the start cell. + col_end = col_start + row_end = row_start + + # Don't offset the image in the cell if the row/col is hidden. + if self._size_col(col_start, anchor) > 0: + width = width + x1 + if self._size_row(row_start, anchor) > 0: + height = height + y1 + + # Subtract the underlying cell widths to find end cell of the object. + while width >= self._size_col(col_end, anchor): + width -= self._size_col(col_end, anchor) + col_end += 1 + + # Subtract the underlying cell heights to find end cell of the object. + while height >= self._size_row(row_end, anchor): + height -= self._size_row(row_end, anchor) + row_end += 1 + + # The end vertices are whatever is left from the width and height. + x2 = width + y2 = height + + return [col_start, row_start, x1, y1, col_end, row_end, x2, y2, x_abs, y_abs] + + def _size_col(self, col, anchor=0): + # Convert the width of a cell from character units to pixels. Excel + # rounds the column width to the nearest pixel. If the width hasn't + # been set by the user we use the default value. A hidden column is + # treated as having a width of zero unless it has the special + # "object_position" of 4 (size with cells). + max_digit_width = 7 # For Calibri 11. + padding = 5 + pixels = 0 + + # Look up the cell value to see if it has been changed. + if col in self.col_info: + width = self.col_info[col][0] + hidden = self.col_info[col][2] + + if width is None: + width = self.default_col_width + + # Convert to pixels. + if hidden and anchor != 4: + pixels = 0 + elif width < 1: + pixels = int(width * (max_digit_width + padding) + 0.5) + else: + pixels = int(width * max_digit_width + 0.5) + padding + else: + pixels = self.default_col_pixels + + return pixels + + def _size_row(self, row, anchor=0): + # Convert the height of a cell from character units to pixels. If the + # height hasn't been set by the user we use the default value. A + # hidden row is treated as having a height of zero unless it has the + # special "object_position" of 4 (size with cells). + pixels = 0 + + # Look up the cell value to see if it has been changed + if row in self.row_sizes: + height = self.row_sizes[row][0] + hidden = self.row_sizes[row][1] + + if hidden and anchor != 4: + pixels = 0 + else: + pixels = int(4.0 / 3.0 * height) + else: + pixels = int(4.0 / 3.0 * self.default_row_height) + + return pixels + + def _pixels_to_width(self, pixels): + # Convert the width of a cell from pixels to character units. + max_digit_width = 7.0 # For Calabri 11. + padding = 5.0 + + if pixels <= 12: + width = pixels / (max_digit_width + padding) + else: + width = (pixels - padding) / max_digit_width + + return width + + def _pixels_to_height(self, pixels): + # Convert the height of a cell from pixels to character units. + return 0.75 * pixels + + def _comment_params(self, row, col, string, options): + # This method handles the additional optional parameters to + # write_comment() as well as calculating the comment object + # position and vertices. + default_width = 128 + default_height = 74 + anchor = 0 + + params = { + "author": None, + "color": "#ffffe1", + "start_cell": None, + "start_col": None, + "start_row": None, + "visible": None, + "width": default_width, + "height": default_height, + "x_offset": None, + "x_scale": 1, + "y_offset": None, + "y_scale": 1, + "font_name": "Tahoma", + "font_size": 8, + "font_family": 2, + } + + # Overwrite the defaults with any user supplied values. Incorrect or + # misspelled parameters are silently ignored. + for key in options.keys(): + params[key] = options[key] + + # Ensure that a width and height have been set. + if not params["width"]: + params["width"] = default_width + if not params["height"]: + params["height"] = default_height + + # Set the comment background color. + params["color"] = _xl_color(params["color"]).lower() + + # Convert from Excel XML style color to XML html style color. + params["color"] = params["color"].replace("ff", "#", 1) + + # Convert a cell reference to a row and column. + if params["start_cell"] is not None: + (start_row, start_col) = xl_cell_to_rowcol(params["start_cell"]) + params["start_row"] = start_row + params["start_col"] = start_col + + # Set the default start cell and offsets for the comment. These are + # generally fixed in relation to the parent cell. However there are + # some edge cases for cells at the, er, edges. + row_max = self.xls_rowmax + col_max = self.xls_colmax + + if params["start_row"] is None: + if row == 0: + params["start_row"] = 0 + elif row == row_max - 3: + params["start_row"] = row_max - 7 + elif row == row_max - 2: + params["start_row"] = row_max - 6 + elif row == row_max - 1: + params["start_row"] = row_max - 5 + else: + params["start_row"] = row - 1 + + if params["y_offset"] is None: + if row == 0: + params["y_offset"] = 2 + elif row == row_max - 3: + params["y_offset"] = 16 + elif row == row_max - 2: + params["y_offset"] = 16 + elif row == row_max - 1: + params["y_offset"] = 14 + else: + params["y_offset"] = 10 + + if params["start_col"] is None: + if col == col_max - 3: + params["start_col"] = col_max - 6 + elif col == col_max - 2: + params["start_col"] = col_max - 5 + elif col == col_max - 1: + params["start_col"] = col_max - 4 + else: + params["start_col"] = col + 1 + + if params["x_offset"] is None: + if col == col_max - 3: + params["x_offset"] = 49 + elif col == col_max - 2: + params["x_offset"] = 49 + elif col == col_max - 1: + params["x_offset"] = 49 + else: + params["x_offset"] = 15 + + # Scale the size of the comment box if required. + if params["x_scale"]: + params["width"] = params["width"] * params["x_scale"] + + if params["y_scale"]: + params["height"] = params["height"] * params["y_scale"] + + # Round the dimensions to the nearest pixel. + params["width"] = int(0.5 + params["width"]) + params["height"] = int(0.5 + params["height"]) + + # Calculate the positions of the comment object. + vertices = self._position_object_pixels( + params["start_col"], + params["start_row"], + params["x_offset"], + params["y_offset"], + params["width"], + params["height"], + anchor, + ) + + # Add the width and height for VML. + vertices.append(params["width"]) + vertices.append(params["height"]) + + return [ + row, + col, + string, + params["author"], + params["visible"], + params["color"], + params["font_name"], + params["font_size"], + params["font_family"], + ] + [vertices] + + def _button_params(self, row, col, options): + # This method handles the parameters passed to insert_button() as well + # as calculating the button object position and vertices. + + default_height = self.default_row_pixels + default_width = self.default_col_pixels + anchor = 0 + + button_number = 1 + len(self.buttons_list) + button = {"row": row, "col": col, "font": {}} + params = {} + + # Overwrite the defaults with any user supplied values. Incorrect or + # misspelled parameters are silently ignored. + for key in options.keys(): + params[key] = options[key] + + # Set the button caption. + caption = params.get("caption") + + # Set a default caption if none was specified by user. + if caption is None: + caption = f"Button {button_number}" + + button["font"]["caption"] = caption + + # Set the macro name. + if params.get("macro"): + button["macro"] = "[0]!" + params["macro"] + else: + button["macro"] = f"[0]!Button{button_number}_Click" + + # Set the alt text for the button. + button["description"] = params.get("description") + + # Ensure that a width and height have been set. + params["width"] = params.get("width", default_width) + params["height"] = params.get("height", default_height) + + # Set the x/y offsets. + params["x_offset"] = params.get("x_offset", 0) + params["y_offset"] = params.get("y_offset", 0) + + # Scale the size of the button if required. + params["width"] = params["width"] * params.get("x_scale", 1) + params["height"] = params["height"] * params.get("y_scale", 1) + + # Round the dimensions to the nearest pixel. + params["width"] = int(0.5 + params["width"]) + params["height"] = int(0.5 + params["height"]) + + params["start_row"] = row + params["start_col"] = col + + # Calculate the positions of the button object. + vertices = self._position_object_pixels( + params["start_col"], + params["start_row"], + params["x_offset"], + params["y_offset"], + params["width"], + params["height"], + anchor, + ) + + # Add the width and height for VML. + vertices.append(params["width"]) + vertices.append(params["height"]) + + button["vertices"] = vertices + + return button + + def _prepare_vml_objects( + self, vml_data_id, vml_shape_id, vml_drawing_id, comment_id + ): + comments = [] + # Sort the comments into row/column order for easier comparison + # testing and set the external links for comments and buttons. + row_nums = sorted(self.comments.keys()) + + for row in row_nums: + col_nums = sorted(self.comments[row].keys()) + + for col in col_nums: + user_options = self.comments[row][col] + params = self._comment_params(*user_options) + self.comments[row][col] = params + + # Set comment visibility if required and not user defined. + if self.comments_visible: + if self.comments[row][col][4] is None: + self.comments[row][col][4] = 1 + + # Set comment author if not already user defined. + if self.comments[row][col][3] is None: + self.comments[row][col][3] = self.comments_author + + comments.append(self.comments[row][col]) + + self.external_vml_links.append( + ["/vmlDrawing", "../drawings/vmlDrawing" + str(vml_drawing_id) + ".vml"] + ) + + if self.has_comments: + self.comments_list = comments + + self.external_comment_links.append( + ["/comments", "../comments" + str(comment_id) + ".xml"] + ) + + count = len(comments) + start_data_id = vml_data_id + + # The VML o:idmap data id contains a comma separated range when there + # is more than one 1024 block of comments, like this: data="1,2". + for i in range(int(count / 1024)): + data_id = start_data_id + i + 1 + vml_data_id = f"{vml_data_id},{data_id}" + + self.vml_data_id = vml_data_id + self.vml_shape_id = vml_shape_id + + return count + + def _prepare_header_vml_objects(self, vml_header_id, vml_drawing_id): + # Set up external linkage for VML header/footer images. + + self.vml_header_id = vml_header_id + + self.external_vml_links.append( + ["/vmlDrawing", "../drawings/vmlDrawing" + str(vml_drawing_id) + ".vml"] + ) + + def _prepare_tables(self, table_id, seen): + # Set the table ids for the worksheet tables. + for table in self.tables: + table["id"] = table_id + + if table.get("name") is None: + # Set a default name. + table["name"] = "Table" + str(table_id) + + # Check for duplicate table names. + name = table["name"].lower() + + if name in seen: + raise DuplicateTableName( + f"Duplicate name '{table['name']}' used in worksheet.add_table()." + ) + + seen[name] = True + + # Store the link used for the rels file. + self.external_table_links.append( + ["/table", "../tables/table" + str(table_id) + ".xml"] + ) + table_id += 1 + + def _table_function_to_formula(self, function, col_name): + # Convert a table total function to a worksheet formula. + formula = "" + + # Escape special characters, as required by Excel. + col_name = col_name.replace("'", "''") + col_name = col_name.replace("#", "'#") + col_name = col_name.replace("]", "']") + col_name = col_name.replace("[", "'[") + + subtotals = { + "average": 101, + "countNums": 102, + "count": 103, + "max": 104, + "min": 105, + "stdDev": 107, + "sum": 109, + "var": 110, + } + + if function in subtotals: + func_num = subtotals[function] + formula = f"SUBTOTAL({func_num},[{col_name}])" + else: + warn(f"Unsupported function '{function}' in add_table()") + + return formula + + def _set_spark_color(self, sparkline, options, user_color): + # Set the sparkline color. + if user_color not in options: + return + + sparkline[user_color] = {"rgb": _xl_color(options[user_color])} + + def _get_range_data(self, row_start, col_start, row_end, col_end): + # Returns a range of data from the worksheet _table to be used in + # chart cached data. Strings are returned as SST ids and decoded + # in the workbook. Return None for data that doesn't exist since + # Excel can chart series with data missing. + + if self.constant_memory: + return () + + data = [] + + # Iterate through the table data. + for row_num in range(row_start, row_end + 1): + # Store None if row doesn't exist. + if row_num not in self.table: + data.append(None) + continue + + for col_num in range(col_start, col_end + 1): + if col_num in self.table[row_num]: + cell = self.table[row_num][col_num] + + cell_type = cell.__class__.__name__ + + if cell_type in ("Number", "Datetime"): + # Return a number with Excel's precision. + data.append(f"{cell.number:.16g}") + + elif cell_type == "String": + # Return a string from it's shared string index. + index = cell.string + string = self.str_table._get_shared_string(index) + + data.append(string) + + elif cell_type in ("Formula", "ArrayFormula"): + # Return the formula value. + value = cell.value + + if value is None: + value = 0 + + data.append(value) + + elif cell_type == "Blank": + # Return a empty cell. + data.append("") + else: + # Store None if column doesn't exist. + data.append(None) + + return data + + def _csv_join(self, *items): + # Create a csv string for use with data validation formulas and lists. + + # Convert non string types to string. + items = [str(item) if not isinstance(item, str) else item for item in items] + + return ",".join(items) + + def _escape_url(self, url): + # Don't escape URL if it looks already escaped. + if re.search("%[0-9a-fA-F]{2}", url): + return url + + # Can't use url.quote() here because it doesn't match Excel. + url = url.replace("%", "%25") + url = url.replace('"', "%22") + url = url.replace(" ", "%20") + url = url.replace("<", "%3c") + url = url.replace(">", "%3e") + url = url.replace("[", "%5b") + url = url.replace("]", "%5d") + url = url.replace("^", "%5e") + url = url.replace("`", "%60") + url = url.replace("{", "%7b") + url = url.replace("}", "%7d") + + return url + + def _get_drawing_rel_index(self, target=None): + # Get the index used to address a drawing rel link. + if target is None: + self.drawing_rels_id += 1 + return self.drawing_rels_id + + if self.drawing_rels.get(target): + return self.drawing_rels[target] + + self.drawing_rels_id += 1 + self.drawing_rels[target] = self.drawing_rels_id + return self.drawing_rels_id + + def _get_vml_drawing_rel_index(self, target=None): + # Get the index used to address a vml drawing rel link. + if self.vml_drawing_rels.get(target): + return self.vml_drawing_rels[target] + + self.vml_drawing_rels_id += 1 + self.vml_drawing_rels[target] = self.vml_drawing_rels_id + return self.vml_drawing_rels_id + + ########################################################################### + # + # The following font methods are, more or less, duplicated from the + # Styles class. Not the cleanest version of reuse but works for now. + # + ########################################################################### + def _write_font(self, xf_format): + # Write the <font> element. + xml_writer = self.rstring + + xml_writer._xml_start_tag("rPr") + + # Handle the main font properties. + if xf_format.bold: + xml_writer._xml_empty_tag("b") + if xf_format.italic: + xml_writer._xml_empty_tag("i") + if xf_format.font_strikeout: + xml_writer._xml_empty_tag("strike") + if xf_format.font_outline: + xml_writer._xml_empty_tag("outline") + if xf_format.font_shadow: + xml_writer._xml_empty_tag("shadow") + + # Handle the underline variants. + if xf_format.underline: + self._write_underline(xf_format.underline) + + # Handle super/subscript. + if xf_format.font_script == 1: + self._write_vert_align("superscript") + if xf_format.font_script == 2: + self._write_vert_align("subscript") + + # Write the font size + xml_writer._xml_empty_tag("sz", [("val", xf_format.font_size)]) + + # Handle colors. + if xf_format.theme == -1: + # Ignore for excel2003_style. + pass + elif xf_format.theme: + self._write_color("theme", xf_format.theme) + elif xf_format.color_indexed: + self._write_color("indexed", xf_format.color_indexed) + elif xf_format.font_color: + color = self._get_palette_color(xf_format.font_color) + self._write_rstring_color("rgb", color) + else: + self._write_rstring_color("theme", 1) + + # Write some other font properties related to font families. + xml_writer._xml_empty_tag("rFont", [("val", xf_format.font_name)]) + xml_writer._xml_empty_tag("family", [("val", xf_format.font_family)]) + + if xf_format.font_name == "Calibri" and not xf_format.hyperlink: + xml_writer._xml_empty_tag("scheme", [("val", xf_format.font_scheme)]) + + xml_writer._xml_end_tag("rPr") + + def _write_underline(self, underline): + # Write the underline font element. + attributes = [] + + # Handle the underline variants. + if underline == 2: + attributes = [("val", "double")] + elif underline == 33: + attributes = [("val", "singleAccounting")] + elif underline == 34: + attributes = [("val", "doubleAccounting")] + + self.rstring._xml_empty_tag("u", attributes) + + def _write_vert_align(self, val): + # Write the <vertAlign> font sub-element. + attributes = [("val", val)] + + self.rstring._xml_empty_tag("vertAlign", attributes) + + def _write_rstring_color(self, name, value): + # Write the <color> element. + attributes = [(name, value)] + + self.rstring._xml_empty_tag("color", attributes) + + def _get_palette_color(self, color): + # Convert the RGB color. + if color[0] == "#": + color = color[1:] + + return "FF" + color.upper() + + def _opt_close(self): + # Close the row data filehandle in constant_memory mode. + if not self.row_data_fh_closed: + self.row_data_fh.close() + self.row_data_fh_closed = True + + def _opt_reopen(self): + # Reopen the row data filehandle in constant_memory mode. + if self.row_data_fh_closed: + filename = self.row_data_filename + # pylint: disable=consider-using-with + self.row_data_fh = open(filename, mode="a+", encoding="utf-8") + self.row_data_fh_closed = False + self.fh = self.row_data_fh + + def _set_icon_props(self, total_icons, user_props=None): + # Set the sub-properties for icons. + props = [] + + # Set the defaults. + for _ in range(total_icons): + props.append({"criteria": False, "value": 0, "type": "percent"}) + + # Set the default icon values based on the number of icons. + if total_icons == 3: + props[0]["value"] = 67 + props[1]["value"] = 33 + + if total_icons == 4: + props[0]["value"] = 75 + props[1]["value"] = 50 + props[2]["value"] = 25 + + if total_icons == 5: + props[0]["value"] = 80 + props[1]["value"] = 60 + props[2]["value"] = 40 + props[3]["value"] = 20 + + # Overwrite default properties with user defined properties. + if user_props: + # Ensure we don't set user properties for lowest icon. + max_data = len(user_props) + if max_data >= total_icons: + max_data = total_icons - 1 + + for i in range(max_data): + # Set the user defined 'value' property. + if user_props[i].get("value") is not None: + props[i]["value"] = user_props[i]["value"] + + # Remove the formula '=' sign if it exists. + tmp = props[i]["value"] + if isinstance(tmp, str) and tmp.startswith("="): + props[i]["value"] = tmp.lstrip("=") + + # Set the user defined 'type' property. + if user_props[i].get("type"): + valid_types = ("percent", "percentile", "number", "formula") + + if user_props[i]["type"] not in valid_types: + warn( + f"Unknown icon property type '{user_props[i]['type']}' " + f"for sub-property 'type' in conditional_format()." + ) + else: + props[i]["type"] = user_props[i]["type"] + + if props[i]["type"] == "number": + props[i]["type"] = "num" + + # Set the user defined 'criteria' property. + criteria = user_props[i].get("criteria") + if criteria and criteria == ">": + props[i]["criteria"] = True + + return props + + ########################################################################### + # + # XML methods. + # + ########################################################################### + + def _write_worksheet(self): + # Write the <worksheet> element. This is the root element. + + schema = "http://schemas.openxmlformats.org/" + xmlns = schema + "spreadsheetml/2006/main" + xmlns_r = schema + "officeDocument/2006/relationships" + xmlns_mc = schema + "markup-compatibility/2006" + ms_schema = "http://schemas.microsoft.com/" + xmlns_x14ac = ms_schema + "office/spreadsheetml/2009/9/ac" + + attributes = [("xmlns", xmlns), ("xmlns:r", xmlns_r)] + + # Add some extra attributes for Excel 2010. Mainly for sparklines. + if self.excel_version == 2010: + attributes.append(("xmlns:mc", xmlns_mc)) + attributes.append(("xmlns:x14ac", xmlns_x14ac)) + attributes.append(("mc:Ignorable", "x14ac")) + + self._xml_start_tag("worksheet", attributes) + + def _write_dimension(self): + # Write the <dimension> element. This specifies the range of + # cells in the worksheet. As a special case, empty + # spreadsheets use 'A1' as a range. + + if self.dim_rowmin is None and self.dim_colmin is None: + # If the min dimensions are not defined then no dimensions + # have been set and we use the default 'A1'. + ref = "A1" + + elif self.dim_rowmin is None and self.dim_colmin is not None: + # If the row dimensions aren't set but the column + # dimensions are set then they have been changed via + # set_column(). + + if self.dim_colmin == self.dim_colmax: + # The dimensions are a single cell and not a range. + ref = xl_rowcol_to_cell(0, self.dim_colmin) + else: + # The dimensions are a cell range. + cell_1 = xl_rowcol_to_cell(0, self.dim_colmin) + cell_2 = xl_rowcol_to_cell(0, self.dim_colmax) + ref = cell_1 + ":" + cell_2 + + elif self.dim_rowmin == self.dim_rowmax and self.dim_colmin == self.dim_colmax: + # The dimensions are a single cell and not a range. + ref = xl_rowcol_to_cell(self.dim_rowmin, self.dim_colmin) + else: + # The dimensions are a cell range. + cell_1 = xl_rowcol_to_cell(self.dim_rowmin, self.dim_colmin) + cell_2 = xl_rowcol_to_cell(self.dim_rowmax, self.dim_colmax) + ref = cell_1 + ":" + cell_2 + + self._xml_empty_tag("dimension", [("ref", ref)]) + + def _write_sheet_views(self): + # Write the <sheetViews> element. + self._xml_start_tag("sheetViews") + + # Write the sheetView element. + self._write_sheet_view() + + self._xml_end_tag("sheetViews") + + def _write_sheet_view(self): + # Write the <sheetViews> element. + attributes = [] + + # Hide screen gridlines if required. + if not self.screen_gridlines: + attributes.append(("showGridLines", 0)) + + # Hide screen row/column headers. + if self.row_col_headers: + attributes.append(("showRowColHeaders", 0)) + + # Hide zeroes in cells. + if not self.show_zeros: + attributes.append(("showZeros", 0)) + + # Display worksheet right to left for Hebrew, Arabic and others. + if self.is_right_to_left: + attributes.append(("rightToLeft", 1)) + + # Show that the sheet tab is selected. + if self.selected: + attributes.append(("tabSelected", 1)) + + # Turn outlines off. Also required in the outlinePr element. + if not self.outline_on: + attributes.append(("showOutlineSymbols", 0)) + + # Set the page view/layout mode if required. + if self.page_view == 1: + attributes.append(("view", "pageLayout")) + elif self.page_view == 2: + attributes.append(("view", "pageBreakPreview")) + + # Set the first visible cell. + if self.top_left_cell != "": + attributes.append(("topLeftCell", self.top_left_cell)) + + # Set the zoom level. + if self.zoom != 100: + attributes.append(("zoomScale", self.zoom)) + + if self.page_view == 0 and self.zoom_scale_normal: + attributes.append(("zoomScaleNormal", self.zoom)) + if self.page_view == 1: + attributes.append(("zoomScalePageLayoutView", self.zoom)) + if self.page_view == 2: + attributes.append(("zoomScaleSheetLayoutView", self.zoom)) + + attributes.append(("workbookViewId", 0)) + + if self.panes or self.selections: + self._xml_start_tag("sheetView", attributes) + self._write_panes() + self._write_selections() + self._xml_end_tag("sheetView") + else: + self._xml_empty_tag("sheetView", attributes) + + def _write_sheet_format_pr(self): + # Write the <sheetFormatPr> element. + default_row_height = self.default_row_height + row_level = self.outline_row_level + col_level = self.outline_col_level + + attributes = [("defaultRowHeight", default_row_height)] + + if self.default_row_height != self.original_row_height: + attributes.append(("customHeight", 1)) + + if self.default_row_zeroed: + attributes.append(("zeroHeight", 1)) + + if row_level: + attributes.append(("outlineLevelRow", row_level)) + if col_level: + attributes.append(("outlineLevelCol", col_level)) + + if self.excel_version == 2010: + attributes.append(("x14ac:dyDescent", "0.25")) + + self._xml_empty_tag("sheetFormatPr", attributes) + + def _write_cols(self): + # Write the <cols> element and <col> sub elements. + + # Exit unless some column have been formatted. + if not self.col_info: + return + + self._xml_start_tag("cols") + + # Use the first element of the column information structures to set + # the initial/previous properties. + first_col = (sorted(self.col_info.keys()))[0] + last_col = first_col + prev_col_options = self.col_info[first_col] + del self.col_info[first_col] + deleted_col = first_col + deleted_col_options = prev_col_options + + for col in sorted(self.col_info.keys()): + col_options = self.col_info[col] + # Check if the column number is contiguous with the previous + # column and if the properties are the same. + if col == last_col + 1 and col_options == prev_col_options: + last_col = col + else: + # If not contiguous/equal then we write out the current range + # of columns and start again. + self._write_col_info(first_col, last_col, prev_col_options) + first_col = col + last_col = first_col + prev_col_options = col_options + + # We will exit the previous loop with one unhandled column range. + self._write_col_info(first_col, last_col, prev_col_options) + + # Put back the deleted first column information structure. + self.col_info[deleted_col] = deleted_col_options + + self._xml_end_tag("cols") + + def _write_col_info(self, col_min, col_max, col_info): + # Write the <col> element. + (width, cell_format, hidden, level, collapsed, autofit) = col_info + + custom_width = 1 + xf_index = 0 + + # Get the cell_format index. + if cell_format: + xf_index = cell_format._get_xf_index() + + # Set the Excel default column width. + if width is None: + if not hidden: + width = 8.43 + custom_width = 0 + else: + width = 0 + elif width == 8.43: + # Width is defined but same as default. + custom_width = 0 + + # Convert column width from user units to character width. + if width > 0: + # For Calabri 11. + max_digit_width = 7 + padding = 5 + + if width < 1: + width = ( + int( + (int(width * (max_digit_width + padding) + 0.5)) + / float(max_digit_width) + * 256.0 + ) + / 256.0 + ) + else: + width = ( + int( + (int(width * max_digit_width + 0.5) + padding) + / float(max_digit_width) + * 256.0 + ) + / 256.0 + ) + + attributes = [ + ("min", col_min + 1), + ("max", col_max + 1), + ("width", f"{width:.16g}"), + ] + + if xf_index: + attributes.append(("style", xf_index)) + if hidden: + attributes.append(("hidden", "1")) + if autofit: + attributes.append(("bestFit", "1")) + if custom_width: + attributes.append(("customWidth", "1")) + if level: + attributes.append(("outlineLevel", level)) + if collapsed: + attributes.append(("collapsed", "1")) + + self._xml_empty_tag("col", attributes) + + def _write_sheet_data(self): + # Write the <sheetData> element. + if self.dim_rowmin is None: + # If the dimensions aren't defined there is no data to write. + self._xml_empty_tag("sheetData") + else: + self._xml_start_tag("sheetData") + self._write_rows() + self._xml_end_tag("sheetData") + + def _write_optimized_sheet_data(self): + # Write the <sheetData> element when constant_memory is on. In this + # case we read the data stored in the temp file and rewrite it to the + # XML sheet file. + if self.dim_rowmin is None: + # If the dimensions aren't defined then there is no data to write. + self._xml_empty_tag("sheetData") + else: + self._xml_start_tag("sheetData") + + # Rewind the filehandle that was used for temp row data. + buff_size = 65536 + self.row_data_fh.seek(0) + data = self.row_data_fh.read(buff_size) + + while data: + self.fh.write(data) + data = self.row_data_fh.read(buff_size) + + self.row_data_fh.close() + os.unlink(self.row_data_filename) + + self._xml_end_tag("sheetData") + + def _write_page_margins(self): + # Write the <pageMargins> element. + attributes = [ + ("left", self.margin_left), + ("right", self.margin_right), + ("top", self.margin_top), + ("bottom", self.margin_bottom), + ("header", self.margin_header), + ("footer", self.margin_footer), + ] + + self._xml_empty_tag("pageMargins", attributes) + + def _write_page_setup(self): + # Write the <pageSetup> element. + # + # The following is an example taken from Excel. + # + # <pageSetup + # paperSize="9" + # scale="110" + # fitToWidth="2" + # fitToHeight="2" + # pageOrder="overThenDown" + # orientation="portrait" + # blackAndWhite="1" + # draft="1" + # horizontalDpi="200" + # verticalDpi="200" + # r:id="rId1" + # /> + # + attributes = [] + + # Skip this element if no page setup has changed. + if not self.page_setup_changed: + return + + # Set paper size. + if self.paper_size: + attributes.append(("paperSize", self.paper_size)) + + # Set the print_scale. + if self.print_scale != 100: + attributes.append(("scale", self.print_scale)) + + # Set the "Fit to page" properties. + if self.fit_page and self.fit_width != 1: + attributes.append(("fitToWidth", self.fit_width)) + + if self.fit_page and self.fit_height != 1: + attributes.append(("fitToHeight", self.fit_height)) + + # Set the page print direction. + if self.page_order: + attributes.append(("pageOrder", "overThenDown")) + + # Set start page for printing. + if self.page_start > 1: + attributes.append(("firstPageNumber", self.page_start)) + + # Set page orientation. + if self.orientation: + attributes.append(("orientation", "portrait")) + else: + attributes.append(("orientation", "landscape")) + + # Set the print in black and white option. + if self.black_white: + attributes.append(("blackAndWhite", "1")) + + # Set start page for printing. + if self.page_start != 0: + attributes.append(("useFirstPageNumber", "1")) + + # Set the DPI. Mainly only for testing. + if self.is_chartsheet: + if self.horizontal_dpi: + attributes.append(("horizontalDpi", self.horizontal_dpi)) + + if self.vertical_dpi: + attributes.append(("verticalDpi", self.vertical_dpi)) + else: + if self.vertical_dpi: + attributes.append(("verticalDpi", self.vertical_dpi)) + + if self.horizontal_dpi: + attributes.append(("horizontalDpi", self.horizontal_dpi)) + + self._xml_empty_tag("pageSetup", attributes) + + def _write_print_options(self): + # Write the <printOptions> element. + attributes = [] + + if not self.print_options_changed: + return + + # Set horizontal centering. + if self.hcenter: + attributes.append(("horizontalCentered", 1)) + + # Set vertical centering. + if self.vcenter: + attributes.append(("verticalCentered", 1)) + + # Enable row and column headers. + if self.print_headers: + attributes.append(("headings", 1)) + + # Set printed gridlines. + if self.print_gridlines: + attributes.append(("gridLines", 1)) + + self._xml_empty_tag("printOptions", attributes) + + def _write_header_footer(self): + # Write the <headerFooter> element. + attributes = [] + + if not self.header_footer_scales: + attributes.append(("scaleWithDoc", 0)) + + if not self.header_footer_aligns: + attributes.append(("alignWithMargins", 0)) + + if self.header_footer_changed: + self._xml_start_tag("headerFooter", attributes) + if self.header: + self._write_odd_header() + if self.footer: + self._write_odd_footer() + self._xml_end_tag("headerFooter") + elif self.excel2003_style: + self._xml_empty_tag("headerFooter", attributes) + + def _write_odd_header(self): + # Write the <headerFooter> element. + self._xml_data_element("oddHeader", self.header) + + def _write_odd_footer(self): + # Write the <headerFooter> element. + self._xml_data_element("oddFooter", self.footer) + + def _write_rows(self): + # Write out the worksheet data as a series of rows and cells. + self._calculate_spans() + + for row_num in range(self.dim_rowmin, self.dim_rowmax + 1): + if ( + row_num in self.set_rows + or row_num in self.comments + or self.table[row_num] + ): + # Only process rows with formatting, cell data and/or comments. + + span_index = int(row_num / 16) + + if span_index in self.row_spans: + span = self.row_spans[span_index] + else: + span = None + + if self.table[row_num]: + # Write the cells if the row contains data. + if row_num not in self.set_rows: + self._write_row(row_num, span) + else: + self._write_row(row_num, span, self.set_rows[row_num]) + + for col_num in range(self.dim_colmin, self.dim_colmax + 1): + if col_num in self.table[row_num]: + col_ref = self.table[row_num][col_num] + self._write_cell(row_num, col_num, col_ref) + + self._xml_end_tag("row") + + elif row_num in self.comments: + # Row with comments in cells. + self._write_empty_row(row_num, span, self.set_rows[row_num]) + else: + # Blank row with attributes only. + self._write_empty_row(row_num, span, self.set_rows[row_num]) + + def _write_single_row(self, current_row_num=0): + # Write out the worksheet data as a single row with cells. + # This method is used when constant_memory is on. A single + # row is written and the data table is reset. That way only + # one row of data is kept in memory at any one time. We don't + # write span data in the optimized case since it is optional. + + # Set the new previous row as the current row. + row_num = self.previous_row + self.previous_row = current_row_num + + if row_num in self.set_rows or row_num in self.comments or self.table[row_num]: + # Only process rows with formatting, cell data and/or comments. + + # No span data in optimized mode. + span = None + + if self.table[row_num]: + # Write the cells if the row contains data. + if row_num not in self.set_rows: + self._write_row(row_num, span) + else: + self._write_row(row_num, span, self.set_rows[row_num]) + + for col_num in range(self.dim_colmin, self.dim_colmax + 1): + if col_num in self.table[row_num]: + col_ref = self.table[row_num][col_num] + self._write_cell(row_num, col_num, col_ref) + + self._xml_end_tag("row") + else: + # Row attributes or comments only. + self._write_empty_row(row_num, span, self.set_rows[row_num]) + + # Reset table. + self.table.clear() + + def _calculate_spans(self): + # Calculate the "spans" attribute of the <row> tag. This is an + # XLSX optimization and isn't strictly required. However, it + # makes comparing files easier. The span is the same for each + # block of 16 rows. + spans = {} + span_min = None + span_max = None + + for row_num in range(self.dim_rowmin, self.dim_rowmax + 1): + if row_num in self.table: + # Calculate spans for cell data. + for col_num in range(self.dim_colmin, self.dim_colmax + 1): + if col_num in self.table[row_num]: + if span_min is None: + span_min = col_num + span_max = col_num + else: + span_min = min(span_min, col_num) + span_max = max(span_max, col_num) + + if row_num in self.comments: + # Calculate spans for comments. + for col_num in range(self.dim_colmin, self.dim_colmax + 1): + if row_num in self.comments and col_num in self.comments[row_num]: + if span_min is None: + span_min = col_num + span_max = col_num + else: + span_min = min(span_min, col_num) + span_max = max(span_max, col_num) + + if ((row_num + 1) % 16 == 0) or row_num == self.dim_rowmax: + span_index = int(row_num / 16) + + if span_min is not None: + span_min += 1 + span_max += 1 + spans[span_index] = f"{span_min}:{span_max}" + span_min = None + + self.row_spans = spans + + def _write_row(self, row, spans, properties=None, empty_row=False): + # Write the <row> element. + xf_index = 0 + + if properties: + height, cell_format, hidden, level, collapsed = properties + else: + height, cell_format, hidden, level, collapsed = None, None, 0, 0, 0 + + if height is None: + height = self.default_row_height + + attributes = [("r", row + 1)] + + # Get the cell_format index. + if cell_format: + xf_index = cell_format._get_xf_index() + + # Add row attributes where applicable. + if spans: + attributes.append(("spans", spans)) + + if xf_index: + attributes.append(("s", xf_index)) + + if cell_format: + attributes.append(("customFormat", 1)) + + if height != self.original_row_height or ( + height == self.original_row_height and height != self.default_row_height + ): + attributes.append(("ht", f"{height:g}")) + + if hidden: + attributes.append(("hidden", 1)) + + if height != self.original_row_height or ( + height == self.original_row_height and height != self.default_row_height + ): + attributes.append(("customHeight", 1)) + + if level: + attributes.append(("outlineLevel", level)) + + if collapsed: + attributes.append(("collapsed", 1)) + + if self.excel_version == 2010: + attributes.append(("x14ac:dyDescent", "0.25")) + + if empty_row: + self._xml_empty_tag_unencoded("row", attributes) + else: + self._xml_start_tag_unencoded("row", attributes) + + def _write_empty_row(self, row, spans, properties=None): + # Write and empty <row> element. + self._write_row(row, spans, properties, empty_row=True) + + def _write_cell(self, row, col, cell): + # Write the <cell> element. + # Note. This is the innermost loop so efficiency is important. + + cell_range = xl_rowcol_to_cell_fast(row, col) + attributes = [("r", cell_range)] + + if cell.format: + # Add the cell format index. + xf_index = cell.format._get_xf_index() + attributes.append(("s", xf_index)) + elif row in self.set_rows and self.set_rows[row][1]: + # Add the row format. + row_xf = self.set_rows[row][1] + attributes.append(("s", row_xf._get_xf_index())) + elif col in self.col_info: + # Add the column format. + col_xf = self.col_info[col][1] + if col_xf is not None: + attributes.append(("s", col_xf._get_xf_index())) + + type_cell_name = cell.__class__.__name__ + + # Write the various cell types. + if type_cell_name in ("Number", "Datetime"): + # Write a number. + self._xml_number_element(cell.number, attributes) + + elif type_cell_name in ("String", "RichString"): + # Write a string. + string = cell.string + + if not self.constant_memory: + # Write a shared string. + self._xml_string_element(string, attributes) + else: + # Write an optimized in-line string. + + # Convert control character to a _xHHHH_ escape. + string = self._escape_control_characters(string) + + # Write any rich strings without further tags. + if string.startswith("<r>") and string.endswith("</r>"): + self._xml_rich_inline_string(string, attributes) + else: + # Add attribute to preserve leading or trailing whitespace. + preserve = _preserve_whitespace(string) + self._xml_inline_string(string, preserve, attributes) + + elif type_cell_name == "Formula": + # Write a formula. First check the formula value type. + value = cell.value + if isinstance(cell.value, bool): + attributes.append(("t", "b")) + if cell.value: + value = 1 + else: + value = 0 + + elif isinstance(cell.value, str): + error_codes = ( + "#DIV/0!", + "#N/A", + "#NAME?", + "#NULL!", + "#NUM!", + "#REF!", + "#VALUE!", + ) + + if cell.value == "": + # Allow blank to force recalc in some third party apps. + pass + elif cell.value in error_codes: + attributes.append(("t", "e")) + else: + attributes.append(("t", "str")) + + self._xml_formula_element(cell.formula, value, attributes) + + elif type_cell_name == "ArrayFormula": + # Write a array formula. + + if cell.atype == "dynamic": + attributes.append(("cm", 1)) + + # First check if the formula value is a string. + try: + float(cell.value) + except ValueError: + attributes.append(("t", "str")) + + # Write an array formula. + self._xml_start_tag("c", attributes) + + self._write_cell_array_formula(cell.formula, cell.range) + self._write_cell_value(cell.value) + self._xml_end_tag("c") + + elif type_cell_name == "Blank": + # Write a empty cell. + self._xml_empty_tag("c", attributes) + + elif type_cell_name == "Boolean": + # Write a boolean cell. + attributes.append(("t", "b")) + self._xml_start_tag("c", attributes) + self._write_cell_value(cell.boolean) + self._xml_end_tag("c") + + elif type_cell_name == "Error": + # Write a boolean cell. + attributes.append(("t", "e")) + attributes.append(("vm", cell.value)) + self._xml_start_tag("c", attributes) + self._write_cell_value(cell.error) + self._xml_end_tag("c") + + def _write_cell_value(self, value): + # Write the cell value <v> element. + if value is None: + value = "" + + self._xml_data_element("v", value) + + def _write_cell_array_formula(self, formula, cell_range): + # Write the cell array formula <f> element. + attributes = [("t", "array"), ("ref", cell_range)] + + self._xml_data_element("f", formula, attributes) + + def _write_sheet_pr(self): + # Write the <sheetPr> element for Sheet level properties. + attributes = [] + + if ( + not self.fit_page + and not self.filter_on + and not self.tab_color + and not self.outline_changed + and not self.vba_codename + ): + return + + if self.vba_codename: + attributes.append(("codeName", self.vba_codename)) + + if self.filter_on: + attributes.append(("filterMode", 1)) + + if self.fit_page or self.tab_color or self.outline_changed: + self._xml_start_tag("sheetPr", attributes) + self._write_tab_color() + self._write_outline_pr() + self._write_page_set_up_pr() + self._xml_end_tag("sheetPr") + else: + self._xml_empty_tag("sheetPr", attributes) + + def _write_page_set_up_pr(self): + # Write the <pageSetUpPr> element. + if not self.fit_page: + return + + attributes = [("fitToPage", 1)] + self._xml_empty_tag("pageSetUpPr", attributes) + + def _write_tab_color(self): + # Write the <tabColor> element. + color = self.tab_color + + if not color: + return + + attributes = [("rgb", color)] + + self._xml_empty_tag("tabColor", attributes) + + def _write_outline_pr(self): + # Write the <outlinePr> element. + attributes = [] + + if not self.outline_changed: + return + + if self.outline_style: + attributes.append(("applyStyles", 1)) + if not self.outline_below: + attributes.append(("summaryBelow", 0)) + if not self.outline_right: + attributes.append(("summaryRight", 0)) + if not self.outline_on: + attributes.append(("showOutlineSymbols", 0)) + + self._xml_empty_tag("outlinePr", attributes) + + def _write_row_breaks(self): + # Write the <rowBreaks> element. + page_breaks = self._sort_pagebreaks(self.hbreaks) + + if not page_breaks: + return + + count = len(page_breaks) + + attributes = [ + ("count", count), + ("manualBreakCount", count), + ] + + self._xml_start_tag("rowBreaks", attributes) + + for row_num in page_breaks: + self._write_brk(row_num, 16383) + + self._xml_end_tag("rowBreaks") + + def _write_col_breaks(self): + # Write the <colBreaks> element. + page_breaks = self._sort_pagebreaks(self.vbreaks) + + if not page_breaks: + return + + count = len(page_breaks) + + attributes = [ + ("count", count), + ("manualBreakCount", count), + ] + + self._xml_start_tag("colBreaks", attributes) + + for col_num in page_breaks: + self._write_brk(col_num, 1048575) + + self._xml_end_tag("colBreaks") + + def _write_brk(self, brk_id, brk_max): + # Write the <brk> element. + attributes = [("id", brk_id), ("max", brk_max), ("man", 1)] + + self._xml_empty_tag("brk", attributes) + + def _write_merge_cells(self): + # Write the <mergeCells> element. + merged_cells = self.merge + count = len(merged_cells) + + if not count: + return + + attributes = [("count", count)] + + self._xml_start_tag("mergeCells", attributes) + + for merged_range in merged_cells: + # Write the mergeCell element. + self._write_merge_cell(merged_range) + + self._xml_end_tag("mergeCells") + + def _write_merge_cell(self, merged_range): + # Write the <mergeCell> element. + (row_min, col_min, row_max, col_max) = merged_range + + # Convert the merge dimensions to a cell range. + cell_1 = xl_rowcol_to_cell(row_min, col_min) + cell_2 = xl_rowcol_to_cell(row_max, col_max) + ref = cell_1 + ":" + cell_2 + + attributes = [("ref", ref)] + + self._xml_empty_tag("mergeCell", attributes) + + def _write_hyperlinks(self): + # Process any stored hyperlinks in row/col order and write the + # <hyperlinks> element. The attributes are different for internal + # and external links. + hlink_refs = [] + display = None + + # Sort the hyperlinks into row order. + row_nums = sorted(self.hyperlinks.keys()) + + # Exit if there are no hyperlinks to process. + if not row_nums: + return + + # Iterate over the rows. + for row_num in row_nums: + # Sort the hyperlinks into column order. + col_nums = sorted(self.hyperlinks[row_num].keys()) + + # Iterate over the columns. + for col_num in col_nums: + # Get the link data for this cell. + link = self.hyperlinks[row_num][col_num] + link_type = link["link_type"] + + # If the cell isn't a string then we have to add the url as + # the string to display. + if self.table and self.table[row_num] and self.table[row_num][col_num]: + cell = self.table[row_num][col_num] + if cell.__class__.__name__ != "String": + display = link["url"] + + if link_type == 1: + # External link with rel file relationship. + self.rel_count += 1 + + hlink_refs.append( + [ + link_type, + row_num, + col_num, + self.rel_count, + link["str"], + display, + link["tip"], + ] + ) + + # Links for use by the packager. + self.external_hyper_links.append( + ["/hyperlink", link["url"], "External"] + ) + else: + # Internal link with rel file relationship. + hlink_refs.append( + [ + link_type, + row_num, + col_num, + link["url"], + link["str"], + link["tip"], + ] + ) + + # Write the hyperlink elements. + self._xml_start_tag("hyperlinks") + + for args in hlink_refs: + link_type = args.pop(0) + + if link_type == 1: + self._write_hyperlink_external(*args) + elif link_type == 2: + self._write_hyperlink_internal(*args) + + self._xml_end_tag("hyperlinks") + + def _write_hyperlink_external( + self, row, col, id_num, location=None, display=None, tooltip=None + ): + # Write the <hyperlink> element for external links. + ref = xl_rowcol_to_cell(row, col) + r_id = "rId" + str(id_num) + + attributes = [("ref", ref), ("r:id", r_id)] + + if location is not None: + attributes.append(("location", location)) + if display is not None: + attributes.append(("display", display)) + if tooltip is not None: + attributes.append(("tooltip", tooltip)) + + self._xml_empty_tag("hyperlink", attributes) + + def _write_hyperlink_internal( + self, row, col, location=None, display=None, tooltip=None + ): + # Write the <hyperlink> element for internal links. + ref = xl_rowcol_to_cell(row, col) + + attributes = [("ref", ref), ("location", location)] + + if tooltip is not None: + attributes.append(("tooltip", tooltip)) + attributes.append(("display", display)) + + self._xml_empty_tag("hyperlink", attributes) + + def _write_auto_filter(self): + # Write the <autoFilter> element. + if not self.autofilter_ref: + return + + attributes = [("ref", self.autofilter_ref)] + + if self.filter_on: + # Autofilter defined active filters. + self._xml_start_tag("autoFilter", attributes) + self._write_autofilters() + self._xml_end_tag("autoFilter") + + else: + # Autofilter defined without active filters. + self._xml_empty_tag("autoFilter", attributes) + + def _write_autofilters(self): + # Function to iterate through the columns that form part of an + # autofilter range and write the appropriate filters. + (col1, col2) = self.filter_range + + for col in range(col1, col2 + 1): + # Skip if column doesn't have an active filter. + if col not in self.filter_cols: + continue + + # Retrieve the filter tokens and write the autofilter records. + tokens = self.filter_cols[col] + filter_type = self.filter_type[col] + + # Filters are relative to first column in the autofilter. + self._write_filter_column(col - col1, filter_type, tokens) + + def _write_filter_column(self, col_id, filter_type, filters): + # Write the <filterColumn> element. + attributes = [("colId", col_id)] + + self._xml_start_tag("filterColumn", attributes) + + if filter_type == 1: + # Type == 1 is the new XLSX style filter. + self._write_filters(filters) + else: + # Type == 0 is the classic "custom" filter. + self._write_custom_filters(filters) + + self._xml_end_tag("filterColumn") + + def _write_filters(self, filters): + # Write the <filters> element. + non_blanks = [filter for filter in filters if str(filter).lower() != "blanks"] + attributes = [] + + if len(filters) != len(non_blanks): + attributes = [("blank", 1)] + + if len(filters) == 1 and len(non_blanks) == 0: + # Special case for blank cells only. + self._xml_empty_tag("filters", attributes) + else: + # General case. + self._xml_start_tag("filters", attributes) + + for autofilter in sorted(non_blanks): + self._write_filter(autofilter) + + self._xml_end_tag("filters") + + def _write_filter(self, val): + # Write the <filter> element. + attributes = [("val", val)] + + self._xml_empty_tag("filter", attributes) + + def _write_custom_filters(self, tokens): + # Write the <customFilters> element. + if len(tokens) == 2: + # One filter expression only. + self._xml_start_tag("customFilters") + self._write_custom_filter(*tokens) + self._xml_end_tag("customFilters") + else: + # Two filter expressions. + attributes = [] + + # Check if the "join" operand is "and" or "or". + if tokens[2] == 0: + attributes = [("and", 1)] + else: + attributes = [("and", 0)] + + # Write the two custom filters. + self._xml_start_tag("customFilters", attributes) + self._write_custom_filter(tokens[0], tokens[1]) + self._write_custom_filter(tokens[3], tokens[4]) + self._xml_end_tag("customFilters") + + def _write_custom_filter(self, operator, val): + # Write the <customFilter> element. + attributes = [] + + operators = { + 1: "lessThan", + 2: "equal", + 3: "lessThanOrEqual", + 4: "greaterThan", + 5: "notEqual", + 6: "greaterThanOrEqual", + 22: "equal", + } + + # Convert the operator from a number to a descriptive string. + if operators[operator] is not None: + operator = operators[operator] + else: + warn(f"Unknown operator = {operator}") + + # The 'equal' operator is the default attribute and isn't stored. + if operator != "equal": + attributes.append(("operator", operator)) + attributes.append(("val", val)) + + self._xml_empty_tag("customFilter", attributes) + + def _write_sheet_protection(self): + # Write the <sheetProtection> element. + attributes = [] + + if not self.protect_options: + return + + options = self.protect_options + + if options["password"]: + attributes.append(("password", options["password"])) + if options["sheet"]: + attributes.append(("sheet", 1)) + if options["content"]: + attributes.append(("content", 1)) + if not options["objects"]: + attributes.append(("objects", 1)) + if not options["scenarios"]: + attributes.append(("scenarios", 1)) + if options["format_cells"]: + attributes.append(("formatCells", 0)) + if options["format_columns"]: + attributes.append(("formatColumns", 0)) + if options["format_rows"]: + attributes.append(("formatRows", 0)) + if options["insert_columns"]: + attributes.append(("insertColumns", 0)) + if options["insert_rows"]: + attributes.append(("insertRows", 0)) + if options["insert_hyperlinks"]: + attributes.append(("insertHyperlinks", 0)) + if options["delete_columns"]: + attributes.append(("deleteColumns", 0)) + if options["delete_rows"]: + attributes.append(("deleteRows", 0)) + if not options["select_locked_cells"]: + attributes.append(("selectLockedCells", 1)) + if options["sort"]: + attributes.append(("sort", 0)) + if options["autofilter"]: + attributes.append(("autoFilter", 0)) + if options["pivot_tables"]: + attributes.append(("pivotTables", 0)) + if not options["select_unlocked_cells"]: + attributes.append(("selectUnlockedCells", 1)) + + self._xml_empty_tag("sheetProtection", attributes) + + def _write_protected_ranges(self): + # Write the <protectedRanges> element. + if self.num_protected_ranges == 0: + return + + self._xml_start_tag("protectedRanges") + + for cell_range, range_name, password in self.protected_ranges: + self._write_protected_range(cell_range, range_name, password) + + self._xml_end_tag("protectedRanges") + + def _write_protected_range(self, cell_range, range_name, password): + # Write the <protectedRange> element. + attributes = [] + + if password: + attributes.append(("password", password)) + + attributes.append(("sqref", cell_range)) + attributes.append(("name", range_name)) + + self._xml_empty_tag("protectedRange", attributes) + + def _write_drawings(self): + # Write the <drawing> elements. + if not self.drawing: + return + + self.rel_count += 1 + self._write_drawing(self.rel_count) + + def _write_drawing(self, drawing_id): + # Write the <drawing> element. + r_id = "rId" + str(drawing_id) + + attributes = [("r:id", r_id)] + + self._xml_empty_tag("drawing", attributes) + + def _write_legacy_drawing(self): + # Write the <legacyDrawing> element. + if not self.has_vml: + return + + # Increment the relationship id for any drawings or comments. + self.rel_count += 1 + r_id = "rId" + str(self.rel_count) + + attributes = [("r:id", r_id)] + + self._xml_empty_tag("legacyDrawing", attributes) + + def _write_legacy_drawing_hf(self): + # Write the <legacyDrawingHF> element. + if not self.has_header_vml: + return + + # Increment the relationship id for any drawings or comments. + self.rel_count += 1 + r_id = "rId" + str(self.rel_count) + + attributes = [("r:id", r_id)] + + self._xml_empty_tag("legacyDrawingHF", attributes) + + def _write_picture(self): + # Write the <picture> element. + if not self.background_image: + return + + # Increment the relationship id. + self.rel_count += 1 + r_id = "rId" + str(self.rel_count) + + attributes = [("r:id", r_id)] + + self._xml_empty_tag("picture", attributes) + + def _write_data_validations(self): + # Write the <dataValidations> element. + validations = self.validations + count = len(validations) + + if not count: + return + + attributes = [("count", count)] + + self._xml_start_tag("dataValidations", attributes) + + for validation in validations: + # Write the dataValidation element. + self._write_data_validation(validation) + + self._xml_end_tag("dataValidations") + + def _write_data_validation(self, options): + # Write the <dataValidation> element. + sqref = "" + attributes = [] + + # Set the cell range(s) for the data validation. + for cells in options["cells"]: + # Add a space between multiple cell ranges. + if sqref != "": + sqref += " " + + (row_first, col_first, row_last, col_last) = cells + + # Swap last row/col for first row/col as necessary + if row_first > row_last: + (row_first, row_last) = (row_last, row_first) + + if col_first > col_last: + (col_first, col_last) = (col_last, col_first) + + sqref += xl_range(row_first, col_first, row_last, col_last) + + if options.get("multi_range"): + sqref = options["multi_range"] + + if options["validate"] != "none": + attributes.append(("type", options["validate"])) + + if options["criteria"] != "between": + attributes.append(("operator", options["criteria"])) + + if "error_type" in options: + if options["error_type"] == 1: + attributes.append(("errorStyle", "warning")) + if options["error_type"] == 2: + attributes.append(("errorStyle", "information")) + + if options["ignore_blank"]: + attributes.append(("allowBlank", 1)) + + if not options["dropdown"]: + attributes.append(("showDropDown", 1)) + + if options["show_input"]: + attributes.append(("showInputMessage", 1)) + + if options["show_error"]: + attributes.append(("showErrorMessage", 1)) + + if "error_title" in options: + attributes.append(("errorTitle", options["error_title"])) + + if "error_message" in options: + attributes.append(("error", options["error_message"])) + + if "input_title" in options: + attributes.append(("promptTitle", options["input_title"])) + + if "input_message" in options: + attributes.append(("prompt", options["input_message"])) + + attributes.append(("sqref", sqref)) + + if options["validate"] == "none": + self._xml_empty_tag("dataValidation", attributes) + else: + self._xml_start_tag("dataValidation", attributes) + + # Write the formula1 element. + self._write_formula_1(options["value"]) + + # Write the formula2 element. + if options["maximum"] is not None: + self._write_formula_2(options["maximum"]) + + self._xml_end_tag("dataValidation") + + def _write_formula_1(self, formula): + # Write the <formula1> element. + + if isinstance(formula, list): + formula = self._csv_join(*formula) + formula = f'"{formula}"' + else: + # Check if the formula is a number. + try: + float(formula) + except ValueError: + # Not a number. Remove the formula '=' sign if it exists. + if formula.startswith("="): + formula = formula.lstrip("=") + + self._xml_data_element("formula1", formula) + + def _write_formula_2(self, formula): + # Write the <formula2> element. + + # Check if the formula is a number. + try: + float(formula) + except ValueError: + # Not a number. Remove the formula '=' sign if it exists. + if formula.startswith("="): + formula = formula.lstrip("=") + + self._xml_data_element("formula2", formula) + + def _write_conditional_formats(self): + # Write the Worksheet conditional formats. + ranges = sorted(self.cond_formats.keys()) + + if not ranges: + return + + for cond_range in ranges: + self._write_conditional_formatting( + cond_range, self.cond_formats[cond_range] + ) + + def _write_conditional_formatting(self, cond_range, params): + # Write the <conditionalFormatting> element. + attributes = [("sqref", cond_range)] + self._xml_start_tag("conditionalFormatting", attributes) + for param in params: + # Write the cfRule element. + self._write_cf_rule(param) + self._xml_end_tag("conditionalFormatting") + + def _write_cf_rule(self, params): + # Write the <cfRule> element. + attributes = [("type", params["type"])] + + if "format" in params and params["format"] is not None: + attributes.append(("dxfId", params["format"])) + + attributes.append(("priority", params["priority"])) + + if params.get("stop_if_true"): + attributes.append(("stopIfTrue", 1)) + + if params["type"] == "cellIs": + attributes.append(("operator", params["criteria"])) + + self._xml_start_tag("cfRule", attributes) + + if "minimum" in params and "maximum" in params: + self._write_formula_element(params["minimum"]) + self._write_formula_element(params["maximum"]) + else: + self._write_formula_element(params["value"]) + + self._xml_end_tag("cfRule") + + elif params["type"] == "aboveAverage": + if re.search("below", params["criteria"]): + attributes.append(("aboveAverage", 0)) + + if re.search("equal", params["criteria"]): + attributes.append(("equalAverage", 1)) + + if re.search("[123] std dev", params["criteria"]): + match = re.search("([123]) std dev", params["criteria"]) + attributes.append(("stdDev", match.group(1))) + + self._xml_empty_tag("cfRule", attributes) + + elif params["type"] == "top10": + if "criteria" in params and params["criteria"] == "%": + attributes.append(("percent", 1)) + + if "direction" in params: + attributes.append(("bottom", 1)) + + rank = params["value"] or 10 + attributes.append(("rank", rank)) + + self._xml_empty_tag("cfRule", attributes) + + elif params["type"] == "duplicateValues": + self._xml_empty_tag("cfRule", attributes) + + elif params["type"] == "uniqueValues": + self._xml_empty_tag("cfRule", attributes) + + elif ( + params["type"] == "containsText" + or params["type"] == "notContainsText" + or params["type"] == "beginsWith" + or params["type"] == "endsWith" + ): + attributes.append(("operator", params["criteria"])) + attributes.append(("text", params["value"])) + self._xml_start_tag("cfRule", attributes) + self._write_formula_element(params["formula"]) + self._xml_end_tag("cfRule") + + elif params["type"] == "timePeriod": + attributes.append(("timePeriod", params["criteria"])) + self._xml_start_tag("cfRule", attributes) + self._write_formula_element(params["formula"]) + self._xml_end_tag("cfRule") + + elif ( + params["type"] == "containsBlanks" + or params["type"] == "notContainsBlanks" + or params["type"] == "containsErrors" + or params["type"] == "notContainsErrors" + ): + self._xml_start_tag("cfRule", attributes) + self._write_formula_element(params["formula"]) + self._xml_end_tag("cfRule") + + elif params["type"] == "colorScale": + self._xml_start_tag("cfRule", attributes) + self._write_color_scale(params) + self._xml_end_tag("cfRule") + + elif params["type"] == "dataBar": + self._xml_start_tag("cfRule", attributes) + self._write_data_bar(params) + + if params.get("is_data_bar_2010"): + self._write_data_bar_ext(params) + + self._xml_end_tag("cfRule") + + elif params["type"] == "expression": + self._xml_start_tag("cfRule", attributes) + self._write_formula_element(params["criteria"]) + self._xml_end_tag("cfRule") + + elif params["type"] == "iconSet": + self._xml_start_tag("cfRule", attributes) + self._write_icon_set(params) + self._xml_end_tag("cfRule") + + def _write_formula_element(self, formula): + # Write the <formula> element. + + # Check if the formula is a number. + try: + float(formula) + except ValueError: + # Not a number. Remove the formula '=' sign if it exists. + if formula.startswith("="): + formula = formula.lstrip("=") + + self._xml_data_element("formula", formula) + + def _write_color_scale(self, param): + # Write the <colorScale> element. + + self._xml_start_tag("colorScale") + + self._write_cfvo(param["min_type"], param["min_value"]) + + if param["mid_type"] is not None: + self._write_cfvo(param["mid_type"], param["mid_value"]) + + self._write_cfvo(param["max_type"], param["max_value"]) + + self._write_color("rgb", param["min_color"]) + + if param["mid_color"] is not None: + self._write_color("rgb", param["mid_color"]) + + self._write_color("rgb", param["max_color"]) + + self._xml_end_tag("colorScale") + + def _write_data_bar(self, param): + # Write the <dataBar> element. + attributes = [] + + # Min and max bar lengths in in the spec but not supported directly by + # Excel. + if "min_length" in param: + attributes.append(("minLength", param["min_length"])) + + if "max_length" in param: + attributes.append(("maxLength", param["max_length"])) + + if param.get("bar_only"): + attributes.append(("showValue", 0)) + + self._xml_start_tag("dataBar", attributes) + + self._write_cfvo(param["min_type"], param["min_value"]) + self._write_cfvo(param["max_type"], param["max_value"]) + self._write_color("rgb", param["bar_color"]) + + self._xml_end_tag("dataBar") + + def _write_data_bar_ext(self, param): + # Write the <extLst> dataBar extension element. + + # Create a pseudo GUID for each unique Excel 2010 data bar. + worksheet_count = self.index + 1 + data_bar_count = len(self.data_bars_2010) + 1 + guid = "{DA7ABA51-AAAA-BBBB-%04X-%012X}" % (worksheet_count, data_bar_count) + + # Store the 2010 data bar parameters to write the extLst elements. + param["guid"] = guid + self.data_bars_2010.append(param) + + self._xml_start_tag("extLst") + self._write_ext("{B025F937-C7B1-47D3-B67F-A62EFF666E3E}") + self._xml_data_element("x14:id", guid) + self._xml_end_tag("ext") + self._xml_end_tag("extLst") + + def _write_icon_set(self, param): + # Write the <iconSet> element. + attributes = [] + + # Don't set attribute for default style. + if param["icon_style"] != "3TrafficLights": + attributes = [("iconSet", param["icon_style"])] + + if param.get("icons_only"): + attributes.append(("showValue", 0)) + + if param.get("reverse_icons"): + attributes.append(("reverse", 1)) + + self._xml_start_tag("iconSet", attributes) + + # Write the properties for different icon styles. + for icon in reversed(param["icons"]): + self._write_cfvo(icon["type"], icon["value"], icon["criteria"]) + + self._xml_end_tag("iconSet") + + def _write_cfvo(self, cf_type, val, criteria=None): + # Write the <cfvo> element. + attributes = [("type", cf_type)] + + if val is not None: + attributes.append(("val", val)) + + if criteria: + attributes.append(("gte", 0)) + + self._xml_empty_tag("cfvo", attributes) + + def _write_color(self, name, value): + # Write the <color> element. + attributes = [(name, value)] + + self._xml_empty_tag("color", attributes) + + def _write_selections(self): + # Write the <selection> elements. + for selection in self.selections: + self._write_selection(*selection) + + def _write_selection(self, pane, active_cell, sqref): + # Write the <selection> element. + attributes = [] + + if pane: + attributes.append(("pane", pane)) + + if active_cell: + attributes.append(("activeCell", active_cell)) + + if sqref: + attributes.append(("sqref", sqref)) + + self._xml_empty_tag("selection", attributes) + + def _write_panes(self): + # Write the frozen or split <pane> elements. + panes = self.panes + + if not panes: + return + + if panes[4] == 2: + self._write_split_panes(*panes) + else: + self._write_freeze_panes(*panes) + + def _write_freeze_panes(self, row, col, top_row, left_col, pane_type): + # Write the <pane> element for freeze panes. + attributes = [] + + y_split = row + x_split = col + top_left_cell = xl_rowcol_to_cell(top_row, left_col) + active_pane = "" + state = "" + active_cell = "" + sqref = "" + + # Move user cell selection to the panes. + if self.selections: + (_, active_cell, sqref) = self.selections[0] + self.selections = [] + + # Set the active pane. + if row and col: + active_pane = "bottomRight" + + row_cell = xl_rowcol_to_cell(row, 0) + col_cell = xl_rowcol_to_cell(0, col) + + self.selections.append(["topRight", col_cell, col_cell]) + self.selections.append(["bottomLeft", row_cell, row_cell]) + self.selections.append(["bottomRight", active_cell, sqref]) + + elif col: + active_pane = "topRight" + self.selections.append(["topRight", active_cell, sqref]) + + else: + active_pane = "bottomLeft" + self.selections.append(["bottomLeft", active_cell, sqref]) + + # Set the pane type. + if pane_type == 0: + state = "frozen" + elif pane_type == 1: + state = "frozenSplit" + else: + state = "split" + + if x_split: + attributes.append(("xSplit", x_split)) + + if y_split: + attributes.append(("ySplit", y_split)) + + attributes.append(("topLeftCell", top_left_cell)) + attributes.append(("activePane", active_pane)) + attributes.append(("state", state)) + + self._xml_empty_tag("pane", attributes) + + def _write_split_panes(self, row, col, top_row, left_col, _): + # Write the <pane> element for split panes. + attributes = [] + has_selection = 0 + active_pane = "" + active_cell = "" + sqref = "" + + y_split = row + x_split = col + + # Move user cell selection to the panes. + if self.selections: + (_, active_cell, sqref) = self.selections[0] + self.selections = [] + has_selection = 1 + + # Convert the row and col to 1/20 twip units with padding. + if y_split: + y_split = int(20 * y_split + 300) + + if x_split: + x_split = self._calculate_x_split_width(x_split) + + # For non-explicit topLeft definitions, estimate the cell offset based + # on the pixels dimensions. This is only a workaround and doesn't take + # adjusted cell dimensions into account. + if top_row == row and left_col == col: + top_row = int(0.5 + (y_split - 300) / 20 / 15) + left_col = int(0.5 + (x_split - 390) / 20 / 3 * 4 / 64) + + top_left_cell = xl_rowcol_to_cell(top_row, left_col) + + # If there is no selection set the active cell to the top left cell. + if not has_selection: + active_cell = top_left_cell + sqref = top_left_cell + + # Set the Cell selections. + if row and col: + active_pane = "bottomRight" + + row_cell = xl_rowcol_to_cell(top_row, 0) + col_cell = xl_rowcol_to_cell(0, left_col) + + self.selections.append(["topRight", col_cell, col_cell]) + self.selections.append(["bottomLeft", row_cell, row_cell]) + self.selections.append(["bottomRight", active_cell, sqref]) + + elif col: + active_pane = "topRight" + self.selections.append(["topRight", active_cell, sqref]) + + else: + active_pane = "bottomLeft" + self.selections.append(["bottomLeft", active_cell, sqref]) + + # Format splits to the same precision as Excel. + if x_split: + attributes.append(("xSplit", f"{x_split:.16g}")) + + if y_split: + attributes.append(("ySplit", f"{y_split:.16g}")) + + attributes.append(("topLeftCell", top_left_cell)) + + if has_selection: + attributes.append(("activePane", active_pane)) + + self._xml_empty_tag("pane", attributes) + + def _calculate_x_split_width(self, width): + # Convert column width from user units to pane split width. + + max_digit_width = 7 # For Calabri 11. + padding = 5 + + # Convert to pixels. + if width < 1: + pixels = int(width * (max_digit_width + padding) + 0.5) + else: + pixels = int(width * max_digit_width + 0.5) + padding + + # Convert to points. + points = pixels * 3 / 4 + + # Convert to twips (twentieths of a point). + twips = points * 20 + + # Add offset/padding. + width = twips + 390 + + return width + + def _write_table_parts(self): + # Write the <tableParts> element. + tables = self.tables + count = len(tables) + + # Return if worksheet doesn't contain any tables. + if not count: + return + + attributes = [ + ( + "count", + count, + ) + ] + + self._xml_start_tag("tableParts", attributes) + + for _ in tables: + # Write the tablePart element. + self.rel_count += 1 + self._write_table_part(self.rel_count) + + self._xml_end_tag("tableParts") + + def _write_table_part(self, r_id): + # Write the <tablePart> element. + + r_id = "rId" + str(r_id) + + attributes = [ + ( + "r:id", + r_id, + ) + ] + + self._xml_empty_tag("tablePart", attributes) + + def _write_ext_list(self): + # Write the <extLst> element for data bars and sparklines. + has_data_bars = len(self.data_bars_2010) + has_sparklines = len(self.sparklines) + + if not has_data_bars and not has_sparklines: + return + + # Write the extLst element. + self._xml_start_tag("extLst") + + if has_data_bars: + self._write_ext_list_data_bars() + + if has_sparklines: + self._write_ext_list_sparklines() + + self._xml_end_tag("extLst") + + def _write_ext_list_data_bars(self): + # Write the Excel 2010 data_bar subelements. + self._write_ext("{78C0D931-6437-407d-A8EE-F0AAD7539E65}") + + self._xml_start_tag("x14:conditionalFormattings") + + # Write the Excel 2010 conditional formatting data bar elements. + for data_bar in self.data_bars_2010: + # Write the x14:conditionalFormatting element. + self._write_conditional_formatting_2010(data_bar) + + self._xml_end_tag("x14:conditionalFormattings") + self._xml_end_tag("ext") + + def _write_conditional_formatting_2010(self, data_bar): + # Write the <x14:conditionalFormatting> element. + xmlns_xm = "http://schemas.microsoft.com/office/excel/2006/main" + + attributes = [("xmlns:xm", xmlns_xm)] + + self._xml_start_tag("x14:conditionalFormatting", attributes) + + # Write the x14:cfRule element. + self._write_x14_cf_rule(data_bar) + + # Write the x14:dataBar element. + self._write_x14_data_bar(data_bar) + + # Write the x14 max and min data bars. + self._write_x14_cfvo(data_bar["x14_min_type"], data_bar["min_value"]) + self._write_x14_cfvo(data_bar["x14_max_type"], data_bar["max_value"]) + + if not data_bar["bar_no_border"]: + # Write the x14:borderColor element. + self._write_x14_border_color(data_bar["bar_border_color"]) + + # Write the x14:negativeFillColor element. + if not data_bar["bar_negative_color_same"]: + self._write_x14_negative_fill_color(data_bar["bar_negative_color"]) + + # Write the x14:negativeBorderColor element. + if ( + not data_bar["bar_no_border"] + and not data_bar["bar_negative_border_color_same"] + ): + self._write_x14_negative_border_color(data_bar["bar_negative_border_color"]) + + # Write the x14:axisColor element. + if data_bar["bar_axis_position"] != "none": + self._write_x14_axis_color(data_bar["bar_axis_color"]) + + self._xml_end_tag("x14:dataBar") + self._xml_end_tag("x14:cfRule") + + # Write the xm:sqref element. + self._xml_data_element("xm:sqref", data_bar["range"]) + + self._xml_end_tag("x14:conditionalFormatting") + + def _write_x14_cf_rule(self, data_bar): + # Write the <x14:cfRule> element. + rule_type = "dataBar" + guid = data_bar["guid"] + attributes = [("type", rule_type), ("id", guid)] + + self._xml_start_tag("x14:cfRule", attributes) + + def _write_x14_data_bar(self, data_bar): + # Write the <x14:dataBar> element. + min_length = 0 + max_length = 100 + + attributes = [ + ("minLength", min_length), + ("maxLength", max_length), + ] + + if not data_bar["bar_no_border"]: + attributes.append(("border", 1)) + + if data_bar["bar_solid"]: + attributes.append(("gradient", 0)) + + if data_bar["bar_direction"] == "left": + attributes.append(("direction", "leftToRight")) + + if data_bar["bar_direction"] == "right": + attributes.append(("direction", "rightToLeft")) + + if data_bar["bar_negative_color_same"]: + attributes.append(("negativeBarColorSameAsPositive", 1)) + + if ( + not data_bar["bar_no_border"] + and not data_bar["bar_negative_border_color_same"] + ): + attributes.append(("negativeBarBorderColorSameAsPositive", 0)) + + if data_bar["bar_axis_position"] == "middle": + attributes.append(("axisPosition", "middle")) + + if data_bar["bar_axis_position"] == "none": + attributes.append(("axisPosition", "none")) + + self._xml_start_tag("x14:dataBar", attributes) + + def _write_x14_cfvo(self, rule_type, value): + # Write the <x14:cfvo> element. + attributes = [("type", rule_type)] + + if rule_type in ("min", "max", "autoMin", "autoMax"): + self._xml_empty_tag("x14:cfvo", attributes) + else: + self._xml_start_tag("x14:cfvo", attributes) + self._xml_data_element("xm:f", value) + self._xml_end_tag("x14:cfvo") + + def _write_x14_border_color(self, rgb): + # Write the <x14:borderColor> element. + attributes = [("rgb", rgb)] + self._xml_empty_tag("x14:borderColor", attributes) + + def _write_x14_negative_fill_color(self, rgb): + # Write the <x14:negativeFillColor> element. + attributes = [("rgb", rgb)] + self._xml_empty_tag("x14:negativeFillColor", attributes) + + def _write_x14_negative_border_color(self, rgb): + # Write the <x14:negativeBorderColor> element. + attributes = [("rgb", rgb)] + self._xml_empty_tag("x14:negativeBorderColor", attributes) + + def _write_x14_axis_color(self, rgb): + # Write the <x14:axisColor> element. + attributes = [("rgb", rgb)] + self._xml_empty_tag("x14:axisColor", attributes) + + def _write_ext_list_sparklines(self): + # Write the sparkline extension sub-elements. + self._write_ext("{05C60535-1F16-4fd2-B633-F4F36F0B64E0}") + + # Write the x14:sparklineGroups element. + self._write_sparkline_groups() + + # Write the sparkline elements. + for sparkline in reversed(self.sparklines): + # Write the x14:sparklineGroup element. + self._write_sparkline_group(sparkline) + + # Write the x14:colorSeries element. + self._write_color_series(sparkline["series_color"]) + + # Write the x14:colorNegative element. + self._write_color_negative(sparkline["negative_color"]) + + # Write the x14:colorAxis element. + self._write_color_axis() + + # Write the x14:colorMarkers element. + self._write_color_markers(sparkline["markers_color"]) + + # Write the x14:colorFirst element. + self._write_color_first(sparkline["first_color"]) + + # Write the x14:colorLast element. + self._write_color_last(sparkline["last_color"]) + + # Write the x14:colorHigh element. + self._write_color_high(sparkline["high_color"]) + + # Write the x14:colorLow element. + self._write_color_low(sparkline["low_color"]) + + if sparkline["date_axis"]: + self._xml_data_element("xm:f", sparkline["date_axis"]) + + self._write_sparklines(sparkline) + + self._xml_end_tag("x14:sparklineGroup") + + self._xml_end_tag("x14:sparklineGroups") + self._xml_end_tag("ext") + + def _write_sparklines(self, sparkline): + # Write the <x14:sparklines> element and <x14:sparkline> sub-elements. + + # Write the sparkline elements. + self._xml_start_tag("x14:sparklines") + + for i in range(sparkline["count"]): + spark_range = sparkline["ranges"][i] + location = sparkline["locations"][i] + + self._xml_start_tag("x14:sparkline") + self._xml_data_element("xm:f", spark_range) + self._xml_data_element("xm:sqref", location) + self._xml_end_tag("x14:sparkline") + + self._xml_end_tag("x14:sparklines") + + def _write_ext(self, uri): + # Write the <ext> element. + schema = "http://schemas.microsoft.com/office/" + xmlns_x14 = schema + "spreadsheetml/2009/9/main" + + attributes = [ + ("xmlns:x14", xmlns_x14), + ("uri", uri), + ] + + self._xml_start_tag("ext", attributes) + + def _write_sparkline_groups(self): + # Write the <x14:sparklineGroups> element. + xmlns_xm = "http://schemas.microsoft.com/office/excel/2006/main" + + attributes = [("xmlns:xm", xmlns_xm)] + + self._xml_start_tag("x14:sparklineGroups", attributes) + + def _write_sparkline_group(self, options): + # Write the <x14:sparklineGroup> element. + # + # Example for order. + # + # <x14:sparklineGroup + # manualMax="0" + # manualMin="0" + # lineWeight="2.25" + # type="column" + # dateAxis="1" + # displayEmptyCellsAs="span" + # markers="1" + # high="1" + # low="1" + # first="1" + # last="1" + # negative="1" + # displayXAxis="1" + # displayHidden="1" + # minAxisType="custom" + # maxAxisType="custom" + # rightToLeft="1"> + # + empty = options.get("empty") + attributes = [] + + if options.get("max") is not None: + if options["max"] == "group": + options["cust_max"] = "group" + else: + attributes.append(("manualMax", options["max"])) + options["cust_max"] = "custom" + + if options.get("min") is not None: + if options["min"] == "group": + options["cust_min"] = "group" + else: + attributes.append(("manualMin", options["min"])) + options["cust_min"] = "custom" + + # Ignore the default type attribute (line). + if options["type"] != "line": + attributes.append(("type", options["type"])) + + if options.get("weight"): + attributes.append(("lineWeight", options["weight"])) + + if options.get("date_axis"): + attributes.append(("dateAxis", 1)) + + if empty: + attributes.append(("displayEmptyCellsAs", empty)) + + if options.get("markers"): + attributes.append(("markers", 1)) + + if options.get("high"): + attributes.append(("high", 1)) + + if options.get("low"): + attributes.append(("low", 1)) + + if options.get("first"): + attributes.append(("first", 1)) + + if options.get("last"): + attributes.append(("last", 1)) + + if options.get("negative"): + attributes.append(("negative", 1)) + + if options.get("axis"): + attributes.append(("displayXAxis", 1)) + + if options.get("hidden"): + attributes.append(("displayHidden", 1)) + + if options.get("cust_min"): + attributes.append(("minAxisType", options["cust_min"])) + + if options.get("cust_max"): + attributes.append(("maxAxisType", options["cust_max"])) + + if options.get("reverse"): + attributes.append(("rightToLeft", 1)) + + self._xml_start_tag("x14:sparklineGroup", attributes) + + def _write_spark_color(self, element, color): + # Helper function for the sparkline color functions below. + attributes = [] + + if color.get("rgb"): + attributes.append(("rgb", color["rgb"])) + + if color.get("theme"): + attributes.append(("theme", color["theme"])) + + if color.get("tint"): + attributes.append(("tint", color["tint"])) + + self._xml_empty_tag(element, attributes) + + def _write_color_series(self, color): + # Write the <x14:colorSeries> element. + self._write_spark_color("x14:colorSeries", color) + + def _write_color_negative(self, color): + # Write the <x14:colorNegative> element. + self._write_spark_color("x14:colorNegative", color) + + def _write_color_axis(self): + # Write the <x14:colorAxis> element. + self._write_spark_color("x14:colorAxis", {"rgb": "FF000000"}) + + def _write_color_markers(self, color): + # Write the <x14:colorMarkers> element. + self._write_spark_color("x14:colorMarkers", color) + + def _write_color_first(self, color): + # Write the <x14:colorFirst> element. + self._write_spark_color("x14:colorFirst", color) + + def _write_color_last(self, color): + # Write the <x14:colorLast> element. + self._write_spark_color("x14:colorLast", color) + + def _write_color_high(self, color): + # Write the <x14:colorHigh> element. + self._write_spark_color("x14:colorHigh", color) + + def _write_color_low(self, color): + # Write the <x14:colorLow> element. + self._write_spark_color("x14:colorLow", color) + + def _write_phonetic_pr(self): + # Write the <phoneticPr> element. + attributes = [ + ("fontId", "0"), + ("type", "noConversion"), + ] + + self._xml_empty_tag("phoneticPr", attributes) + + def _write_ignored_errors(self): + # Write the <ignoredErrors> element. + if not self.ignored_errors: + return + + self._xml_start_tag("ignoredErrors") + + if self.ignored_errors.get("number_stored_as_text"): + ignored_range = self.ignored_errors["number_stored_as_text"] + self._write_ignored_error("numberStoredAsText", ignored_range) + + if self.ignored_errors.get("eval_error"): + ignored_range = self.ignored_errors["eval_error"] + self._write_ignored_error("evalError", ignored_range) + + if self.ignored_errors.get("formula_differs"): + ignored_range = self.ignored_errors["formula_differs"] + self._write_ignored_error("formula", ignored_range) + + if self.ignored_errors.get("formula_range"): + ignored_range = self.ignored_errors["formula_range"] + self._write_ignored_error("formulaRange", ignored_range) + + if self.ignored_errors.get("formula_unlocked"): + ignored_range = self.ignored_errors["formula_unlocked"] + self._write_ignored_error("unlockedFormula", ignored_range) + + if self.ignored_errors.get("empty_cell_reference"): + ignored_range = self.ignored_errors["empty_cell_reference"] + self._write_ignored_error("emptyCellReference", ignored_range) + + if self.ignored_errors.get("list_data_validation"): + ignored_range = self.ignored_errors["list_data_validation"] + self._write_ignored_error("listDataValidation", ignored_range) + + if self.ignored_errors.get("calculated_column"): + ignored_range = self.ignored_errors["calculated_column"] + self._write_ignored_error("calculatedColumn", ignored_range) + + if self.ignored_errors.get("two_digit_text_year"): + ignored_range = self.ignored_errors["two_digit_text_year"] + self._write_ignored_error("twoDigitTextYear", ignored_range) + + self._xml_end_tag("ignoredErrors") + + def _write_ignored_error(self, error_type, ignored_range): + # Write the <ignoredError> element. + attributes = [ + ("sqref", ignored_range), + (error_type, 1), + ] + + self._xml_empty_tag("ignoredError", attributes) |