aboutsummaryrefslogtreecommitdiff
path: root/.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py
diff options
context:
space:
mode:
authorS. Solomon Darnell2025-03-28 21:52:21 -0500
committerS. Solomon Darnell2025-03-28 21:52:21 -0500
commit4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch)
treeee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py
parentcc961e04ba734dd72309fb548a2f97d67d578813 (diff)
downloadgn-ai-master.tar.gz
two version of R2R are hereHEADmaster
Diffstat (limited to '.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py')
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py8554
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)