about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/xlsxwriter
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/xlsxwriter')
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/__init__.py8
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/app.py199
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart.py4382
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_area.py102
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_bar.py176
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_column.py133
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_doughnut.py101
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_line.py144
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_pie.py263
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_radar.py103
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_scatter.py336
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chart_stock.py125
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/chartsheet.py197
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/comments.py212
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/contenttypes.py269
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/core.py206
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/custom.py142
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/drawing.py1196
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/exceptions.py56
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/feature_property_bag.py156
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/format.py1217
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/metadata.py266
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/packager.py880
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/relationships.py143
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/rich_value.py97
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_rel.py82
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_structure.py99
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_types.py111
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/shape.py416
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/sharedstrings.py138
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/styles.py803
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/table.py194
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/theme.py69
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/utility.py1207
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/vml.py707
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/workbook.py1856
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/worksheet.py8554
-rw-r--r--.venv/lib/python3.12/site-packages/xlsxwriter/xmlwriter.py235
38 files changed, 25580 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/__init__.py b/.venv/lib/python3.12/site-packages/xlsxwriter/__init__.py
new file mode 100644
index 00000000..db42495c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/__init__.py
@@ -0,0 +1,8 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+__version__ = "3.2.2"
+__VERSION__ = __version__
+from .workbook import Workbook  # noqa
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/app.py b/.venv/lib/python3.12/site-packages/xlsxwriter/app.py
new file mode 100644
index 00000000..bf565b54
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/app.py
@@ -0,0 +1,199 @@
+###############################################################################
+#
+# App - A class for writing the Excel XLSX App file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class App(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX App file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.part_names = []
+        self.heading_pairs = []
+        self.properties = {}
+        self.doc_security = 0
+
+    def _add_part_name(self, part_name):
+        # Add the name of a workbook Part such as 'Sheet1' or 'Print_Titles'.
+        self.part_names.append(part_name)
+
+    def _add_heading_pair(self, heading_pair):
+        # Add the name of a workbook Heading Pair such as 'Worksheets',
+        # 'Charts' or 'Named Ranges'.
+
+        # Ignore empty pairs such as chartsheets.
+        if not heading_pair[1]:
+            return
+
+        self.heading_pairs.append(("lpstr", heading_pair[0]))
+        self.heading_pairs.append(("i4", heading_pair[1]))
+
+    def _set_properties(self, properties):
+        # Set the document properties.
+        self.properties = properties
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        self._write_properties()
+        self._write_application()
+        self._write_doc_security()
+        self._write_scale_crop()
+        self._write_heading_pairs()
+        self._write_titles_of_parts()
+        self._write_manager()
+        self._write_company()
+        self._write_links_up_to_date()
+        self._write_shared_doc()
+        self._write_hyperlink_base()
+        self._write_hyperlinks_changed()
+        self._write_app_version()
+
+        self._xml_end_tag("Properties")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_properties(self):
+        # Write the <Properties> element.
+        schema = "http://schemas.openxmlformats.org/officeDocument/2006/"
+        xmlns = schema + "extended-properties"
+        xmlns_vt = schema + "docPropsVTypes"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("xmlns:vt", xmlns_vt),
+        ]
+
+        self._xml_start_tag("Properties", attributes)
+
+    def _write_application(self):
+        # Write the <Application> element.
+        self._xml_data_element("Application", "Microsoft Excel")
+
+    def _write_doc_security(self):
+        # Write the <DocSecurity> element.
+        self._xml_data_element("DocSecurity", self.doc_security)
+
+    def _write_scale_crop(self):
+        # Write the <ScaleCrop> element.
+        self._xml_data_element("ScaleCrop", "false")
+
+    def _write_heading_pairs(self):
+        # Write the <HeadingPairs> element.
+        self._xml_start_tag("HeadingPairs")
+        self._write_vt_vector("variant", self.heading_pairs)
+        self._xml_end_tag("HeadingPairs")
+
+    def _write_titles_of_parts(self):
+        # Write the <TitlesOfParts> element.
+        parts_data = []
+
+        self._xml_start_tag("TitlesOfParts")
+
+        for part_name in self.part_names:
+            parts_data.append(("lpstr", part_name))
+
+        self._write_vt_vector("lpstr", parts_data)
+
+        self._xml_end_tag("TitlesOfParts")
+
+    def _write_vt_vector(self, base_type, vector_data):
+        # Write the <vt:vector> element.
+        attributes = [
+            ("size", len(vector_data)),
+            ("baseType", base_type),
+        ]
+
+        self._xml_start_tag("vt:vector", attributes)
+
+        for vt_data in vector_data:
+            if base_type == "variant":
+                self._xml_start_tag("vt:variant")
+
+            self._write_vt_data(vt_data)
+
+            if base_type == "variant":
+                self._xml_end_tag("vt:variant")
+
+        self._xml_end_tag("vt:vector")
+
+    def _write_vt_data(self, vt_data):
+        # Write the <vt:*> elements such as <vt:lpstr> and <vt:if>.
+        self._xml_data_element(f"vt:{vt_data[0]}", vt_data[1])
+
+    def _write_company(self):
+        company = self.properties.get("company", "")
+
+        self._xml_data_element("Company", company)
+
+    def _write_manager(self):
+        # Write the <Manager> element.
+        if "manager" not in self.properties:
+            return
+
+        self._xml_data_element("Manager", self.properties["manager"])
+
+    def _write_links_up_to_date(self):
+        # Write the <LinksUpToDate> element.
+        self._xml_data_element("LinksUpToDate", "false")
+
+    def _write_shared_doc(self):
+        # Write the <SharedDoc> element.
+        self._xml_data_element("SharedDoc", "false")
+
+    def _write_hyperlink_base(self):
+        # Write the <HyperlinkBase> element.
+        hyperlink_base = self.properties.get("hyperlink_base")
+
+        if hyperlink_base is None:
+            return
+
+        self._xml_data_element("HyperlinkBase", hyperlink_base)
+
+    def _write_hyperlinks_changed(self):
+        # Write the <HyperlinksChanged> element.
+        self._xml_data_element("HyperlinksChanged", "false")
+
+    def _write_app_version(self):
+        # Write the <AppVersion> element.
+        self._xml_data_element("AppVersion", "12.0000")
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart.py
new file mode 100644
index 00000000..08151aed
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart.py
@@ -0,0 +1,4382 @@
+###############################################################################
+#
+# Chart - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+import copy
+import re
+from warnings import warn
+
+from . import xmlwriter
+from .shape import Shape
+from .utility import (
+    _datetime_to_excel_datetime,
+    _get_rgb_color,
+    _supported_datetime,
+    quote_sheetname,
+    xl_range_formula,
+    xl_rowcol_to_cell,
+)
+
+
+class Chart(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Chart file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.subtype = None
+        self.sheet_type = 0x0200
+        self.orientation = 0x0
+        self.series = []
+        self.embedded = 0
+        self.id = -1
+        self.series_index = 0
+        self.style_id = 2
+        self.axis_ids = []
+        self.axis2_ids = []
+        self.cat_has_num_fmt = 0
+        self.requires_category = False
+        self.legend = {}
+        self.cat_axis_position = "b"
+        self.val_axis_position = "l"
+        self.formula_ids = {}
+        self.formula_data = []
+        self.horiz_cat_axis = 0
+        self.horiz_val_axis = 1
+        self.protection = 0
+        self.chartarea = {}
+        self.plotarea = {}
+        self.x_axis = {}
+        self.y_axis = {}
+        self.y2_axis = {}
+        self.x2_axis = {}
+        self.chart_name = ""
+        self.show_blanks = "gap"
+        self.show_na_as_empty = False
+        self.show_hidden = False
+        self.show_crosses = True
+        self.width = 480
+        self.height = 288
+        self.x_scale = 1
+        self.y_scale = 1
+        self.x_offset = 0
+        self.y_offset = 0
+        self.table = None
+        self.cross_between = "between"
+        self.default_marker = None
+        self.series_gap_1 = None
+        self.series_gap_2 = None
+        self.series_overlap_1 = None
+        self.series_overlap_2 = None
+        self.drop_lines = None
+        self.hi_low_lines = None
+        self.up_down_bars = None
+        self.smooth_allowed = False
+        self.title_font = None
+        self.title_name = None
+        self.title_formula = None
+        self.title_data_id = None
+        self.title_layout = None
+        self.title_overlay = None
+        self.title_none = False
+        self.date_category = False
+        self.date_1904 = False
+        self.remove_timezone = False
+        self.label_positions = {}
+        self.label_position_default = ""
+        self.already_inserted = False
+        self.combined = None
+        self.is_secondary = False
+        self.warn_sheetname = True
+        self._set_default_properties()
+        self.fill = {}
+
+    def add_series(self, options=None):
+        """
+        Add a data series to a chart.
+
+        Args:
+            options:  A dictionary of chart series options.
+
+        Returns:
+            Nothing.
+
+        """
+        # Add a series and it's properties to a chart.
+        if options is None:
+            options = {}
+
+        # Check that the required input has been specified.
+        if "values" not in options:
+            warn("Must specify 'values' in add_series()")
+            return
+
+        if self.requires_category and "categories" not in options:
+            warn("Must specify 'categories' in add_series() for this chart type")
+            return
+
+        if len(self.series) == 255:
+            warn(
+                "The maximum number of series that can be added to an "
+                "Excel Chart is 255"
+            )
+            return
+
+        # Convert list into a formula string.
+        values = self._list_to_formula(options.get("values"))
+        categories = self._list_to_formula(options.get("categories"))
+
+        # Switch name and name_formula parameters if required.
+        name, name_formula = self._process_names(
+            options.get("name"), options.get("name_formula")
+        )
+
+        # Get an id for the data equivalent to the range formula.
+        cat_id = self._get_data_id(categories, options.get("categories_data"))
+        val_id = self._get_data_id(values, options.get("values_data"))
+        name_id = self._get_data_id(name_formula, options.get("name_data"))
+
+        # Set the line properties for the series.
+        line = Shape._get_line_properties(options.get("line"))
+
+        # Allow 'border' as a synonym for 'line' in bar/column style charts.
+        if options.get("border"):
+            line = Shape._get_line_properties(options["border"])
+
+        # Set the fill properties for the series.
+        fill = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        # Set the marker properties for the series.
+        marker = self._get_marker_properties(options.get("marker"))
+
+        # Set the trendline properties for the series.
+        trendline = self._get_trendline_properties(options.get("trendline"))
+
+        # Set the line smooth property for the series.
+        smooth = options.get("smooth")
+
+        # Set the error bars properties for the series.
+        y_error_bars = self._get_error_bars_props(options.get("y_error_bars"))
+        x_error_bars = self._get_error_bars_props(options.get("x_error_bars"))
+
+        error_bars = {"x_error_bars": x_error_bars, "y_error_bars": y_error_bars}
+
+        # Set the point properties for the series.
+        points = self._get_points_properties(options.get("points"))
+
+        # Set the labels properties for the series.
+        labels = self._get_labels_properties(options.get("data_labels"))
+
+        # Set the "invert if negative" fill property.
+        invert_if_neg = options.get("invert_if_negative", False)
+        inverted_color = options.get("invert_if_negative_color", False)
+
+        # Set the secondary axis properties.
+        x2_axis = options.get("x2_axis")
+        y2_axis = options.get("y2_axis")
+
+        # Store secondary status for combined charts.
+        if x2_axis or y2_axis:
+            self.is_secondary = True
+
+        # Set the gap for Bar/Column charts.
+        if options.get("gap") is not None:
+            if y2_axis:
+                self.series_gap_2 = options["gap"]
+            else:
+                self.series_gap_1 = options["gap"]
+
+        # Set the overlap for Bar/Column charts.
+        if options.get("overlap"):
+            if y2_axis:
+                self.series_overlap_2 = options["overlap"]
+            else:
+                self.series_overlap_1 = options["overlap"]
+
+        # Add the user supplied data to the internal structures.
+        series = {
+            "values": values,
+            "categories": categories,
+            "name": name,
+            "name_formula": name_formula,
+            "name_id": name_id,
+            "val_data_id": val_id,
+            "cat_data_id": cat_id,
+            "line": line,
+            "fill": fill,
+            "pattern": pattern,
+            "gradient": gradient,
+            "marker": marker,
+            "trendline": trendline,
+            "labels": labels,
+            "invert_if_neg": invert_if_neg,
+            "inverted_color": inverted_color,
+            "x2_axis": x2_axis,
+            "y2_axis": y2_axis,
+            "points": points,
+            "error_bars": error_bars,
+            "smooth": smooth,
+        }
+
+        self.series.append(series)
+
+    def set_x_axis(self, options):
+        """
+        Set the chart X axis options.
+
+        Args:
+            options:  A dictionary of axis options.
+
+        Returns:
+            Nothing.
+
+        """
+        axis = self._convert_axis_args(self.x_axis, options)
+
+        self.x_axis = axis
+
+    def set_y_axis(self, options):
+        """
+        Set the chart Y axis options.
+
+        Args:
+            options: A dictionary of axis options.
+
+        Returns:
+            Nothing.
+
+        """
+        axis = self._convert_axis_args(self.y_axis, options)
+
+        self.y_axis = axis
+
+    def set_x2_axis(self, options):
+        """
+        Set the chart secondary X axis options.
+
+        Args:
+            options: A dictionary of axis options.
+
+        Returns:
+            Nothing.
+
+        """
+        axis = self._convert_axis_args(self.x2_axis, options)
+
+        self.x2_axis = axis
+
+    def set_y2_axis(self, options):
+        """
+        Set the chart secondary Y axis options.
+
+        Args:
+            options: A dictionary of axis options.
+
+        Returns:
+            Nothing.
+
+        """
+        axis = self._convert_axis_args(self.y2_axis, options)
+
+        self.y2_axis = axis
+
+    def set_title(self, options=None):
+        """
+        Set the chart title options.
+
+        Args:
+            options: A dictionary of chart title options.
+
+        Returns:
+            Nothing.
+
+        """
+        if options is None:
+            options = {}
+
+        name, name_formula = self._process_names(
+            options.get("name"), options.get("name_formula")
+        )
+
+        data_id = self._get_data_id(name_formula, options.get("data"))
+
+        self.title_name = name
+        self.title_formula = name_formula
+        self.title_data_id = data_id
+
+        # Set the font properties if present.
+        self.title_font = self._convert_font_args(options.get("name_font"))
+
+        # Set the axis name layout.
+        self.title_layout = self._get_layout_properties(options.get("layout"), True)
+        # Set the title overlay option.
+        self.title_overlay = options.get("overlay")
+
+        # Set the automatic title option.
+        self.title_none = options.get("none")
+
+    def set_legend(self, options):
+        """
+        Set the chart legend options.
+
+        Args:
+            options: A dictionary of chart legend options.
+
+        Returns:
+            Nothing.
+        """
+        # Convert the user defined properties to internal properties.
+        self.legend = self._get_legend_properties(options)
+
+    def set_plotarea(self, options):
+        """
+        Set the chart plot area options.
+
+        Args:
+            options: A dictionary of chart plot area options.
+
+        Returns:
+            Nothing.
+        """
+        # Convert the user defined properties to internal properties.
+        self.plotarea = self._get_area_properties(options)
+
+    def set_chartarea(self, options):
+        """
+        Set the chart area options.
+
+        Args:
+            options: A dictionary of chart area options.
+
+        Returns:
+            Nothing.
+        """
+        # Convert the user defined properties to internal properties.
+        self.chartarea = self._get_area_properties(options)
+
+    def set_style(self, style_id):
+        """
+        Set the chart style type.
+
+        Args:
+            style_id: An int representing the chart style.
+
+        Returns:
+            Nothing.
+        """
+        # Set one of the 48 built-in Excel chart styles. The default is 2.
+        if style_id is None:
+            style_id = 2
+
+        if style_id < 1 or style_id > 48:
+            style_id = 2
+
+        self.style_id = style_id
+
+    def show_blanks_as(self, option):
+        """
+        Set the option for displaying blank data in a chart.
+
+        Args:
+            option: A string representing the display option.
+
+        Returns:
+            Nothing.
+        """
+        if not option:
+            return
+
+        valid_options = {
+            "gap": 1,
+            "zero": 1,
+            "span": 1,
+        }
+
+        if option not in valid_options:
+            warn(f"Unknown show_blanks_as() option '{option}'")
+            return
+
+        self.show_blanks = option
+
+    def show_na_as_empty_cell(self):
+        """
+        Display ``#N/A`` on charts as blank/empty cells.
+
+        Args:
+            None.
+
+        Returns:
+            Nothing.
+        """
+        self.show_na_as_empty = True
+
+    def show_hidden_data(self):
+        """
+        Display data on charts from hidden rows or columns.
+
+        Args:
+            None.
+
+        Returns:
+            Nothing.
+        """
+        self.show_hidden = True
+
+    def set_size(self, options=None):
+        """
+        Set size or scale of the chart.
+
+        Args:
+            options: A dictionary of chart size options.
+
+        Returns:
+            Nothing.
+        """
+        if options is None:
+            options = {}
+
+        # Set dimensions or scale for the chart.
+        self.width = options.get("width", self.width)
+        self.height = options.get("height", self.height)
+        self.x_scale = options.get("x_scale", 1)
+        self.y_scale = options.get("y_scale", 1)
+        self.x_offset = options.get("x_offset", 0)
+        self.y_offset = options.get("y_offset", 0)
+
+    def set_table(self, options=None):
+        """
+        Set properties for an axis data table.
+
+        Args:
+            options: A dictionary of axis table options.
+
+        Returns:
+            Nothing.
+
+        """
+        if options is None:
+            options = {}
+
+        table = {}
+
+        table["horizontal"] = options.get("horizontal", 1)
+        table["vertical"] = options.get("vertical", 1)
+        table["outline"] = options.get("outline", 1)
+        table["show_keys"] = options.get("show_keys", 0)
+        table["font"] = self._convert_font_args(options.get("font"))
+
+        self.table = table
+
+    def set_up_down_bars(self, options=None):
+        """
+        Set properties for the chart up-down bars.
+
+        Args:
+            options: A dictionary of options.
+
+        Returns:
+            Nothing.
+
+        """
+        if options is None:
+            options = {}
+
+        # Defaults.
+        up_line = None
+        up_fill = None
+        down_line = None
+        down_fill = None
+
+        # Set properties for 'up' bar.
+        if options.get("up"):
+            if "border" in options["up"]:
+                # Map border to line.
+                up_line = Shape._get_line_properties(options["up"]["border"])
+
+            if "line" in options["up"]:
+                up_line = Shape._get_line_properties(options["up"]["line"])
+
+            if "fill" in options["up"]:
+                up_fill = Shape._get_fill_properties(options["up"]["fill"])
+
+        # Set properties for 'down' bar.
+        if options.get("down"):
+            if "border" in options["down"]:
+                # Map border to line.
+                down_line = Shape._get_line_properties(options["down"]["border"])
+
+            if "line" in options["down"]:
+                down_line = Shape._get_line_properties(options["down"]["line"])
+
+            if "fill" in options["down"]:
+                down_fill = Shape._get_fill_properties(options["down"]["fill"])
+
+        self.up_down_bars = {
+            "up": {
+                "line": up_line,
+                "fill": up_fill,
+            },
+            "down": {
+                "line": down_line,
+                "fill": down_fill,
+            },
+        }
+
+    def set_drop_lines(self, options=None):
+        """
+        Set properties for the chart drop lines.
+
+        Args:
+            options: A dictionary of options.
+
+        Returns:
+            Nothing.
+
+        """
+        if options is None:
+            options = {}
+
+        line = Shape._get_line_properties(options.get("line"))
+        fill = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        self.drop_lines = {
+            "line": line,
+            "fill": fill,
+            "pattern": pattern,
+            "gradient": gradient,
+        }
+
+    def set_high_low_lines(self, options=None):
+        """
+        Set properties for the chart high-low lines.
+
+        Args:
+            options: A dictionary of options.
+
+        Returns:
+            Nothing.
+
+        """
+        if options is None:
+            options = {}
+
+        line = Shape._get_line_properties(options.get("line"))
+        fill = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        self.hi_low_lines = {
+            "line": line,
+            "fill": fill,
+            "pattern": pattern,
+            "gradient": gradient,
+        }
+
+    def combine(self, chart=None):
+        """
+        Create a combination chart with a secondary chart.
+
+        Args:
+            chart: The secondary chart to combine with the primary chart.
+
+        Returns:
+            Nothing.
+
+        """
+        if chart is None:
+            return
+
+        self.combined = chart
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the c:chartSpace element.
+        self._write_chart_space()
+
+        # Write the c:lang element.
+        self._write_lang()
+
+        # Write the c:style element.
+        self._write_style()
+
+        # Write the c:protection element.
+        self._write_protection()
+
+        # Write the c:chart element.
+        self._write_chart()
+
+        # Write the c:spPr element for the chartarea formatting.
+        self._write_sp_pr(self.chartarea)
+
+        # Write the c:printSettings element.
+        if self.embedded:
+            self._write_print_settings()
+
+        # Close the worksheet tag.
+        self._xml_end_tag("c:chartSpace")
+        # Close the file.
+        self._xml_close()
+
+    def _convert_axis_args(self, axis, user_options):
+        # Convert user defined axis values into private hash values.
+        options = axis["defaults"].copy()
+        options.update(user_options)
+
+        name, name_formula = self._process_names(
+            options.get("name"), options.get("name_formula")
+        )
+
+        data_id = self._get_data_id(name_formula, options.get("data"))
+
+        axis = {
+            "defaults": axis["defaults"],
+            "name": name,
+            "formula": name_formula,
+            "data_id": data_id,
+            "reverse": options.get("reverse"),
+            "min": options.get("min"),
+            "max": options.get("max"),
+            "minor_unit": options.get("minor_unit"),
+            "major_unit": options.get("major_unit"),
+            "minor_unit_type": options.get("minor_unit_type"),
+            "major_unit_type": options.get("major_unit_type"),
+            "display_units": options.get("display_units"),
+            "log_base": options.get("log_base"),
+            "crossing": options.get("crossing"),
+            "position_axis": options.get("position_axis"),
+            "position": options.get("position"),
+            "label_position": options.get("label_position"),
+            "label_align": options.get("label_align"),
+            "num_format": options.get("num_format"),
+            "num_format_linked": options.get("num_format_linked"),
+            "interval_unit": options.get("interval_unit"),
+            "interval_tick": options.get("interval_tick"),
+            "text_axis": False,
+        }
+
+        axis["visible"] = options.get("visible", True)
+
+        # Convert the display units.
+        axis["display_units"] = self._get_display_units(axis["display_units"])
+        axis["display_units_visible"] = options.get("display_units_visible", True)
+
+        # Map major_gridlines properties.
+        if options.get("major_gridlines") and options["major_gridlines"]["visible"]:
+            axis["major_gridlines"] = self._get_gridline_properties(
+                options["major_gridlines"]
+            )
+
+        # Map minor_gridlines properties.
+        if options.get("minor_gridlines") and options["minor_gridlines"]["visible"]:
+            axis["minor_gridlines"] = self._get_gridline_properties(
+                options["minor_gridlines"]
+            )
+
+        # Only use the first letter of bottom, top, left or right.
+        if axis.get("position"):
+            axis["position"] = axis["position"].lower()[0]
+
+        # Set the position for a category axis on or between the tick marks.
+        if axis.get("position_axis"):
+            if axis["position_axis"] == "on_tick":
+                axis["position_axis"] = "midCat"
+            elif axis["position_axis"] == "between":
+                # Doesn't need to be modified.
+                pass
+            else:
+                # Otherwise use the default value.
+                axis["position_axis"] = None
+
+        # Set the category axis as a date axis.
+        if options.get("date_axis"):
+            self.date_category = True
+
+        # Set the category axis as a text axis.
+        if options.get("text_axis"):
+            self.date_category = False
+            axis["text_axis"] = True
+
+        # Convert datetime args if required.
+        if axis.get("min") and _supported_datetime(axis["min"]):
+            axis["min"] = _datetime_to_excel_datetime(
+                axis["min"], self.date_1904, self.remove_timezone
+            )
+        if axis.get("max") and _supported_datetime(axis["max"]):
+            axis["max"] = _datetime_to_excel_datetime(
+                axis["max"], self.date_1904, self.remove_timezone
+            )
+        if axis.get("crossing") and _supported_datetime(axis["crossing"]):
+            axis["crossing"] = _datetime_to_excel_datetime(
+                axis["crossing"], self.date_1904, self.remove_timezone
+            )
+
+        # Set the font properties if present.
+        axis["num_font"] = self._convert_font_args(options.get("num_font"))
+        axis["name_font"] = self._convert_font_args(options.get("name_font"))
+
+        # Set the axis name layout.
+        axis["name_layout"] = self._get_layout_properties(
+            options.get("name_layout"), True
+        )
+
+        # Set the line properties for the axis.
+        axis["line"] = Shape._get_line_properties(options.get("line"))
+
+        # Set the fill properties for the axis.
+        axis["fill"] = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        axis["pattern"] = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        axis["gradient"] = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if axis.get("pattern"):
+            axis["fill"] = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if axis.get("gradient"):
+            axis["pattern"] = None
+            axis["fill"] = None
+
+        # Set the tick marker types.
+        axis["minor_tick_mark"] = self._get_tick_type(options.get("minor_tick_mark"))
+        axis["major_tick_mark"] = self._get_tick_type(options.get("major_tick_mark"))
+
+        return axis
+
+    def _convert_font_args(self, options):
+        # Convert user defined font values into private dict values.
+        if not options:
+            return {}
+
+        font = {
+            "name": options.get("name"),
+            "color": options.get("color"),
+            "size": options.get("size"),
+            "bold": options.get("bold"),
+            "italic": options.get("italic"),
+            "underline": options.get("underline"),
+            "pitch_family": options.get("pitch_family"),
+            "charset": options.get("charset"),
+            "baseline": options.get("baseline", 0),
+            "rotation": options.get("rotation"),
+        }
+
+        # Convert font size units.
+        if font["size"]:
+            font["size"] = int(font["size"] * 100)
+
+        # Convert rotation into 60,000ths of a degree.
+        if font["rotation"]:
+            font["rotation"] = 60000 * int(font["rotation"])
+
+        return font
+
+    def _list_to_formula(self, data):
+        # Convert and list of row col values to a range formula.
+
+        # If it isn't an array ref it is probably a formula already.
+        if not isinstance(data, list):
+            # Check for unquoted sheetnames.
+            if data and " " in data and "'" not in data and self.warn_sheetname:
+                warn(
+                    f"Sheetname in '{data}' contains spaces but isn't quoted. "
+                    f"This may cause an error in Excel."
+                )
+            return data
+
+        formula = xl_range_formula(*data)
+
+        return formula
+
+    def _process_names(self, name, name_formula):
+        # Switch name and name_formula parameters if required.
+
+        if name is not None:
+            if isinstance(name, list):
+                # Convert a list of values into a name formula.
+                cell = xl_rowcol_to_cell(name[1], name[2], True, True)
+                name_formula = quote_sheetname(name[0]) + "!" + cell
+                name = ""
+            elif re.match(r"^=?[^!]+!\$?[A-Z]+\$?\d+", name):
+                # Name looks like a formula, use it to set name_formula.
+                name_formula = name
+                name = ""
+
+        return name, name_formula
+
+    def _get_data_type(self, data):
+        # Find the overall type of the data associated with a series.
+
+        # Check for no data in the series.
+        if data is None or len(data) == 0:
+            return "none"
+
+        if isinstance(data[0], list):
+            return "multi_str"
+
+        # Determine if data is numeric or strings.
+        for token in data:
+            if token is None:
+                continue
+
+            # Check for strings that would evaluate to float like
+            # '1.1_1' of ' 1'.
+            if isinstance(token, str) and re.search("[_ ]", token):
+                # Assume entire data series is string data.
+                return "str"
+
+            try:
+                float(token)
+            except ValueError:
+                # Not a number. Assume entire data series is string data.
+                return "str"
+
+        # The series data was all numeric.
+        return "num"
+
+    def _get_data_id(self, formula, data):
+        # Assign an id to a each unique series formula or title/axis formula.
+        # Repeated formulas such as for categories get the same id. If the
+        # series or title has user specified data associated with it then
+        # that is also stored. This data is used to populate cached Excel
+        # data when creating a chart. If there is no user defined data then
+        # it will be populated by the parent Workbook._add_chart_data().
+
+        # Ignore series without a range formula.
+        if not formula:
+            return None
+
+        # Strip the leading '=' from the formula.
+        if formula.startswith("="):
+            formula = formula.lstrip("=")
+
+        # Store the data id in a hash keyed by the formula and store the data
+        # in a separate array with the same id.
+        if formula not in self.formula_ids:
+            # Haven't seen this formula before.
+            formula_id = len(self.formula_data)
+
+            self.formula_data.append(data)
+            self.formula_ids[formula] = formula_id
+        else:
+            # Formula already seen. Return existing id.
+            formula_id = self.formula_ids[formula]
+
+            # Store user defined data if it isn't already there.
+            if self.formula_data[formula_id] is None:
+                self.formula_data[formula_id] = data
+
+        return formula_id
+
+    def _get_marker_properties(self, marker):
+        # Convert user marker properties to the structure required internally.
+
+        if not marker:
+            return None
+
+        # Copy the user defined properties since they will be modified.
+        marker = copy.deepcopy(marker)
+
+        types = {
+            "automatic": "automatic",
+            "none": "none",
+            "square": "square",
+            "diamond": "diamond",
+            "triangle": "triangle",
+            "x": "x",
+            "star": "star",
+            "dot": "dot",
+            "short_dash": "dot",
+            "dash": "dash",
+            "long_dash": "dash",
+            "circle": "circle",
+            "plus": "plus",
+            "picture": "picture",
+        }
+
+        # Check for valid types.
+        marker_type = marker.get("type")
+
+        if marker_type is not None:
+            if marker_type in types:
+                marker["type"] = types[marker_type]
+            else:
+                warn(f"Unknown marker type '{marker_type}")
+                return None
+
+        # Set the line properties for the marker.
+        line = Shape._get_line_properties(marker.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if "border" in marker:
+            line = Shape._get_line_properties(marker["border"])
+
+        # Set the fill properties for the marker.
+        fill = Shape._get_fill_properties(marker.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(marker.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(marker.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        marker["line"] = line
+        marker["fill"] = fill
+        marker["pattern"] = pattern
+        marker["gradient"] = gradient
+
+        return marker
+
+    def _get_trendline_properties(self, trendline):
+        # Convert user trendline properties to structure required internally.
+
+        if not trendline:
+            return None
+
+        # Copy the user defined properties since they will be modified.
+        trendline = copy.deepcopy(trendline)
+
+        types = {
+            "exponential": "exp",
+            "linear": "linear",
+            "log": "log",
+            "moving_average": "movingAvg",
+            "polynomial": "poly",
+            "power": "power",
+        }
+
+        # Check the trendline type.
+        trend_type = trendline.get("type")
+
+        if trend_type in types:
+            trendline["type"] = types[trend_type]
+        else:
+            warn(f"Unknown trendline type '{trend_type}'")
+            return None
+
+        # Set the line properties for the trendline.
+        line = Shape._get_line_properties(trendline.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if "border" in trendline:
+            line = Shape._get_line_properties(trendline["border"])
+
+        # Set the fill properties for the trendline.
+        fill = Shape._get_fill_properties(trendline.get("fill"))
+
+        # Set the pattern fill properties for the trendline.
+        pattern = Shape._get_pattern_properties(trendline.get("pattern"))
+
+        # Set the gradient fill properties for the trendline.
+        gradient = Shape._get_gradient_properties(trendline.get("gradient"))
+
+        # Set the format properties for the trendline label.
+        label = self._get_trendline_label_properties(trendline.get("label"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        trendline["line"] = line
+        trendline["fill"] = fill
+        trendline["pattern"] = pattern
+        trendline["gradient"] = gradient
+        trendline["label"] = label
+
+        return trendline
+
+    def _get_trendline_label_properties(self, label):
+        # Convert user trendline properties to structure required internally.
+
+        if not label:
+            return {}
+
+        # Copy the user defined properties since they will be modified.
+        label = copy.deepcopy(label)
+
+        # Set the font properties if present.
+        font = self._convert_font_args(label.get("font"))
+
+        # Set the line properties for the label.
+        line = Shape._get_line_properties(label.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if "border" in label:
+            line = Shape._get_line_properties(label["border"])
+
+        # Set the fill properties for the label.
+        fill = Shape._get_fill_properties(label.get("fill"))
+
+        # Set the pattern fill properties for the label.
+        pattern = Shape._get_pattern_properties(label.get("pattern"))
+
+        # Set the gradient fill properties for the label.
+        gradient = Shape._get_gradient_properties(label.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        label["font"] = font
+        label["line"] = line
+        label["fill"] = fill
+        label["pattern"] = pattern
+        label["gradient"] = gradient
+
+        return label
+
+    def _get_error_bars_props(self, options):
+        # Convert user error bars properties to structure required internally.
+        if not options:
+            return {}
+
+        # Default values.
+        error_bars = {"type": "fixedVal", "value": 1, "endcap": 1, "direction": "both"}
+
+        types = {
+            "fixed": "fixedVal",
+            "percentage": "percentage",
+            "standard_deviation": "stdDev",
+            "standard_error": "stdErr",
+            "custom": "cust",
+        }
+
+        # Check the error bars type.
+        error_type = options["type"]
+
+        if error_type in types:
+            error_bars["type"] = types[error_type]
+        else:
+            warn(f"Unknown error bars type '{error_type}")
+            return {}
+
+        # Set the value for error types that require it.
+        if "value" in options:
+            error_bars["value"] = options["value"]
+
+        # Set the end-cap style.
+        if "end_style" in options:
+            error_bars["endcap"] = options["end_style"]
+
+        # Set the error bar direction.
+        if "direction" in options:
+            if options["direction"] == "minus":
+                error_bars["direction"] = "minus"
+            elif options["direction"] == "plus":
+                error_bars["direction"] = "plus"
+            else:
+                # Default to 'both'.
+                pass
+
+        # Set any custom values.
+        error_bars["plus_values"] = options.get("plus_values")
+        error_bars["minus_values"] = options.get("minus_values")
+        error_bars["plus_data"] = options.get("plus_data")
+        error_bars["minus_data"] = options.get("minus_data")
+
+        # Set the line properties for the error bars.
+        error_bars["line"] = Shape._get_line_properties(options.get("line"))
+
+        return error_bars
+
+    def _get_gridline_properties(self, options):
+        # Convert user gridline properties to structure required internally.
+
+        # Set the visible property for the gridline.
+        gridline = {"visible": options.get("visible")}
+
+        # Set the line properties for the gridline.
+        gridline["line"] = Shape._get_line_properties(options.get("line"))
+
+        return gridline
+
+    def _get_labels_properties(self, labels):
+        # Convert user labels properties to the structure required internally.
+
+        if not labels:
+            return None
+
+        # Copy the user defined properties since they will be modified.
+        labels = copy.deepcopy(labels)
+
+        # Map user defined label positions to Excel positions.
+        position = labels.get("position")
+
+        if position:
+            if position in self.label_positions:
+                if position == self.label_position_default:
+                    labels["position"] = None
+                else:
+                    labels["position"] = self.label_positions[position]
+            else:
+                warn(f"Unsupported label position '{position}' for this chart type")
+                return None
+
+        # Map the user defined label separator to the Excel separator.
+        separator = labels.get("separator")
+        separators = {
+            ",": ", ",
+            ";": "; ",
+            ".": ". ",
+            "\n": "\n",
+            " ": " ",
+        }
+
+        if separator:
+            if separator in separators:
+                labels["separator"] = separators[separator]
+            else:
+                warn("Unsupported label separator")
+                return None
+
+        # Set the font properties if present.
+        labels["font"] = self._convert_font_args(labels.get("font"))
+
+        # Set the line properties for the labels.
+        line = Shape._get_line_properties(labels.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if "border" in labels:
+            line = Shape._get_line_properties(labels["border"])
+
+        # Set the fill properties for the labels.
+        fill = Shape._get_fill_properties(labels.get("fill"))
+
+        # Set the pattern fill properties for the labels.
+        pattern = Shape._get_pattern_properties(labels.get("pattern"))
+
+        # Set the gradient fill properties for the labels.
+        gradient = Shape._get_gradient_properties(labels.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        labels["line"] = line
+        labels["fill"] = fill
+        labels["pattern"] = pattern
+        labels["gradient"] = gradient
+
+        if labels.get("custom"):
+            for label in labels["custom"]:
+                if label is None:
+                    continue
+
+                value = label.get("value")
+                if value and re.match(r"^=?[^!]+!\$?[A-Z]+\$?\d+", str(value)):
+                    label["formula"] = value
+
+                formula = label.get("formula")
+                if formula and formula.startswith("="):
+                    label["formula"] = formula.lstrip("=")
+
+                data_id = self._get_data_id(formula, label.get("data"))
+                label["data_id"] = data_id
+
+                label["font"] = self._convert_font_args(label.get("font"))
+
+                # Set the line properties for the label.
+                line = Shape._get_line_properties(label.get("line"))
+
+                # Allow 'border' as a synonym for 'line'.
+                if "border" in label:
+                    line = Shape._get_line_properties(label["border"])
+
+                # Set the fill properties for the label.
+                fill = Shape._get_fill_properties(label.get("fill"))
+
+                # Set the pattern fill properties for the label.
+                pattern = Shape._get_pattern_properties(label.get("pattern"))
+
+                # Set the gradient fill properties for the label.
+                gradient = Shape._get_gradient_properties(label.get("gradient"))
+
+                # Pattern fill overrides solid fill.
+                if pattern:
+                    self.fill = None
+
+                # Gradient fill overrides the solid and pattern fill.
+                if gradient:
+                    pattern = None
+                    fill = None
+
+                label["line"] = line
+                label["fill"] = fill
+                label["pattern"] = pattern
+                label["gradient"] = gradient
+
+        return labels
+
+    def _get_area_properties(self, options):
+        # Convert user area properties to the structure required internally.
+        area = {}
+
+        # Set the line properties for the chartarea.
+        line = Shape._get_line_properties(options.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if options.get("border"):
+            line = Shape._get_line_properties(options["border"])
+
+        # Set the fill properties for the chartarea.
+        fill = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        # Set the plotarea layout.
+        layout = self._get_layout_properties(options.get("layout"), False)
+
+        area["line"] = line
+        area["fill"] = fill
+        area["pattern"] = pattern
+        area["layout"] = layout
+        area["gradient"] = gradient
+
+        return area
+
+    def _get_legend_properties(self, options=None):
+        # Convert user legend properties to the structure required internally.
+        legend = {}
+
+        if options is None:
+            options = {}
+
+        legend["position"] = options.get("position", "right")
+        legend["delete_series"] = options.get("delete_series")
+        legend["font"] = self._convert_font_args(options.get("font"))
+        legend["layout"] = self._get_layout_properties(options.get("layout"), False)
+
+        # Turn off the legend.
+        if options.get("none"):
+            legend["position"] = "none"
+
+        # Set the line properties for the legend.
+        line = Shape._get_line_properties(options.get("line"))
+
+        # Allow 'border' as a synonym for 'line'.
+        if options.get("border"):
+            line = Shape._get_line_properties(options["border"])
+
+        # Set the fill properties for the legend.
+        fill = Shape._get_fill_properties(options.get("fill"))
+
+        # Set the pattern fill properties for the series.
+        pattern = Shape._get_pattern_properties(options.get("pattern"))
+
+        # Set the gradient fill properties for the series.
+        gradient = Shape._get_gradient_properties(options.get("gradient"))
+
+        # Pattern fill overrides solid fill.
+        if pattern:
+            self.fill = None
+
+        # Gradient fill overrides the solid and pattern fill.
+        if gradient:
+            pattern = None
+            fill = None
+
+        # Set the legend layout.
+        layout = self._get_layout_properties(options.get("layout"), False)
+
+        legend["line"] = line
+        legend["fill"] = fill
+        legend["pattern"] = pattern
+        legend["layout"] = layout
+        legend["gradient"] = gradient
+
+        return legend
+
+    def _get_layout_properties(self, args, is_text):
+        # Convert user defined layout properties to format used internally.
+        layout = {}
+
+        if not args:
+            return {}
+
+        if is_text:
+            properties = ("x", "y")
+        else:
+            properties = ("x", "y", "width", "height")
+
+        # Check for valid properties.
+        for key in args.keys():
+            if key not in properties:
+                warn(f"Property '{key}' not supported in layout options")
+                return {}
+
+        # Set the layout properties.
+        for prop in properties:
+            if prop not in args.keys():
+                warn(f"Property '{prop}' must be specified in layout options")
+                return {}
+
+            value = args[prop]
+
+            try:
+                float(value)
+            except ValueError:
+                warn(f"Property '{prop}' value '{value}' must be numeric in layout")
+                return {}
+
+            if value < 0 or value > 1:
+                warn(
+                    f"Property '{prop}' value '{value}' must be in range "
+                    f"0 < x <= 1 in layout options"
+                )
+                return {}
+
+            # Convert to the format used by Excel for easier testing
+            layout[prop] = f"{value:.17g}"
+
+        return layout
+
+    def _get_points_properties(self, user_points):
+        # Convert user points properties to structure required internally.
+        points = []
+
+        if not user_points:
+            return []
+
+        for user_point in user_points:
+            point = {}
+
+            if user_point is not None:
+                # Set the line properties for the point.
+                line = Shape._get_line_properties(user_point.get("line"))
+
+                # Allow 'border' as a synonym for 'line'.
+                if "border" in user_point:
+                    line = Shape._get_line_properties(user_point["border"])
+
+                # Set the fill properties for the chartarea.
+                fill = Shape._get_fill_properties(user_point.get("fill"))
+
+                # Set the pattern fill properties for the series.
+                pattern = Shape._get_pattern_properties(user_point.get("pattern"))
+
+                # Set the gradient fill properties for the series.
+                gradient = Shape._get_gradient_properties(user_point.get("gradient"))
+
+                # Pattern fill overrides solid fill.
+                if pattern:
+                    self.fill = None
+
+                # Gradient fill overrides the solid and pattern fill.
+                if gradient:
+                    pattern = None
+                    fill = None
+
+                point["line"] = line
+                point["fill"] = fill
+                point["pattern"] = pattern
+                point["gradient"] = gradient
+
+            points.append(point)
+
+        return points
+
+    def _has_fill_formatting(self, element):
+        # Check if a chart element has line, fill or gradient formatting.
+        has_fill = False
+        has_line = False
+        has_pattern = element.get("pattern")
+        has_gradient = element.get("gradient")
+
+        if element.get("fill") and element["fill"]["defined"]:
+            has_fill = True
+
+        if element.get("line") and element["line"]["defined"]:
+            has_line = True
+
+        return has_fill or has_line or has_pattern or has_gradient
+
+    def _get_display_units(self, display_units):
+        # Convert user defined display units to internal units.
+        if not display_units:
+            return None
+
+        types = {
+            "hundreds": "hundreds",
+            "thousands": "thousands",
+            "ten_thousands": "tenThousands",
+            "hundred_thousands": "hundredThousands",
+            "millions": "millions",
+            "ten_millions": "tenMillions",
+            "hundred_millions": "hundredMillions",
+            "billions": "billions",
+            "trillions": "trillions",
+        }
+
+        if display_units in types:
+            display_units = types[display_units]
+        else:
+            warn(f"Unknown display_units type '{display_units}'")
+            return None
+
+        return display_units
+
+    def _get_tick_type(self, tick_type):
+        # Convert user defined display units to internal units.
+        if not tick_type:
+            return None
+
+        types = {
+            "outside": "out",
+            "inside": "in",
+            "none": "none",
+            "cross": "cross",
+        }
+
+        if tick_type in types:
+            tick_type = types[tick_type]
+        else:
+            warn(f"Unknown tick_type '{tick_type}'")
+            return None
+
+        return tick_type
+
+    def _get_primary_axes_series(self):
+        # Returns series which use the primary axes.
+        primary_axes_series = []
+
+        for series in self.series:
+            if not series["y2_axis"]:
+                primary_axes_series.append(series)
+
+        return primary_axes_series
+
+    def _get_secondary_axes_series(self):
+        # Returns series which use the secondary axes.
+        secondary_axes_series = []
+
+        for series in self.series:
+            if series["y2_axis"]:
+                secondary_axes_series.append(series)
+
+        return secondary_axes_series
+
+    def _add_axis_ids(self, args):
+        # Add unique ids for primary or secondary axes
+        chart_id = 5001 + int(self.id)
+        axis_count = 1 + len(self.axis2_ids) + len(self.axis_ids)
+
+        id1 = f"{chart_id:04d}{axis_count:04d}"
+        id2 = f"{chart_id:04d}{axis_count + 1:04d}"
+
+        if args["primary_axes"]:
+            self.axis_ids.append(id1)
+            self.axis_ids.append(id2)
+
+        if not args["primary_axes"]:
+            self.axis2_ids.append(id1)
+            self.axis2_ids.append(id2)
+
+    def _set_default_properties(self):
+        # Setup the default properties for a chart.
+
+        self.x_axis["defaults"] = {
+            "num_format": "General",
+            "major_gridlines": {"visible": 0},
+        }
+
+        self.y_axis["defaults"] = {
+            "num_format": "General",
+            "major_gridlines": {"visible": 1},
+        }
+
+        self.x2_axis["defaults"] = {
+            "num_format": "General",
+            "label_position": "none",
+            "crossing": "max",
+            "visible": 0,
+        }
+
+        self.y2_axis["defaults"] = {
+            "num_format": "General",
+            "major_gridlines": {"visible": 0},
+            "position": "right",
+            "visible": 1,
+        }
+
+        self.set_x_axis({})
+        self.set_y_axis({})
+
+        self.set_x2_axis({})
+        self.set_y2_axis({})
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_chart_space(self):
+        # Write the <c:chartSpace> element.
+        schema = "http://schemas.openxmlformats.org/"
+        xmlns_c = schema + "drawingml/2006/chart"
+        xmlns_a = schema + "drawingml/2006/main"
+        xmlns_r = schema + "officeDocument/2006/relationships"
+
+        attributes = [
+            ("xmlns:c", xmlns_c),
+            ("xmlns:a", xmlns_a),
+            ("xmlns:r", xmlns_r),
+        ]
+
+        self._xml_start_tag("c:chartSpace", attributes)
+
+    def _write_lang(self):
+        # Write the <c:lang> element.
+        val = "en-US"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:lang", attributes)
+
+    def _write_style(self):
+        # Write the <c:style> element.
+        style_id = self.style_id
+
+        # Don't write an element for the default style, 2.
+        if style_id == 2:
+            return
+
+        attributes = [("val", style_id)]
+
+        self._xml_empty_tag("c:style", attributes)
+
+    def _write_chart(self):
+        # Write the <c:chart> element.
+        self._xml_start_tag("c:chart")
+
+        if self.title_none:
+            # Turn off the title.
+            self._write_c_auto_title_deleted()
+        else:
+            # Write the chart title elements.
+            if self.title_formula is not None:
+                self._write_title_formula(
+                    self.title_formula,
+                    self.title_data_id,
+                    None,
+                    self.title_font,
+                    self.title_layout,
+                    self.title_overlay,
+                )
+            elif self.title_name is not None:
+                self._write_title_rich(
+                    self.title_name,
+                    None,
+                    self.title_font,
+                    self.title_layout,
+                    self.title_overlay,
+                )
+
+        # Write the c:plotArea element.
+        self._write_plot_area()
+
+        # Write the c:legend element.
+        self._write_legend()
+
+        # Write the c:plotVisOnly element.
+        self._write_plot_vis_only()
+
+        # Write the c:dispBlanksAs element.
+        self._write_disp_blanks_as()
+
+        # Write the c:extLst element.
+        if self.show_na_as_empty:
+            self._write_c_ext_lst_display_na()
+
+        self._xml_end_tag("c:chart")
+
+    def _write_disp_blanks_as(self):
+        # Write the <c:dispBlanksAs> element.
+        val = self.show_blanks
+
+        # Ignore the default value.
+        if val == "gap":
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:dispBlanksAs", attributes)
+
+    def _write_plot_area(self):
+        # Write the <c:plotArea> element.
+        self._xml_start_tag("c:plotArea")
+
+        # Write the c:layout element.
+        self._write_layout(self.plotarea.get("layout"), "plot")
+
+        # Write  subclass chart type elements for primary and secondary axes.
+        self._write_chart_type({"primary_axes": True})
+        self._write_chart_type({"primary_axes": False})
+
+        # Configure a combined chart if present.
+        second_chart = self.combined
+        if second_chart:
+            # Secondary axis has unique id otherwise use same as primary.
+            if second_chart.is_secondary:
+                second_chart.id = 1000 + self.id
+            else:
+                second_chart.id = self.id
+
+            # Share the same filehandle for writing.
+            second_chart.fh = self.fh
+
+            # Share series index with primary chart.
+            second_chart.series_index = self.series_index
+
+            # Write the subclass chart type elements for combined chart.
+            second_chart._write_chart_type({"primary_axes": True})
+            second_chart._write_chart_type({"primary_axes": False})
+
+        # Write the category and value elements for the primary axes.
+        args = {"x_axis": self.x_axis, "y_axis": self.y_axis, "axis_ids": self.axis_ids}
+
+        if self.date_category:
+            self._write_date_axis(args)
+        else:
+            self._write_cat_axis(args)
+
+        self._write_val_axis(args)
+
+        # Write the category and value elements for the secondary axes.
+        args = {
+            "x_axis": self.x2_axis,
+            "y_axis": self.y2_axis,
+            "axis_ids": self.axis2_ids,
+        }
+
+        self._write_val_axis(args)
+
+        # Write the secondary axis for the secondary chart.
+        if second_chart and second_chart.is_secondary:
+            args = {
+                "x_axis": second_chart.x2_axis,
+                "y_axis": second_chart.y2_axis,
+                "axis_ids": second_chart.axis2_ids,
+            }
+
+            second_chart._write_val_axis(args)
+
+        if self.date_category:
+            self._write_date_axis(args)
+        else:
+            self._write_cat_axis(args)
+
+        # Write the c:dTable element.
+        self._write_d_table()
+
+        # Write the c:spPr element for the plotarea formatting.
+        self._write_sp_pr(self.plotarea)
+
+        self._xml_end_tag("c:plotArea")
+
+    def _write_layout(self, layout, layout_type):
+        # Write the <c:layout> element.
+
+        if not layout:
+            # Automatic layout.
+            self._xml_empty_tag("c:layout")
+        else:
+            # User defined manual layout.
+            self._xml_start_tag("c:layout")
+            self._write_manual_layout(layout, layout_type)
+            self._xml_end_tag("c:layout")
+
+    def _write_manual_layout(self, layout, layout_type):
+        # Write the <c:manualLayout> element.
+        self._xml_start_tag("c:manualLayout")
+
+        # Plotarea has a layoutTarget element.
+        if layout_type == "plot":
+            self._xml_empty_tag("c:layoutTarget", [("val", "inner")])
+
+        # Set the x, y positions.
+        self._xml_empty_tag("c:xMode", [("val", "edge")])
+        self._xml_empty_tag("c:yMode", [("val", "edge")])
+        self._xml_empty_tag("c:x", [("val", layout["x"])])
+        self._xml_empty_tag("c:y", [("val", layout["y"])])
+
+        # For plotarea and legend set the width and height.
+        if layout_type != "text":
+            self._xml_empty_tag("c:w", [("val", layout["width"])])
+            self._xml_empty_tag("c:h", [("val", layout["height"])])
+
+        self._xml_end_tag("c:manualLayout")
+
+    def _write_chart_type(self, args):
+        # pylint: disable=unused-argument
+        # Write the chart type element. This method should be overridden
+        # by the subclasses.
+        return
+
+    def _write_grouping(self, val):
+        # Write the <c:grouping> element.
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:grouping", attributes)
+
+    def _write_series(self, series):
+        # Write the series elements.
+        self._write_ser(series)
+
+    def _write_ser(self, series):
+        # Write the <c:ser> element.
+        index = self.series_index
+        self.series_index += 1
+
+        self._xml_start_tag("c:ser")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        # Write the c:order element.
+        self._write_order(index)
+
+        # Write the series name.
+        self._write_series_name(series)
+
+        # Write the c:spPr element.
+        self._write_sp_pr(series)
+
+        # Write the c:marker element.
+        self._write_marker(series["marker"])
+
+        # Write the c:invertIfNegative element.
+        self._write_c_invert_if_negative(series["invert_if_neg"])
+
+        # Write the c:dPt element.
+        self._write_d_pt(series["points"])
+
+        # Write the c:dLbls element.
+        self._write_d_lbls(series["labels"])
+
+        # Write the c:trendline element.
+        self._write_trendline(series["trendline"])
+
+        # Write the c:errBars element.
+        self._write_error_bars(series["error_bars"])
+
+        # Write the c:cat element.
+        self._write_cat(series)
+
+        # Write the c:val element.
+        self._write_val(series)
+
+        # Write the c:smooth element.
+        if self.smooth_allowed:
+            self._write_c_smooth(series["smooth"])
+
+        # Write the c:extLst element.
+        if series.get("inverted_color"):
+            self._write_c_ext_lst_inverted_color(series["inverted_color"])
+
+        self._xml_end_tag("c:ser")
+
+    def _write_c_ext_lst_inverted_color(self, color):
+        # Write the <c:extLst> element for the inverted fill color.
+
+        uri = "{6F2FDCE9-48DA-4B69-8628-5D25D57E5C99}"
+        xmlns_c_14 = "http://schemas.microsoft.com/office/drawing/2007/8/2/chart"
+
+        attributes1 = [
+            ("uri", uri),
+            ("xmlns:c14", xmlns_c_14),
+        ]
+
+        attributes2 = [("xmlns:c14", xmlns_c_14)]
+
+        self._xml_start_tag("c:extLst")
+        self._xml_start_tag("c:ext", attributes1)
+        self._xml_start_tag("c14:invertSolidFillFmt")
+        self._xml_start_tag("c14:spPr", attributes2)
+
+        self._write_a_solid_fill({"color": color})
+
+        self._xml_end_tag("c14:spPr")
+        self._xml_end_tag("c14:invertSolidFillFmt")
+        self._xml_end_tag("c:ext")
+        self._xml_end_tag("c:extLst")
+
+    def _write_c_ext_lst_display_na(self):
+        # Write the <c:extLst> element for the display NA as empty cell option.
+
+        uri = "{56B9EC1D-385E-4148-901F-78D8002777C0}"
+        xmlns_c_16 = "http://schemas.microsoft.com/office/drawing/2017/03/chart"
+
+        attributes1 = [
+            ("uri", uri),
+            ("xmlns:c16r3", xmlns_c_16),
+        ]
+
+        attributes2 = [("val", 1)]
+
+        self._xml_start_tag("c:extLst")
+        self._xml_start_tag("c:ext", attributes1)
+        self._xml_start_tag("c16r3:dataDisplayOptions16")
+        self._xml_empty_tag("c16r3:dispNaAsBlank", attributes2)
+        self._xml_end_tag("c16r3:dataDisplayOptions16")
+        self._xml_end_tag("c:ext")
+        self._xml_end_tag("c:extLst")
+
+    def _write_idx(self, val):
+        # Write the <c:idx> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:idx", attributes)
+
+    def _write_order(self, val):
+        # Write the <c:order> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:order", attributes)
+
+    def _write_series_name(self, series):
+        # Write the series name.
+
+        if series["name_formula"] is not None:
+            self._write_tx_formula(series["name_formula"], series["name_id"])
+        elif series["name"] is not None:
+            self._write_tx_value(series["name"])
+
+    def _write_c_smooth(self, smooth):
+        # Write the <c:smooth> element.
+
+        if smooth:
+            self._xml_empty_tag("c:smooth", [("val", "1")])
+
+    def _write_cat(self, series):
+        # Write the <c:cat> element.
+        formula = series["categories"]
+        data_id = series["cat_data_id"]
+        data = None
+
+        if data_id is not None:
+            data = self.formula_data[data_id]
+
+        # Ignore <c:cat> elements for charts without category values.
+        if not formula:
+            return
+
+        self._xml_start_tag("c:cat")
+
+        # Check the type of cached data.
+        cat_type = self._get_data_type(data)
+
+        if cat_type == "str":
+            self.cat_has_num_fmt = 0
+            # Write the c:numRef element.
+            self._write_str_ref(formula, data, cat_type)
+
+        elif cat_type == "multi_str":
+            self.cat_has_num_fmt = 0
+            # Write the c:numRef element.
+            self._write_multi_lvl_str_ref(formula, data)
+
+        else:
+            self.cat_has_num_fmt = 1
+            # Write the c:numRef element.
+            self._write_num_ref(formula, data, cat_type)
+
+        self._xml_end_tag("c:cat")
+
+    def _write_val(self, series):
+        # Write the <c:val> element.
+        formula = series["values"]
+        data_id = series["val_data_id"]
+        data = self.formula_data[data_id]
+
+        self._xml_start_tag("c:val")
+
+        # Unlike Cat axes data should only be numeric.
+        # Write the c:numRef element.
+        self._write_num_ref(formula, data, "num")
+
+        self._xml_end_tag("c:val")
+
+    def _write_num_ref(self, formula, data, ref_type):
+        # Write the <c:numRef> element.
+        self._xml_start_tag("c:numRef")
+
+        # Write the c:f element.
+        self._write_series_formula(formula)
+
+        if ref_type == "num":
+            # Write the c:numCache element.
+            self._write_num_cache(data)
+        elif ref_type == "str":
+            # Write the c:strCache element.
+            self._write_str_cache(data)
+
+        self._xml_end_tag("c:numRef")
+
+    def _write_str_ref(self, formula, data, ref_type):
+        # Write the <c:strRef> element.
+
+        self._xml_start_tag("c:strRef")
+
+        # Write the c:f element.
+        self._write_series_formula(formula)
+
+        if ref_type == "num":
+            # Write the c:numCache element.
+            self._write_num_cache(data)
+        elif ref_type == "str":
+            # Write the c:strCache element.
+            self._write_str_cache(data)
+
+        self._xml_end_tag("c:strRef")
+
+    def _write_multi_lvl_str_ref(self, formula, data):
+        # Write the <c:multiLvlStrRef> element.
+
+        if not data:
+            return
+
+        self._xml_start_tag("c:multiLvlStrRef")
+
+        # Write the c:f element.
+        self._write_series_formula(formula)
+
+        self._xml_start_tag("c:multiLvlStrCache")
+
+        # Write the c:ptCount element.
+        count = len(data[-1])
+        self._write_pt_count(count)
+
+        for cat_data in reversed(data):
+            self._xml_start_tag("c:lvl")
+
+            for i, point in enumerate(cat_data):
+                # Write the c:pt element.
+                self._write_pt(i, point)
+
+            self._xml_end_tag("c:lvl")
+
+        self._xml_end_tag("c:multiLvlStrCache")
+        self._xml_end_tag("c:multiLvlStrRef")
+
+    def _write_series_formula(self, formula):
+        # Write the <c:f> element.
+
+        # Strip the leading '=' from the formula.
+        if formula.startswith("="):
+            formula = formula.lstrip("=")
+
+        self._xml_data_element("c:f", formula)
+
+    def _write_axis_ids(self, args):
+        # Write the <c:axId> elements for the primary or secondary axes.
+
+        # Generate the axis ids.
+        self._add_axis_ids(args)
+
+        if args["primary_axes"]:
+            # Write the axis ids for the primary axes.
+            self._write_axis_id(self.axis_ids[0])
+            self._write_axis_id(self.axis_ids[1])
+        else:
+            # Write the axis ids for the secondary axes.
+            self._write_axis_id(self.axis2_ids[0])
+            self._write_axis_id(self.axis2_ids[1])
+
+    def _write_axis_id(self, val):
+        # Write the <c:axId> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:axId", attributes)
+
+    def _write_cat_axis(self, args):
+        # Write the <c:catAx> element. Usually the X axis.
+        x_axis = args["x_axis"]
+        y_axis = args["y_axis"]
+        axis_ids = args["axis_ids"]
+
+        # If there are no axis_ids then we don't need to write this element.
+        if axis_ids is None or not axis_ids:
+            return
+
+        position = self.cat_axis_position
+        is_y_axis = self.horiz_cat_axis
+
+        # Overwrite the default axis position with a user supplied value.
+        if x_axis.get("position"):
+            position = x_axis["position"]
+
+        self._xml_start_tag("c:catAx")
+
+        self._write_axis_id(axis_ids[0])
+
+        # Write the c:scaling element.
+        self._write_scaling(x_axis.get("reverse"), None, None, None)
+
+        if not x_axis.get("visible"):
+            self._write_delete(1)
+
+        # Write the c:axPos element.
+        self._write_axis_pos(position, y_axis.get("reverse"))
+
+        # Write the c:majorGridlines element.
+        self._write_major_gridlines(x_axis.get("major_gridlines"))
+
+        # Write the c:minorGridlines element.
+        self._write_minor_gridlines(x_axis.get("minor_gridlines"))
+
+        # Write the axis title elements.
+        if x_axis["formula"] is not None:
+            self._write_title_formula(
+                x_axis["formula"],
+                x_axis["data_id"],
+                is_y_axis,
+                x_axis["name_font"],
+                x_axis["name_layout"],
+            )
+        elif x_axis["name"] is not None:
+            self._write_title_rich(
+                x_axis["name"], is_y_axis, x_axis["name_font"], x_axis["name_layout"]
+            )
+
+        # Write the c:numFmt element.
+        self._write_cat_number_format(x_axis)
+
+        # Write the c:majorTickMark element.
+        self._write_major_tick_mark(x_axis.get("major_tick_mark"))
+
+        # Write the c:minorTickMark element.
+        self._write_minor_tick_mark(x_axis.get("minor_tick_mark"))
+
+        # Write the c:tickLblPos element.
+        self._write_tick_label_pos(x_axis.get("label_position"))
+
+        # Write the c:spPr element for the axis line.
+        self._write_sp_pr(x_axis)
+
+        # Write the axis font elements.
+        self._write_axis_font(x_axis.get("num_font"))
+
+        # Write the c:crossAx element.
+        self._write_cross_axis(axis_ids[1])
+
+        if self.show_crosses or x_axis.get("visible"):
+            # Note, the category crossing comes from the value axis.
+            if (
+                y_axis.get("crossing") is None
+                or y_axis.get("crossing") == "max"
+                or y_axis["crossing"] == "min"
+            ):
+                # Write the c:crosses element.
+                self._write_crosses(y_axis.get("crossing"))
+            else:
+                # Write the c:crossesAt element.
+                self._write_c_crosses_at(y_axis.get("crossing"))
+
+        # Write the c:auto element.
+        if not x_axis.get("text_axis"):
+            self._write_auto(1)
+
+        # Write the c:labelAlign element.
+        self._write_label_align(x_axis.get("label_align"))
+
+        # Write the c:labelOffset element.
+        self._write_label_offset(100)
+
+        # Write the c:tickLblSkip element.
+        self._write_c_tick_lbl_skip(x_axis.get("interval_unit"))
+
+        # Write the c:tickMarkSkip element.
+        self._write_c_tick_mark_skip(x_axis.get("interval_tick"))
+
+        self._xml_end_tag("c:catAx")
+
+    def _write_val_axis(self, args):
+        # Write the <c:valAx> element. Usually the Y axis.
+        x_axis = args["x_axis"]
+        y_axis = args["y_axis"]
+        axis_ids = args["axis_ids"]
+        position = args.get("position", self.val_axis_position)
+        is_y_axis = self.horiz_val_axis
+
+        # If there are no axis_ids then we don't need to write this element.
+        if axis_ids is None or not axis_ids:
+            return
+
+        # Overwrite the default axis position with a user supplied value.
+        position = y_axis.get("position") or position
+
+        self._xml_start_tag("c:valAx")
+
+        self._write_axis_id(axis_ids[1])
+
+        # Write the c:scaling element.
+        self._write_scaling(
+            y_axis.get("reverse"),
+            y_axis.get("min"),
+            y_axis.get("max"),
+            y_axis.get("log_base"),
+        )
+
+        if not y_axis.get("visible"):
+            self._write_delete(1)
+
+        # Write the c:axPos element.
+        self._write_axis_pos(position, x_axis.get("reverse"))
+
+        # Write the c:majorGridlines element.
+        self._write_major_gridlines(y_axis.get("major_gridlines"))
+
+        # Write the c:minorGridlines element.
+        self._write_minor_gridlines(y_axis.get("minor_gridlines"))
+
+        # Write the axis title elements.
+        if y_axis["formula"] is not None:
+            self._write_title_formula(
+                y_axis["formula"],
+                y_axis["data_id"],
+                is_y_axis,
+                y_axis["name_font"],
+                y_axis["name_layout"],
+            )
+        elif y_axis["name"] is not None:
+            self._write_title_rich(
+                y_axis["name"],
+                is_y_axis,
+                y_axis.get("name_font"),
+                y_axis.get("name_layout"),
+            )
+
+        # Write the c:numberFormat element.
+        self._write_number_format(y_axis)
+
+        # Write the c:majorTickMark element.
+        self._write_major_tick_mark(y_axis.get("major_tick_mark"))
+
+        # Write the c:minorTickMark element.
+        self._write_minor_tick_mark(y_axis.get("minor_tick_mark"))
+
+        # Write the c:tickLblPos element.
+        self._write_tick_label_pos(y_axis.get("label_position"))
+
+        # Write the c:spPr element for the axis line.
+        self._write_sp_pr(y_axis)
+
+        # Write the axis font elements.
+        self._write_axis_font(y_axis.get("num_font"))
+
+        # Write the c:crossAx element.
+        self._write_cross_axis(axis_ids[0])
+
+        # Note, the category crossing comes from the value axis.
+        if (
+            x_axis.get("crossing") is None
+            or x_axis["crossing"] == "max"
+            or x_axis["crossing"] == "min"
+        ):
+            # Write the c:crosses element.
+            self._write_crosses(x_axis.get("crossing"))
+        else:
+            # Write the c:crossesAt element.
+            self._write_c_crosses_at(x_axis.get("crossing"))
+
+        # Write the c:crossBetween element.
+        self._write_cross_between(x_axis.get("position_axis"))
+
+        # Write the c:majorUnit element.
+        self._write_c_major_unit(y_axis.get("major_unit"))
+
+        # Write the c:minorUnit element.
+        self._write_c_minor_unit(y_axis.get("minor_unit"))
+
+        # Write the c:dispUnits element.
+        self._write_disp_units(
+            y_axis.get("display_units"), y_axis.get("display_units_visible")
+        )
+
+        self._xml_end_tag("c:valAx")
+
+    def _write_cat_val_axis(self, args):
+        # Write the <c:valAx> element. This is for the second valAx
+        # in scatter plots. Usually the X axis.
+        x_axis = args["x_axis"]
+        y_axis = args["y_axis"]
+        axis_ids = args["axis_ids"]
+        position = args["position"] or self.val_axis_position
+        is_y_axis = self.horiz_val_axis
+
+        # If there are no axis_ids then we don't need to write this element.
+        if axis_ids is None or not axis_ids:
+            return
+
+        # Overwrite the default axis position with a user supplied value.
+        position = x_axis.get("position") or position
+
+        self._xml_start_tag("c:valAx")
+
+        self._write_axis_id(axis_ids[0])
+
+        # Write the c:scaling element.
+        self._write_scaling(
+            x_axis.get("reverse"),
+            x_axis.get("min"),
+            x_axis.get("max"),
+            x_axis.get("log_base"),
+        )
+
+        if not x_axis.get("visible"):
+            self._write_delete(1)
+
+        # Write the c:axPos element.
+        self._write_axis_pos(position, y_axis.get("reverse"))
+
+        # Write the c:majorGridlines element.
+        self._write_major_gridlines(x_axis.get("major_gridlines"))
+
+        # Write the c:minorGridlines element.
+        self._write_minor_gridlines(x_axis.get("minor_gridlines"))
+
+        # Write the axis title elements.
+        if x_axis["formula"] is not None:
+            self._write_title_formula(
+                x_axis["formula"],
+                x_axis["data_id"],
+                is_y_axis,
+                x_axis["name_font"],
+                x_axis["name_layout"],
+            )
+        elif x_axis["name"] is not None:
+            self._write_title_rich(
+                x_axis["name"], is_y_axis, x_axis["name_font"], x_axis["name_layout"]
+            )
+
+        # Write the c:numberFormat element.
+        self._write_number_format(x_axis)
+
+        # Write the c:majorTickMark element.
+        self._write_major_tick_mark(x_axis.get("major_tick_mark"))
+
+        # Write the c:minorTickMark element.
+        self._write_minor_tick_mark(x_axis.get("minor_tick_mark"))
+
+        # Write the c:tickLblPos element.
+        self._write_tick_label_pos(x_axis.get("label_position"))
+
+        # Write the c:spPr element for the axis line.
+        self._write_sp_pr(x_axis)
+
+        # Write the axis font elements.
+        self._write_axis_font(x_axis.get("num_font"))
+
+        # Write the c:crossAx element.
+        self._write_cross_axis(axis_ids[1])
+
+        # Note, the category crossing comes from the value axis.
+        if (
+            y_axis.get("crossing") is None
+            or y_axis["crossing"] == "max"
+            or y_axis["crossing"] == "min"
+        ):
+            # Write the c:crosses element.
+            self._write_crosses(y_axis.get("crossing"))
+        else:
+            # Write the c:crossesAt element.
+            self._write_c_crosses_at(y_axis.get("crossing"))
+
+        # Write the c:crossBetween element.
+        self._write_cross_between(y_axis.get("position_axis"))
+
+        # Write the c:majorUnit element.
+        self._write_c_major_unit(x_axis.get("major_unit"))
+
+        # Write the c:minorUnit element.
+        self._write_c_minor_unit(x_axis.get("minor_unit"))
+
+        # Write the c:dispUnits element.
+        self._write_disp_units(
+            x_axis.get("display_units"), x_axis.get("display_units_visible")
+        )
+
+        self._xml_end_tag("c:valAx")
+
+    def _write_date_axis(self, args):
+        # Write the <c:dateAx> element. Usually the X axis.
+        x_axis = args["x_axis"]
+        y_axis = args["y_axis"]
+        axis_ids = args["axis_ids"]
+
+        # If there are no axis_ids then we don't need to write this element.
+        if axis_ids is None or not axis_ids:
+            return
+
+        position = self.cat_axis_position
+
+        # Overwrite the default axis position with a user supplied value.
+        position = x_axis.get("position") or position
+
+        self._xml_start_tag("c:dateAx")
+
+        self._write_axis_id(axis_ids[0])
+
+        # Write the c:scaling element.
+        self._write_scaling(
+            x_axis.get("reverse"),
+            x_axis.get("min"),
+            x_axis.get("max"),
+            x_axis.get("log_base"),
+        )
+
+        if not x_axis.get("visible"):
+            self._write_delete(1)
+
+        # Write the c:axPos element.
+        self._write_axis_pos(position, y_axis.get("reverse"))
+
+        # Write the c:majorGridlines element.
+        self._write_major_gridlines(x_axis.get("major_gridlines"))
+
+        # Write the c:minorGridlines element.
+        self._write_minor_gridlines(x_axis.get("minor_gridlines"))
+
+        # Write the axis title elements.
+        if x_axis["formula"] is not None:
+            self._write_title_formula(
+                x_axis["formula"],
+                x_axis["data_id"],
+                None,
+                x_axis["name_font"],
+                x_axis["name_layout"],
+            )
+        elif x_axis["name"] is not None:
+            self._write_title_rich(
+                x_axis["name"], None, x_axis["name_font"], x_axis["name_layout"]
+            )
+
+        # Write the c:numFmt element.
+        self._write_number_format(x_axis)
+
+        # Write the c:majorTickMark element.
+        self._write_major_tick_mark(x_axis.get("major_tick_mark"))
+
+        # Write the c:minorTickMark element.
+        self._write_minor_tick_mark(x_axis.get("minor_tick_mark"))
+
+        # Write the c:tickLblPos element.
+        self._write_tick_label_pos(x_axis.get("label_position"))
+
+        # Write the c:spPr element for the axis line.
+        self._write_sp_pr(x_axis)
+
+        # Write the axis font elements.
+        self._write_axis_font(x_axis.get("num_font"))
+
+        # Write the c:crossAx element.
+        self._write_cross_axis(axis_ids[1])
+
+        if self.show_crosses or x_axis.get("visible"):
+            # Note, the category crossing comes from the value axis.
+            if (
+                y_axis.get("crossing") is None
+                or y_axis.get("crossing") == "max"
+                or y_axis["crossing"] == "min"
+            ):
+                # Write the c:crosses element.
+                self._write_crosses(y_axis.get("crossing"))
+            else:
+                # Write the c:crossesAt element.
+                self._write_c_crosses_at(y_axis.get("crossing"))
+
+        # Write the c:auto element.
+        self._write_auto(1)
+
+        # Write the c:labelOffset element.
+        self._write_label_offset(100)
+
+        # Write the c:tickLblSkip element.
+        self._write_c_tick_lbl_skip(x_axis.get("interval_unit"))
+
+        # Write the c:tickMarkSkip element.
+        self._write_c_tick_mark_skip(x_axis.get("interval_tick"))
+
+        # Write the c:majorUnit element.
+        self._write_c_major_unit(x_axis.get("major_unit"))
+
+        # Write the c:majorTimeUnit element.
+        if x_axis.get("major_unit"):
+            self._write_c_major_time_unit(x_axis["major_unit_type"])
+
+        # Write the c:minorUnit element.
+        self._write_c_minor_unit(x_axis.get("minor_unit"))
+
+        # Write the c:minorTimeUnit element.
+        if x_axis.get("minor_unit"):
+            self._write_c_minor_time_unit(x_axis["minor_unit_type"])
+
+        self._xml_end_tag("c:dateAx")
+
+    def _write_scaling(self, reverse, min_val, max_val, log_base):
+        # Write the <c:scaling> element.
+
+        self._xml_start_tag("c:scaling")
+
+        # Write the c:logBase element.
+        self._write_c_log_base(log_base)
+
+        # Write the c:orientation element.
+        self._write_orientation(reverse)
+
+        # Write the c:max element.
+        self._write_c_max(max_val)
+
+        # Write the c:min element.
+        self._write_c_min(min_val)
+
+        self._xml_end_tag("c:scaling")
+
+    def _write_c_log_base(self, val):
+        # Write the <c:logBase> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:logBase", attributes)
+
+    def _write_orientation(self, reverse):
+        # Write the <c:orientation> element.
+        val = "minMax"
+
+        if reverse:
+            val = "maxMin"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:orientation", attributes)
+
+    def _write_c_max(self, max_val):
+        # Write the <c:max> element.
+
+        if max_val is None:
+            return
+
+        attributes = [("val", max_val)]
+
+        self._xml_empty_tag("c:max", attributes)
+
+    def _write_c_min(self, min_val):
+        # Write the <c:min> element.
+
+        if min_val is None:
+            return
+
+        attributes = [("val", min_val)]
+
+        self._xml_empty_tag("c:min", attributes)
+
+    def _write_axis_pos(self, val, reverse):
+        # Write the <c:axPos> element.
+
+        if reverse:
+            if val == "l":
+                val = "r"
+            if val == "b":
+                val = "t"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:axPos", attributes)
+
+    def _write_number_format(self, axis):
+        # Write the <c:numberFormat> element. Note: It is assumed that if
+        # a user defined number format is supplied (i.e., non-default) then
+        # the sourceLinked attribute is 0.
+        # The user can override this if required.
+        format_code = axis.get("num_format")
+        source_linked = 1
+
+        # Check if a user defined number format has been set.
+        if format_code is not None and format_code != axis["defaults"]["num_format"]:
+            source_linked = 0
+
+        # User override of sourceLinked.
+        if axis.get("num_format_linked"):
+            source_linked = 1
+
+        attributes = [
+            ("formatCode", format_code),
+            ("sourceLinked", source_linked),
+        ]
+
+        self._xml_empty_tag("c:numFmt", attributes)
+
+    def _write_cat_number_format(self, axis):
+        # Write the <c:numFmt> element. Special case handler for category
+        # axes which don't always have a number format.
+        format_code = axis.get("num_format")
+        source_linked = 1
+        default_format = 1
+
+        # Check if a user defined number format has been set.
+        if format_code is not None and format_code != axis["defaults"]["num_format"]:
+            source_linked = 0
+            default_format = 0
+
+        # User override of sourceLinked.
+        if axis.get("num_format_linked"):
+            source_linked = 1
+
+        # Skip if cat doesn't have a num format (unless it is non-default).
+        if not self.cat_has_num_fmt and default_format:
+            return
+
+        attributes = [
+            ("formatCode", format_code),
+            ("sourceLinked", source_linked),
+        ]
+
+        self._xml_empty_tag("c:numFmt", attributes)
+
+    def _write_data_label_number_format(self, format_code):
+        # Write the <c:numberFormat> element for data labels.
+        source_linked = 0
+
+        attributes = [
+            ("formatCode", format_code),
+            ("sourceLinked", source_linked),
+        ]
+
+        self._xml_empty_tag("c:numFmt", attributes)
+
+    def _write_major_tick_mark(self, val):
+        # Write the <c:majorTickMark> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:majorTickMark", attributes)
+
+    def _write_minor_tick_mark(self, val):
+        # Write the <c:minorTickMark> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:minorTickMark", attributes)
+
+    def _write_tick_label_pos(self, val=None):
+        # Write the <c:tickLblPos> element.
+        if val is None or val == "next_to":
+            val = "nextTo"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:tickLblPos", attributes)
+
+    def _write_cross_axis(self, val):
+        # Write the <c:crossAx> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:crossAx", attributes)
+
+    def _write_crosses(self, val=None):
+        # Write the <c:crosses> element.
+        if val is None:
+            val = "autoZero"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:crosses", attributes)
+
+    def _write_c_crosses_at(self, val):
+        # Write the <c:crossesAt> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:crossesAt", attributes)
+
+    def _write_auto(self, val):
+        # Write the <c:auto> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:auto", attributes)
+
+    def _write_label_align(self, val=None):
+        # Write the <c:labelAlign> element.
+
+        if val is None:
+            val = "ctr"
+
+        if val == "right":
+            val = "r"
+
+        if val == "left":
+            val = "l"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:lblAlgn", attributes)
+
+    def _write_label_offset(self, val):
+        # Write the <c:labelOffset> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:lblOffset", attributes)
+
+    def _write_c_tick_lbl_skip(self, val):
+        # Write the <c:tickLblSkip> element.
+        if val is None:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:tickLblSkip", attributes)
+
+    def _write_c_tick_mark_skip(self, val):
+        # Write the <c:tickMarkSkip> element.
+        if val is None:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:tickMarkSkip", attributes)
+
+    def _write_major_gridlines(self, gridlines):
+        # Write the <c:majorGridlines> element.
+
+        if not gridlines:
+            return
+
+        if not gridlines["visible"]:
+            return
+
+        if gridlines["line"]["defined"]:
+            self._xml_start_tag("c:majorGridlines")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(gridlines)
+
+            self._xml_end_tag("c:majorGridlines")
+        else:
+            self._xml_empty_tag("c:majorGridlines")
+
+    def _write_minor_gridlines(self, gridlines):
+        # Write the <c:minorGridlines> element.
+
+        if not gridlines:
+            return
+
+        if not gridlines["visible"]:
+            return
+
+        if gridlines["line"]["defined"]:
+            self._xml_start_tag("c:minorGridlines")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(gridlines)
+
+            self._xml_end_tag("c:minorGridlines")
+        else:
+            self._xml_empty_tag("c:minorGridlines")
+
+    def _write_cross_between(self, val):
+        # Write the <c:crossBetween> element.
+        if val is None:
+            val = self.cross_between
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:crossBetween", attributes)
+
+    def _write_c_major_unit(self, val):
+        # Write the <c:majorUnit> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:majorUnit", attributes)
+
+    def _write_c_minor_unit(self, val):
+        # Write the <c:minorUnit> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:minorUnit", attributes)
+
+    def _write_c_major_time_unit(self, val=None):
+        # Write the <c:majorTimeUnit> element.
+        if val is None:
+            val = "days"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:majorTimeUnit", attributes)
+
+    def _write_c_minor_time_unit(self, val=None):
+        # Write the <c:minorTimeUnit> element.
+        if val is None:
+            val = "days"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:minorTimeUnit", attributes)
+
+    def _write_legend(self):
+        # Write the <c:legend> element.
+        legend = self.legend
+        position = legend.get("position", "right")
+        font = legend.get("font")
+        delete_series = []
+        overlay = 0
+
+        if legend.get("delete_series") and isinstance(legend["delete_series"], list):
+            delete_series = legend["delete_series"]
+
+        if position.startswith("overlay_"):
+            position = position.replace("overlay_", "")
+            overlay = 1
+
+        allowed = {
+            "right": "r",
+            "left": "l",
+            "top": "t",
+            "bottom": "b",
+            "top_right": "tr",
+        }
+
+        if position == "none":
+            return
+
+        if position not in allowed:
+            return
+
+        position = allowed[position]
+
+        self._xml_start_tag("c:legend")
+
+        # Write the c:legendPos element.
+        self._write_legend_pos(position)
+
+        # Remove series labels from the legend.
+        for index in delete_series:
+            # Write the c:legendEntry element.
+            self._write_legend_entry(index)
+
+        # Write the c:layout element.
+        self._write_layout(legend.get("layout"), "legend")
+
+        # Write the c:overlay element.
+        if overlay:
+            self._write_overlay()
+
+        if font:
+            self._write_tx_pr(font)
+
+        # Write the c:spPr element.
+        self._write_sp_pr(legend)
+
+        self._xml_end_tag("c:legend")
+
+    def _write_legend_pos(self, val):
+        # Write the <c:legendPos> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:legendPos", attributes)
+
+    def _write_legend_entry(self, index):
+        # Write the <c:legendEntry> element.
+
+        self._xml_start_tag("c:legendEntry")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        # Write the c:delete element.
+        self._write_delete(1)
+
+        self._xml_end_tag("c:legendEntry")
+
+    def _write_overlay(self):
+        # Write the <c:overlay> element.
+        val = 1
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:overlay", attributes)
+
+    def _write_plot_vis_only(self):
+        # Write the <c:plotVisOnly> element.
+        val = 1
+
+        # Ignore this element if we are plotting hidden data.
+        if self.show_hidden:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:plotVisOnly", attributes)
+
+    def _write_print_settings(self):
+        # Write the <c:printSettings> element.
+        self._xml_start_tag("c:printSettings")
+
+        # Write the c:headerFooter element.
+        self._write_header_footer()
+
+        # Write the c:pageMargins element.
+        self._write_page_margins()
+
+        # Write the c:pageSetup element.
+        self._write_page_setup()
+
+        self._xml_end_tag("c:printSettings")
+
+    def _write_header_footer(self):
+        # Write the <c:headerFooter> element.
+        self._xml_empty_tag("c:headerFooter")
+
+    def _write_page_margins(self):
+        # Write the <c:pageMargins> element.
+        bottom = 0.75
+        left = 0.7
+        right = 0.7
+        top = 0.75
+        header = 0.3
+        footer = 0.3
+
+        attributes = [
+            ("b", bottom),
+            ("l", left),
+            ("r", right),
+            ("t", top),
+            ("header", header),
+            ("footer", footer),
+        ]
+
+        self._xml_empty_tag("c:pageMargins", attributes)
+
+    def _write_page_setup(self):
+        # Write the <c:pageSetup> element.
+        self._xml_empty_tag("c:pageSetup")
+
+    def _write_c_auto_title_deleted(self):
+        # Write the <c:autoTitleDeleted> element.
+        self._xml_empty_tag("c:autoTitleDeleted", [("val", 1)])
+
+    def _write_title_rich(self, title, is_y_axis, font, layout, overlay=False):
+        # Write the <c:title> element for a rich string.
+
+        self._xml_start_tag("c:title")
+
+        # Write the c:tx element.
+        self._write_tx_rich(title, is_y_axis, font)
+
+        # Write the c:layout element.
+        self._write_layout(layout, "text")
+
+        # Write the c:overlay element.
+        if overlay:
+            self._write_overlay()
+
+        self._xml_end_tag("c:title")
+
+    def _write_title_formula(
+        self, title, data_id, is_y_axis, font, layout, overlay=False
+    ):
+        # Write the <c:title> element for a rich string.
+
+        self._xml_start_tag("c:title")
+
+        # Write the c:tx element.
+        self._write_tx_formula(title, data_id)
+
+        # Write the c:layout element.
+        self._write_layout(layout, "text")
+
+        # Write the c:overlay element.
+        if overlay:
+            self._write_overlay()
+
+        # Write the c:txPr element.
+        self._write_tx_pr(font, is_y_axis)
+
+        self._xml_end_tag("c:title")
+
+    def _write_tx_rich(self, title, is_y_axis, font):
+        # Write the <c:tx> element.
+
+        self._xml_start_tag("c:tx")
+
+        # Write the c:rich element.
+        self._write_rich(title, font, is_y_axis, ignore_rich_pr=False)
+
+        self._xml_end_tag("c:tx")
+
+    def _write_tx_value(self, title):
+        # Write the <c:tx> element with a value such as for series names.
+
+        self._xml_start_tag("c:tx")
+
+        # Write the c:v element.
+        self._write_v(title)
+
+        self._xml_end_tag("c:tx")
+
+    def _write_tx_formula(self, title, data_id):
+        # Write the <c:tx> element.
+        data = None
+
+        if data_id is not None:
+            data = self.formula_data[data_id]
+
+        self._xml_start_tag("c:tx")
+
+        # Write the c:strRef element.
+        self._write_str_ref(title, data, "str")
+
+        self._xml_end_tag("c:tx")
+
+    def _write_rich(self, title, font, is_y_axis, ignore_rich_pr):
+        # Write the <c:rich> element.
+
+        if font and font.get("rotation") is not None:
+            rotation = font["rotation"]
+        else:
+            rotation = None
+
+        self._xml_start_tag("c:rich")
+
+        # Write the a:bodyPr element.
+        self._write_a_body_pr(rotation, is_y_axis)
+
+        # Write the a:lstStyle element.
+        self._write_a_lst_style()
+
+        # Write the a:p element.
+        self._write_a_p_rich(title, font, ignore_rich_pr)
+
+        self._xml_end_tag("c:rich")
+
+    def _write_a_body_pr(self, rotation, is_y_axis):
+        # Write the <a:bodyPr> element.
+        attributes = []
+
+        if rotation is None and is_y_axis:
+            rotation = -5400000
+
+        if rotation is not None:
+            if rotation == 16200000:
+                # 270 deg/stacked angle.
+                attributes.append(("rot", 0))
+                attributes.append(("vert", "wordArtVert"))
+            elif rotation == 16260000:
+                # 271 deg/East Asian vertical.
+                attributes.append(("rot", 0))
+                attributes.append(("vert", "eaVert"))
+            else:
+                attributes.append(("rot", rotation))
+                attributes.append(("vert", "horz"))
+
+        self._xml_empty_tag("a:bodyPr", attributes)
+
+    def _write_a_lst_style(self):
+        # Write the <a:lstStyle> element.
+        self._xml_empty_tag("a:lstStyle")
+
+    def _write_a_p_rich(self, title, font, ignore_rich_pr):
+        # Write the <a:p> element for rich string titles.
+
+        self._xml_start_tag("a:p")
+
+        # Write the a:pPr element.
+        if not ignore_rich_pr:
+            self._write_a_p_pr_rich(font)
+
+        # Write the a:r element.
+        self._write_a_r(title, font)
+
+        self._xml_end_tag("a:p")
+
+    def _write_a_p_formula(self, font):
+        # Write the <a:p> element for formula titles.
+
+        self._xml_start_tag("a:p")
+
+        # Write the a:pPr element.
+        self._write_a_p_pr_rich(font)
+
+        # Write the a:endParaRPr element.
+        self._write_a_end_para_rpr()
+
+        self._xml_end_tag("a:p")
+
+    def _write_a_p_pr_rich(self, font):
+        # Write the <a:pPr> element for rich string titles.
+
+        self._xml_start_tag("a:pPr")
+
+        # Write the a:defRPr element.
+        self._write_a_def_rpr(font)
+
+        self._xml_end_tag("a:pPr")
+
+    def _write_a_def_rpr(self, font):
+        # Write the <a:defRPr> element.
+        has_color = 0
+
+        style_attributes = Shape._get_font_style_attributes(font)
+        latin_attributes = Shape._get_font_latin_attributes(font)
+
+        if font and font.get("color") is not None:
+            has_color = 1
+
+        if latin_attributes or has_color:
+            self._xml_start_tag("a:defRPr", style_attributes)
+
+            if has_color:
+                self._write_a_solid_fill({"color": font["color"]})
+
+            if latin_attributes:
+                self._write_a_latin(latin_attributes)
+
+            self._xml_end_tag("a:defRPr")
+        else:
+            self._xml_empty_tag("a:defRPr", style_attributes)
+
+    def _write_a_end_para_rpr(self):
+        # Write the <a:endParaRPr> element.
+        lang = "en-US"
+
+        attributes = [("lang", lang)]
+
+        self._xml_empty_tag("a:endParaRPr", attributes)
+
+    def _write_a_r(self, title, font):
+        # Write the <a:r> element.
+
+        self._xml_start_tag("a:r")
+
+        # Write the a:rPr element.
+        self._write_a_r_pr(font)
+
+        # Write the a:t element.
+        self._write_a_t(title)
+
+        self._xml_end_tag("a:r")
+
+    def _write_a_r_pr(self, font):
+        # Write the <a:rPr> element.
+        has_color = 0
+        lang = "en-US"
+
+        style_attributes = Shape._get_font_style_attributes(font)
+        latin_attributes = Shape._get_font_latin_attributes(font)
+
+        if font and font["color"] is not None:
+            has_color = 1
+
+        # Add the lang type to the attributes.
+        style_attributes.insert(0, ("lang", lang))
+
+        if latin_attributes or has_color:
+            self._xml_start_tag("a:rPr", style_attributes)
+
+            if has_color:
+                self._write_a_solid_fill({"color": font["color"]})
+
+            if latin_attributes:
+                self._write_a_latin(latin_attributes)
+
+            self._xml_end_tag("a:rPr")
+        else:
+            self._xml_empty_tag("a:rPr", style_attributes)
+
+    def _write_a_t(self, title):
+        # Write the <a:t> element.
+
+        self._xml_data_element("a:t", title)
+
+    def _write_tx_pr(self, font, is_y_axis=False):
+        # Write the <c:txPr> element.
+
+        if font and font.get("rotation") is not None:
+            rotation = font["rotation"]
+        else:
+            rotation = None
+
+        self._xml_start_tag("c:txPr")
+
+        # Write the a:bodyPr element.
+        self._write_a_body_pr(rotation, is_y_axis)
+
+        # Write the a:lstStyle element.
+        self._write_a_lst_style()
+
+        # Write the a:p element.
+        self._write_a_p_formula(font)
+
+        self._xml_end_tag("c:txPr")
+
+    def _write_marker(self, marker):
+        # Write the <c:marker> element.
+        if marker is None:
+            marker = self.default_marker
+
+        if not marker:
+            return
+
+        if marker["type"] == "automatic":
+            return
+
+        self._xml_start_tag("c:marker")
+
+        # Write the c:symbol element.
+        self._write_symbol(marker["type"])
+
+        # Write the c:size element.
+        if marker.get("size"):
+            self._write_marker_size(marker["size"])
+
+        # Write the c:spPr element.
+        self._write_sp_pr(marker)
+
+        self._xml_end_tag("c:marker")
+
+    def _write_marker_size(self, val):
+        # Write the <c:size> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:size", attributes)
+
+    def _write_symbol(self, val):
+        # Write the <c:symbol> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:symbol", attributes)
+
+    def _write_sp_pr(self, series):
+        # Write the <c:spPr> element.
+
+        if not self._has_fill_formatting(series):
+            return
+
+        self._xml_start_tag("c:spPr")
+
+        # Write the fill elements for solid charts such as pie and bar.
+        if series.get("fill") and series["fill"]["defined"]:
+            if "none" in series["fill"]:
+                # Write the a:noFill element.
+                self._write_a_no_fill()
+            else:
+                # Write the a:solidFill element.
+                self._write_a_solid_fill(series["fill"])
+
+        if series.get("pattern"):
+            # Write the a:gradFill element.
+            self._write_a_patt_fill(series["pattern"])
+
+        if series.get("gradient"):
+            # Write the a:gradFill element.
+            self._write_a_grad_fill(series["gradient"])
+
+        # Write the a:ln element.
+        if series.get("line") and series["line"]["defined"]:
+            self._write_a_ln(series["line"])
+
+        self._xml_end_tag("c:spPr")
+
+    def _write_a_ln(self, line):
+        # Write the <a:ln> element.
+        attributes = []
+
+        # Add the line width as an attribute.
+        width = line.get("width")
+
+        if width is not None:
+            # Round width to nearest 0.25, like Excel.
+            width = int((width + 0.125) * 4) / 4.0
+
+            # Convert to internal units.
+            width = int(0.5 + (12700 * width))
+
+            attributes = [("w", width)]
+
+        if line.get("none") or line.get("color") or line.get("dash_type"):
+            self._xml_start_tag("a:ln", attributes)
+
+            # Write the line fill.
+            if "none" in line:
+                # Write the a:noFill element.
+                self._write_a_no_fill()
+            elif "color" in line:
+                # Write the a:solidFill element.
+                self._write_a_solid_fill(line)
+
+            # Write the line/dash type.
+            line_type = line.get("dash_type")
+            if line_type:
+                # Write the a:prstDash element.
+                self._write_a_prst_dash(line_type)
+
+            self._xml_end_tag("a:ln")
+        else:
+            self._xml_empty_tag("a:ln", attributes)
+
+    def _write_a_no_fill(self):
+        # Write the <a:noFill> element.
+        self._xml_empty_tag("a:noFill")
+
+    def _write_a_solid_fill(self, fill):
+        # Write the <a:solidFill> element.
+
+        self._xml_start_tag("a:solidFill")
+
+        if "color" in fill:
+            color = _get_rgb_color(fill["color"])
+            transparency = fill.get("transparency")
+            # Write the a:srgbClr element.
+            self._write_a_srgb_clr(color, transparency)
+
+        self._xml_end_tag("a:solidFill")
+
+    def _write_a_srgb_clr(self, val, transparency=None):
+        # Write the <a:srgbClr> element.
+        attributes = [("val", val)]
+
+        if transparency:
+            self._xml_start_tag("a:srgbClr", attributes)
+
+            # Write the a:alpha element.
+            self._write_a_alpha(transparency)
+
+            self._xml_end_tag("a:srgbClr")
+        else:
+            self._xml_empty_tag("a:srgbClr", attributes)
+
+    def _write_a_alpha(self, val):
+        # Write the <a:alpha> element.
+
+        val = int((100 - int(val)) * 1000)
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("a:alpha", attributes)
+
+    def _write_a_prst_dash(self, val):
+        # Write the <a:prstDash> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("a:prstDash", attributes)
+
+    def _write_trendline(self, trendline):
+        # Write the <c:trendline> element.
+
+        if not trendline:
+            return
+
+        self._xml_start_tag("c:trendline")
+
+        # Write the c:name element.
+        self._write_name(trendline.get("name"))
+
+        # Write the c:spPr element.
+        self._write_sp_pr(trendline)
+
+        # Write the c:trendlineType element.
+        self._write_trendline_type(trendline["type"])
+
+        # Write the c:order element for polynomial trendlines.
+        if trendline["type"] == "poly":
+            self._write_trendline_order(trendline.get("order"))
+
+        # Write the c:period element for moving average trendlines.
+        if trendline["type"] == "movingAvg":
+            self._write_period(trendline.get("period"))
+
+        # Write the c:forward element.
+        self._write_forward(trendline.get("forward"))
+
+        # Write the c:backward element.
+        self._write_backward(trendline.get("backward"))
+
+        if "intercept" in trendline:
+            # Write the c:intercept element.
+            self._write_c_intercept(trendline["intercept"])
+
+        if trendline.get("display_r_squared"):
+            # Write the c:dispRSqr element.
+            self._write_c_disp_rsqr()
+
+        if trendline.get("display_equation"):
+            # Write the c:dispEq element.
+            self._write_c_disp_eq()
+
+            # Write the c:trendlineLbl element.
+            self._write_c_trendline_lbl(trendline)
+
+        self._xml_end_tag("c:trendline")
+
+    def _write_trendline_type(self, val):
+        # Write the <c:trendlineType> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:trendlineType", attributes)
+
+    def _write_name(self, data):
+        # Write the <c:name> element.
+
+        if data is None:
+            return
+
+        self._xml_data_element("c:name", data)
+
+    def _write_trendline_order(self, val):
+        # Write the <c:order> element.
+        val = max(val, 2)
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:order", attributes)
+
+    def _write_period(self, val):
+        # Write the <c:period> element.
+        val = max(val, 2)
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:period", attributes)
+
+    def _write_forward(self, val):
+        # Write the <c:forward> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:forward", attributes)
+
+    def _write_backward(self, val):
+        # Write the <c:backward> element.
+
+        if not val:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:backward", attributes)
+
+    def _write_c_intercept(self, val):
+        # Write the <c:intercept> element.
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:intercept", attributes)
+
+    def _write_c_disp_eq(self):
+        # Write the <c:dispEq> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:dispEq", attributes)
+
+    def _write_c_disp_rsqr(self):
+        # Write the <c:dispRSqr> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:dispRSqr", attributes)
+
+    def _write_c_trendline_lbl(self, trendline):
+        # Write the <c:trendlineLbl> element.
+        self._xml_start_tag("c:trendlineLbl")
+
+        # Write the c:layout element.
+        self._write_layout(None, None)
+
+        # Write the c:numFmt element.
+        self._write_trendline_num_fmt()
+
+        # Write the c:spPr element.
+        self._write_sp_pr(trendline["label"])
+
+        # Write the data label font elements.
+        if trendline["label"]:
+            font = trendline["label"].get("font")
+            if font:
+                self._write_axis_font(font)
+
+        self._xml_end_tag("c:trendlineLbl")
+
+    def _write_trendline_num_fmt(self):
+        # Write the <c:numFmt> element.
+        attributes = [
+            ("formatCode", "General"),
+            ("sourceLinked", 0),
+        ]
+
+        self._xml_empty_tag("c:numFmt", attributes)
+
+    def _write_hi_low_lines(self):
+        # Write the <c:hiLowLines> element.
+        hi_low_lines = self.hi_low_lines
+
+        if hi_low_lines is None:
+            return
+
+        if "line" in hi_low_lines and hi_low_lines["line"]["defined"]:
+            self._xml_start_tag("c:hiLowLines")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(hi_low_lines)
+
+            self._xml_end_tag("c:hiLowLines")
+        else:
+            self._xml_empty_tag("c:hiLowLines")
+
+    def _write_drop_lines(self):
+        # Write the <c:dropLines> element.
+        drop_lines = self.drop_lines
+
+        if drop_lines is None:
+            return
+
+        if drop_lines["line"]["defined"]:
+            self._xml_start_tag("c:dropLines")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(drop_lines)
+
+            self._xml_end_tag("c:dropLines")
+        else:
+            self._xml_empty_tag("c:dropLines")
+
+    def _write_overlap(self, val):
+        # Write the <c:overlap> element.
+
+        if val is None:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:overlap", attributes)
+
+    def _write_num_cache(self, data):
+        # Write the <c:numCache> element.
+        if data:
+            count = len(data)
+        else:
+            count = 0
+
+        self._xml_start_tag("c:numCache")
+
+        # Write the c:formatCode element.
+        self._write_format_code("General")
+
+        # Write the c:ptCount element.
+        self._write_pt_count(count)
+
+        for i in range(count):
+            token = data[i]
+
+            if token is None:
+                continue
+
+            try:
+                float(token)
+            except ValueError:
+                # Write non-numeric data as 0.
+                token = 0
+
+            # Write the c:pt element.
+            self._write_pt(i, token)
+
+        self._xml_end_tag("c:numCache")
+
+    def _write_str_cache(self, data):
+        # Write the <c:strCache> element.
+        count = len(data)
+
+        self._xml_start_tag("c:strCache")
+
+        # Write the c:ptCount element.
+        self._write_pt_count(count)
+
+        for i in range(count):
+            # Write the c:pt element.
+            self._write_pt(i, data[i])
+
+        self._xml_end_tag("c:strCache")
+
+    def _write_format_code(self, data):
+        # Write the <c:formatCode> element.
+
+        self._xml_data_element("c:formatCode", data)
+
+    def _write_pt_count(self, val):
+        # Write the <c:ptCount> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:ptCount", attributes)
+
+    def _write_pt(self, idx, value):
+        # Write the <c:pt> element.
+
+        if value is None:
+            return
+
+        attributes = [("idx", idx)]
+
+        self._xml_start_tag("c:pt", attributes)
+
+        # Write the c:v element.
+        self._write_v(value)
+
+        self._xml_end_tag("c:pt")
+
+    def _write_v(self, data):
+        # Write the <c:v> element.
+
+        self._xml_data_element("c:v", data)
+
+    def _write_protection(self):
+        # Write the <c:protection> element.
+        if not self.protection:
+            return
+
+        self._xml_empty_tag("c:protection")
+
+    def _write_d_pt(self, points):
+        # Write the <c:dPt> elements.
+        index = -1
+
+        if not points:
+            return
+
+        for point in points:
+            index += 1
+            if not point:
+                continue
+
+            self._write_d_pt_point(index, point)
+
+    def _write_d_pt_point(self, index, point):
+        # Write an individual <c:dPt> element.
+
+        self._xml_start_tag("c:dPt")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        # Write the c:spPr element.
+        self._write_sp_pr(point)
+
+        self._xml_end_tag("c:dPt")
+
+    def _write_d_lbls(self, labels):
+        # Write the <c:dLbls> element.
+
+        if not labels:
+            return
+
+        self._xml_start_tag("c:dLbls")
+
+        # Write the custom c:dLbl elements.
+        if labels.get("custom"):
+            self._write_custom_labels(labels, labels["custom"])
+
+        # Write the c:numFmt element.
+        if labels.get("num_format"):
+            self._write_data_label_number_format(labels["num_format"])
+
+        # Write the c:spPr element for the plotarea formatting.
+        self._write_sp_pr(labels)
+
+        # Write the data label font elements.
+        if labels.get("font"):
+            self._write_axis_font(labels["font"])
+
+        # Write the c:dLblPos element.
+        if labels.get("position"):
+            self._write_d_lbl_pos(labels["position"])
+
+        # Write the c:showLegendKey element.
+        if labels.get("legend_key"):
+            self._write_show_legend_key()
+
+        # Write the c:showVal element.
+        if labels.get("value"):
+            self._write_show_val()
+
+        # Write the c:showCatName element.
+        if labels.get("category"):
+            self._write_show_cat_name()
+
+        # Write the c:showSerName element.
+        if labels.get("series_name"):
+            self._write_show_ser_name()
+
+        # Write the c:showPercent element.
+        if labels.get("percentage"):
+            self._write_show_percent()
+
+        # Write the c:separator element.
+        if labels.get("separator"):
+            self._write_separator(labels["separator"])
+
+        # Write the c:showLeaderLines element.
+        if labels.get("leader_lines"):
+            self._write_show_leader_lines()
+
+        self._xml_end_tag("c:dLbls")
+
+    def _write_custom_labels(self, parent, labels):
+        # Write the <c:showLegendKey> element.
+        index = 0
+
+        for label in labels:
+            index += 1
+
+            if label is None:
+                continue
+
+            self._xml_start_tag("c:dLbl")
+
+            # Write the c:idx element.
+            self._write_idx(index - 1)
+
+            delete_label = label.get("delete")
+
+            if delete_label:
+                self._write_delete(1)
+
+            elif label.get("formula"):
+                self._write_custom_label_formula(label)
+
+                if parent.get("position"):
+                    self._write_d_lbl_pos(parent["position"])
+
+                if parent.get("value"):
+                    self._write_show_val()
+                if parent.get("category"):
+                    self._write_show_cat_name()
+                if parent.get("series_name"):
+                    self._write_show_ser_name()
+
+            elif label.get("value"):
+                self._write_custom_label_str(label)
+
+                if parent.get("position"):
+                    self._write_d_lbl_pos(parent["position"])
+
+                if parent.get("value"):
+                    self._write_show_val()
+                if parent.get("category"):
+                    self._write_show_cat_name()
+                if parent.get("series_name"):
+                    self._write_show_ser_name()
+            else:
+                self._write_custom_label_format_only(label)
+
+            self._xml_end_tag("c:dLbl")
+
+    def _write_custom_label_str(self, label):
+        # Write parts of the <c:dLbl> element for strings.
+        title = label.get("value")
+        font = label.get("font")
+        has_formatting = self._has_fill_formatting(label)
+
+        # Write the c:layout element.
+        self._write_layout(None, None)
+
+        self._xml_start_tag("c:tx")
+
+        # Write the c:rich element.
+        self._write_rich(title, font, False, not has_formatting)
+
+        self._xml_end_tag("c:tx")
+
+        # Write the c:spPr element.
+        self._write_sp_pr(label)
+
+    def _write_custom_label_formula(self, label):
+        # Write parts of the <c:dLbl> element for formulas.
+        formula = label.get("formula")
+        data_id = label.get("data_id")
+        data = None
+
+        if data_id is not None:
+            data = self.formula_data[data_id]
+
+        # Write the c:layout element.
+        self._write_layout(None, None)
+
+        self._xml_start_tag("c:tx")
+
+        # Write the c:strRef element.
+        self._write_str_ref(formula, data, "str")
+
+        self._xml_end_tag("c:tx")
+
+        # Write the data label formatting, if any.
+        self._write_custom_label_format_only(label)
+
+    def _write_custom_label_format_only(self, label):
+        # Write parts of the <c:dLbl> labels with changed formatting.
+        font = label.get("font")
+        has_formatting = self._has_fill_formatting(label)
+
+        if has_formatting:
+            self._write_sp_pr(label)
+            self._write_tx_pr(font)
+        elif font:
+            self._xml_empty_tag("c:spPr")
+            self._write_tx_pr(font)
+
+    def _write_show_legend_key(self):
+        # Write the <c:showLegendKey> element.
+        val = "1"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:showLegendKey", attributes)
+
+    def _write_show_val(self):
+        # Write the <c:showVal> element.
+        val = 1
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:showVal", attributes)
+
+    def _write_show_cat_name(self):
+        # Write the <c:showCatName> element.
+        val = 1
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:showCatName", attributes)
+
+    def _write_show_ser_name(self):
+        # Write the <c:showSerName> element.
+        val = 1
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:showSerName", attributes)
+
+    def _write_show_percent(self):
+        # Write the <c:showPercent> element.
+        val = 1
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:showPercent", attributes)
+
+    def _write_separator(self, data):
+        # Write the <c:separator> element.
+        self._xml_data_element("c:separator", data)
+
+    def _write_show_leader_lines(self):
+        # Write the <c:showLeaderLines> element.
+        #
+        # This is different for Pie/Doughnut charts. Other chart types only
+        # supported leader lines after Excel 2015 via an extension element.
+        #
+        uri = "{CE6537A1-D6FC-4f65-9D91-7224C49458BB}"
+        xmlns_c_15 = "http://schemas.microsoft.com/office/drawing/2012/chart"
+
+        attributes = [
+            ("uri", uri),
+            ("xmlns:c15", xmlns_c_15),
+        ]
+
+        self._xml_start_tag("c:extLst")
+        self._xml_start_tag("c:ext", attributes)
+        self._xml_empty_tag("c15:showLeaderLines", [("val", 1)])
+        self._xml_end_tag("c:ext")
+        self._xml_end_tag("c:extLst")
+
+    def _write_d_lbl_pos(self, val):
+        # Write the <c:dLblPos> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:dLblPos", attributes)
+
+    def _write_delete(self, val):
+        # Write the <c:delete> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:delete", attributes)
+
+    def _write_c_invert_if_negative(self, invert):
+        # Write the <c:invertIfNegative> element.
+        val = 1
+
+        if not invert:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:invertIfNegative", attributes)
+
+    def _write_axis_font(self, font):
+        # Write the axis font elements.
+
+        if not font:
+            return
+
+        self._xml_start_tag("c:txPr")
+        self._write_a_body_pr(font.get("rotation"), None)
+        self._write_a_lst_style()
+        self._xml_start_tag("a:p")
+
+        self._write_a_p_pr_rich(font)
+
+        self._write_a_end_para_rpr()
+        self._xml_end_tag("a:p")
+        self._xml_end_tag("c:txPr")
+
+    def _write_a_latin(self, attributes):
+        # Write the <a:latin> element.
+        self._xml_empty_tag("a:latin", attributes)
+
+    def _write_d_table(self):
+        # Write the <c:dTable> element.
+        table = self.table
+
+        if not table:
+            return
+
+        self._xml_start_tag("c:dTable")
+
+        if table["horizontal"]:
+            # Write the c:showHorzBorder element.
+            self._write_show_horz_border()
+
+        if table["vertical"]:
+            # Write the c:showVertBorder element.
+            self._write_show_vert_border()
+
+        if table["outline"]:
+            # Write the c:showOutline element.
+            self._write_show_outline()
+
+        if table["show_keys"]:
+            # Write the c:showKeys element.
+            self._write_show_keys()
+
+        if table["font"]:
+            # Write the table font.
+            self._write_tx_pr(table["font"])
+
+        self._xml_end_tag("c:dTable")
+
+    def _write_show_horz_border(self):
+        # Write the <c:showHorzBorder> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:showHorzBorder", attributes)
+
+    def _write_show_vert_border(self):
+        # Write the <c:showVertBorder> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:showVertBorder", attributes)
+
+    def _write_show_outline(self):
+        # Write the <c:showOutline> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:showOutline", attributes)
+
+    def _write_show_keys(self):
+        # Write the <c:showKeys> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:showKeys", attributes)
+
+    def _write_error_bars(self, error_bars):
+        # Write the X and Y error bars.
+
+        if not error_bars:
+            return
+
+        if error_bars["x_error_bars"]:
+            self._write_err_bars("x", error_bars["x_error_bars"])
+
+        if error_bars["y_error_bars"]:
+            self._write_err_bars("y", error_bars["y_error_bars"])
+
+    def _write_err_bars(self, direction, error_bars):
+        # Write the <c:errBars> element.
+
+        if not error_bars:
+            return
+
+        self._xml_start_tag("c:errBars")
+
+        # Write the c:errDir element.
+        self._write_err_dir(direction)
+
+        # Write the c:errBarType element.
+        self._write_err_bar_type(error_bars["direction"])
+
+        # Write the c:errValType element.
+        self._write_err_val_type(error_bars["type"])
+
+        if not error_bars["endcap"]:
+            # Write the c:noEndCap element.
+            self._write_no_end_cap()
+
+        if error_bars["type"] == "stdErr":
+            # Don't need to write a c:errValType tag.
+            pass
+        elif error_bars["type"] == "cust":
+            # Write the custom error tags.
+            self._write_custom_error(error_bars)
+        else:
+            # Write the c:val element.
+            self._write_error_val(error_bars["value"])
+
+        # Write the c:spPr element.
+        self._write_sp_pr(error_bars)
+
+        self._xml_end_tag("c:errBars")
+
+    def _write_err_dir(self, val):
+        # Write the <c:errDir> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:errDir", attributes)
+
+    def _write_err_bar_type(self, val):
+        # Write the <c:errBarType> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:errBarType", attributes)
+
+    def _write_err_val_type(self, val):
+        # Write the <c:errValType> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:errValType", attributes)
+
+    def _write_no_end_cap(self):
+        # Write the <c:noEndCap> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:noEndCap", attributes)
+
+    def _write_error_val(self, val):
+        # Write the <c:val> element for error bars.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:val", attributes)
+
+    def _write_custom_error(self, error_bars):
+        # Write the custom error bars tags.
+
+        if error_bars["plus_values"]:
+            # Write the c:plus element.
+            self._xml_start_tag("c:plus")
+
+            if isinstance(error_bars["plus_values"], list):
+                self._write_num_lit(error_bars["plus_values"])
+            else:
+                self._write_num_ref(
+                    error_bars["plus_values"], error_bars["plus_data"], "num"
+                )
+            self._xml_end_tag("c:plus")
+
+        if error_bars["minus_values"]:
+            # Write the c:minus element.
+            self._xml_start_tag("c:minus")
+
+            if isinstance(error_bars["minus_values"], list):
+                self._write_num_lit(error_bars["minus_values"])
+            else:
+                self._write_num_ref(
+                    error_bars["minus_values"], error_bars["minus_data"], "num"
+                )
+            self._xml_end_tag("c:minus")
+
+    def _write_num_lit(self, data):
+        # Write the <c:numLit> element for literal number list elements.
+        count = len(data)
+
+        # Write the c:numLit element.
+        self._xml_start_tag("c:numLit")
+
+        # Write the c:formatCode element.
+        self._write_format_code("General")
+
+        # Write the c:ptCount element.
+        self._write_pt_count(count)
+
+        for i in range(count):
+            token = data[i]
+
+            if token is None:
+                continue
+
+            try:
+                float(token)
+            except ValueError:
+                # Write non-numeric data as 0.
+                token = 0
+
+            # Write the c:pt element.
+            self._write_pt(i, token)
+
+        self._xml_end_tag("c:numLit")
+
+    def _write_up_down_bars(self):
+        # Write the <c:upDownBars> element.
+        up_down_bars = self.up_down_bars
+
+        if up_down_bars is None:
+            return
+
+        self._xml_start_tag("c:upDownBars")
+
+        # Write the c:gapWidth element.
+        self._write_gap_width(150)
+
+        # Write the c:upBars element.
+        self._write_up_bars(up_down_bars.get("up"))
+
+        # Write the c:downBars element.
+        self._write_down_bars(up_down_bars.get("down"))
+
+        self._xml_end_tag("c:upDownBars")
+
+    def _write_gap_width(self, val):
+        # Write the <c:gapWidth> element.
+
+        if val is None:
+            return
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:gapWidth", attributes)
+
+    def _write_up_bars(self, bar_format):
+        # Write the <c:upBars> element.
+
+        if bar_format["line"] and bar_format["line"]["defined"]:
+            self._xml_start_tag("c:upBars")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(bar_format)
+
+            self._xml_end_tag("c:upBars")
+        else:
+            self._xml_empty_tag("c:upBars")
+
+    def _write_down_bars(self, bar_format):
+        # Write the <c:downBars> element.
+
+        if bar_format["line"] and bar_format["line"]["defined"]:
+            self._xml_start_tag("c:downBars")
+
+            # Write the c:spPr element.
+            self._write_sp_pr(bar_format)
+
+            self._xml_end_tag("c:downBars")
+        else:
+            self._xml_empty_tag("c:downBars")
+
+    def _write_disp_units(self, units, display):
+        # Write the <c:dispUnits> element.
+
+        if not units:
+            return
+
+        attributes = [("val", units)]
+
+        self._xml_start_tag("c:dispUnits")
+        self._xml_empty_tag("c:builtInUnit", attributes)
+
+        if display:
+            self._xml_start_tag("c:dispUnitsLbl")
+            self._xml_empty_tag("c:layout")
+            self._xml_end_tag("c:dispUnitsLbl")
+
+        self._xml_end_tag("c:dispUnits")
+
+    def _write_a_grad_fill(self, gradient):
+        # Write the <a:gradFill> element.
+
+        attributes = [("flip", "none"), ("rotWithShape", "1")]
+
+        if gradient["type"] == "linear":
+            attributes = []
+
+        self._xml_start_tag("a:gradFill", attributes)
+
+        # Write the a:gsLst element.
+        self._write_a_gs_lst(gradient)
+
+        if gradient["type"] == "linear":
+            # Write the a:lin element.
+            self._write_a_lin(gradient["angle"])
+        else:
+            # Write the a:path element.
+            self._write_a_path(gradient["type"])
+
+            # Write the a:tileRect element.
+            self._write_a_tile_rect(gradient["type"])
+
+        self._xml_end_tag("a:gradFill")
+
+    def _write_a_gs_lst(self, gradient):
+        # Write the <a:gsLst> element.
+        positions = gradient["positions"]
+        colors = gradient["colors"]
+
+        self._xml_start_tag("a:gsLst")
+
+        for i, color in enumerate(colors):
+            pos = int(positions[i] * 1000)
+            attributes = [("pos", pos)]
+            self._xml_start_tag("a:gs", attributes)
+
+            # Write the a:srgbClr element.
+            color = _get_rgb_color(color)
+            self._write_a_srgb_clr(color)
+
+            self._xml_end_tag("a:gs")
+
+        self._xml_end_tag("a:gsLst")
+
+    def _write_a_lin(self, angle):
+        # Write the <a:lin> element.
+
+        angle = int(60000 * angle)
+
+        attributes = [
+            ("ang", angle),
+            ("scaled", "0"),
+        ]
+
+        self._xml_empty_tag("a:lin", attributes)
+
+    def _write_a_path(self, gradient_type):
+        # Write the <a:path> element.
+
+        attributes = [("path", gradient_type)]
+
+        self._xml_start_tag("a:path", attributes)
+
+        # Write the a:fillToRect element.
+        self._write_a_fill_to_rect(gradient_type)
+
+        self._xml_end_tag("a:path")
+
+    def _write_a_fill_to_rect(self, gradient_type):
+        # Write the <a:fillToRect> element.
+
+        if gradient_type == "shape":
+            attributes = [
+                ("l", "50000"),
+                ("t", "50000"),
+                ("r", "50000"),
+                ("b", "50000"),
+            ]
+        else:
+            attributes = [
+                ("l", "100000"),
+                ("t", "100000"),
+            ]
+
+        self._xml_empty_tag("a:fillToRect", attributes)
+
+    def _write_a_tile_rect(self, gradient_type):
+        # Write the <a:tileRect> element.
+
+        if gradient_type == "shape":
+            attributes = []
+        else:
+            attributes = [
+                ("r", "-100000"),
+                ("b", "-100000"),
+            ]
+
+        self._xml_empty_tag("a:tileRect", attributes)
+
+    def _write_a_patt_fill(self, pattern):
+        # Write the <a:pattFill> element.
+
+        attributes = [("prst", pattern["pattern"])]
+
+        self._xml_start_tag("a:pattFill", attributes)
+
+        # Write the a:fgClr element.
+        self._write_a_fg_clr(pattern["fg_color"])
+
+        # Write the a:bgClr element.
+        self._write_a_bg_clr(pattern["bg_color"])
+
+        self._xml_end_tag("a:pattFill")
+
+    def _write_a_fg_clr(self, color):
+        # Write the <a:fgClr> element.
+
+        color = _get_rgb_color(color)
+
+        self._xml_start_tag("a:fgClr")
+
+        # Write the a:srgbClr element.
+        self._write_a_srgb_clr(color)
+
+        self._xml_end_tag("a:fgClr")
+
+    def _write_a_bg_clr(self, color):
+        # Write the <a:bgClr> element.
+
+        color = _get_rgb_color(color)
+
+        self._xml_start_tag("a:bgClr")
+
+        # Write the a:srgbClr element.
+        self._write_a_srgb_clr(color)
+
+        self._xml_end_tag("a:bgClr")
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_area.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_area.py
new file mode 100644
index 00000000..4747e5b0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_area.py
@@ -0,0 +1,102 @@
+###############################################################################
+#
+# ChartArea - A class for writing the Excel XLSX Area charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import chart
+
+
+class ChartArea(chart.Chart):
+    """
+    A class for writing the Excel XLSX Area charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "standard"
+
+        self.cross_between = "midCat"
+        self.show_crosses = False
+
+        # Override and reset the default axis values.
+        if self.subtype == "percent_stacked":
+            self.y_axis["defaults"]["num_format"] = "0%"
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "center"
+        self.label_positions = {"center": "ctr"}
+
+        self.set_y_axis({})
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:areaChart element.
+        self._write_area_chart(args)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+    #
+    def _write_area_chart(self, args):
+        # Write the <c:areaChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        subtype = self.subtype
+
+        if subtype == "percent_stacked":
+            subtype = "percentStacked"
+
+        self._xml_start_tag("c:areaChart")
+
+        # Write the c:grouping element.
+        self._write_grouping(subtype)
+
+        # Write the series elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:dropLines element.
+        self._write_drop_lines()
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:areaChart")
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_bar.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_bar.py
new file mode 100644
index 00000000..cd138083
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_bar.py
@@ -0,0 +1,176 @@
+###############################################################################
+#
+# ChartBar - A class for writing the Excel XLSX Bar charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from warnings import warn
+
+from . import chart
+
+
+class ChartBar(chart.Chart):
+    """
+    A class for writing the Excel XLSX Bar charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "clustered"
+
+        self.cat_axis_position = "l"
+        self.val_axis_position = "b"
+        self.horiz_val_axis = 0
+        self.horiz_cat_axis = 1
+        self.show_crosses = False
+
+        # Override and reset the default axis values.
+        self.x_axis["defaults"]["major_gridlines"] = {"visible": 1}
+        self.y_axis["defaults"]["major_gridlines"] = {"visible": 0}
+
+        if self.subtype == "percent_stacked":
+            self.x_axis["defaults"]["num_format"] = "0%"
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "outside_end"
+        self.label_positions = {
+            "center": "ctr",
+            "inside_base": "inBase",
+            "inside_end": "inEnd",
+            "outside_end": "outEnd",
+        }
+
+        self.set_x_axis({})
+        self.set_y_axis({})
+
+    def combine(self, chart=None):
+        # pylint: disable=redefined-outer-name
+        """
+        Create a combination chart with a secondary chart.
+
+        Note: Override parent method to add an extra check that is required
+        for Bar charts to ensure that their combined chart is on a secondary
+        axis.
+
+        Args:
+            chart: The secondary chart to combine with the primary chart.
+
+        Returns:
+            Nothing.
+
+        """
+        if chart is None:
+            return
+
+        if not chart.is_secondary:
+            warn("Charts combined with Bar charts must be on a secondary axis")
+
+        self.combined = chart
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        if args["primary_axes"]:
+            # Reverse X and Y axes for Bar charts.
+            tmp = self.y_axis
+            self.y_axis = self.x_axis
+            self.x_axis = tmp
+
+            if self.y2_axis["position"] == "r":
+                self.y2_axis["position"] = "t"
+
+        # Write the c:barChart element.
+        self._write_bar_chart(args)
+
+    def _write_bar_chart(self, args):
+        # Write the <c:barChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        subtype = self.subtype
+        if subtype == "percent_stacked":
+            subtype = "percentStacked"
+
+        # Set a default overlap for stacked charts.
+        if "stacked" in self.subtype and self.series_overlap_1 is None:
+            self.series_overlap_1 = 100
+
+        self._xml_start_tag("c:barChart")
+
+        # Write the c:barDir element.
+        self._write_bar_dir()
+
+        # Write the c:grouping element.
+        self._write_grouping(subtype)
+
+        # Write the c:ser elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:gapWidth element.
+        if args["primary_axes"]:
+            self._write_gap_width(self.series_gap_1)
+        else:
+            self._write_gap_width(self.series_gap_2)
+
+        # Write the c:overlap element.
+        if args["primary_axes"]:
+            self._write_overlap(self.series_overlap_1)
+        else:
+            self._write_overlap(self.series_overlap_2)
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:barChart")
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_bar_dir(self):
+        # Write the <c:barDir> element.
+        val = "bar"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:barDir", attributes)
+
+    def _write_err_dir(self, val):
+        # Overridden from Chart class since it is not used in Bar charts.
+        pass
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_column.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_column.py
new file mode 100644
index 00000000..6211b3b5
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_column.py
@@ -0,0 +1,133 @@
+###############################################################################
+#
+# ChartColumn - A class for writing the Excel XLSX Column charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import chart
+
+
+class ChartColumn(chart.Chart):
+    """
+    A class for writing the Excel XLSX Column charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "clustered"
+
+        self.horiz_val_axis = 0
+
+        if self.subtype == "percent_stacked":
+            self.y_axis["defaults"]["num_format"] = "0%"
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "outside_end"
+        self.label_positions = {
+            "center": "ctr",
+            "inside_base": "inBase",
+            "inside_end": "inEnd",
+            "outside_end": "outEnd",
+        }
+
+        self.set_y_axis({})
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+
+        # Write the c:barChart element.
+        self._write_bar_chart(args)
+
+    def _write_bar_chart(self, args):
+        # Write the <c:barChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        subtype = self.subtype
+        if subtype == "percent_stacked":
+            subtype = "percentStacked"
+
+        # Set a default overlap for stacked charts.
+        if "stacked" in self.subtype and self.series_overlap_1 is None:
+            self.series_overlap_1 = 100
+
+        self._xml_start_tag("c:barChart")
+
+        # Write the c:barDir element.
+        self._write_bar_dir()
+
+        # Write the c:grouping element.
+        self._write_grouping(subtype)
+
+        # Write the c:ser elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:gapWidth element.
+        if args["primary_axes"]:
+            self._write_gap_width(self.series_gap_1)
+        else:
+            self._write_gap_width(self.series_gap_2)
+
+        # Write the c:overlap element.
+        if args["primary_axes"]:
+            self._write_overlap(self.series_overlap_1)
+        else:
+            self._write_overlap(self.series_overlap_2)
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:barChart")
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_bar_dir(self):
+        # Write the <c:barDir> element.
+        val = "col"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:barDir", attributes)
+
+    def _write_err_dir(self, val):
+        # Overridden from Chart class since it is not used in Column charts.
+        pass
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_doughnut.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_doughnut.py
new file mode 100644
index 00000000..d4cb4f4f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_doughnut.py
@@ -0,0 +1,101 @@
+###############################################################################
+#
+# ChartDoughnut - A class for writing the Excel XLSX Doughnut charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from warnings import warn
+
+from . import chart_pie
+
+
+class ChartDoughnut(chart_pie.ChartPie):
+    """
+    A class for writing the Excel XLSX Doughnut charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        self.vary_data_color = 1
+        self.rotation = 0
+        self.hole_size = 50
+
+    def set_hole_size(self, size):
+        """
+        Set the Doughnut chart hole size.
+
+        Args:
+            size: 10 <= size <= 90.
+
+        Returns:
+            Nothing.
+
+        """
+        if size is None:
+            return
+
+        # Ensure the size is in Excel's range.
+        if size < 10 or size > 90:
+            warn("Chart hole size '{size}' outside Excel range: 10 <= size <= 90")
+            return
+
+        self.hole_size = int(size)
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:doughnutChart element.
+        self._write_doughnut_chart()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_doughnut_chart(self):
+        # Write the <c:doughnutChart> element.  Over-ridden method to remove
+        # axis_id code since Doughnut charts don't require val and cat axes.
+        self._xml_start_tag("c:doughnutChart")
+
+        # Write the c:varyColors element.
+        self._write_vary_colors()
+
+        # Write the series elements.
+        for data in self.series:
+            self._write_ser(data)
+
+        # Write the c:firstSliceAng element.
+        self._write_first_slice_ang()
+
+        # Write the c:holeSize element.
+        self._write_c_hole_size()
+
+        self._xml_end_tag("c:doughnutChart")
+
+    def _write_c_hole_size(self):
+        # Write the <c:holeSize> element.
+        attributes = [("val", self.hole_size)]
+
+        self._xml_empty_tag("c:holeSize", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_line.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_line.py
new file mode 100644
index 00000000..949a94de
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_line.py
@@ -0,0 +1,144 @@
+###############################################################################
+#
+# ChartLine - A class for writing the Excel XLSX Line charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import chart
+
+
+class ChartLine(chart.Chart):
+    """
+    A class for writing the Excel XLSX Line charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "standard"
+
+        self.default_marker = {"type": "none"}
+        self.smooth_allowed = True
+
+        # Override and reset the default axis values.
+        if self.subtype == "percent_stacked":
+            self.y_axis["defaults"]["num_format"] = "0%"
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "right"
+        self.label_positions = {
+            "center": "ctr",
+            "right": "r",
+            "left": "l",
+            "above": "t",
+            "below": "b",
+            # For backward compatibility.
+            "top": "t",
+            "bottom": "b",
+        }
+
+        self.set_y_axis({})
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:lineChart element.
+        self._write_line_chart(args)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_line_chart(self, args):
+        # Write the <c:lineChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        subtype = self.subtype
+
+        if subtype == "percent_stacked":
+            subtype = "percentStacked"
+
+        self._xml_start_tag("c:lineChart")
+
+        # Write the c:grouping element.
+        self._write_grouping(subtype)
+
+        # Write the series elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:dropLines element.
+        self._write_drop_lines()
+
+        # Write the c:hiLowLines element.
+        self._write_hi_low_lines()
+
+        # Write the c:upDownBars element.
+        self._write_up_down_bars()
+
+        # Write the c:marker element.
+        self._write_marker_value()
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:lineChart")
+
+    def _write_d_pt_point(self, index, point):
+        # Write an individual <c:dPt> element. Override the parent method to
+        # add markers.
+
+        self._xml_start_tag("c:dPt")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        self._xml_start_tag("c:marker")
+
+        # Write the c:spPr element.
+        self._write_sp_pr(point)
+
+        self._xml_end_tag("c:marker")
+
+        self._xml_end_tag("c:dPt")
+
+    def _write_marker_value(self):
+        # Write the <c:marker> element without a sub-element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:marker", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_pie.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_pie.py
new file mode 100644
index 00000000..2b55ba76
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_pie.py
@@ -0,0 +1,263 @@
+###############################################################################
+#
+# ChartPie - A class for writing the Excel XLSX Pie charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from warnings import warn
+
+from . import chart
+
+
+class ChartPie(chart.Chart):
+    """
+    A class for writing the Excel XLSX Pie charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        self.vary_data_color = 1
+        self.rotation = 0
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "best_fit"
+        self.label_positions = {
+            "center": "ctr",
+            "inside_end": "inEnd",
+            "outside_end": "outEnd",
+            "best_fit": "bestFit",
+        }
+
+    def set_rotation(self, rotation):
+        """
+        Set the Pie/Doughnut chart rotation: the angle of the first slice.
+
+        Args:
+            rotation: First segment angle: 0 <= rotation <= 360.
+
+        Returns:
+            Nothing.
+
+        """
+        if rotation is None:
+            return
+
+        # Ensure the rotation is in Excel's range.
+        if rotation < 0 or rotation > 360:
+            warn(
+                f"Chart rotation '{rotation}' outside Excel range: 0 <= rotation <= 360"
+            )
+            return
+
+        self.rotation = int(rotation)
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:pieChart element.
+        self._write_pie_chart()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_pie_chart(self):
+        # Write the <c:pieChart> element.  Over-ridden method to remove
+        # axis_id code since Pie charts don't require val and cat axes.
+        self._xml_start_tag("c:pieChart")
+
+        # Write the c:varyColors element.
+        self._write_vary_colors()
+
+        # Write the series elements.
+        for data in self.series:
+            self._write_ser(data)
+
+        # Write the c:firstSliceAng element.
+        self._write_first_slice_ang()
+
+        self._xml_end_tag("c:pieChart")
+
+    def _write_plot_area(self):
+        # Over-ridden method to remove the cat_axis() and val_axis() code
+        # since Pie charts don't require those axes.
+        #
+        # Write the <c:plotArea> element.
+
+        self._xml_start_tag("c:plotArea")
+
+        # Write the c:layout element.
+        self._write_layout(self.plotarea.get("layout"), "plot")
+
+        # Write the subclass chart type element.
+        self._write_chart_type(None)
+        # Configure a combined chart if present.
+        second_chart = self.combined
+
+        if second_chart:
+            # Secondary axis has unique id otherwise use same as primary.
+            if second_chart.is_secondary:
+                second_chart.id = 1000 + self.id
+            else:
+                second_chart.id = self.id
+
+            # Share the same filehandle for writing.
+            second_chart.fh = self.fh
+
+            # Share series index with primary chart.
+            second_chart.series_index = self.series_index
+
+            # Write the subclass chart type elements for combined chart.
+            # pylint: disable-next=protected-access
+            second_chart._write_chart_type(None)
+
+        # Write the c:spPr element for the plotarea formatting.
+        self._write_sp_pr(self.plotarea)
+
+        self._xml_end_tag("c:plotArea")
+
+    def _write_legend(self):
+        # Over-ridden method to add <c:txPr> to legend.
+        # Write the <c:legend> element.
+        legend = self.legend
+        position = legend.get("position", "right")
+        font = legend.get("font")
+        delete_series = []
+        overlay = 0
+
+        if legend.get("delete_series") and isinstance(legend["delete_series"], list):
+            delete_series = legend["delete_series"]
+
+        if position.startswith("overlay_"):
+            position = position.replace("overlay_", "")
+            overlay = 1
+
+        allowed = {
+            "right": "r",
+            "left": "l",
+            "top": "t",
+            "bottom": "b",
+            "top_right": "tr",
+        }
+
+        if position == "none":
+            return
+
+        if position not in allowed:
+            return
+
+        position = allowed[position]
+
+        self._xml_start_tag("c:legend")
+
+        # Write the c:legendPos element.
+        self._write_legend_pos(position)
+
+        # Remove series labels from the legend.
+        for index in delete_series:
+            # Write the c:legendEntry element.
+            self._write_legend_entry(index)
+
+        # Write the c:layout element.
+        self._write_layout(legend.get("layout"), "legend")
+
+        # Write the c:overlay element.
+        if overlay:
+            self._write_overlay()
+
+        # Write the c:spPr element.
+        self._write_sp_pr(legend)
+
+        # Write the c:txPr element. Over-ridden.
+        self._write_tx_pr_legend(None, font)
+
+        self._xml_end_tag("c:legend")
+
+    def _write_tx_pr_legend(self, horiz, font):
+        # Write the <c:txPr> element for legends.
+
+        if font and font.get("rotation"):
+            rotation = font["rotation"]
+        else:
+            rotation = None
+
+        self._xml_start_tag("c:txPr")
+
+        # Write the a:bodyPr element.
+        self._write_a_body_pr(rotation, horiz)
+
+        # Write the a:lstStyle element.
+        self._write_a_lst_style()
+
+        # Write the a:p element.
+        self._write_a_p_legend(font)
+
+        self._xml_end_tag("c:txPr")
+
+    def _write_a_p_legend(self, font):
+        # Write the <a:p> element for legends.
+
+        self._xml_start_tag("a:p")
+
+        # Write the a:pPr element.
+        self._write_a_p_pr_legend(font)
+
+        # Write the a:endParaRPr element.
+        self._write_a_end_para_rpr()
+
+        self._xml_end_tag("a:p")
+
+    def _write_a_p_pr_legend(self, font):
+        # Write the <a:pPr> element for legends.
+        attributes = [("rtl", 0)]
+
+        self._xml_start_tag("a:pPr", attributes)
+
+        # Write the a:defRPr element.
+        self._write_a_def_rpr(font)
+
+        self._xml_end_tag("a:pPr")
+
+    def _write_vary_colors(self):
+        # Write the <c:varyColors> element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:varyColors", attributes)
+
+    def _write_first_slice_ang(self):
+        # Write the <c:firstSliceAng> element.
+        attributes = [("val", self.rotation)]
+
+        self._xml_empty_tag("c:firstSliceAng", attributes)
+
+    def _write_show_leader_lines(self):
+        # Write the <c:showLeaderLines> element.
+        #
+        # This is for Pie/Doughnut charts. Other chart types only supported
+        # leader lines after Excel 2015 via an extension element.
+        attributes = [("val", 1)]
+
+        self._xml_empty_tag("c:showLeaderLines", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_radar.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_radar.py
new file mode 100644
index 00000000..6b0c8b47
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_radar.py
@@ -0,0 +1,103 @@
+###############################################################################
+#
+# ChartRadar - A class for writing the Excel XLSX Radar charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import chart
+
+
+class ChartRadar(chart.Chart):
+    """
+    A class for writing the Excel XLSX Radar charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "marker"
+            self.default_marker = {"type": "none"}
+
+        # Override and reset the default axis values.
+        self.x_axis["defaults"]["major_gridlines"] = {"visible": 1}
+        self.set_x_axis({})
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "center"
+        self.label_positions = {"center": "ctr"}
+
+        # Hardcode major_tick_mark for now until there is an accessor.
+        self.y_axis["major_tick_mark"] = "cross"
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Write the c:radarChart element.
+        self._write_radar_chart(args)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_radar_chart(self, args):
+        # Write the <c:radarChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        self._xml_start_tag("c:radarChart")
+
+        # Write the c:radarStyle element.
+        self._write_radar_style()
+
+        # Write the series elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:radarChart")
+
+    def _write_radar_style(self):
+        # Write the <c:radarStyle> element.
+        val = "marker"
+
+        if self.subtype == "filled":
+            val = "filled"
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:radarStyle", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_scatter.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_scatter.py
new file mode 100644
index 00000000..322eb9a0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_scatter.py
@@ -0,0 +1,336 @@
+###############################################################################
+#
+# ChartScatter - A class for writing the Excel XLSX Scatter charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from warnings import warn
+
+from . import chart
+
+
+class ChartScatter(chart.Chart):
+    """
+    A class for writing the Excel XLSX Scatter charts.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, options=None):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        if options is None:
+            options = {}
+
+        self.subtype = options.get("subtype")
+
+        if not self.subtype:
+            self.subtype = "marker_only"
+
+        self.cross_between = "midCat"
+        self.horiz_val_axis = 0
+        self.val_axis_position = "b"
+        self.smooth_allowed = True
+        self.requires_category = True
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "right"
+        self.label_positions = {
+            "center": "ctr",
+            "right": "r",
+            "left": "l",
+            "above": "t",
+            "below": "b",
+            # For backward compatibility.
+            "top": "t",
+            "bottom": "b",
+        }
+
+    def combine(self, chart=None):
+        # pylint: disable=redefined-outer-name
+        """
+        Create a combination chart with a secondary chart.
+
+        Note: Override parent method to add a warning.
+
+        Args:
+            chart: The secondary chart to combine with the primary chart.
+
+        Returns:
+            Nothing.
+
+        """
+        if chart is None:
+            return
+
+        warn(
+            "Combined chart not currently supported with scatter chart "
+            "as the primary chart"
+        )
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:scatterChart element.
+        self._write_scatter_chart(args)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_scatter_chart(self, args):
+        # Write the <c:scatterChart> element.
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        style = "lineMarker"
+        subtype = self.subtype
+
+        # Set the user defined chart subtype.
+        if subtype == "marker_only":
+            style = "lineMarker"
+
+        if subtype == "straight_with_markers":
+            style = "lineMarker"
+
+        if subtype == "straight":
+            style = "lineMarker"
+            self.default_marker = {"type": "none"}
+
+        if subtype == "smooth_with_markers":
+            style = "smoothMarker"
+
+        if subtype == "smooth":
+            style = "smoothMarker"
+            self.default_marker = {"type": "none"}
+
+        # Add default formatting to the series data.
+        self._modify_series_formatting()
+
+        self._xml_start_tag("c:scatterChart")
+
+        # Write the c:scatterStyle element.
+        self._write_scatter_style(style)
+
+        # Write the series elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:scatterChart")
+
+    def _write_ser(self, series):
+        # Over-ridden to write c:xVal/c:yVal instead of c:cat/c:val elements.
+        # Write the <c:ser> element.
+
+        index = self.series_index
+        self.series_index += 1
+
+        self._xml_start_tag("c:ser")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        # Write the c:order element.
+        self._write_order(index)
+
+        # Write the series name.
+        self._write_series_name(series)
+
+        # Write the c:spPr element.
+        self._write_sp_pr(series)
+
+        # Write the c:marker element.
+        self._write_marker(series.get("marker"))
+
+        # Write the c:dPt element.
+        self._write_d_pt(series.get("points"))
+
+        # Write the c:dLbls element.
+        self._write_d_lbls(series.get("labels"))
+
+        # Write the c:trendline element.
+        self._write_trendline(series.get("trendline"))
+
+        # Write the c:errBars element.
+        self._write_error_bars(series.get("error_bars"))
+
+        # Write the c:xVal element.
+        self._write_x_val(series)
+
+        # Write the c:yVal element.
+        self._write_y_val(series)
+
+        # Write the c:smooth element.
+        if "smooth" in self.subtype and series["smooth"] is None:
+            # Default is on for smooth scatter charts.
+            self._write_c_smooth(True)
+        else:
+            self._write_c_smooth(series["smooth"])
+
+        self._xml_end_tag("c:ser")
+
+    def _write_plot_area(self):
+        # Over-ridden to have 2 valAx elements for scatter charts instead
+        # of catAx/valAx.
+        #
+        # Write the <c:plotArea> element.
+        self._xml_start_tag("c:plotArea")
+
+        # Write the c:layout element.
+        self._write_layout(self.plotarea.get("layout"), "plot")
+
+        # Write the subclass chart elements for primary and secondary axes.
+        self._write_chart_type({"primary_axes": 1})
+        self._write_chart_type({"primary_axes": 0})
+
+        # Write c:catAx and c:valAx elements for series using primary axes.
+        self._write_cat_val_axis(
+            {
+                "x_axis": self.x_axis,
+                "y_axis": self.y_axis,
+                "axis_ids": self.axis_ids,
+                "position": "b",
+            }
+        )
+
+        tmp = self.horiz_val_axis
+        self.horiz_val_axis = 1
+
+        self._write_val_axis(
+            {
+                "x_axis": self.x_axis,
+                "y_axis": self.y_axis,
+                "axis_ids": self.axis_ids,
+                "position": "l",
+            }
+        )
+
+        self.horiz_val_axis = tmp
+
+        # Write c:valAx and c:catAx elements for series using secondary axes
+        self._write_cat_val_axis(
+            {
+                "x_axis": self.x2_axis,
+                "y_axis": self.y2_axis,
+                "axis_ids": self.axis2_ids,
+                "position": "b",
+            }
+        )
+        self.horiz_val_axis = 1
+        self._write_val_axis(
+            {
+                "x_axis": self.x2_axis,
+                "y_axis": self.y2_axis,
+                "axis_ids": self.axis2_ids,
+                "position": "l",
+            }
+        )
+
+        # Write the c:spPr element for the plotarea formatting.
+        self._write_sp_pr(self.plotarea)
+
+        self._xml_end_tag("c:plotArea")
+
+    def _write_x_val(self, series):
+        # Write the <c:xVal> element.
+        formula = series.get("categories")
+        data_id = series.get("cat_data_id")
+        data = self.formula_data[data_id]
+
+        self._xml_start_tag("c:xVal")
+
+        # Check the type of cached data.
+        data_type = self._get_data_type(data)
+
+        if data_type == "str":
+            # Write the c:numRef element.
+            self._write_str_ref(formula, data, data_type)
+        else:
+            # Write the c:numRef element.
+            self._write_num_ref(formula, data, data_type)
+
+        self._xml_end_tag("c:xVal")
+
+    def _write_y_val(self, series):
+        # Write the <c:yVal> element.
+        formula = series.get("values")
+        data_id = series.get("val_data_id")
+        data = self.formula_data[data_id]
+
+        self._xml_start_tag("c:yVal")
+
+        # Unlike Cat axes data should only be numeric.
+        # Write the c:numRef element.
+        self._write_num_ref(formula, data, "num")
+
+        self._xml_end_tag("c:yVal")
+
+    def _write_scatter_style(self, val):
+        # Write the <c:scatterStyle> element.
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("c:scatterStyle", attributes)
+
+    def _modify_series_formatting(self):
+        # Add default formatting to the series data unless it has already been
+        # specified by the user.
+        subtype = self.subtype
+
+        # The default scatter style "markers only" requires a line type.
+        if subtype == "marker_only":
+            # Go through each series and define default values.
+            for series in self.series:
+                # Set a line type unless there is already a user defined type.
+                if not series["line"]["defined"]:
+                    series["line"] = {
+                        "width": 2.25,
+                        "none": 1,
+                        "defined": 1,
+                    }
+
+    def _write_d_pt_point(self, index, point):
+        # Write an individual <c:dPt> element. Override the parent method to
+        # add markers.
+
+        self._xml_start_tag("c:dPt")
+
+        # Write the c:idx element.
+        self._write_idx(index)
+
+        self._xml_start_tag("c:marker")
+
+        # Write the c:spPr element.
+        self._write_sp_pr(point)
+
+        self._xml_end_tag("c:marker")
+
+        self._xml_end_tag("c:dPt")
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chart_stock.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_stock.py
new file mode 100644
index 00000000..640f3565
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chart_stock.py
@@ -0,0 +1,125 @@
+###############################################################################
+#
+# ChartStock - A class for writing the Excel XLSX Stock charts.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import chart
+
+
+class ChartStock(chart.Chart):
+    """
+    A class for writing the Excel XLSX Stock charts.
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+
+        self.show_crosses = False
+        self.hi_low_lines = {}
+        self.date_category = True
+
+        # Override and reset the default axis values.
+        self.x_axis["defaults"]["num_format"] = "dd/mm/yyyy"
+        self.x2_axis["defaults"]["num_format"] = "dd/mm/yyyy"
+
+        # Set the available data label positions for this chart type.
+        self.label_position_default = "right"
+        self.label_positions = {
+            "center": "ctr",
+            "right": "r",
+            "left": "l",
+            "above": "t",
+            "below": "b",
+            # For backward compatibility.
+            "top": "t",
+            "bottom": "b",
+        }
+
+        self.set_x_axis({})
+        self.set_x2_axis({})
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _write_chart_type(self, args):
+        # Override the virtual superclass method with a chart specific method.
+        # Write the c:stockChart element.
+        self._write_stock_chart(args)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_stock_chart(self, args):
+        # Write the <c:stockChart> element.
+        # Overridden to add hi_low_lines().
+
+        if args["primary_axes"]:
+            series = self._get_primary_axes_series()
+        else:
+            series = self._get_secondary_axes_series()
+
+        if not series:
+            return
+
+        # Add default formatting to the series data.
+        self._modify_series_formatting()
+
+        self._xml_start_tag("c:stockChart")
+
+        # Write the series elements.
+        for data in series:
+            self._write_ser(data)
+
+        # Write the c:dropLines element.
+        self._write_drop_lines()
+
+        # Write the c:hiLowLines element.
+        if args.get("primary_axes"):
+            self._write_hi_low_lines()
+
+        # Write the c:upDownBars element.
+        self._write_up_down_bars()
+
+        # Write the c:axId elements
+        self._write_axis_ids(args)
+
+        self._xml_end_tag("c:stockChart")
+
+    def _modify_series_formatting(self):
+        # Add default formatting to the series data.
+
+        index = 0
+
+        for series in self.series:
+            if index % 4 != 3:
+                if not series["line"]["defined"]:
+                    series["line"] = {"width": 2.25, "none": 1, "defined": 1}
+
+                if series["marker"] is None:
+                    if index % 4 == 2:
+                        series["marker"] = {"type": "dot", "size": 3}
+                    else:
+                        series["marker"] = {"type": "none"}
+
+            index += 1
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/chartsheet.py b/.venv/lib/python3.12/site-packages/xlsxwriter/chartsheet.py
new file mode 100644
index 00000000..bfce373b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/chartsheet.py
@@ -0,0 +1,197 @@
+###############################################################################
+#
+# Chartsheet - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import worksheet
+from .drawing import Drawing
+
+
+class Chartsheet(worksheet.Worksheet):
+    """
+    A class for writing the Excel XLSX Chartsheet file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.is_chartsheet = True
+        self.drawing = None
+        self.chart = None
+        self.charts = []
+        self.zoom_scale_normal = 0
+        self.orientation = 0
+        self.protection = False
+
+    def set_chart(self, chart):
+        """
+        Set the chart object for the chartsheet.
+        Args:
+            chart:  Chart object.
+        Returns:
+            chart:  A reference to the chart object.
+        """
+        chart.embedded = False
+        chart.protection = self.protection
+        self.chart = chart
+        self.charts.append([0, 0, chart, 0, 0, 1, 1])
+        return chart
+
+    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.
+
+        """
+        # This method is overridden from parent worksheet class.
+
+        # Chartsheets only allow a reduced set of protect options.
+        copy = {}
+
+        if not options:
+            options = {}
+
+        if options.get("objects") is None:
+            copy["objects"] = False
+        else:
+            # Objects are default on for chartsheets, so reverse state.
+            copy["objects"] = not options["objects"]
+
+        if options.get("content") is None:
+            copy["content"] = True
+        else:
+            copy["content"] = options["content"]
+
+        copy["sheet"] = False
+        copy["scenarios"] = True
+
+        # If objects and content are both off then the chartsheet isn't
+        # protected, unless it has a password.
+        if password == "" and copy["objects"] and not copy["content"]:
+            return
+
+        if self.chart:
+            self.chart.protection = True
+        else:
+            self.protection = True
+
+        # Call the parent method.
+        super().protect(password, copy)
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+    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_chartsheet()
+
+        # Write the worksheet properties.
+        self._write_sheet_pr()
+
+        # Write the sheet view properties.
+        self._write_sheet_views()
+
+        # Write the sheetProtection element.
+        self._write_sheet_protection()
+
+        # 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 drawing element.
+        self._write_drawings()
+
+        # Write the legacyDrawingHF element.
+        self._write_legacy_drawing_hf()
+
+        # Close the worksheet tag.
+        self._xml_end_tag("chartsheet")
+
+        # Close the file.
+        self._xml_close()
+
+    def _prepare_chart(self, index, chart_id, drawing_id):
+        # Set up chart/drawings.
+
+        self.chart.id = chart_id - 1
+
+        self.drawing = Drawing()
+        self.drawing.orientation = self.orientation
+
+        self.external_drawing_links.append(
+            ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml"]
+        )
+
+        self.drawing_links.append(
+            ["/chart", "../charts/chart" + str(chart_id) + ".xml"]
+        )
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_chartsheet(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"
+
+        attributes = [("xmlns", xmlns), ("xmlns:r", xmlns_r)]
+
+        self._xml_start_tag("chartsheet", attributes)
+
+    def _write_sheet_pr(self):
+        # Write the <sheetPr> element for Sheet level properties.
+        attributes = []
+
+        if self.filter_on:
+            attributes.append(("filterMode", 1))
+
+        if self.fit_page or self.tab_color:
+            self._xml_start_tag("sheetPr", attributes)
+            self._write_tab_color()
+            self._write_page_set_up_pr()
+            self._xml_end_tag("sheetPr")
+        else:
+            self._xml_empty_tag("sheetPr", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/comments.py b/.venv/lib/python3.12/site-packages/xlsxwriter/comments.py
new file mode 100644
index 00000000..06fa66e7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/comments.py
@@ -0,0 +1,212 @@
+###############################################################################
+#
+# Comments - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import xmlwriter
+from .utility import _preserve_whitespace, xl_rowcol_to_cell
+
+
+class Comments(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Comments file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+        self.author_ids = {}
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self, comments_data=None):
+        # Assemble and write the XML file.
+
+        if comments_data is None:
+            comments_data = []
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the comments element.
+        self._write_comments()
+
+        # Write the authors element.
+        self._write_authors(comments_data)
+
+        # Write the commentList element.
+        self._write_comment_list(comments_data)
+
+        self._xml_end_tag("comments")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_comments(self):
+        # Write the <comments> element.
+        xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+
+        attributes = [("xmlns", xmlns)]
+
+        self._xml_start_tag("comments", attributes)
+
+    def _write_authors(self, comment_data):
+        # Write the <authors> element.
+        author_count = 0
+
+        self._xml_start_tag("authors")
+
+        for comment in comment_data:
+            author = comment[3]
+
+            if author is not None and author not in self.author_ids:
+                # Store the author id.
+                self.author_ids[author] = author_count
+                author_count += 1
+
+                # Write the author element.
+                self._write_author(author)
+
+        self._xml_end_tag("authors")
+
+    def _write_author(self, data):
+        # Write the <author> element.
+        self._xml_data_element("author", data)
+
+    def _write_comment_list(self, comment_data):
+        # Write the <commentList> element.
+        self._xml_start_tag("commentList")
+
+        for comment in comment_data:
+            row = comment[0]
+            col = comment[1]
+            text = comment[2]
+            author = comment[3]
+            font_name = comment[6]
+            font_size = comment[7]
+            font_family = comment[8]
+
+            # Look up the author id.
+            author_id = None
+            if author is not None:
+                author_id = self.author_ids[author]
+
+            # Write the comment element.
+            font = (font_name, font_size, font_family)
+            self._write_comment(row, col, text, author_id, font)
+
+        self._xml_end_tag("commentList")
+
+    def _write_comment(self, row, col, text, author_id, font):
+        # Write the <comment> element.
+        ref = xl_rowcol_to_cell(row, col)
+
+        attributes = [("ref", ref)]
+
+        if author_id is not None:
+            attributes.append(("authorId", author_id))
+
+        self._xml_start_tag("comment", attributes)
+
+        # Write the text element.
+        self._write_text(text, font)
+
+        self._xml_end_tag("comment")
+
+    def _write_text(self, text, font):
+        # Write the <text> element.
+        self._xml_start_tag("text")
+
+        # Write the text r element.
+        self._write_text_r(text, font)
+
+        self._xml_end_tag("text")
+
+    def _write_text_r(self, text, font):
+        # Write the <r> element.
+        self._xml_start_tag("r")
+
+        # Write the rPr element.
+        self._write_r_pr(font)
+
+        # Write the text r element.
+        self._write_text_t(text)
+
+        self._xml_end_tag("r")
+
+    def _write_text_t(self, text):
+        # Write the text <t> element.
+        attributes = []
+
+        if _preserve_whitespace(text):
+            attributes.append(("xml:space", "preserve"))
+
+        self._xml_data_element("t", text, attributes)
+
+    def _write_r_pr(self, font):
+        # Write the <rPr> element.
+        self._xml_start_tag("rPr")
+
+        # Write the sz element.
+        self._write_sz(font[1])
+
+        # Write the color element.
+        self._write_color()
+
+        # Write the rFont element.
+        self._write_r_font(font[0])
+
+        # Write the family element.
+        self._write_family(font[2])
+
+        self._xml_end_tag("rPr")
+
+    def _write_sz(self, font_size):
+        # Write the <sz> element.
+        attributes = [("val", font_size)]
+
+        self._xml_empty_tag("sz", attributes)
+
+    def _write_color(self):
+        # Write the <color> element.
+        attributes = [("indexed", 81)]
+
+        self._xml_empty_tag("color", attributes)
+
+    def _write_r_font(self, font_name):
+        # Write the <rFont> element.
+        attributes = [("val", font_name)]
+
+        self._xml_empty_tag("rFont", attributes)
+
+    def _write_family(self, font_family):
+        # Write the <family> element.
+        attributes = [("val", font_family)]
+
+        self._xml_empty_tag("family", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/contenttypes.py b/.venv/lib/python3.12/site-packages/xlsxwriter/contenttypes.py
new file mode 100644
index 00000000..bc144406
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/contenttypes.py
@@ -0,0 +1,269 @@
+###############################################################################
+#
+# ContentTypes - A class for writing the Excel XLSX ContentTypes file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+import copy
+
+from . import xmlwriter
+
+# Long namespace strings used in the class.
+APP_PACKAGE = "application/vnd.openxmlformats-package."
+APP_DOCUMENT = "application/vnd.openxmlformats-officedocument."
+
+defaults = [
+    ["rels", APP_PACKAGE + "relationships+xml"],
+    ["xml", "application/xml"],
+]
+
+overrides = [
+    ["/docProps/app.xml", APP_DOCUMENT + "extended-properties+xml"],
+    ["/docProps/core.xml", APP_PACKAGE + "core-properties+xml"],
+    ["/xl/styles.xml", APP_DOCUMENT + "spreadsheetml.styles+xml"],
+    ["/xl/theme/theme1.xml", APP_DOCUMENT + "theme+xml"],
+    ["/xl/workbook.xml", APP_DOCUMENT + "spreadsheetml.sheet.main+xml"],
+]
+
+
+class ContentTypes(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX ContentTypes file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        # Copy the defaults in case we need to change them.
+        self.defaults = copy.deepcopy(defaults)
+        self.overrides = copy.deepcopy(overrides)
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        self._write_types()
+        self._write_defaults()
+        self._write_overrides()
+
+        self._xml_end_tag("Types")
+
+        # Close the file.
+        self._xml_close()
+
+    def _add_default(self, default):
+        # Add elements to the ContentTypes defaults.
+        self.defaults.append(default)
+
+    def _add_override(self, override):
+        # Add elements to the ContentTypes overrides.
+        self.overrides.append(override)
+
+    def _add_worksheet_name(self, worksheet_name):
+        # Add the name of a worksheet to the ContentTypes overrides.
+        worksheet_name = "/xl/worksheets/" + worksheet_name + ".xml"
+
+        self._add_override(
+            (worksheet_name, APP_DOCUMENT + "spreadsheetml.worksheet+xml")
+        )
+
+    def _add_chartsheet_name(self, chartsheet_name):
+        # Add the name of a chartsheet to the ContentTypes overrides.
+        chartsheet_name = "/xl/chartsheets/" + chartsheet_name + ".xml"
+
+        self._add_override(
+            (chartsheet_name, APP_DOCUMENT + "spreadsheetml.chartsheet+xml")
+        )
+
+    def _add_chart_name(self, chart_name):
+        # Add the name of a chart to the ContentTypes overrides.
+        chart_name = "/xl/charts/" + chart_name + ".xml"
+
+        self._add_override((chart_name, APP_DOCUMENT + "drawingml.chart+xml"))
+
+    def _add_drawing_name(self, drawing_name):
+        # Add the name of a drawing to the ContentTypes overrides.
+        drawing_name = "/xl/drawings/" + drawing_name + ".xml"
+
+        self._add_override((drawing_name, APP_DOCUMENT + "drawing+xml"))
+
+    def _add_vml_name(self):
+        # Add the name of a VML drawing to the ContentTypes defaults.
+        self._add_default(("vml", APP_DOCUMENT + "vmlDrawing"))
+
+    def _add_comment_name(self, comment_name):
+        # Add the name of a comment to the ContentTypes overrides.
+        comment_name = "/xl/" + comment_name + ".xml"
+
+        self._add_override((comment_name, APP_DOCUMENT + "spreadsheetml.comments+xml"))
+
+    def _add_shared_strings(self):
+        # Add the sharedStrings link to the ContentTypes overrides.
+        self._add_override(
+            ("/xl/sharedStrings.xml", APP_DOCUMENT + "spreadsheetml.sharedStrings+xml")
+        )
+
+    def _add_calc_chain(self):
+        # Add the calcChain link to the ContentTypes overrides.
+        self._add_override(
+            ("/xl/calcChain.xml", APP_DOCUMENT + "spreadsheetml.calcChain+xml")
+        )
+
+    def _add_image_types(self, image_types):
+        # Add the image default types.
+        for image_type in image_types:
+            extension = image_type
+
+            if image_type in ("wmf", "emf"):
+                image_type = "x-" + image_type
+
+            self._add_default((extension, "image/" + image_type))
+
+    def _add_table_name(self, table_name):
+        # Add the name of a table to the ContentTypes overrides.
+        table_name = "/xl/tables/" + table_name + ".xml"
+
+        self._add_override((table_name, APP_DOCUMENT + "spreadsheetml.table+xml"))
+
+    def _add_vba_project(self):
+        # Add a vbaProject to the ContentTypes defaults.
+
+        # Change the workbook.xml content-type from xlsx to xlsm.
+        for i, override in enumerate(self.overrides):
+            if override[0] == "/xl/workbook.xml":
+                xlsm = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"
+                self.overrides[i][1] = xlsm
+
+        self._add_default(("bin", "application/vnd.ms-office.vbaProject"))
+
+    def _add_vba_project_signature(self):
+        # Add a vbaProjectSignature to the ContentTypes overrides.
+        self._add_override(
+            (
+                "/xl/vbaProjectSignature.bin",
+                "application/vnd.ms-office.vbaProjectSignature",
+            )
+        )
+
+    def _add_custom_properties(self):
+        # Add the custom properties to the ContentTypes overrides.
+        self._add_override(
+            ("/docProps/custom.xml", APP_DOCUMENT + "custom-properties+xml")
+        )
+
+    def _add_metadata(self):
+        # Add the metadata file to the ContentTypes overrides.
+        self._add_override(
+            ("/xl/metadata.xml", APP_DOCUMENT + "spreadsheetml.sheetMetadata+xml")
+        )
+
+    def _add_feature_bag_property(self):
+        # Add the featurePropertyBag file to the ContentTypes overrides.
+        self._add_override(
+            (
+                "/xl/featurePropertyBag/featurePropertyBag.xml",
+                "application/vnd.ms-excel.featurepropertybag+xml",
+            )
+        )
+
+    def _add_rich_value(self):
+        # Add the richValue files to the ContentTypes overrides.
+        self._add_override(
+            (
+                "/xl/richData/rdRichValueTypes.xml",
+                "application/vnd.ms-excel.rdrichvaluetypes+xml",
+            )
+        )
+
+        self._add_override(
+            ("/xl/richData/rdrichvalue.xml", "application/vnd.ms-excel.rdrichvalue+xml")
+        )
+
+        self._add_override(
+            (
+                "/xl/richData/rdrichvaluestructure.xml",
+                "application/vnd.ms-excel.rdrichvaluestructure+xml",
+            )
+        )
+
+        self._add_override(
+            (
+                "/xl/richData/richValueRel.xml",
+                "application/vnd.ms-excel.richvaluerel+xml",
+            )
+        )
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_defaults(self):
+        # Write out all of the <Default> types.
+
+        for extension, content_type in self.defaults:
+            self._xml_empty_tag(
+                "Default", [("Extension", extension), ("ContentType", content_type)]
+            )
+
+    def _write_overrides(self):
+        # Write out all of the <Override> types.
+        for part_name, content_type in self.overrides:
+            self._xml_empty_tag(
+                "Override", [("PartName", part_name), ("ContentType", content_type)]
+            )
+
+    def _write_types(self):
+        # Write the <Types> element.
+        xmlns = "http://schemas.openxmlformats.org/package/2006/content-types"
+
+        attributes = [
+            (
+                "xmlns",
+                xmlns,
+            )
+        ]
+        self._xml_start_tag("Types", attributes)
+
+    def _write_default(self, extension, content_type):
+        # Write the <Default> element.
+        attributes = [
+            ("Extension", extension),
+            ("ContentType", content_type),
+        ]
+
+        self._xml_empty_tag("Default", attributes)
+
+    def _write_override(self, part_name, content_type):
+        # Write the <Override> element.
+        attributes = [
+            ("PartName", part_name),
+            ("ContentType", content_type),
+        ]
+
+        self._xml_empty_tag("Override", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/core.py b/.venv/lib/python3.12/site-packages/xlsxwriter/core.py
new file mode 100644
index 00000000..114e47a1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/core.py
@@ -0,0 +1,206 @@
+###############################################################################
+#
+# Core - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Standard packages.
+from datetime import datetime, timezone
+
+# Package imports.
+from . import xmlwriter
+
+
+class Core(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Core file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.properties = {}
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        self._write_cp_core_properties()
+        self._write_dc_title()
+        self._write_dc_subject()
+        self._write_dc_creator()
+        self._write_cp_keywords()
+        self._write_dc_description()
+        self._write_cp_last_modified_by()
+        self._write_dcterms_created()
+        self._write_dcterms_modified()
+        self._write_cp_category()
+        self._write_cp_content_status()
+
+        self._xml_end_tag("cp:coreProperties")
+
+        # Close the file.
+        self._xml_close()
+
+    def _set_properties(self, properties):
+        # Set the document properties.
+        self.properties = properties
+
+    def _datetime_to_iso8601_date(self, date):
+        # Convert to a ISO 8601 style "2010-01-01T00:00:00Z" date.
+        if not date:
+            date = datetime.now(timezone.utc)
+
+        return date.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_cp_core_properties(self):
+        # Write the <cp:coreProperties> element.
+
+        xmlns_cp = (
+            "http://schemas.openxmlformats.org/package/2006/"
+            + "metadata/core-properties"
+        )
+        xmlns_dc = "http://purl.org/dc/elements/1.1/"
+        xmlns_dcterms = "http://purl.org/dc/terms/"
+        xmlns_dcmitype = "http://purl.org/dc/dcmitype/"
+        xmlns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
+
+        attributes = [
+            ("xmlns:cp", xmlns_cp),
+            ("xmlns:dc", xmlns_dc),
+            ("xmlns:dcterms", xmlns_dcterms),
+            ("xmlns:dcmitype", xmlns_dcmitype),
+            ("xmlns:xsi", xmlns_xsi),
+        ]
+
+        self._xml_start_tag("cp:coreProperties", attributes)
+
+    def _write_dc_creator(self):
+        # Write the <dc:creator> element.
+        data = self.properties.get("author", "")
+
+        self._xml_data_element("dc:creator", data)
+
+    def _write_cp_last_modified_by(self):
+        # Write the <cp:lastModifiedBy> element.
+        data = self.properties.get("author", "")
+
+        self._xml_data_element("cp:lastModifiedBy", data)
+
+    def _write_dcterms_created(self):
+        # Write the <dcterms:created> element.
+        date = self.properties.get("created", datetime.now(timezone.utc))
+
+        xsi_type = "dcterms:W3CDTF"
+
+        date = self._datetime_to_iso8601_date(date)
+
+        attributes = [
+            (
+                "xsi:type",
+                xsi_type,
+            )
+        ]
+
+        self._xml_data_element("dcterms:created", date, attributes)
+
+    def _write_dcterms_modified(self):
+        # Write the <dcterms:modified> element.
+        date = self.properties.get("created", datetime.now(timezone.utc))
+
+        xsi_type = "dcterms:W3CDTF"
+
+        date = self._datetime_to_iso8601_date(date)
+
+        attributes = [
+            (
+                "xsi:type",
+                xsi_type,
+            )
+        ]
+
+        self._xml_data_element("dcterms:modified", date, attributes)
+
+    def _write_dc_title(self):
+        # Write the <dc:title> element.
+        if "title" in self.properties:
+            data = self.properties["title"]
+        else:
+            return
+
+        self._xml_data_element("dc:title", data)
+
+    def _write_dc_subject(self):
+        # Write the <dc:subject> element.
+        if "subject" in self.properties:
+            data = self.properties["subject"]
+        else:
+            return
+
+        self._xml_data_element("dc:subject", data)
+
+    def _write_cp_keywords(self):
+        # Write the <cp:keywords> element.
+        if "keywords" in self.properties:
+            data = self.properties["keywords"]
+        else:
+            return
+
+        self._xml_data_element("cp:keywords", data)
+
+    def _write_dc_description(self):
+        # Write the <dc:description> element.
+        if "comments" in self.properties:
+            data = self.properties["comments"]
+        else:
+            return
+
+        self._xml_data_element("dc:description", data)
+
+    def _write_cp_category(self):
+        # Write the <cp:category> element.
+        if "category" in self.properties:
+            data = self.properties["category"]
+        else:
+            return
+
+        self._xml_data_element("cp:category", data)
+
+    def _write_cp_content_status(self):
+        # Write the <cp:contentStatus> element.
+        if "status" in self.properties:
+            data = self.properties["status"]
+        else:
+            return
+
+        self._xml_data_element("cp:contentStatus", data)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/custom.py b/.venv/lib/python3.12/site-packages/xlsxwriter/custom.py
new file mode 100644
index 00000000..400f1645
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/custom.py
@@ -0,0 +1,142 @@
+###############################################################################
+#
+# Custom - A class for writing the Excel XLSX Custom Property file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class Custom(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Custom Workbook Property file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.properties = []
+        self.pid = 1
+
+    def _set_properties(self, properties):
+        # Set the document properties.
+        self.properties = properties
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        self._write_properties()
+
+        self._xml_end_tag("Properties")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_properties(self):
+        # Write the <Properties> element.
+        schema = "http://schemas.openxmlformats.org/officeDocument/2006/"
+        xmlns = schema + "custom-properties"
+        xmlns_vt = schema + "docPropsVTypes"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("xmlns:vt", xmlns_vt),
+        ]
+
+        self._xml_start_tag("Properties", attributes)
+
+        for custom_property in self.properties:
+            # Write the property element.
+            self._write_property(custom_property)
+
+    def _write_property(self, custom_property):
+        # Write the <property> element.
+
+        fmtid = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"
+
+        name, value, property_type = custom_property
+        self.pid += 1
+
+        attributes = [
+            ("fmtid", fmtid),
+            ("pid", self.pid),
+            ("name", name),
+        ]
+
+        self._xml_start_tag("property", attributes)
+
+        if property_type == "number_int":
+            # Write the vt:i4 element.
+            self._write_vt_i4(value)
+        elif property_type == "number":
+            # Write the vt:r8 element.
+            self._write_vt_r8(value)
+        elif property_type == "date":
+            # Write the vt:filetime element.
+            self._write_vt_filetime(value)
+        elif property_type == "bool":
+            # Write the vt:bool element.
+            self._write_vt_bool(value)
+        else:
+            # Write the vt:lpwstr element.
+            self._write_vt_lpwstr(value)
+
+        self._xml_end_tag("property")
+
+    def _write_vt_lpwstr(self, value):
+        # Write the <vt:lpwstr> element.
+        self._xml_data_element("vt:lpwstr", value)
+
+    def _write_vt_filetime(self, value):
+        # Write the <vt:filetime> element.
+        self._xml_data_element("vt:filetime", value)
+
+    def _write_vt_i4(self, value):
+        # Write the <vt:i4> element.
+        self._xml_data_element("vt:i4", value)
+
+    def _write_vt_r8(self, value):
+        # Write the <vt:r8> element.
+        self._xml_data_element("vt:r8", value)
+
+    def _write_vt_bool(self, value):
+        # Write the <vt:bool> element.
+
+        if value:
+            value = "true"
+        else:
+            value = "false"
+
+        self._xml_data_element("vt:bool", value)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/drawing.py b/.venv/lib/python3.12/site-packages/xlsxwriter/drawing.py
new file mode 100644
index 00000000..078e532c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/drawing.py
@@ -0,0 +1,1196 @@
+###############################################################################
+#
+# Drawing - A class for writing the Excel XLSX Drawing file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import xmlwriter
+from .shape import Shape
+from .utility import _get_rgb_color
+
+
+class Drawing(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Drawing file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.drawings = []
+        self.embedded = 0
+        self.orientation = 0
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the xdr:wsDr element.
+        self._write_drawing_workspace()
+
+        if self.embedded:
+            index = 0
+            for drawing_properties in self.drawings:
+                # Write the xdr:twoCellAnchor element.
+                index += 1
+                self._write_two_cell_anchor(index, drawing_properties)
+
+        else:
+            # Write the xdr:absoluteAnchor element.
+            self._write_absolute_anchor(1)
+
+        self._xml_end_tag("xdr:wsDr")
+
+        # Close the file.
+        self._xml_close()
+
+    def _add_drawing_object(self):
+        # Add a chart, image or shape sub object to the drawing.
+
+        drawing_object = {
+            "anchor_type": None,
+            "dimensions": [],
+            "width": 0,
+            "height": 0,
+            "shape": None,
+            "anchor": None,
+            "rel_index": 0,
+            "url_rel_index": 0,
+            "tip": None,
+            "name": None,
+            "description": None,
+            "decorative": False,
+        }
+
+        self.drawings.append(drawing_object)
+
+        return drawing_object
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_drawing_workspace(self):
+        # Write the <xdr:wsDr> element.
+        schema = "http://schemas.openxmlformats.org/drawingml/"
+        xmlns_xdr = schema + "2006/spreadsheetDrawing"
+        xmlns_a = schema + "2006/main"
+
+        attributes = [
+            ("xmlns:xdr", xmlns_xdr),
+            ("xmlns:a", xmlns_a),
+        ]
+
+        self._xml_start_tag("xdr:wsDr", attributes)
+
+    def _write_two_cell_anchor(self, index, drawing_properties):
+        # Write the <xdr:twoCellAnchor> element.
+        anchor_type = drawing_properties["type"]
+        dimensions = drawing_properties["dimensions"]
+        col_from = dimensions[0]
+        row_from = dimensions[1]
+        col_from_offset = dimensions[2]
+        row_from_offset = dimensions[3]
+        col_to = dimensions[4]
+        row_to = dimensions[5]
+        col_to_offset = dimensions[6]
+        row_to_offset = dimensions[7]
+        col_absolute = dimensions[8]
+        row_absolute = dimensions[9]
+        width = drawing_properties["width"]
+        height = drawing_properties["height"]
+        shape = drawing_properties["shape"]
+        anchor = drawing_properties["anchor"]
+        rel_index = drawing_properties["rel_index"]
+        url_rel_index = drawing_properties["url_rel_index"]
+        tip = drawing_properties["tip"]
+        name = drawing_properties["name"]
+        description = drawing_properties["description"]
+        decorative = drawing_properties["decorative"]
+
+        attributes = []
+
+        # Add attribute for positioning.
+        if anchor == 2:
+            attributes.append(("editAs", "oneCell"))
+        elif anchor == 3:
+            attributes.append(("editAs", "absolute"))
+
+        # Add editAs attribute for shapes.
+        if shape and shape.edit_as:
+            attributes.append(("editAs", shape.edit_as))
+
+        self._xml_start_tag("xdr:twoCellAnchor", attributes)
+
+        # Write the xdr:from element.
+        self._write_from(col_from, row_from, col_from_offset, row_from_offset)
+
+        # Write the xdr:from element.
+        self._write_to(col_to, row_to, col_to_offset, row_to_offset)
+
+        if anchor_type == 1:
+            # Graphic frame.
+            # Write the xdr:graphicFrame element for charts.
+            self._write_graphic_frame(index, rel_index, name, description, decorative)
+        elif anchor_type == 2:
+            # Write the xdr:pic element.
+            self._write_pic(
+                index,
+                rel_index,
+                col_absolute,
+                row_absolute,
+                width,
+                height,
+                shape,
+                description,
+                url_rel_index,
+                tip,
+                decorative,
+            )
+        else:
+            # Write the xdr:sp element for shapes.
+            self._write_sp(
+                index,
+                col_absolute,
+                row_absolute,
+                width,
+                height,
+                shape,
+                description,
+                url_rel_index,
+                tip,
+                decorative,
+            )
+
+        # Write the xdr:clientData element.
+        self._write_client_data()
+
+        self._xml_end_tag("xdr:twoCellAnchor")
+
+    def _write_absolute_anchor(self, frame_index):
+        self._xml_start_tag("xdr:absoluteAnchor")
+        # Write the <xdr:absoluteAnchor> element.
+
+        # Different coordinates for horizontal (= 0) and vertical (= 1).
+        if self.orientation == 0:
+            # Write the xdr:pos element.
+            self._write_pos(0, 0)
+
+            # Write the xdr:ext element.
+            self._write_xdr_ext(9308969, 6078325)
+
+        else:
+            # Write the xdr:pos element.
+            self._write_pos(0, -47625)
+
+            # Write the xdr:ext element.
+            self._write_xdr_ext(6162675, 6124575)
+
+        # Write the xdr:graphicFrame element.
+        self._write_graphic_frame(frame_index, frame_index)
+
+        # Write the xdr:clientData element.
+        self._write_client_data()
+
+        self._xml_end_tag("xdr:absoluteAnchor")
+
+    def _write_from(self, col, row, col_offset, row_offset):
+        # Write the <xdr:from> element.
+        self._xml_start_tag("xdr:from")
+
+        # Write the xdr:col element.
+        self._write_col(col)
+
+        # Write the xdr:colOff element.
+        self._write_col_off(col_offset)
+
+        # Write the xdr:row element.
+        self._write_row(row)
+
+        # Write the xdr:rowOff element.
+        self._write_row_off(row_offset)
+
+        self._xml_end_tag("xdr:from")
+
+    def _write_to(self, col, row, col_offset, row_offset):
+        # Write the <xdr:to> element.
+        self._xml_start_tag("xdr:to")
+
+        # Write the xdr:col element.
+        self._write_col(col)
+
+        # Write the xdr:colOff element.
+        self._write_col_off(col_offset)
+
+        # Write the xdr:row element.
+        self._write_row(row)
+
+        # Write the xdr:rowOff element.
+        self._write_row_off(row_offset)
+
+        self._xml_end_tag("xdr:to")
+
+    def _write_col(self, data):
+        # Write the <xdr:col> element.
+        self._xml_data_element("xdr:col", data)
+
+    def _write_col_off(self, data):
+        # Write the <xdr:colOff> element.
+        self._xml_data_element("xdr:colOff", data)
+
+    def _write_row(self, data):
+        # Write the <xdr:row> element.
+        self._xml_data_element("xdr:row", data)
+
+    def _write_row_off(self, data):
+        # Write the <xdr:rowOff> element.
+        self._xml_data_element("xdr:rowOff", data)
+
+    def _write_pos(self, x, y):
+        # Write the <xdr:pos> element.
+
+        attributes = [("x", x), ("y", y)]
+
+        self._xml_empty_tag("xdr:pos", attributes)
+
+    def _write_xdr_ext(self, cx, cy):
+        # Write the <xdr:ext> element.
+
+        attributes = [("cx", cx), ("cy", cy)]
+
+        self._xml_empty_tag("xdr:ext", attributes)
+
+    def _write_graphic_frame(
+        self, index, rel_index, name=None, description=None, decorative=None
+    ):
+        # Write the <xdr:graphicFrame> element.
+        attributes = [("macro", "")]
+
+        self._xml_start_tag("xdr:graphicFrame", attributes)
+
+        # Write the xdr:nvGraphicFramePr element.
+        self._write_nv_graphic_frame_pr(index, name, description, decorative)
+
+        # Write the xdr:xfrm element.
+        self._write_xfrm()
+
+        # Write the a:graphic element.
+        self._write_atag_graphic(rel_index)
+
+        self._xml_end_tag("xdr:graphicFrame")
+
+    def _write_nv_graphic_frame_pr(self, index, name, description, decorative):
+        # Write the <xdr:nvGraphicFramePr> element.
+
+        if not name:
+            name = "Chart " + str(index)
+
+        self._xml_start_tag("xdr:nvGraphicFramePr")
+
+        # Write the xdr:cNvPr element.
+        self._write_c_nv_pr(index + 1, name, description, None, None, decorative)
+
+        # Write the xdr:cNvGraphicFramePr element.
+        self._write_c_nv_graphic_frame_pr()
+
+        self._xml_end_tag("xdr:nvGraphicFramePr")
+
+    def _write_c_nv_pr(self, index, name, description, url_rel_index, tip, decorative):
+        # Write the <xdr:cNvPr> element.
+        attributes = [("id", index), ("name", name)]
+
+        # Add description attribute for images.
+        if description and not decorative:
+            attributes.append(("descr", description))
+
+        if url_rel_index or decorative:
+            self._xml_start_tag("xdr:cNvPr", attributes)
+
+            if url_rel_index:
+                self._write_a_hlink_click(url_rel_index, tip)
+
+            if decorative:
+                self._write_decorative()
+
+            self._xml_end_tag("xdr:cNvPr")
+        else:
+            self._xml_empty_tag("xdr:cNvPr", attributes)
+
+    def _write_decorative(self):
+        self._xml_start_tag("a:extLst")
+
+        self._write_uri_ext("{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}")
+        self._write_a16_creation_id()
+        self._xml_end_tag("a:ext")
+
+        self._write_uri_ext("{C183D7F6-B498-43B3-948B-1728B52AA6E4}")
+        self._write_adec_decorative()
+        self._xml_end_tag("a:ext")
+
+        self._xml_end_tag("a:extLst")
+
+    def _write_uri_ext(self, uri):
+        # Write the <a:ext> element.
+        attributes = [("uri", uri)]
+
+        self._xml_start_tag("a:ext", attributes)
+
+    def _write_adec_decorative(self):
+        # Write the <adec:decorative> element.
+        xmlns = "http://schemas.microsoft.com/office/drawing/2017/decorative"
+        val = "1"
+
+        attributes = [
+            ("xmlns:adec", xmlns),
+            ("val", val),
+        ]
+
+        self._xml_empty_tag("adec:decorative", attributes)
+
+    def _write_a16_creation_id(self):
+        # Write the <a16:creationId> element.
+
+        xmlns_a_16 = "http://schemas.microsoft.com/office/drawing/2014/main"
+        creation_id = "{00000000-0008-0000-0000-000002000000}"
+
+        attributes = [
+            ("xmlns:a16", xmlns_a_16),
+            ("id", creation_id),
+        ]
+
+        self._xml_empty_tag("a16:creationId", attributes)
+
+    def _write_a_hlink_click(self, rel_index, tip):
+        # Write the <a:hlinkClick> element.
+        schema = "http://schemas.openxmlformats.org/officeDocument/"
+        xmlns_r = schema + "2006/relationships"
+
+        attributes = [
+            ("xmlns:r", xmlns_r),
+            ("r:id", "rId" + str(rel_index)),
+        ]
+
+        if tip:
+            attributes.append(("tooltip", tip))
+
+        self._xml_empty_tag("a:hlinkClick", attributes)
+
+    def _write_c_nv_graphic_frame_pr(self):
+        # Write the <xdr:cNvGraphicFramePr> element.
+        if self.embedded:
+            self._xml_empty_tag("xdr:cNvGraphicFramePr")
+        else:
+            self._xml_start_tag("xdr:cNvGraphicFramePr")
+
+            # Write the a:graphicFrameLocks element.
+            self._write_a_graphic_frame_locks()
+
+            self._xml_end_tag("xdr:cNvGraphicFramePr")
+
+    def _write_a_graphic_frame_locks(self):
+        # Write the <a:graphicFrameLocks> element.
+        attributes = [("noGrp", 1)]
+
+        self._xml_empty_tag("a:graphicFrameLocks", attributes)
+
+    def _write_xfrm(self):
+        # Write the <xdr:xfrm> element.
+        self._xml_start_tag("xdr:xfrm")
+
+        # Write the xfrmOffset element.
+        self._write_xfrm_offset()
+
+        # Write the xfrmOffset element.
+        self._write_xfrm_extension()
+
+        self._xml_end_tag("xdr:xfrm")
+
+    def _write_xfrm_offset(self):
+        # Write the <a:off> xfrm sub-element.
+
+        attributes = [
+            ("x", 0),
+            ("y", 0),
+        ]
+
+        self._xml_empty_tag("a:off", attributes)
+
+    def _write_xfrm_extension(self):
+        # Write the <a:ext> xfrm sub-element.
+
+        attributes = [
+            ("cx", 0),
+            ("cy", 0),
+        ]
+
+        self._xml_empty_tag("a:ext", attributes)
+
+    def _write_atag_graphic(self, index):
+        # Write the <a:graphic> element.
+        self._xml_start_tag("a:graphic")
+
+        # Write the a:graphicData element.
+        self._write_atag_graphic_data(index)
+
+        self._xml_end_tag("a:graphic")
+
+    def _write_atag_graphic_data(self, index):
+        # Write the <a:graphicData> element.
+        uri = "http://schemas.openxmlformats.org/drawingml/2006/chart"
+
+        attributes = [
+            (
+                "uri",
+                uri,
+            )
+        ]
+
+        self._xml_start_tag("a:graphicData", attributes)
+
+        # Write the c:chart element.
+        self._write_c_chart("rId" + str(index))
+
+        self._xml_end_tag("a:graphicData")
+
+    def _write_c_chart(self, r_id):
+        # Write the <c:chart> element.
+
+        schema = "http://schemas.openxmlformats.org/"
+        xmlns_c = schema + "drawingml/2006/chart"
+        xmlns_r = schema + "officeDocument/2006/relationships"
+
+        attributes = [
+            ("xmlns:c", xmlns_c),
+            ("xmlns:r", xmlns_r),
+            ("r:id", r_id),
+        ]
+
+        self._xml_empty_tag("c:chart", attributes)
+
+    def _write_client_data(self):
+        # Write the <xdr:clientData> element.
+        self._xml_empty_tag("xdr:clientData")
+
+    def _write_sp(
+        self,
+        index,
+        col_absolute,
+        row_absolute,
+        width,
+        height,
+        shape,
+        description,
+        url_rel_index,
+        tip,
+        decorative,
+    ):
+        # Write the <xdr:sp> element.
+
+        if shape and shape.connect:
+            attributes = [("macro", "")]
+            self._xml_start_tag("xdr:cxnSp", attributes)
+
+            # Write the xdr:nvCxnSpPr element.
+            self._write_nv_cxn_sp_pr(index, shape)
+
+            # Write the xdr:spPr element.
+            self._write_xdr_sp_pr(col_absolute, row_absolute, width, height, shape)
+
+            self._xml_end_tag("xdr:cxnSp")
+        else:
+            # Add attribute for shapes.
+            attributes = [("macro", ""), ("textlink", shape.textlink)]
+
+            self._xml_start_tag("xdr:sp", attributes)
+
+            # Write the xdr:nvSpPr element.
+            self._write_nv_sp_pr(
+                index, shape, url_rel_index, tip, description, decorative
+            )
+
+            # Write the xdr:spPr element.
+            self._write_xdr_sp_pr(col_absolute, row_absolute, width, height, shape)
+
+            # Write the xdr:style element.
+            self._write_style()
+
+            # Write the xdr:txBody element.
+            if shape.text is not None:
+                self._write_tx_body(shape)
+
+            self._xml_end_tag("xdr:sp")
+
+    def _write_nv_cxn_sp_pr(self, index, shape):
+        # Write the <xdr:nvCxnSpPr> element.
+        self._xml_start_tag("xdr:nvCxnSpPr")
+
+        name = shape.name + " " + str(index)
+        if name is not None:
+            self._write_c_nv_pr(index, name, None, None, None, None)
+
+        self._xml_start_tag("xdr:cNvCxnSpPr")
+
+        attributes = [("noChangeShapeType", "1")]
+        self._xml_empty_tag("a:cxnSpLocks", attributes)
+
+        if shape.start:
+            attributes = [("id", shape.start), ("idx", shape.start_index)]
+            self._xml_empty_tag("a:stCxn", attributes)
+
+        if shape.end:
+            attributes = [("id", shape.end), ("idx", shape.end_index)]
+            self._xml_empty_tag("a:endCxn", attributes)
+
+        self._xml_end_tag("xdr:cNvCxnSpPr")
+        self._xml_end_tag("xdr:nvCxnSpPr")
+
+    def _write_nv_sp_pr(
+        self, index, shape, url_rel_index, tip, description, decorative
+    ):
+        # Write the <xdr:NvSpPr> element.
+        attributes = []
+
+        self._xml_start_tag("xdr:nvSpPr")
+
+        name = shape.name + " " + str(index)
+
+        self._write_c_nv_pr(
+            index + 1, name, description, url_rel_index, tip, decorative
+        )
+
+        if shape.name == "TextBox":
+            attributes = [("txBox", 1)]
+
+        self._xml_empty_tag("xdr:cNvSpPr", attributes)
+
+        self._xml_end_tag("xdr:nvSpPr")
+
+    def _write_pic(
+        self,
+        index,
+        rel_index,
+        col_absolute,
+        row_absolute,
+        width,
+        height,
+        shape,
+        description,
+        url_rel_index,
+        tip,
+        decorative,
+    ):
+        # Write the <xdr:pic> element.
+        self._xml_start_tag("xdr:pic")
+
+        # Write the xdr:nvPicPr element.
+        self._write_nv_pic_pr(index, description, url_rel_index, tip, decorative)
+        # Write the xdr:blipFill element.
+        self._write_blip_fill(rel_index)
+
+        # Write the xdr:spPr element.
+        self._write_sp_pr(col_absolute, row_absolute, width, height, shape)
+
+        self._xml_end_tag("xdr:pic")
+
+    def _write_nv_pic_pr(self, index, description, url_rel_index, tip, decorative):
+        # Write the <xdr:nvPicPr> element.
+        self._xml_start_tag("xdr:nvPicPr")
+
+        # Write the xdr:cNvPr element.
+        self._write_c_nv_pr(
+            index + 1,
+            "Picture " + str(index),
+            description,
+            url_rel_index,
+            tip,
+            decorative,
+        )
+
+        # Write the xdr:cNvPicPr element.
+        self._write_c_nv_pic_pr()
+
+        self._xml_end_tag("xdr:nvPicPr")
+
+    def _write_c_nv_pic_pr(self):
+        # Write the <xdr:cNvPicPr> element.
+        self._xml_start_tag("xdr:cNvPicPr")
+
+        # Write the a:picLocks element.
+        self._write_a_pic_locks()
+
+        self._xml_end_tag("xdr:cNvPicPr")
+
+    def _write_a_pic_locks(self):
+        # Write the <a:picLocks> element.
+        attributes = [("noChangeAspect", 1)]
+
+        self._xml_empty_tag("a:picLocks", attributes)
+
+    def _write_blip_fill(self, index):
+        # Write the <xdr:blipFill> element.
+        self._xml_start_tag("xdr:blipFill")
+
+        # Write the a:blip element.
+        self._write_a_blip(index)
+
+        # Write the a:stretch element.
+        self._write_a_stretch()
+
+        self._xml_end_tag("xdr:blipFill")
+
+    def _write_a_blip(self, index):
+        # Write the <a:blip> element.
+        schema = "http://schemas.openxmlformats.org/officeDocument/"
+        xmlns_r = schema + "2006/relationships"
+        r_embed = "rId" + str(index)
+
+        attributes = [("xmlns:r", xmlns_r), ("r:embed", r_embed)]
+
+        self._xml_empty_tag("a:blip", attributes)
+
+    def _write_a_stretch(self):
+        # Write the <a:stretch> element.
+        self._xml_start_tag("a:stretch")
+
+        # Write the a:fillRect element.
+        self._write_a_fill_rect()
+
+        self._xml_end_tag("a:stretch")
+
+    def _write_a_fill_rect(self):
+        # Write the <a:fillRect> element.
+        self._xml_empty_tag("a:fillRect")
+
+    def _write_sp_pr(self, col_absolute, row_absolute, width, height, shape=None):
+        # Write the <xdr:spPr> element, for charts.
+
+        self._xml_start_tag("xdr:spPr")
+
+        # Write the a:xfrm element.
+        self._write_a_xfrm(col_absolute, row_absolute, width, height)
+
+        # Write the a:prstGeom element.
+        self._write_a_prst_geom(shape)
+
+        self._xml_end_tag("xdr:spPr")
+
+    def _write_xdr_sp_pr(self, col_absolute, row_absolute, width, height, shape):
+        # Write the <xdr:spPr> element for shapes.
+        self._xml_start_tag("xdr:spPr")
+
+        # Write the a:xfrm element.
+        self._write_a_xfrm(col_absolute, row_absolute, width, height, shape)
+
+        # Write the a:prstGeom element.
+        self._write_a_prst_geom(shape)
+
+        if shape.fill:
+            if not shape.fill["defined"]:
+                # Write the a:solidFill element.
+                self._write_a_solid_fill_scheme("lt1")
+            elif "none" in shape.fill:
+                # Write the a:noFill element.
+                self._xml_empty_tag("a:noFill")
+            elif "color" in shape.fill:
+                # Write the a:solidFill element.
+                self._write_a_solid_fill(_get_rgb_color(shape.fill["color"]))
+
+        if shape.gradient:
+            # Write the a:gradFill element.
+            self._write_a_grad_fill(shape.gradient)
+
+        # Write the a:ln element.
+        self._write_a_ln(shape.line)
+
+        self._xml_end_tag("xdr:spPr")
+
+    def _write_a_xfrm(self, col_absolute, row_absolute, width, height, shape=None):
+        # Write the <a:xfrm> element.
+        attributes = []
+
+        if shape:
+            if shape.rotation:
+                rotation = shape.rotation
+                rotation *= 60000
+                attributes.append(("rot", rotation))
+
+            if shape.flip_h:
+                attributes.append(("flipH", 1))
+            if shape.flip_v:
+                attributes.append(("flipV", 1))
+
+        self._xml_start_tag("a:xfrm", attributes)
+
+        # Write the a:off element.
+        self._write_a_off(col_absolute, row_absolute)
+
+        # Write the a:ext element.
+        self._write_a_ext(width, height)
+
+        self._xml_end_tag("a:xfrm")
+
+    def _write_a_off(self, x, y):
+        # Write the <a:off> element.
+        attributes = [
+            ("x", x),
+            ("y", y),
+        ]
+
+        self._xml_empty_tag("a:off", attributes)
+
+    def _write_a_ext(self, cx, cy):
+        # Write the <a:ext> element.
+        attributes = [
+            ("cx", cx),
+            ("cy", cy),
+        ]
+
+        self._xml_empty_tag("a:ext", attributes)
+
+    def _write_a_prst_geom(self, shape=None):
+        # Write the <a:prstGeom> element.
+        attributes = [("prst", "rect")]
+
+        self._xml_start_tag("a:prstGeom", attributes)
+
+        # Write the a:avLst element.
+        self._write_a_av_lst(shape)
+
+        self._xml_end_tag("a:prstGeom")
+
+    def _write_a_av_lst(self, shape=None):
+        # Write the <a:avLst> element.
+        adjustments = []
+
+        if shape and shape.adjustments:
+            adjustments = shape.adjustments
+
+        if adjustments:
+            self._xml_start_tag("a:avLst")
+
+            i = 0
+            for adj in adjustments:
+                i += 1
+                # Only connectors have multiple adjustments.
+                if shape.connect:
+                    suffix = i
+                else:
+                    suffix = ""
+
+                # Scale Adjustments: 100,000 = 100%.
+                adj_int = str(int(adj * 1000))
+
+                attributes = [("name", "adj" + suffix), ("fmla", "val" + adj_int)]
+
+                self._xml_empty_tag("a:gd", attributes)
+
+            self._xml_end_tag("a:avLst")
+        else:
+            self._xml_empty_tag("a:avLst")
+
+    def _write_a_solid_fill(self, rgb):
+        # Write the <a:solidFill> element.
+        if rgb is None:
+            rgb = "FFFFFF"
+
+        self._xml_start_tag("a:solidFill")
+
+        # Write the a:srgbClr element.
+        self._write_a_srgb_clr(rgb)
+
+        self._xml_end_tag("a:solidFill")
+
+    def _write_a_solid_fill_scheme(self, color, shade=None):
+        attributes = [("val", color)]
+
+        self._xml_start_tag("a:solidFill")
+
+        if shade:
+            self._xml_start_tag("a:schemeClr", attributes)
+            self._write_a_shade(shade)
+            self._xml_end_tag("a:schemeClr")
+        else:
+            self._xml_empty_tag("a:schemeClr", attributes)
+
+        self._xml_end_tag("a:solidFill")
+
+    def _write_a_ln(self, line):
+        # Write the <a:ln> element.
+        width = line.get("width", 0.75)
+
+        # Round width to nearest 0.25, like Excel.
+        width = int((width + 0.125) * 4) / 4.0
+
+        # Convert to internal units.
+        width = int(0.5 + (12700 * width))
+
+        attributes = [("w", width), ("cmpd", "sng")]
+
+        self._xml_start_tag("a:ln", attributes)
+
+        if "none" in line:
+            # Write the a:noFill element.
+            self._xml_empty_tag("a:noFill")
+
+        elif "color" in line:
+            # Write the a:solidFill element.
+            self._write_a_solid_fill(_get_rgb_color(line["color"]))
+
+        else:
+            # Write the a:solidFill element.
+            self._write_a_solid_fill_scheme("lt1", "50000")
+
+        # Write the line/dash type.
+        line_type = line.get("dash_type")
+        if line_type:
+            # Write the a:prstDash element.
+            self._write_a_prst_dash(line_type)
+
+        self._xml_end_tag("a:ln")
+
+    def _write_tx_body(self, shape):
+        # Write the <xdr:txBody> element.
+        attributes = []
+
+        if shape.text_rotation != 0:
+            if shape.text_rotation == 90:
+                attributes.append(("vert", "vert270"))
+            if shape.text_rotation == -90:
+                attributes.append(("vert", "vert"))
+            if shape.text_rotation == 270:
+                attributes.append(("vert", "wordArtVert"))
+            if shape.text_rotation == 271:
+                attributes.append(("vert", "eaVert"))
+
+        attributes.append(("wrap", "square"))
+        attributes.append(("rtlCol", "0"))
+
+        if not shape.align["defined"]:
+            attributes.append(("anchor", "t"))
+        else:
+            if "vertical" in shape.align:
+                align = shape.align["vertical"]
+                if align == "top":
+                    attributes.append(("anchor", "t"))
+                elif align == "middle":
+                    attributes.append(("anchor", "ctr"))
+                elif align == "bottom":
+                    attributes.append(("anchor", "b"))
+            else:
+                attributes.append(("anchor", "t"))
+
+            if "horizontal" in shape.align:
+                align = shape.align["horizontal"]
+                if align == "center":
+                    attributes.append(("anchorCtr", "1"))
+            else:
+                attributes.append(("anchorCtr", "0"))
+
+        self._xml_start_tag("xdr:txBody")
+        self._xml_empty_tag("a:bodyPr", attributes)
+        self._xml_empty_tag("a:lstStyle")
+
+        lines = shape.text.split("\n")
+
+        # Set the font attributes.
+        font = shape.font
+        # pylint: disable=protected-access
+        style_attrs = Shape._get_font_style_attributes(font)
+        latin_attrs = Shape._get_font_latin_attributes(font)
+        style_attrs.insert(0, ("lang", font["lang"]))
+
+        if shape.textlink != "":
+            attributes = [
+                ("id", "{B8ADDEFE-BF52-4FD4-8C5D-6B85EF6FF707}"),
+                ("type", "TxLink"),
+            ]
+
+            self._xml_start_tag("a:p")
+            self._xml_start_tag("a:fld", attributes)
+
+            self._write_font_run(font, style_attrs, latin_attrs, "a:rPr")
+
+            self._xml_data_element("a:t", shape.text)
+            self._xml_end_tag("a:fld")
+
+            self._write_font_run(font, style_attrs, latin_attrs, "a:endParaRPr")
+
+            self._xml_end_tag("a:p")
+        else:
+            for line in lines:
+                self._xml_start_tag("a:p")
+
+                if line == "":
+                    self._write_font_run(font, style_attrs, latin_attrs, "a:endParaRPr")
+                    self._xml_end_tag("a:p")
+                    continue
+
+                if "text" in shape.align:
+                    if shape.align["text"] == "left":
+                        self._xml_empty_tag("a:pPr", [("algn", "l")])
+                    if shape.align["text"] == "center":
+                        self._xml_empty_tag("a:pPr", [("algn", "ctr")])
+                    if shape.align["text"] == "right":
+                        self._xml_empty_tag("a:pPr", [("algn", "r")])
+
+                self._xml_start_tag("a:r")
+
+                self._write_font_run(font, style_attrs, latin_attrs, "a:rPr")
+
+                self._xml_data_element("a:t", line)
+
+                self._xml_end_tag("a:r")
+                self._xml_end_tag("a:p")
+
+        self._xml_end_tag("xdr:txBody")
+
+    def _write_font_run(self, font, style_attrs, latin_attrs, run_type):
+        # Write a:rPr or a:endParaRPr.
+        has_color = font.get("color") is not None
+
+        if latin_attrs or has_color:
+            self._xml_start_tag(run_type, style_attrs)
+
+            if has_color:
+                self._write_a_solid_fill(_get_rgb_color(font["color"]))
+
+            if latin_attrs:
+                self._write_a_latin(latin_attrs)
+                self._write_a_cs(latin_attrs)
+
+            self._xml_end_tag(run_type)
+        else:
+            self._xml_empty_tag(run_type, style_attrs)
+
+    def _write_style(self):
+        # Write the <xdr:style> element.
+        self._xml_start_tag("xdr:style")
+
+        # Write the a:lnRef element.
+        self._write_a_ln_ref()
+
+        # Write the a:fillRef element.
+        self._write_a_fill_ref()
+
+        # Write the a:effectRef element.
+        self._write_a_effect_ref()
+
+        # Write the a:fontRef element.
+        self._write_a_font_ref()
+
+        self._xml_end_tag("xdr:style")
+
+    def _write_a_ln_ref(self):
+        # Write the <a:lnRef> element.
+        attributes = [("idx", "0")]
+
+        self._xml_start_tag("a:lnRef", attributes)
+
+        # Write the a:scrgbClr element.
+        self._write_a_scrgb_clr()
+
+        self._xml_end_tag("a:lnRef")
+
+    def _write_a_fill_ref(self):
+        # Write the <a:fillRef> element.
+        attributes = [("idx", "0")]
+
+        self._xml_start_tag("a:fillRef", attributes)
+
+        # Write the a:scrgbClr element.
+        self._write_a_scrgb_clr()
+
+        self._xml_end_tag("a:fillRef")
+
+    def _write_a_effect_ref(self):
+        # Write the <a:effectRef> element.
+        attributes = [("idx", "0")]
+
+        self._xml_start_tag("a:effectRef", attributes)
+
+        # Write the a:scrgbClr element.
+        self._write_a_scrgb_clr()
+
+        self._xml_end_tag("a:effectRef")
+
+    def _write_a_scrgb_clr(self):
+        # Write the <a:scrgbClr> element.
+
+        attributes = [
+            ("r", "0"),
+            ("g", "0"),
+            ("b", "0"),
+        ]
+
+        self._xml_empty_tag("a:scrgbClr", attributes)
+
+    def _write_a_font_ref(self):
+        # Write the <a:fontRef> element.
+        attributes = [("idx", "minor")]
+
+        self._xml_start_tag("a:fontRef", attributes)
+
+        # Write the a:schemeClr element.
+        self._write_a_scheme_clr("dk1")
+
+        self._xml_end_tag("a:fontRef")
+
+    def _write_a_scheme_clr(self, val):
+        # Write the <a:schemeClr> element.
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("a:schemeClr", attributes)
+
+    def _write_a_shade(self, shade):
+        # Write the <a:shade> element.
+        attributes = [("val", shade)]
+
+        self._xml_empty_tag("a:shade", attributes)
+
+    def _write_a_prst_dash(self, val):
+        # Write the <a:prstDash> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("a:prstDash", attributes)
+
+    def _write_a_grad_fill(self, gradient):
+        # Write the <a:gradFill> element.
+
+        attributes = [("flip", "none"), ("rotWithShape", "1")]
+
+        if gradient["type"] == "linear":
+            attributes = []
+
+        self._xml_start_tag("a:gradFill", attributes)
+
+        # Write the a:gsLst element.
+        self._write_a_gs_lst(gradient)
+
+        if gradient["type"] == "linear":
+            # Write the a:lin element.
+            self._write_a_lin(gradient["angle"])
+        else:
+            # Write the a:path element.
+            self._write_a_path(gradient["type"])
+
+            # Write the a:tileRect element.
+            self._write_a_tile_rect(gradient["type"])
+
+        self._xml_end_tag("a:gradFill")
+
+    def _write_a_gs_lst(self, gradient):
+        # Write the <a:gsLst> element.
+        positions = gradient["positions"]
+        colors = gradient["colors"]
+
+        self._xml_start_tag("a:gsLst")
+
+        for i, color in enumerate(colors):
+            pos = int(positions[i] * 1000)
+            attributes = [("pos", pos)]
+            self._xml_start_tag("a:gs", attributes)
+
+            # Write the a:srgbClr element.
+            color = _get_rgb_color(color)
+            self._write_a_srgb_clr(color)
+
+            self._xml_end_tag("a:gs")
+
+        self._xml_end_tag("a:gsLst")
+
+    def _write_a_lin(self, angle):
+        # Write the <a:lin> element.
+
+        angle = int(60000 * angle)
+
+        attributes = [
+            ("ang", angle),
+            ("scaled", "0"),
+        ]
+
+        self._xml_empty_tag("a:lin", attributes)
+
+    def _write_a_path(self, gradient_type):
+        # Write the <a:path> element.
+
+        attributes = [("path", gradient_type)]
+
+        self._xml_start_tag("a:path", attributes)
+
+        # Write the a:fillToRect element.
+        self._write_a_fill_to_rect(gradient_type)
+
+        self._xml_end_tag("a:path")
+
+    def _write_a_fill_to_rect(self, gradient_type):
+        # Write the <a:fillToRect> element.
+
+        if gradient_type == "shape":
+            attributes = [
+                ("l", "50000"),
+                ("t", "50000"),
+                ("r", "50000"),
+                ("b", "50000"),
+            ]
+        else:
+            attributes = [
+                ("l", "100000"),
+                ("t", "100000"),
+            ]
+
+        self._xml_empty_tag("a:fillToRect", attributes)
+
+    def _write_a_tile_rect(self, gradient_type):
+        # Write the <a:tileRect> element.
+
+        if gradient_type == "shape":
+            attributes = []
+        else:
+            attributes = [
+                ("r", "-100000"),
+                ("b", "-100000"),
+            ]
+
+        self._xml_empty_tag("a:tileRect", attributes)
+
+    def _write_a_srgb_clr(self, val):
+        # Write the <a:srgbClr> element.
+
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("a:srgbClr", attributes)
+
+    def _write_a_latin(self, attributes):
+        # Write the <a:latin> element.
+        self._xml_empty_tag("a:latin", attributes)
+
+    def _write_a_cs(self, attributes):
+        # Write the <a:latin> element.
+        self._xml_empty_tag("a:cs", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/exceptions.py b/.venv/lib/python3.12/site-packages/xlsxwriter/exceptions.py
new file mode 100644
index 00000000..6c8198b3
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/exceptions.py
@@ -0,0 +1,56 @@
+###############################################################################
+#
+# Exceptions - A class for XlsxWriter exceptions.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+
+class XlsxWriterException(Exception):
+    """Base exception for XlsxWriter."""
+
+
+class XlsxInputError(XlsxWriterException):
+    """Base exception for all input data related errors."""
+
+
+class XlsxFileError(XlsxWriterException):
+    """Base exception for all file related errors."""
+
+
+class EmptyChartSeries(XlsxInputError):
+    """Chart must contain at least one data series."""
+
+
+class DuplicateTableName(XlsxInputError):
+    """Worksheet table name already exists."""
+
+
+class InvalidWorksheetName(XlsxInputError):
+    """Worksheet name is too long or contains restricted characters."""
+
+
+class DuplicateWorksheetName(XlsxInputError):
+    """Worksheet name already exists."""
+
+
+class OverlappingRange(XlsxInputError):
+    """Worksheet merge range or table overlaps previous range."""
+
+
+class UndefinedImageSize(XlsxFileError):
+    """No size data found in image file."""
+
+
+class UnsupportedImageFormat(XlsxFileError):
+    """Unsupported image file format."""
+
+
+class FileCreateError(XlsxFileError):
+    """IO error when creating xlsx file."""
+
+
+class FileSizeError(XlsxFileError):
+    """Filesize would require ZIP64 extensions. Use workbook.use_zip64()."""
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/feature_property_bag.py b/.venv/lib/python3.12/site-packages/xlsxwriter/feature_property_bag.py
new file mode 100644
index 00000000..d38bcc7c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/feature_property_bag.py
@@ -0,0 +1,156 @@
+###############################################################################
+#
+# FeaturePropertyBag - A class for writing the Excel XLSX featurePropertyBag.xml
+#                      file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class FeaturePropertyBag(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX FeaturePropertyBag file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.feature_property_bags = set()
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the FeaturePropertyBags element.
+        self._write_feature_property_bags()
+
+        # Write the Checkbox bag element.
+        self._write_checkbox_bag()
+
+        # Write the XFControls bag element.
+        self._write_xf_control_bag()
+
+        # Write the XFComplement bag element.
+        self._write_xf_compliment_bag()
+
+        # Write the XFComplements bag element.
+        self._write_xf_compliments_bag()
+
+        # Write the DXFComplements bag element.
+        if "DXFComplements" in self.feature_property_bags:
+            self._write_dxf_compliments_bag()
+
+        self._xml_end_tag("FeaturePropertyBags")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_feature_property_bags(self):
+        # Write the <FeaturePropertyBags> element.
+
+        xmlns = (
+            "http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag"
+        )
+
+        attributes = [("xmlns", xmlns)]
+
+        self._xml_start_tag("FeaturePropertyBags", attributes)
+
+    def _write_checkbox_bag(self):
+        # Write the Checkbox <bag> element.
+        attributes = [("type", "Checkbox")]
+
+        self._xml_empty_tag("bag", attributes)
+
+    def _write_xf_control_bag(self):
+        # Write the XFControls<bag> element.
+        attributes = [("type", "XFControls")]
+
+        self._xml_start_tag("bag", attributes)
+
+        # Write the bagId element.
+        self._write_bag_id("CellControl", 0)
+
+        self._xml_end_tag("bag")
+
+    def _write_xf_compliment_bag(self):
+        # Write the XFComplement <bag> element.
+        attributes = [("type", "XFComplement")]
+
+        self._xml_start_tag("bag", attributes)
+
+        # Write the bagId element.
+        self._write_bag_id("XFControls", 1)
+
+        self._xml_end_tag("bag")
+
+    def _write_xf_compliments_bag(self):
+        # Write the XFComplements <bag> element.
+        attributes = [
+            ("type", "XFComplements"),
+            ("extRef", "XFComplementsMapperExtRef"),
+        ]
+
+        self._xml_start_tag("bag", attributes)
+        self._xml_start_tag("a", [("k", "MappedFeaturePropertyBags")])
+
+        self._write_bag_id("", 2)
+
+        self._xml_end_tag("a")
+        self._xml_end_tag("bag")
+
+    def _write_dxf_compliments_bag(self):
+        # Write the DXFComplements <bag> element.
+        attributes = [
+            ("type", "DXFComplements"),
+            ("extRef", "DXFComplementsMapperExtRef"),
+        ]
+
+        self._xml_start_tag("bag", attributes)
+        self._xml_start_tag("a", [("k", "MappedFeaturePropertyBags")])
+
+        self._write_bag_id("", 2)
+
+        self._xml_end_tag("a")
+        self._xml_end_tag("bag")
+
+    def _write_bag_id(self, key, bag_id):
+        # Write the <bagId> element.
+        attributes = []
+
+        if key:
+            attributes = [("k", key)]
+
+        self._xml_data_element("bagId", bag_id, attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/format.py b/.venv/lib/python3.12/site-packages/xlsxwriter/format.py
new file mode 100644
index 00000000..22996fed
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/format.py
@@ -0,0 +1,1217 @@
+###############################################################################
+#
+# Format - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from warnings import warn
+
+from . import xmlwriter
+
+
+class Format(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Format file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, properties=None, xf_indices=None, dxf_indices=None):
+        """
+        Constructor.
+
+        """
+        if properties is None:
+            properties = {}
+
+        super().__init__()
+
+        self.xf_format_indices = xf_indices
+        self.dxf_format_indices = dxf_indices
+        self.xf_index = None
+        self.dxf_index = None
+
+        self.num_format = "General"
+        self.num_format_index = 0
+        self.font_index = 0
+        self.has_font = 0
+        self.has_dxf_font = 0
+
+        self.bold = 0
+        self.underline = 0
+        self.italic = 0
+        self.font_name = "Calibri"
+        self.font_size = 11
+        self.font_color = 0x0
+        self.font_strikeout = 0
+        self.font_outline = 0
+        self.font_shadow = 0
+        self.font_script = 0
+        self.font_family = 2
+        self.font_charset = 0
+        self.font_scheme = "minor"
+        self.font_condense = 0
+        self.font_extend = 0
+        self.theme = 0
+        self.hyperlink = False
+        self.xf_id = 0
+
+        self.hidden = 0
+        self.locked = 1
+
+        self.text_h_align = 0
+        self.text_wrap = 0
+        self.text_v_align = 0
+        self.text_justlast = 0
+        self.rotation = 0
+
+        self.fg_color = 0
+        self.bg_color = 0
+        self.pattern = 0
+        self.has_fill = 0
+        self.has_dxf_fill = 0
+        self.fill_index = 0
+        self.fill_count = 0
+
+        self.border_index = 0
+        self.has_border = 0
+        self.has_dxf_border = 0
+        self.border_count = 0
+
+        self.bottom = 0
+        self.bottom_color = 0
+        self.diag_border = 0
+        self.diag_color = 0
+        self.diag_type = 0
+        self.left = 0
+        self.left_color = 0
+        self.right = 0
+        self.right_color = 0
+        self.top = 0
+        self.top_color = 0
+
+        self.indent = 0
+        self.shrink = 0
+        self.merge_range = 0
+        self.reading_order = 0
+        self.just_distrib = 0
+        self.color_indexed = 0
+        self.font_only = 0
+
+        self.quote_prefix = False
+        self.checkbox = False
+
+        # Convert properties in the constructor to method calls.
+        for key, value in properties.items():
+            getattr(self, "set_" + key)(value)
+
+        self._format_key = None
+
+    ###########################################################################
+    #
+    # Format properties.
+    #
+    ###########################################################################
+
+    def set_font_name(self, font_name):
+        """
+        Set the Format font_name property such as 'Time New Roman'. The
+        default Excel font is 'Calibri'.
+
+        Args:
+            font_name: String with the font name. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_name = font_name
+
+    def set_font_size(self, font_size=11):
+        """
+        Set the Format font_size property. The default Excel font size is 11.
+
+        Args:
+            font_size: Int with font size. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_size = font_size
+
+    def set_font_color(self, font_color):
+        """
+        Set the Format font_color property. The Excel default is black.
+
+        Args:
+            font_color: String with the font color. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_color = self._get_color(font_color)
+
+    def set_bold(self, bold=True):
+        """
+        Set the Format bold property.
+
+        Args:
+            bold: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.bold = bold
+
+    def set_italic(self, italic=True):
+        """
+        Set the Format italic property.
+
+        Args:
+            italic: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.italic = italic
+
+    def set_underline(self, underline=1):
+        """
+        Set the Format underline property.
+
+        Args:
+            underline: Default is 1, single underline.
+
+        Returns:
+            Nothing.
+
+        """
+        self.underline = underline
+
+    def set_font_strikeout(self, font_strikeout=True):
+        """
+        Set the Format font_strikeout property.
+
+        Args:
+            font_strikeout: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_strikeout = font_strikeout
+
+    def set_font_script(self, font_script=1):
+        """
+        Set the Format font_script property.
+
+        Args:
+            font_script: Default is 1, superscript.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_script = font_script
+
+    def set_font_outline(self, font_outline=True):
+        """
+        Set the Format font_outline property.
+
+        Args:
+            font_outline: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_outline = font_outline
+
+    def set_font_shadow(self, font_shadow=True):
+        """
+        Set the Format font_shadow property.
+
+        Args:
+            font_shadow: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_shadow = font_shadow
+
+    def set_num_format(self, num_format):
+        """
+        Set the Format num_format property such as '#,##0'.
+
+        Args:
+            num_format: String representing the number format. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.num_format = num_format
+
+    def set_locked(self, locked=True):
+        """
+        Set the Format locked property.
+
+        Args:
+            locked: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.locked = locked
+
+    def set_hidden(self, hidden=True):
+        """
+        Set the Format hidden property.
+
+        Args:
+            hidden: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.hidden = hidden
+
+    def set_align(self, alignment):
+        """
+        Set the Format cell alignment.
+
+        Args:
+            alignment: String representing alignment. No default.
+
+        Returns:
+            Nothing.
+        """
+        alignment = alignment.lower()
+
+        # Set horizontal alignment properties.
+        if alignment == "left":
+            self.set_text_h_align(1)
+        if alignment == "centre":
+            self.set_text_h_align(2)
+        if alignment == "center":
+            self.set_text_h_align(2)
+        if alignment == "right":
+            self.set_text_h_align(3)
+        if alignment == "fill":
+            self.set_text_h_align(4)
+        if alignment == "justify":
+            self.set_text_h_align(5)
+        if alignment == "center_across":
+            self.set_text_h_align(6)
+        if alignment == "centre_across":
+            self.set_text_h_align(6)
+        if alignment == "distributed":
+            self.set_text_h_align(7)
+        if alignment == "justify_distributed":
+            self.set_text_h_align(7)
+
+        if alignment == "justify_distributed":
+            self.just_distrib = 1
+
+        # Set vertical alignment properties.
+        if alignment == "top":
+            self.set_text_v_align(1)
+        if alignment == "vcentre":
+            self.set_text_v_align(2)
+        if alignment == "vcenter":
+            self.set_text_v_align(2)
+        if alignment == "bottom":
+            self.set_text_v_align(3)
+        if alignment == "vjustify":
+            self.set_text_v_align(4)
+        if alignment == "vdistributed":
+            self.set_text_v_align(5)
+
+    def set_center_across(self, align_type=None):
+        # pylint: disable=unused-argument
+        """
+        Set the Format center_across property.
+
+        Returns:
+            Nothing.
+
+        """
+        self.set_text_h_align(6)
+
+    def set_text_wrap(self, text_wrap=True):
+        """
+        Set the Format text_wrap property.
+
+        Args:
+            text_wrap: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.text_wrap = text_wrap
+
+    def set_rotation(self, rotation):
+        """
+        Set the Format rotation property.
+
+        Args:
+            rotation: Rotation angle. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        rotation = int(rotation)
+
+        # Map user angle to Excel angle.
+        if rotation == 270:
+            rotation = 255
+        elif -90 <= rotation <= 90:
+            if rotation < 0:
+                rotation = -rotation + 90
+        else:
+            warn("Rotation rotation outside range: -90 <= angle <= 90")
+            return
+
+        self.rotation = rotation
+
+    def set_indent(self, indent=1):
+        """
+        Set the Format indent property.
+
+        Args:
+            indent: Default is 1, first indentation level.
+
+        Returns:
+            Nothing.
+
+        """
+        self.indent = indent
+
+    def set_shrink(self, shrink=True):
+        """
+        Set the Format shrink property.
+
+        Args:
+            shrink: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.shrink = shrink
+
+    def set_text_justlast(self, text_justlast=True):
+        """
+        Set the Format text_justlast property.
+
+        Args:
+            text_justlast: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.text_justlast = text_justlast
+
+    def set_pattern(self, pattern=1):
+        """
+        Set the Format pattern property.
+
+        Args:
+            pattern: Default is 1, solid fill.
+
+        Returns:
+            Nothing.
+
+        """
+        self.pattern = pattern
+
+    def set_bg_color(self, bg_color):
+        """
+        Set the Format bg_color property.
+
+        Args:
+            bg_color: Background color. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.bg_color = self._get_color(bg_color)
+
+    def set_fg_color(self, fg_color):
+        """
+        Set the Format fg_color property.
+
+        Args:
+            fg_color: Foreground color. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.fg_color = self._get_color(fg_color)
+
+    # set_border(style) Set cells borders to the same style
+    def set_border(self, style=1):
+        """
+        Set the Format bottom property.
+
+        Args:
+            bottom: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.set_bottom(style)
+        self.set_top(style)
+        self.set_left(style)
+        self.set_right(style)
+
+    # set_border_color(color) Set cells border to the same color
+    def set_border_color(self, color):
+        """
+        Set the Format bottom property.
+
+        Args:
+            color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.set_bottom_color(color)
+        self.set_top_color(color)
+        self.set_left_color(color)
+        self.set_right_color(color)
+
+    def set_bottom(self, bottom=1):
+        """
+        Set the Format bottom property.
+
+        Args:
+            bottom: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.bottom = bottom
+
+    def set_bottom_color(self, bottom_color):
+        """
+        Set the Format bottom_color property.
+
+        Args:
+            bottom_color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.bottom_color = self._get_color(bottom_color)
+
+    def set_diag_type(self, diag_type=1):
+        """
+        Set the Format diag_type property.
+
+        Args:
+            diag_type: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.diag_type = diag_type
+
+    def set_left(self, left=1):
+        """
+        Set the Format left property.
+
+        Args:
+            left: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.left = left
+
+    def set_left_color(self, left_color):
+        """
+        Set the Format left_color property.
+
+        Args:
+            left_color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.left_color = self._get_color(left_color)
+
+    def set_right(self, right=1):
+        """
+        Set the Format right property.
+
+        Args:
+            right: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.right = right
+
+    def set_right_color(self, right_color):
+        """
+        Set the Format right_color property.
+
+        Args:
+            right_color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.right_color = self._get_color(right_color)
+
+    def set_top(self, top=1):
+        """
+        Set the Format top property.
+
+        Args:
+            top: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.top = top
+
+    def set_top_color(self, top_color):
+        """
+        Set the Format top_color property.
+
+        Args:
+            top_color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.top_color = self._get_color(top_color)
+
+    def set_diag_color(self, diag_color):
+        """
+        Set the Format diag_color property.
+
+        Args:
+            diag_color: Color string. No default.
+
+        Returns:
+            Nothing.
+
+        """
+        self.diag_color = self._get_color(diag_color)
+
+    def set_diag_border(self, diag_border=1):
+        """
+        Set the Format diag_border property.
+
+        Args:
+            diag_border: Default is 1, border type 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.diag_border = diag_border
+
+    def set_quote_prefix(self, quote_prefix=True):
+        """
+        Set the Format quote prefix property.
+
+        Args:
+            quote_prefix: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.quote_prefix = quote_prefix
+
+    def set_checkbox(self, checkbox=True):
+        """
+        Set the Format property to show a checkbox in a cell.
+
+        This format property can be used with a cell that contains a boolean
+        value to display it as a checkbox. This property isn't required very
+        often and it is generally easier to create a checkbox using the
+        ``worksheet.insert_checkbox()`` method.
+
+        Args:
+            checkbox: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.checkbox = checkbox
+
+    ###########################################################################
+    #
+    # Internal Format properties. These aren't documented since they are
+    # either only used internally or else are unlikely to be set by the user.
+    #
+    ###########################################################################
+
+    def set_has_font(self, has_font=True):
+        """
+        Set the property to indicate the format has a font.
+
+        Args:
+            has_font: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.has_font = has_font
+
+    def set_has_fill(self, has_fill=True):
+        """
+        Set the property to indicate the format has a fill.
+
+        Args:
+            has_fill: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.has_fill = has_fill
+
+    def set_font_index(self, font_index):
+        """
+        Set the unique font index property.
+
+        Args:
+            font_index: The unique font index.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_index = font_index
+
+    def set_xf_index(self, xf_index):
+        """
+        Set the unique format index property.
+
+        Args:
+            xf_index: The unique Excel format index.
+
+        Returns:
+            Nothing.
+
+        """
+        self.xf_index = xf_index
+
+    def set_dxf_index(self, dxf_index):
+        """
+        Set the unique conditional format index property.
+
+        Args:
+            dxf_index: The unique Excel conditional format index.
+
+        Returns:
+            Nothing.
+
+        """
+        self.dxf_index = dxf_index
+
+    def set_num_format_index(self, num_format_index):
+        """
+        Set the number format_index property.
+
+        Args:
+            num_format_index: The unique number format index.
+
+        Returns:
+            Nothing.
+
+        """
+        self.num_format_index = num_format_index
+
+    def set_text_h_align(self, text_h_align):
+        """
+        Set the horizontal text alignment property.
+
+        Args:
+            text_h_align: Horizontal text alignment.
+
+        Returns:
+            Nothing.
+
+        """
+        self.text_h_align = text_h_align
+
+    def set_text_v_align(self, text_v_align):
+        """
+        Set the vertical text alignment property.
+
+        Args:
+            text_h_align: Vertical text alignment.
+
+        Returns:
+            Nothing.
+
+        """
+        self.text_v_align = text_v_align
+
+    def set_reading_order(self, direction=0):
+        # Set the reading_order property.
+        """
+        Set the reading order property.
+
+        Args:
+            direction: Default is 0, left to right.
+
+        Returns:
+            Nothing.
+
+        """
+        self.reading_order = direction
+
+    def set_valign(self, align):
+        # Set vertical cell alignment. This is required by the constructor
+        # properties dict to differentiate between the vertical and horizontal
+        # properties.
+        """
+        Set vertical cell alignment property.
+
+        This is required by the constructor properties dict to differentiate
+        between the vertical and horizontal properties.
+
+        Args:
+            align: Alignment property.
+
+        Returns:
+            Nothing.
+
+        """
+        self.set_align(align)
+
+    def set_font_family(self, font_family):
+        """
+        Set the font family property.
+
+        Args:
+            font_family: Font family number.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_family = font_family
+
+    def set_font_charset(self, font_charset):
+        """
+        Set the font character set property.
+
+        Args:
+            font_charset: The font character set number.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_charset = font_charset
+
+    def set_font_scheme(self, font_scheme):
+        """
+        Set the font scheme property.
+
+        Args:
+            font_scheme: The font scheme.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_scheme = font_scheme
+
+    def set_font_condense(self, font_condense):
+        """
+        Set the font condense property.
+
+        Args:
+            font_condense: The font condense property.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_condense = font_condense
+
+    def set_font_extend(self, font_extend):
+        """
+        Set the font extend property.
+
+        Args:
+            font_extend: The font extend property.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_extend = font_extend
+
+    def set_theme(self, theme):
+        """
+        Set the theme property.
+
+        Args:
+            theme: Format theme.
+
+        Returns:
+            Nothing.
+
+        """
+        self.theme = theme
+
+    def set_hyperlink(self, hyperlink=True):
+        """
+        Set the properties for the hyperlink style.
+
+        Args:
+            hyperlink: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.xf_id = 1
+        self.set_underline(1)
+        self.set_theme(10)
+        self.hyperlink = hyperlink
+
+    def set_color_indexed(self, color_index):
+        """
+        Set the color index property. Some fundamental format properties use an
+        indexed color instead of a rbg or theme color.
+
+        Args:
+            color_index: Generally 0 or 1.
+
+        Returns:
+            Nothing.
+
+        """
+        self.color_indexed = color_index
+
+    def set_font_only(self, font_only=True):
+        """
+        Set property to indicate that the format is used for fonts only.
+
+        Args:
+            font_only: Default is True, turns property on.
+
+        Returns:
+            Nothing.
+
+        """
+        self.font_only = font_only
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _get_align_properties(self):
+        # pylint: disable=too-many-boolean-expressions
+        # Return properties for an Style xf <alignment> sub-element.
+        changed = 0
+        align = []
+
+        # Check if any alignment options in the format have been changed.
+        if (
+            self.text_h_align
+            or self.text_v_align
+            or self.indent
+            or self.rotation
+            or self.text_wrap
+            or self.shrink
+            or self.reading_order
+        ):
+            changed = 1
+        else:
+            return changed, align
+
+        # Indent is only allowed for some alignment properties. If it is
+        # defined for any other alignment or no alignment has been set then
+        # default to left alignment.
+        if (
+            self.indent
+            and self.text_h_align != 1
+            and self.text_h_align != 3
+            and self.text_h_align != 7
+            and self.text_v_align != 1
+            and self.text_v_align != 3
+            and self.text_v_align != 5
+        ):
+            self.text_h_align = 1
+
+        # Check for properties that are mutually exclusive.
+        if self.text_wrap:
+            self.shrink = 0
+        if self.text_h_align == 4:
+            self.shrink = 0
+        if self.text_h_align == 5:
+            self.shrink = 0
+        if self.text_h_align == 7:
+            self.shrink = 0
+        if self.text_h_align != 7:
+            self.just_distrib = 0
+        if self.indent:
+            self.just_distrib = 0
+
+        continuous = "centerContinuous"
+
+        if self.text_h_align == 1:
+            align.append(("horizontal", "left"))
+        if self.text_h_align == 2:
+            align.append(("horizontal", "center"))
+        if self.text_h_align == 3:
+            align.append(("horizontal", "right"))
+        if self.text_h_align == 4:
+            align.append(("horizontal", "fill"))
+        if self.text_h_align == 5:
+            align.append(("horizontal", "justify"))
+        if self.text_h_align == 6:
+            align.append(("horizontal", continuous))
+        if self.text_h_align == 7:
+            align.append(("horizontal", "distributed"))
+
+        if self.just_distrib:
+            align.append(("justifyLastLine", 1))
+
+        # Property 'vertical' => 'bottom' is a default. It sets applyAlignment
+        # without an alignment sub-element.
+        if self.text_v_align == 1:
+            align.append(("vertical", "top"))
+        if self.text_v_align == 2:
+            align.append(("vertical", "center"))
+        if self.text_v_align == 4:
+            align.append(("vertical", "justify"))
+        if self.text_v_align == 5:
+            align.append(("vertical", "distributed"))
+
+        if self.rotation:
+            align.append(("textRotation", self.rotation))
+        if self.indent:
+            align.append(("indent", self.indent))
+
+        if self.text_wrap:
+            align.append(("wrapText", 1))
+        if self.shrink:
+            align.append(("shrinkToFit", 1))
+
+        if self.reading_order == 1:
+            align.append(("readingOrder", 1))
+        if self.reading_order == 2:
+            align.append(("readingOrder", 2))
+
+        return changed, align
+
+    def _get_protection_properties(self):
+        # Return properties for an Excel XML <Protection> element.
+        attributes = []
+
+        if not self.locked:
+            attributes.append(("locked", 0))
+        if self.hidden:
+            attributes.append(("hidden", 1))
+
+        return attributes
+
+    def _get_format_key(self):
+        # Returns a unique hash key for a format. Used by Workbook.
+        if self._format_key is None:
+            self._format_key = ":".join(
+                str(x)
+                for x in (
+                    self._get_font_key(),
+                    self._get_border_key(),
+                    self._get_fill_key(),
+                    self._get_alignment_key(),
+                    self.num_format,
+                    self.locked,
+                    self.checkbox,
+                    self.quote_prefix,
+                    self.hidden,
+                )
+            )
+
+        return self._format_key
+
+    def _get_font_key(self):
+        # Returns a unique hash key for a font. Used by Workbook.
+        key = ":".join(
+            str(x)
+            for x in (
+                self.bold,
+                self.font_color,
+                self.font_charset,
+                self.font_family,
+                self.font_outline,
+                self.font_script,
+                self.font_shadow,
+                self.font_strikeout,
+                self.font_name,
+                self.italic,
+                self.font_size,
+                self.underline,
+                self.theme,
+            )
+        )
+
+        return key
+
+    def _get_border_key(self):
+        # Returns a unique hash key for a border style. Used by Workbook.
+        key = ":".join(
+            str(x)
+            for x in (
+                self.bottom,
+                self.bottom_color,
+                self.diag_border,
+                self.diag_color,
+                self.diag_type,
+                self.left,
+                self.left_color,
+                self.right,
+                self.right_color,
+                self.top,
+                self.top_color,
+            )
+        )
+
+        return key
+
+    def _get_fill_key(self):
+        # Returns a unique hash key for a fill style. Used by Workbook.
+        key = ":".join(str(x) for x in (self.pattern, self.bg_color, self.fg_color))
+
+        return key
+
+    def _get_alignment_key(self):
+        # Returns a unique hash key for alignment formats.
+
+        key = ":".join(
+            str(x)
+            for x in (
+                self.text_h_align,
+                self.text_v_align,
+                self.indent,
+                self.rotation,
+                self.text_wrap,
+                self.shrink,
+                self.reading_order,
+            )
+        )
+
+        return key
+
+    def _get_xf_index(self):
+        # Returns the XF index number used by Excel to identify a format.
+        if self.xf_index is not None:
+            # Format already has an index number so return it.
+            return self.xf_index
+
+        # Format doesn't have an index number so assign one.
+        key = self._get_format_key()
+
+        if key in self.xf_format_indices:
+            # Format matches existing format with an index.
+            return self.xf_format_indices[key]
+
+        # New format requiring an index. Note. +1 since Excel
+        # has an implicit "General" format at index 0.
+        index = 1 + len(self.xf_format_indices)
+        self.xf_format_indices[key] = index
+        self.xf_index = index
+        return index
+
+    def _get_dxf_index(self):
+        # Returns the DXF index number used by Excel to identify a format.
+        if self.dxf_index is not None:
+            # Format already has an index number so return it.
+            return self.dxf_index
+
+        # Format doesn't have an index number so assign one.
+        key = self._get_format_key()
+
+        if key in self.dxf_format_indices:
+            # Format matches existing format with an index.
+            return self.dxf_format_indices[key]
+
+        # New format requiring an index.
+        index = len(self.dxf_format_indices)
+        self.dxf_format_indices[key] = index
+        self.dxf_index = index
+        return index
+
+    def _get_color(self, color):
+        # Used in conjunction with the set_xxx_color methods to convert a
+        # color name into an RGB formatted string. These colors are for
+        # backward compatibility with older versions of Excel.
+        named_colors = {
+            "black": "#000000",
+            "blue": "#0000FF",
+            "brown": "#800000",
+            "cyan": "#00FFFF",
+            "gray": "#808080",
+            "green": "#008000",
+            "lime": "#00FF00",
+            "magenta": "#FF00FF",
+            "navy": "#000080",
+            "orange": "#FF6600",
+            "pink": "#FF00FF",
+            "purple": "#800080",
+            "red": "#FF0000",
+            "silver": "#C0C0C0",
+            "white": "#FFFFFF",
+            "yellow": "#FFFF00",
+            "automatic": "Automatic",
+        }
+
+        return named_colors.get(color, color)
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/metadata.py b/.venv/lib/python3.12/site-packages/xlsxwriter/metadata.py
new file mode 100644
index 00000000..284d65e4
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/metadata.py
@@ -0,0 +1,266 @@
+###############################################################################
+#
+# Metadata - A class for writing the Excel XLSX Metadata file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import xmlwriter
+
+
+class Metadata(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Metadata file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+        self.has_dynamic_functions = False
+        self.has_embedded_images = False
+        self.num_embedded_images = 0
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        if self.num_embedded_images > 0:
+            self.has_embedded_images = True
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the metadata element.
+        self._write_metadata()
+
+        # Write the metadataTypes element.
+        self._write_metadata_types()
+
+        # Write the futureMetadata elements.
+        if self.has_dynamic_functions:
+            self._write_cell_future_metadata()
+        if self.has_embedded_images:
+            self._write_value_future_metadata()
+
+        # Write the cellMetadata element.
+        if self.has_dynamic_functions:
+            self._write_cell_metadata()
+        if self.has_embedded_images:
+            self._write_value_metadata()
+
+        self._xml_end_tag("metadata")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_metadata(self):
+        # Write the <metadata> element.
+        xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+        schema = "http://schemas.microsoft.com/office/spreadsheetml"
+
+        attributes = [("xmlns", xmlns)]
+
+        if self.has_embedded_images:
+            attributes.append(("xmlns:xlrd", schema + "/2017/richdata"))
+
+        if self.has_dynamic_functions:
+            attributes.append(("xmlns:xda", schema + "/2017/dynamicarray"))
+
+        self._xml_start_tag("metadata", attributes)
+
+    def _write_metadata_types(self):
+        # Write the <metadataTypes> element.
+        count = 0
+
+        if self.has_dynamic_functions:
+            count += 1
+        if self.has_embedded_images:
+            count += 1
+
+        attributes = [("count", count)]
+
+        self._xml_start_tag("metadataTypes", attributes)
+
+        # Write the metadataType element.
+        if self.has_dynamic_functions:
+            self._write_cell_metadata_type()
+        if self.has_embedded_images:
+            self._write_value_metadata_type()
+
+        self._xml_end_tag("metadataTypes")
+
+    def _write_cell_metadata_type(self):
+        # Write the <metadataType> element.
+        attributes = [
+            ("name", "XLDAPR"),
+            ("minSupportedVersion", 120000),
+            ("copy", 1),
+            ("pasteAll", 1),
+            ("pasteValues", 1),
+            ("merge", 1),
+            ("splitFirst", 1),
+            ("rowColShift", 1),
+            ("clearFormats", 1),
+            ("clearComments", 1),
+            ("assign", 1),
+            ("coerce", 1),
+            ("cellMeta", 1),
+        ]
+
+        self._xml_empty_tag("metadataType", attributes)
+
+    def _write_value_metadata_type(self):
+        # Write the <metadataType> element.
+        attributes = [
+            ("name", "XLRICHVALUE"),
+            ("minSupportedVersion", 120000),
+            ("copy", 1),
+            ("pasteAll", 1),
+            ("pasteValues", 1),
+            ("merge", 1),
+            ("splitFirst", 1),
+            ("rowColShift", 1),
+            ("clearFormats", 1),
+            ("clearComments", 1),
+            ("assign", 1),
+            ("coerce", 1),
+        ]
+
+        self._xml_empty_tag("metadataType", attributes)
+
+    def _write_cell_future_metadata(self):
+        # Write the <futureMetadata> element.
+        attributes = [
+            ("name", "XLDAPR"),
+            ("count", 1),
+        ]
+
+        self._xml_start_tag("futureMetadata", attributes)
+        self._xml_start_tag("bk")
+        self._xml_start_tag("extLst")
+        self._write_cell_ext()
+        self._xml_end_tag("extLst")
+        self._xml_end_tag("bk")
+        self._xml_end_tag("futureMetadata")
+
+    def _write_value_future_metadata(self):
+        # Write the <futureMetadata> element.
+        attributes = [
+            ("name", "XLRICHVALUE"),
+            ("count", self.num_embedded_images),
+        ]
+
+        self._xml_start_tag("futureMetadata", attributes)
+
+        for index in range(self.num_embedded_images):
+            self._xml_start_tag("bk")
+            self._xml_start_tag("extLst")
+            self._write_value_ext(index)
+            self._xml_end_tag("extLst")
+            self._xml_end_tag("bk")
+
+        self._xml_end_tag("futureMetadata")
+
+    def _write_cell_ext(self):
+        # Write the <ext> element.
+        attributes = [("uri", "{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}")]
+
+        self._xml_start_tag("ext", attributes)
+
+        # Write the xda:dynamicArrayProperties element.
+        self._write_xda_dynamic_array_properties()
+
+        self._xml_end_tag("ext")
+
+    def _write_xda_dynamic_array_properties(self):
+        # Write the <xda:dynamicArrayProperties> element.
+        attributes = [
+            ("fDynamic", 1),
+            ("fCollapsed", 0),
+        ]
+
+        self._xml_empty_tag("xda:dynamicArrayProperties", attributes)
+
+    def _write_value_ext(self, index):
+        # Write the <ext> element.
+        attributes = [("uri", "{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}")]
+
+        self._xml_start_tag("ext", attributes)
+
+        # Write the xlrd:rvb element.
+        self._write_xlrd_rvb(index)
+
+        self._xml_end_tag("ext")
+
+    def _write_xlrd_rvb(self, index):
+        # Write the <xlrd:rvb> element.
+        attributes = [("i", index)]
+
+        self._xml_empty_tag("xlrd:rvb", attributes)
+
+    def _write_cell_metadata(self):
+        # Write the <cellMetadata> element.
+        attributes = [("count", 1)]
+
+        self._xml_start_tag("cellMetadata", attributes)
+        self._xml_start_tag("bk")
+
+        # Write the rc element.
+        self._write_rc(1, 0)
+
+        self._xml_end_tag("bk")
+        self._xml_end_tag("cellMetadata")
+
+    def _write_value_metadata(self):
+        # Write the <valueMetadata> element.
+        count = self.num_embedded_images
+        rc_type = 1
+
+        if self.has_dynamic_functions:
+            rc_type = 2
+
+        attributes = [("count", count)]
+
+        self._xml_start_tag("valueMetadata", attributes)
+
+        # Write the rc elements.
+        for index in range(self.num_embedded_images):
+            self._xml_start_tag("bk")
+            self._write_rc(rc_type, index)
+            self._xml_end_tag("bk")
+
+        self._xml_end_tag("valueMetadata")
+
+    def _write_rc(self, rc_type, index):
+        # Write the <rc> element.
+        attributes = [
+            ("t", rc_type),
+            ("v", index),
+        ]
+
+        self._xml_empty_tag("rc", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/packager.py b/.venv/lib/python3.12/site-packages/xlsxwriter/packager.py
new file mode 100644
index 00000000..17587f0a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/packager.py
@@ -0,0 +1,880 @@
+###############################################################################
+#
+# Packager - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Standard packages.
+import os
+import stat
+import tempfile
+from io import BytesIO, StringIO
+from shutil import copy
+
+# Package imports.
+from .app import App
+from .comments import Comments
+from .contenttypes import ContentTypes
+from .core import Core
+from .custom import Custom
+from .exceptions import EmptyChartSeries
+from .feature_property_bag import FeaturePropertyBag
+from .metadata import Metadata
+from .relationships import Relationships
+from .rich_value import RichValue
+from .rich_value_rel import RichValueRel
+from .rich_value_structure import RichValueStructure
+from .rich_value_types import RichValueTypes
+from .sharedstrings import SharedStrings
+from .styles import Styles
+from .table import Table
+from .theme import Theme
+from .vml import Vml
+
+
+class Packager:
+    """
+    A class for writing the Excel XLSX Packager file.
+
+    This module is used in conjunction with XlsxWriter to create an
+    Excel XLSX container file.
+
+    From Wikipedia: The Open Packaging Conventions (OPC) is a
+    container-file technology initially created by Microsoft to store
+    a combination of XML and non-XML files that together form a single
+    entity such as an Open XML Paper Specification (OpenXPS)
+    document. http://en.wikipedia.org/wiki/Open_Packaging_Conventions.
+
+    At its simplest an Excel XLSX file contains the following elements::
+
+         ____ [Content_Types].xml
+        |
+        |____ docProps
+        | |____ app.xml
+        | |____ core.xml
+        |
+        |____ xl
+        | |____ workbook.xml
+        | |____ worksheets
+        | | |____ sheet1.xml
+        | |
+        | |____ styles.xml
+        | |
+        | |____ theme
+        | | |____ theme1.xml
+        | |
+        | |_____rels
+        |   |____ workbook.xml.rels
+        |
+        |_____rels
+          |____ .rels
+
+    The Packager class coordinates the classes that represent the
+    elements of the package and writes them into the XLSX file.
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.tmpdir = ""
+        self.in_memory = False
+        self.workbook = None
+        self.worksheet_count = 0
+        self.chartsheet_count = 0
+        self.chart_count = 0
+        self.drawing_count = 0
+        self.table_count = 0
+        self.num_vml_files = 0
+        self.num_comment_files = 0
+        self.named_ranges = []
+        self.filenames = []
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _set_tmpdir(self, tmpdir):
+        # Set an optional user defined temp directory.
+        self.tmpdir = tmpdir
+
+    def _set_in_memory(self, in_memory):
+        # Set the optional 'in_memory' mode.
+        self.in_memory = in_memory
+
+    def _add_workbook(self, workbook):
+        # Add the Excel::Writer::XLSX::Workbook object to the package.
+        self.workbook = workbook
+        self.chart_count = len(workbook.charts)
+        self.drawing_count = len(workbook.drawings)
+        self.num_vml_files = workbook.num_vml_files
+        self.num_comment_files = workbook.num_comment_files
+        self.named_ranges = workbook.named_ranges
+
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                self.chartsheet_count += 1
+            else:
+                self.worksheet_count += 1
+
+    def _create_package(self):
+        # Write the xml files that make up the XLSX OPC package.
+        self._write_content_types_file()
+        self._write_root_rels_file()
+        self._write_workbook_rels_file()
+        self._write_worksheet_files()
+        self._write_chartsheet_files()
+        self._write_workbook_file()
+        self._write_chart_files()
+        self._write_drawing_files()
+        self._write_vml_files()
+        self._write_comment_files()
+        self._write_table_files()
+        self._write_shared_strings_file()
+        self._write_styles_file()
+        self._write_custom_file()
+        self._write_theme_file()
+        self._write_worksheet_rels_files()
+        self._write_chartsheet_rels_files()
+        self._write_drawing_rels_files()
+        self._write_rich_value_rels_files()
+        self._add_image_files()
+        self._add_vba_project()
+        self._add_vba_project_signature()
+        self._write_vba_project_rels_file()
+        self._write_core_file()
+        self._write_app_file()
+        self._write_metadata_file()
+        self._write_feature_bag_property()
+        self._write_rich_value_files()
+
+        return self.filenames
+
+    def _filename(self, xml_filename):
+        # Create a temp filename to write the XML data to and store the Excel
+        # filename to use as the name in the Zip container.
+        if self.in_memory:
+            os_filename = StringIO()
+        else:
+            (fd, os_filename) = tempfile.mkstemp(dir=self.tmpdir)
+            os.close(fd)
+
+        self.filenames.append((os_filename, xml_filename, False))
+
+        return os_filename
+
+    def _write_workbook_file(self):
+        # Write the workbook.xml file.
+        workbook = self.workbook
+
+        workbook._set_xml_writer(self._filename("xl/workbook.xml"))
+        workbook._assemble_xml_file()
+
+    def _write_worksheet_files(self):
+        # Write the worksheet files.
+        index = 1
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                continue
+
+            if worksheet.constant_memory:
+                worksheet._opt_reopen()
+                worksheet._write_single_row()
+
+            worksheet._set_xml_writer(
+                self._filename("xl/worksheets/sheet" + str(index) + ".xml")
+            )
+            worksheet._assemble_xml_file()
+            index += 1
+
+    def _write_chartsheet_files(self):
+        # Write the chartsheet files.
+        index = 1
+        for worksheet in self.workbook.worksheets():
+            if not worksheet.is_chartsheet:
+                continue
+
+            worksheet._set_xml_writer(
+                self._filename("xl/chartsheets/sheet" + str(index) + ".xml")
+            )
+            worksheet._assemble_xml_file()
+            index += 1
+
+    def _write_chart_files(self):
+        # Write the chart files.
+        if not self.workbook.charts:
+            return
+
+        index = 1
+        for chart in self.workbook.charts:
+            # Check that the chart has at least one data series.
+            if not chart.series:
+                raise EmptyChartSeries(
+                    f"Chart{index} must contain at least one "
+                    f"data series. See chart.add_series()."
+                )
+
+            chart._set_xml_writer(
+                self._filename("xl/charts/chart" + str(index) + ".xml")
+            )
+            chart._assemble_xml_file()
+            index += 1
+
+    def _write_drawing_files(self):
+        # Write the drawing files.
+        if not self.drawing_count:
+            return
+
+        index = 1
+        for drawing in self.workbook.drawings:
+            drawing._set_xml_writer(
+                self._filename("xl/drawings/drawing" + str(index) + ".xml")
+            )
+            drawing._assemble_xml_file()
+            index += 1
+
+    def _write_vml_files(self):
+        # Write the comment VML files.
+        index = 1
+        for worksheet in self.workbook.worksheets():
+            if not worksheet.has_vml and not worksheet.has_header_vml:
+                continue
+            if worksheet.has_vml:
+                vml = Vml()
+                vml._set_xml_writer(
+                    self._filename("xl/drawings/vmlDrawing" + str(index) + ".vml")
+                )
+                vml._assemble_xml_file(
+                    worksheet.vml_data_id,
+                    worksheet.vml_shape_id,
+                    worksheet.comments_list,
+                    worksheet.buttons_list,
+                )
+                index += 1
+
+            if worksheet.has_header_vml:
+                vml = Vml()
+
+                vml._set_xml_writer(
+                    self._filename("xl/drawings/vmlDrawing" + str(index) + ".vml")
+                )
+                vml._assemble_xml_file(
+                    worksheet.vml_header_id,
+                    worksheet.vml_header_id * 1024,
+                    None,
+                    None,
+                    worksheet.header_images_list,
+                )
+
+                self._write_vml_drawing_rels_file(worksheet, index)
+                index += 1
+
+    def _write_comment_files(self):
+        # Write the comment files.
+        index = 1
+        for worksheet in self.workbook.worksheets():
+            if not worksheet.has_comments:
+                continue
+
+            comment = Comments()
+            comment._set_xml_writer(self._filename("xl/comments" + str(index) + ".xml"))
+            comment._assemble_xml_file(worksheet.comments_list)
+            index += 1
+
+    def _write_shared_strings_file(self):
+        # Write the sharedStrings.xml file.
+        sst = SharedStrings()
+        sst.string_table = self.workbook.str_table
+
+        if not self.workbook.str_table.count:
+            return
+
+        sst._set_xml_writer(self._filename("xl/sharedStrings.xml"))
+        sst._assemble_xml_file()
+
+    def _write_app_file(self):
+        # Write the app.xml file.
+        properties = self.workbook.doc_properties
+        app = App()
+
+        # Add the Worksheet parts.
+        worksheet_count = 0
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                continue
+
+            # Don't write/count veryHidden sheets.
+            if worksheet.hidden != 2:
+                app._add_part_name(worksheet.name)
+                worksheet_count += 1
+
+        # Add the Worksheet heading pairs.
+        app._add_heading_pair(["Worksheets", worksheet_count])
+
+        # Add the Chartsheet parts.
+        for worksheet in self.workbook.worksheets():
+            if not worksheet.is_chartsheet:
+                continue
+            app._add_part_name(worksheet.name)
+
+        # Add the Chartsheet heading pairs.
+        app._add_heading_pair(["Charts", self.chartsheet_count])
+
+        # Add the Named Range heading pairs.
+        if self.named_ranges:
+            app._add_heading_pair(["Named Ranges", len(self.named_ranges)])
+
+        # Add the Named Ranges parts.
+        for named_range in self.named_ranges:
+            app._add_part_name(named_range)
+
+        app._set_properties(properties)
+        app.doc_security = self.workbook.read_only
+
+        app._set_xml_writer(self._filename("docProps/app.xml"))
+        app._assemble_xml_file()
+
+    def _write_core_file(self):
+        # Write the core.xml file.
+        properties = self.workbook.doc_properties
+        core = Core()
+
+        core._set_properties(properties)
+        core._set_xml_writer(self._filename("docProps/core.xml"))
+        core._assemble_xml_file()
+
+    def _write_metadata_file(self):
+        # Write the metadata.xml file.
+        if not self.workbook.has_metadata:
+            return
+
+        metadata = Metadata()
+        metadata.has_dynamic_functions = self.workbook.has_dynamic_functions
+        metadata.num_embedded_images = len(self.workbook.embedded_images.images)
+
+        metadata._set_xml_writer(self._filename("xl/metadata.xml"))
+        metadata._assemble_xml_file()
+
+    def _write_feature_bag_property(self):
+        # Write the featurePropertyBag.xml file.
+        feature_property_bags = self.workbook._has_feature_property_bags()
+        if not feature_property_bags:
+            return
+
+        property_bag = FeaturePropertyBag()
+        property_bag.feature_property_bags = feature_property_bags
+
+        property_bag._set_xml_writer(
+            self._filename("xl/featurePropertyBag/featurePropertyBag.xml")
+        )
+        property_bag._assemble_xml_file()
+
+    def _write_rich_value_files(self):
+
+        if not self.workbook.embedded_images.has_images():
+            return
+
+        self._write_rich_value()
+        self._write_rich_value_types()
+        self._write_rich_value_structure()
+        self._write_rich_value_rel()
+
+    def _write_rich_value(self):
+        # Write the rdrichvalue.xml file.
+        filename = self._filename("xl/richData/rdrichvalue.xml")
+        xml_file = RichValue()
+        xml_file.embedded_images = self.workbook.embedded_images.images
+        xml_file._set_xml_writer(filename)
+        xml_file._assemble_xml_file()
+
+    def _write_rich_value_types(self):
+        # Write the rdRichValueTypes.xml file.
+        filename = self._filename("xl/richData/rdRichValueTypes.xml")
+        xml_file = RichValueTypes()
+        xml_file._set_xml_writer(filename)
+        xml_file._assemble_xml_file()
+
+    def _write_rich_value_structure(self):
+        # Write the rdrichvaluestructure.xml file.
+        filename = self._filename("xl/richData/rdrichvaluestructure.xml")
+        xml_file = RichValueStructure()
+        xml_file.has_embedded_descriptions = self.workbook.has_embedded_descriptions
+        xml_file._set_xml_writer(filename)
+        xml_file._assemble_xml_file()
+
+    def _write_rich_value_rel(self):
+        # Write the richValueRel.xml file.
+        filename = self._filename("xl/richData/richValueRel.xml")
+        xml_file = RichValueRel()
+        xml_file.num_embedded_images = len(self.workbook.embedded_images.images)
+        xml_file._set_xml_writer(filename)
+        xml_file._assemble_xml_file()
+
+    def _write_custom_file(self):
+        # Write the custom.xml file.
+        properties = self.workbook.custom_properties
+        custom = Custom()
+
+        if not properties:
+            return
+
+        custom._set_properties(properties)
+        custom._set_xml_writer(self._filename("docProps/custom.xml"))
+        custom._assemble_xml_file()
+
+    def _write_content_types_file(self):
+        # Write the ContentTypes.xml file.
+        content = ContentTypes()
+        content._add_image_types(self.workbook.image_types)
+
+        self._get_table_count()
+
+        worksheet_index = 1
+        chartsheet_index = 1
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                content._add_chartsheet_name("sheet" + str(chartsheet_index))
+                chartsheet_index += 1
+            else:
+                content._add_worksheet_name("sheet" + str(worksheet_index))
+                worksheet_index += 1
+
+        for i in range(1, self.chart_count + 1):
+            content._add_chart_name("chart" + str(i))
+
+        for i in range(1, self.drawing_count + 1):
+            content._add_drawing_name("drawing" + str(i))
+
+        if self.num_vml_files:
+            content._add_vml_name()
+
+        for i in range(1, self.table_count + 1):
+            content._add_table_name("table" + str(i))
+
+        for i in range(1, self.num_comment_files + 1):
+            content._add_comment_name("comments" + str(i))
+
+        # Add the sharedString rel if there is string data in the workbook.
+        if self.workbook.str_table.count:
+            content._add_shared_strings()
+
+        # Add vbaProject (and optionally vbaProjectSignature) if present.
+        if self.workbook.vba_project:
+            content._add_vba_project()
+            if self.workbook.vba_project_signature:
+                content._add_vba_project_signature()
+
+        # Add the custom properties if present.
+        if self.workbook.custom_properties:
+            content._add_custom_properties()
+
+        # Add the metadata file if present.
+        if self.workbook.has_metadata:
+            content._add_metadata()
+
+        # Add the metadata file if present.
+        if self.workbook._has_feature_property_bags():
+            content._add_feature_bag_property()
+
+        # Add the RichValue file if present.
+        if self.workbook.embedded_images.has_images():
+            content._add_rich_value()
+
+        content._set_xml_writer(self._filename("[Content_Types].xml"))
+        content._assemble_xml_file()
+
+    def _write_styles_file(self):
+        # Write the style xml file.
+        xf_formats = self.workbook.xf_formats
+        palette = self.workbook.palette
+        font_count = self.workbook.font_count
+        num_formats = self.workbook.num_formats
+        border_count = self.workbook.border_count
+        fill_count = self.workbook.fill_count
+        custom_colors = self.workbook.custom_colors
+        dxf_formats = self.workbook.dxf_formats
+        has_comments = self.workbook.has_comments
+
+        styles = Styles()
+        styles._set_style_properties(
+            [
+                xf_formats,
+                palette,
+                font_count,
+                num_formats,
+                border_count,
+                fill_count,
+                custom_colors,
+                dxf_formats,
+                has_comments,
+            ]
+        )
+
+        styles._set_xml_writer(self._filename("xl/styles.xml"))
+        styles._assemble_xml_file()
+
+    def _write_theme_file(self):
+        # Write the theme xml file.
+        theme = Theme()
+
+        theme._set_xml_writer(self._filename("xl/theme/theme1.xml"))
+        theme._assemble_xml_file()
+
+    def _write_table_files(self):
+        # Write the table files.
+        index = 1
+        for worksheet in self.workbook.worksheets():
+            table_props = worksheet.tables
+
+            if not table_props:
+                continue
+
+            for table_props in table_props:
+                table = Table()
+                table._set_xml_writer(
+                    self._filename("xl/tables/table" + str(index) + ".xml")
+                )
+                table._set_properties(table_props)
+                table._assemble_xml_file()
+                index += 1
+
+    def _get_table_count(self):
+        # Count the table files. Required for the [Content_Types] file.
+        for worksheet in self.workbook.worksheets():
+            for _ in worksheet.tables:
+                self.table_count += 1
+
+    def _write_root_rels_file(self):
+        # Write the _rels/.rels xml file.
+        rels = Relationships()
+
+        rels._add_document_relationship("/officeDocument", "xl/workbook.xml")
+
+        rels._add_package_relationship("/metadata/core-properties", "docProps/core.xml")
+
+        rels._add_document_relationship("/extended-properties", "docProps/app.xml")
+
+        if self.workbook.custom_properties:
+            rels._add_document_relationship("/custom-properties", "docProps/custom.xml")
+
+        rels._set_xml_writer(self._filename("_rels/.rels"))
+
+        rels._assemble_xml_file()
+
+    def _write_workbook_rels_file(self):
+        # Write the _rels/.rels xml file.
+        rels = Relationships()
+
+        worksheet_index = 1
+        chartsheet_index = 1
+
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                rels._add_document_relationship(
+                    "/chartsheet", "chartsheets/sheet" + str(chartsheet_index) + ".xml"
+                )
+                chartsheet_index += 1
+            else:
+                rels._add_document_relationship(
+                    "/worksheet", "worksheets/sheet" + str(worksheet_index) + ".xml"
+                )
+                worksheet_index += 1
+
+        rels._add_document_relationship("/theme", "theme/theme1.xml")
+        rels._add_document_relationship("/styles", "styles.xml")
+
+        # Add the sharedString rel if there is string data in the workbook.
+        if self.workbook.str_table.count:
+            rels._add_document_relationship("/sharedStrings", "sharedStrings.xml")
+
+        # Add vbaProject if present.
+        if self.workbook.vba_project:
+            rels._add_ms_package_relationship("/vbaProject", "vbaProject.bin")
+
+        # Add the metadata file if required.
+        if self.workbook.has_metadata:
+            rels._add_document_relationship("/sheetMetadata", "metadata.xml")
+
+        # Add the RichValue files if present.
+        if self.workbook.embedded_images.has_images():
+            rels._add_rich_value_relationship()
+
+        # Add the checkbox/FeaturePropertyBag file if present.
+        if self.workbook._has_feature_property_bags():
+            rels._add_feature_bag_relationship()
+
+        rels._set_xml_writer(self._filename("xl/_rels/workbook.xml.rels"))
+        rels._assemble_xml_file()
+
+    def _write_worksheet_rels_files(self):
+        # Write data such as hyperlinks or drawings.
+        index = 0
+        for worksheet in self.workbook.worksheets():
+            if worksheet.is_chartsheet:
+                continue
+
+            index += 1
+
+            external_links = (
+                worksheet.external_hyper_links
+                + worksheet.external_drawing_links
+                + worksheet.external_vml_links
+                + worksheet.external_background_links
+                + worksheet.external_table_links
+                + worksheet.external_comment_links
+            )
+
+            if not external_links:
+                continue
+
+            # Create the worksheet .rels dirs.
+            rels = Relationships()
+
+            for link_data in external_links:
+                rels._add_document_relationship(*link_data)
+
+            # Create .rels file such as /xl/worksheets/_rels/sheet1.xml.rels.
+            rels._set_xml_writer(
+                self._filename("xl/worksheets/_rels/sheet" + str(index) + ".xml.rels")
+            )
+            rels._assemble_xml_file()
+
+    def _write_chartsheet_rels_files(self):
+        # Write the chartsheet .rels files for links to drawing files.
+        index = 0
+        for worksheet in self.workbook.worksheets():
+            if not worksheet.is_chartsheet:
+                continue
+
+            index += 1
+
+            external_links = (
+                worksheet.external_drawing_links + worksheet.external_vml_links
+            )
+
+            if not external_links:
+                continue
+
+            # Create the chartsheet .rels xlsx_dir.
+            rels = Relationships()
+
+            for link_data in external_links:
+                rels._add_document_relationship(*link_data)
+
+            # Create .rels file such as /xl/chartsheets/_rels/sheet1.xml.rels.
+            rels._set_xml_writer(
+                self._filename("xl/chartsheets/_rels/sheet" + str(index) + ".xml.rels")
+            )
+            rels._assemble_xml_file()
+
+    def _write_drawing_rels_files(self):
+        # Write the drawing .rels files for worksheets with charts or drawings.
+        index = 0
+        for worksheet in self.workbook.worksheets():
+            if worksheet.drawing:
+                index += 1
+
+            if not worksheet.drawing_links:
+                continue
+
+            # Create the drawing .rels xlsx_dir.
+            rels = Relationships()
+
+            for drawing_data in worksheet.drawing_links:
+                rels._add_document_relationship(*drawing_data)
+
+            # Create .rels file such as /xl/drawings/_rels/sheet1.xml.rels.
+            rels._set_xml_writer(
+                self._filename("xl/drawings/_rels/drawing" + str(index) + ".xml.rels")
+            )
+            rels._assemble_xml_file()
+
+    def _write_vml_drawing_rels_file(self, worksheet, index):
+        # Write the vmlDdrawing .rels files for worksheets with images in
+        # headers or footers.
+
+        # Create the drawing .rels dir.
+        rels = Relationships()
+
+        for drawing_data in worksheet.vml_drawing_links:
+            rels._add_document_relationship(*drawing_data)
+
+        # Create .rels file such as /xl/drawings/_rels/vmlDrawing1.vml.rels.
+        rels._set_xml_writer(
+            self._filename("xl/drawings/_rels/vmlDrawing" + str(index) + ".vml.rels")
+        )
+        rels._assemble_xml_file()
+
+    def _write_vba_project_rels_file(self):
+        # Write the vbaProject.rels xml file if signed macros exist.
+        vba_project_signature = self.workbook.vba_project_signature
+
+        if not vba_project_signature:
+            return
+
+        # Create the vbaProject .rels dir.
+        rels = Relationships()
+
+        rels._add_ms_package_relationship(
+            "/vbaProjectSignature", "vbaProjectSignature.bin"
+        )
+
+        rels._set_xml_writer(self._filename("xl/_rels/vbaProject.bin.rels"))
+        rels._assemble_xml_file()
+
+    def _write_rich_value_rels_files(self):
+        # Write the richValueRel.xml.rels for embedded images.
+        if not self.workbook.embedded_images.has_images():
+            return
+
+        # Create the worksheet .rels dirs.
+        rels = Relationships()
+
+        index = 1
+        for image_data in self.workbook.embedded_images.images:
+            file_type = image_data[1]
+            image_file = f"../media/image{index}.{file_type}"
+            rels._add_document_relationship("/image", image_file)
+            index += 1
+
+        # Create .rels file such as /xl/worksheets/_rels/sheet1.xml.rels.
+        rels._set_xml_writer(self._filename("/xl/richData/_rels/richValueRel.xml.rels"))
+
+        rels._assemble_xml_file()
+
+    def _add_image_files(self):
+        # pylint: disable=consider-using-with
+        # Write the /xl/media/image?.xml files.
+        workbook = self.workbook
+        index = 1
+
+        images = workbook.embedded_images.images + workbook.images
+
+        for image in images:
+            filename = image[0]
+            ext = "." + image[1]
+            image_data = image[2]
+
+            xml_image_name = "xl/media/image" + str(index) + ext
+
+            if not self.in_memory:
+                # In file mode we just write or copy the image file.
+                os_filename = self._filename(xml_image_name)
+
+                if image_data:
+                    # The data is in a byte stream. Write it to the target.
+                    os_file = open(os_filename, mode="wb")
+                    os_file.write(image_data.getvalue())
+                    os_file.close()
+                else:
+                    copy(filename, os_filename)
+
+                    # Allow copies of Windows read-only images to be deleted.
+                    try:
+                        os.chmod(
+                            os_filename, os.stat(os_filename).st_mode | stat.S_IWRITE
+                        )
+                    except OSError:
+                        pass
+            else:
+                # For in-memory mode we read the image into a stream.
+                if image_data:
+                    # The data is already in a byte stream.
+                    os_filename = image_data
+                else:
+                    image_file = open(filename, mode="rb")
+                    image_data = image_file.read()
+                    os_filename = BytesIO(image_data)
+                    image_file.close()
+
+                self.filenames.append((os_filename, xml_image_name, True))
+
+            index += 1
+
+    def _add_vba_project_signature(self):
+        # pylint: disable=consider-using-with
+        # Copy in a vbaProjectSignature.bin file.
+        vba_project_signature = self.workbook.vba_project_signature
+        vba_project_signature_is_stream = self.workbook.vba_project_signature_is_stream
+
+        if not vba_project_signature:
+            return
+
+        xml_vba_signature_name = "xl/vbaProjectSignature.bin"
+
+        if not self.in_memory:
+            # In file mode we just write or copy the VBA project signature file.
+            os_filename = self._filename(xml_vba_signature_name)
+
+            if vba_project_signature_is_stream:
+                # The data is in a byte stream. Write it to the target.
+                os_file = open(os_filename, mode="wb")
+                os_file.write(vba_project_signature.getvalue())
+                os_file.close()
+            else:
+                copy(vba_project_signature, os_filename)
+
+        else:
+            # For in-memory mode we read the vba into a stream.
+            if vba_project_signature_is_stream:
+                # The data is already in a byte stream.
+                os_filename = vba_project_signature
+            else:
+                vba_file = open(vba_project_signature, mode="rb")
+                vba_data = vba_file.read()
+                os_filename = BytesIO(vba_data)
+                vba_file.close()
+
+            self.filenames.append((os_filename, xml_vba_signature_name, True))
+
+    def _add_vba_project(self):
+        # pylint: disable=consider-using-with
+        # Copy in a vbaProject.bin file.
+        vba_project = self.workbook.vba_project
+        vba_project_is_stream = self.workbook.vba_project_is_stream
+
+        if not vba_project:
+            return
+
+        xml_vba_name = "xl/vbaProject.bin"
+
+        if not self.in_memory:
+            # In file mode we just write or copy the VBA file.
+            os_filename = self._filename(xml_vba_name)
+
+            if vba_project_is_stream:
+                # The data is in a byte stream. Write it to the target.
+                os_file = open(os_filename, mode="wb")
+                os_file.write(vba_project.getvalue())
+                os_file.close()
+            else:
+                copy(vba_project, os_filename)
+
+        else:
+            # For in-memory mode we read the vba into a stream.
+            if vba_project_is_stream:
+                # The data is already in a byte stream.
+                os_filename = vba_project
+            else:
+                vba_file = open(vba_project, mode="rb")
+                vba_data = vba_file.read()
+                os_filename = BytesIO(vba_data)
+                vba_file.close()
+
+            self.filenames.append((os_filename, xml_vba_name, True))
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/relationships.py b/.venv/lib/python3.12/site-packages/xlsxwriter/relationships.py
new file mode 100644
index 00000000..9829c5fe
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/relationships.py
@@ -0,0 +1,143 @@
+###############################################################################
+#
+# Relationships - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+# Long namespace strings used in the class.
+SCHEMA_ROOT = "http://schemas.openxmlformats.org"
+PACKAGE_SCHEMA = SCHEMA_ROOT + "/package/2006/relationships"
+DOCUMENT_SCHEMA = SCHEMA_ROOT + "/officeDocument/2006/relationships"
+
+
+class Relationships(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Relationships file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.relationships = []
+        self.id = 1
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        self._write_relationships()
+
+        # Close the file.
+        self._xml_close()
+
+    def _add_document_relationship(self, rel_type, target, target_mode=None):
+        # Add container relationship to XLSX .rels xml files.
+        rel_type = DOCUMENT_SCHEMA + rel_type
+
+        self.relationships.append((rel_type, target, target_mode))
+
+    def _add_package_relationship(self, rel_type, target):
+        # Add container relationship to XLSX .rels xml files.
+        rel_type = PACKAGE_SCHEMA + rel_type
+
+        self.relationships.append((rel_type, target, None))
+
+    def _add_ms_package_relationship(self, rel_type, target):
+        # Add container relationship to XLSX .rels xml files. Uses MS schema.
+        schema = "http://schemas.microsoft.com/office/2006/relationships"
+        rel_type = schema + rel_type
+
+        self.relationships.append((rel_type, target, None))
+
+    def _add_rich_value_relationship(self):
+        # Add RichValue relationship to XLSX .rels xml files.
+        schema = "http://schemas.microsoft.com/office/2022/10/relationships/"
+        rel_type = schema + "richValueRel"
+        target = "richData/richValueRel.xml"
+        self.relationships.append((rel_type, target, None))
+
+        schema = "http://schemas.microsoft.com/office/2017/06/relationships/"
+        rel_type = schema + "rdRichValue"
+        target = "richData/rdrichvalue.xml"
+        self.relationships.append((rel_type, target, None))
+
+        rel_type = schema + "rdRichValueStructure"
+        target = "richData/rdrichvaluestructure.xml"
+        self.relationships.append((rel_type, target, None))
+
+        rel_type = schema + "rdRichValueTypes"
+        target = "richData/rdRichValueTypes.xml"
+        self.relationships.append((rel_type, target, None))
+
+    def _add_feature_bag_relationship(self):
+        # Add FeaturePropertyBag relationship to XLSX .rels xml files.
+        schema = "http://schemas.microsoft.com/office/2022/11/relationships/"
+        rel_type = schema + "FeaturePropertyBag"
+        target = "featurePropertyBag/featurePropertyBag.xml"
+        self.relationships.append((rel_type, target, None))
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_relationships(self):
+        # Write the <Relationships> element.
+        attributes = [
+            (
+                "xmlns",
+                PACKAGE_SCHEMA,
+            )
+        ]
+
+        self._xml_start_tag("Relationships", attributes)
+
+        for relationship in self.relationships:
+            self._write_relationship(relationship)
+
+        self._xml_end_tag("Relationships")
+
+    def _write_relationship(self, relationship):
+        # Write the <Relationship> element.
+        rel_type, target, target_mode = relationship
+
+        attributes = [
+            ("Id", "rId" + str(self.id)),
+            ("Type", rel_type),
+            ("Target", target),
+        ]
+
+        self.id += 1
+
+        if target_mode:
+            attributes.append(("TargetMode", target_mode))
+
+        self._xml_empty_tag("Relationship", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value.py b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value.py
new file mode 100644
index 00000000..6a53f0c6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value.py
@@ -0,0 +1,97 @@
+###############################################################################
+#
+# RichValue - A class for writing the Excel XLSX rdrichvalue.xml file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class RichValue(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX rdrichvalue.xml file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+        self.embedded_images = []
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the rvData element.
+        self._write_rv_data()
+
+        self._xml_end_tag("rvData")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+    def _write_rv_data(self):
+        # Write the <rvData> element.
+        xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("count", len(self.embedded_images)),
+        ]
+
+        self._xml_start_tag("rvData", attributes)
+
+        for index, image_data in enumerate(self.embedded_images):
+            # Write the rv element.
+            self._write_rv(index, image_data[3], image_data[4])
+
+    def _write_rv(self, index, description, decorative):
+        # Write the <rv> element.
+        attributes = [("s", 0)]
+        value = 5
+
+        if decorative:
+            value = 6
+
+        self._xml_start_tag("rv", attributes)
+
+        # Write the v elements.
+        self._write_v(index)
+        self._write_v(value)
+
+        if description:
+            self._write_v(description)
+
+        self._xml_end_tag("rv")
+
+    def _write_v(self, data):
+        # Write the <v> element.
+        self._xml_data_element("v", data)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_rel.py b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_rel.py
new file mode 100644
index 00000000..c8d85932
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_rel.py
@@ -0,0 +1,82 @@
+###############################################################################
+#
+# RichValueRel - A class for writing the Excel XLSX richValueRel.xml file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class RichValueRel(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX richValueRel.xml file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+        self.num_embedded_images = 0
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the richValueRels element.
+        self._write_rich_value_rels()
+
+        self._xml_end_tag("richValueRels")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+    def _write_rich_value_rels(self):
+        # Write the <richValueRels> element.
+        xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"
+        xmlns_r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("xmlns:r", xmlns_r),
+        ]
+
+        self._xml_start_tag("richValueRels", attributes)
+
+        # Write the rel elements.
+        for index in range(self.num_embedded_images):
+            self._write_rel(index + 1)
+
+    def _write_rel(self, index):
+        # Write the <rel> element.
+        r_id = f"rId{index}"
+        attributes = [("r:id", r_id)]
+
+        self._xml_empty_tag("rel", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_structure.py b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_structure.py
new file mode 100644
index 00000000..33e0f021
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_structure.py
@@ -0,0 +1,99 @@
+###############################################################################
+#
+# RichValueStructure - A class for writing the Excel XLSX rdrichvaluestructure.xml file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class RichValueStructure(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX rdrichvaluestructure.xml file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+        self.has_embedded_descriptions = False
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the rvStructures element.
+        self._write_rv_structures()
+
+        self._xml_end_tag("rvStructures")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+    def _write_rv_structures(self):
+        # Write the <rvStructures> element.
+        xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"
+        count = "1"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("count", count),
+        ]
+
+        self._xml_start_tag("rvStructures", attributes)
+
+        # Write the s element.
+        self._write_s()
+
+    def _write_s(self):
+        # Write the <s> element.
+        t = "_localImage"
+        attributes = [("t", t)]
+
+        self._xml_start_tag("s", attributes)
+
+        # Write the k elements.
+        self._write_k("_rvRel:LocalImageIdentifier", "i")
+        self._write_k("CalcOrigin", "i")
+
+        if self.has_embedded_descriptions:
+            self._write_k("Text", "s")
+
+        self._xml_end_tag("s")
+
+    def _write_k(self, name, k_type):
+        # Write the <k> element.
+        attributes = [
+            ("n", name),
+            ("t", k_type),
+        ]
+
+        self._xml_empty_tag("k", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_types.py b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_types.py
new file mode 100644
index 00000000..39c288b0
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/rich_value_types.py
@@ -0,0 +1,111 @@
+###############################################################################
+#
+# RichValueTypes - A class for writing the Excel XLSX rdRichValueTypes.xml file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class RichValueTypes(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX rdRichValueTypes.xml file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the rvTypesInfo element.
+        self._write_rv_types_info()
+
+        # Write the global element.
+        self._write_global()
+
+        self._xml_end_tag("rvTypesInfo")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_rv_types_info(self):
+        # Write the <rvTypesInfo> element.
+        xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata2"
+        xmlns_x = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+        xmlns_mc = "http://schemas.openxmlformats.org/markup-compatibility/2006"
+        mc_ignorable = "x"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("xmlns:mc", xmlns_mc),
+            ("mc:Ignorable", mc_ignorable),
+            ("xmlns:x", xmlns_x),
+        ]
+
+        self._xml_start_tag("rvTypesInfo", attributes)
+
+    def _write_global(self):
+        # Write the <global> element.
+        key_flags = [
+            ["_Self", ["ExcludeFromFile", "ExcludeFromCalcComparison"]],
+            ["_DisplayString", ["ExcludeFromCalcComparison"]],
+            ["_Flags", ["ExcludeFromCalcComparison"]],
+            ["_Format", ["ExcludeFromCalcComparison"]],
+            ["_SubLabel", ["ExcludeFromCalcComparison"]],
+            ["_Attribution", ["ExcludeFromCalcComparison"]],
+            ["_Icon", ["ExcludeFromCalcComparison"]],
+            ["_Display", ["ExcludeFromCalcComparison"]],
+            ["_CanonicalPropertyNames", ["ExcludeFromCalcComparison"]],
+            ["_ClassificationId", ["ExcludeFromCalcComparison"]],
+        ]
+
+        self._xml_start_tag("global")
+        self._xml_start_tag("keyFlags")
+
+        for key_flag in key_flags:
+            # Write the key element.
+            self._write_key(key_flag)
+
+        self._xml_end_tag("keyFlags")
+        self._xml_end_tag("global")
+
+    def _write_key(self, key_flag):
+        # Write the <key> element.
+        name = key_flag[0]
+        attributes = [("name", name)]
+
+        self._xml_start_tag("key", attributes)
+
+        # Write the flag element.
+        for name in key_flag[1]:
+            self._write_flag(name)
+
+        self._xml_end_tag("key")
+
+    def _write_flag(self, name):
+        # Write the <flag> element.
+        attributes = [
+            ("name", name),
+            ("value", "1"),
+        ]
+
+        self._xml_empty_tag("flag", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/shape.py b/.venv/lib/python3.12/site-packages/xlsxwriter/shape.py
new file mode 100644
index 00000000..8ad3676f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/shape.py
@@ -0,0 +1,416 @@
+###############################################################################
+#
+# Shape - A class for to represent Excel XLSX shape objects.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+import copy
+from warnings import warn
+
+
+class Shape:
+    """
+    A class for to represent Excel XLSX shape objects.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self, shape_type, name, options):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+        self.name = name
+        self.shape_type = shape_type
+        self.connect = 0
+        self.drawing = 0
+        self.edit_as = ""
+        self.id = 0
+        self.text = ""
+        self.textlink = ""
+        self.stencil = 1
+        self.element = -1
+        self.start = None
+        self.start_index = None
+        self.end = None
+        self.end_index = None
+        self.adjustments = []
+        self.start_side = ""
+        self.end_side = ""
+        self.flip_h = 0
+        self.flip_v = 0
+        self.rotation = 0
+        self.text_rotation = 0
+        self.textbox = False
+
+        self.align = None
+        self.fill = None
+        self.font = None
+        self.format = None
+        self.line = None
+        self.url_rel_index = None
+        self.tip = None
+
+        self._set_options(options)
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _set_options(self, options):
+        self.align = self._get_align_properties(options.get("align"))
+        self.fill = self._get_fill_properties(options.get("fill"))
+        self.font = self._get_font_properties(options.get("font"))
+        self.gradient = self._get_gradient_properties(options.get("gradient"))
+        self.line = self._get_line_properties(options.get("line"))
+
+        self.text_rotation = options.get("text_rotation", 0)
+
+        self.textlink = options.get("textlink", "")
+        if self.textlink.startswith("="):
+            self.textlink = self.textlink.lstrip("=")
+
+        if options.get("border"):
+            self.line = self._get_line_properties(options["border"])
+
+        # Gradient fill overrides solid fill.
+        if self.gradient:
+            self.fill = None
+
+    ###########################################################################
+    #
+    # Static methods for processing chart/shape style properties.
+    #
+    ###########################################################################
+
+    @staticmethod
+    def _get_line_properties(line):
+        # Convert user line properties to the structure required internally.
+
+        if not line:
+            return {"defined": False}
+
+        # Copy the user defined properties since they will be modified.
+        line = copy.deepcopy(line)
+
+        dash_types = {
+            "solid": "solid",
+            "round_dot": "sysDot",
+            "square_dot": "sysDash",
+            "dash": "dash",
+            "dash_dot": "dashDot",
+            "long_dash": "lgDash",
+            "long_dash_dot": "lgDashDot",
+            "long_dash_dot_dot": "lgDashDotDot",
+            "dot": "dot",
+            "system_dash_dot": "sysDashDot",
+            "system_dash_dot_dot": "sysDashDotDot",
+        }
+
+        # Check the dash type.
+        dash_type = line.get("dash_type")
+
+        if dash_type is not None:
+            if dash_type in dash_types:
+                line["dash_type"] = dash_types[dash_type]
+            else:
+                warn(f"Unknown dash type '{dash_type}'")
+                return {}
+
+        line["defined"] = True
+
+        return line
+
+    @staticmethod
+    def _get_fill_properties(fill):
+        # Convert user fill properties to the structure required internally.
+
+        if not fill:
+            return {"defined": False}
+
+        # Copy the user defined properties since they will be modified.
+        fill = copy.deepcopy(fill)
+
+        fill["defined"] = True
+
+        return fill
+
+    @staticmethod
+    def _get_pattern_properties(pattern):
+        # Convert user defined pattern to the structure required internally.
+
+        if not pattern:
+            return {}
+
+        # Copy the user defined properties since they will be modified.
+        pattern = copy.deepcopy(pattern)
+
+        if not pattern.get("pattern"):
+            warn("Pattern must include 'pattern'")
+            return {}
+
+        if not pattern.get("fg_color"):
+            warn("Pattern must include 'fg_color'")
+            return {}
+
+        types = {
+            "percent_5": "pct5",
+            "percent_10": "pct10",
+            "percent_20": "pct20",
+            "percent_25": "pct25",
+            "percent_30": "pct30",
+            "percent_40": "pct40",
+            "percent_50": "pct50",
+            "percent_60": "pct60",
+            "percent_70": "pct70",
+            "percent_75": "pct75",
+            "percent_80": "pct80",
+            "percent_90": "pct90",
+            "light_downward_diagonal": "ltDnDiag",
+            "light_upward_diagonal": "ltUpDiag",
+            "dark_downward_diagonal": "dkDnDiag",
+            "dark_upward_diagonal": "dkUpDiag",
+            "wide_downward_diagonal": "wdDnDiag",
+            "wide_upward_diagonal": "wdUpDiag",
+            "light_vertical": "ltVert",
+            "light_horizontal": "ltHorz",
+            "narrow_vertical": "narVert",
+            "narrow_horizontal": "narHorz",
+            "dark_vertical": "dkVert",
+            "dark_horizontal": "dkHorz",
+            "dashed_downward_diagonal": "dashDnDiag",
+            "dashed_upward_diagonal": "dashUpDiag",
+            "dashed_horizontal": "dashHorz",
+            "dashed_vertical": "dashVert",
+            "small_confetti": "smConfetti",
+            "large_confetti": "lgConfetti",
+            "zigzag": "zigZag",
+            "wave": "wave",
+            "diagonal_brick": "diagBrick",
+            "horizontal_brick": "horzBrick",
+            "weave": "weave",
+            "plaid": "plaid",
+            "divot": "divot",
+            "dotted_grid": "dotGrid",
+            "dotted_diamond": "dotDmnd",
+            "shingle": "shingle",
+            "trellis": "trellis",
+            "sphere": "sphere",
+            "small_grid": "smGrid",
+            "large_grid": "lgGrid",
+            "small_check": "smCheck",
+            "large_check": "lgCheck",
+            "outlined_diamond": "openDmnd",
+            "solid_diamond": "solidDmnd",
+        }
+
+        # Check for valid types.
+        if pattern["pattern"] not in types:
+            warn(f"unknown pattern type '{pattern['pattern']}'")
+            return {}
+
+        pattern["pattern"] = types[pattern["pattern"]]
+
+        # Specify a default background color.
+        pattern["bg_color"] = pattern.get("bg_color", "#FFFFFF")
+
+        return pattern
+
+    @staticmethod
+    def _get_gradient_properties(gradient):
+        # pylint: disable=too-many-return-statements
+        # Convert user defined gradient to the structure required internally.
+
+        if not gradient:
+            return {}
+
+        # Copy the user defined properties since they will be modified.
+        gradient = copy.deepcopy(gradient)
+
+        types = {
+            "linear": "linear",
+            "radial": "circle",
+            "rectangular": "rect",
+            "path": "shape",
+        }
+
+        # Check the colors array exists and is valid.
+        if "colors" not in gradient or not isinstance(gradient["colors"], list):
+            warn("Gradient must include colors list")
+            return {}
+
+        # Check the colors array has the required number of entries.
+        if not 2 <= len(gradient["colors"]) <= 10:
+            warn("Gradient colors list must at least 2 values and not more than 10")
+            return {}
+
+        if "positions" in gradient:
+            # Check the positions array has the right number of entries.
+            if len(gradient["positions"]) != len(gradient["colors"]):
+                warn("Gradient positions not equal to number of colors")
+                return {}
+
+            # Check the positions are in the correct range.
+            for pos in gradient["positions"]:
+                if not 0 <= pos <= 100:
+                    warn("Gradient position must be in the range 0 <= position <= 100")
+                    return {}
+        else:
+            # Use the default gradient positions.
+            if len(gradient["colors"]) == 2:
+                gradient["positions"] = [0, 100]
+
+            elif len(gradient["colors"]) == 3:
+                gradient["positions"] = [0, 50, 100]
+
+            elif len(gradient["colors"]) == 4:
+                gradient["positions"] = [0, 33, 66, 100]
+
+            else:
+                warn("Must specify gradient positions")
+                return {}
+
+        angle = gradient.get("angle")
+        if angle:
+            if not 0 <= angle < 360:
+                warn("Gradient angle must be in the range 0 <= angle < 360")
+                return {}
+        else:
+            gradient["angle"] = 90
+
+        # Check for valid types.
+        gradient_type = gradient.get("type")
+
+        if gradient_type is not None:
+            if gradient_type in types:
+                gradient["type"] = types[gradient_type]
+            else:
+                warn(f"Unknown gradient type '{gradient_type}")
+                return {}
+        else:
+            gradient["type"] = "linear"
+
+        return gradient
+
+    @staticmethod
+    def _get_font_properties(options):
+        # Convert user defined font values into private dict values.
+        if options is None:
+            options = {}
+
+        font = {
+            "name": options.get("name"),
+            "color": options.get("color"),
+            "size": options.get("size", 11),
+            "bold": options.get("bold"),
+            "italic": options.get("italic"),
+            "underline": options.get("underline"),
+            "pitch_family": options.get("pitch_family"),
+            "charset": options.get("charset"),
+            "baseline": options.get("baseline", -1),
+            "lang": options.get("lang", "en-US"),
+        }
+
+        # Convert font size units.
+        if font["size"]:
+            font["size"] = int(font["size"] * 100)
+
+        return font
+
+    @staticmethod
+    def _get_font_style_attributes(font):
+        # _get_font_style_attributes.
+        attributes = []
+
+        if not font:
+            return attributes
+
+        if font.get("size"):
+            attributes.append(("sz", font["size"]))
+
+        if font.get("bold") is not None:
+            attributes.append(("b", 0 + font["bold"]))
+
+        if font.get("italic") is not None:
+            attributes.append(("i", 0 + font["italic"]))
+
+        if font.get("underline") is not None:
+            attributes.append(("u", "sng"))
+
+        if font.get("baseline") != -1:
+            attributes.append(("baseline", font["baseline"]))
+
+        return attributes
+
+    @staticmethod
+    def _get_font_latin_attributes(font):
+        # _get_font_latin_attributes.
+        attributes = []
+
+        if not font:
+            return attributes
+
+        if font["name"] is not None:
+            attributes.append(("typeface", font["name"]))
+
+        if font["pitch_family"] is not None:
+            attributes.append(("pitchFamily", font["pitch_family"]))
+
+        if font["charset"] is not None:
+            attributes.append(("charset", font["charset"]))
+
+        return attributes
+
+    @staticmethod
+    def _get_align_properties(align):
+        # Convert user defined align to the structure required internally.
+        if not align:
+            return {"defined": False}
+
+        # Copy the user defined properties since they will be modified.
+        align = copy.deepcopy(align)
+
+        if "vertical" in align:
+            align_type = align["vertical"]
+
+            align_types = {
+                "top": "top",
+                "middle": "middle",
+                "bottom": "bottom",
+            }
+
+            if align_type in align_types:
+                align["vertical"] = align_types[align_type]
+            else:
+                warn(f"Unknown alignment type '{align_type}'")
+                return {"defined": False}
+
+        if "horizontal" in align:
+            align_type = align["horizontal"]
+
+            align_types = {
+                "left": "left",
+                "center": "center",
+                "right": "right",
+            }
+
+            if align_type in align_types:
+                align["horizontal"] = align_types[align_type]
+            else:
+                warn(f"Unknown alignment type '{align_type}'")
+                return {"defined": False}
+
+        align["defined"] = True
+
+        return align
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/sharedstrings.py b/.venv/lib/python3.12/site-packages/xlsxwriter/sharedstrings.py
new file mode 100644
index 00000000..df65c2ed
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/sharedstrings.py
@@ -0,0 +1,138 @@
+###############################################################################
+#
+# SharedStrings - A class for writing the Excel XLSX sharedStrings file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+from .utility import _preserve_whitespace
+
+
+class SharedStrings(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX sharedStrings file.
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.string_table = None
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the sst element.
+        self._write_sst()
+
+        # Write the sst strings.
+        self._write_sst_strings()
+
+        # Close the sst tag.
+        self._xml_end_tag("sst")
+
+        # Close the file.
+        self._xml_close()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_sst(self):
+        # Write the <sst> element.
+        xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("count", self.string_table.count),
+            ("uniqueCount", self.string_table.unique_count),
+        ]
+
+        self._xml_start_tag("sst", attributes)
+
+    def _write_sst_strings(self):
+        # Write the sst string elements.
+
+        for string in self.string_table.string_array:
+            self._write_si(string)
+
+    def _write_si(self, string):
+        # Write the <si> element.
+        attributes = []
+
+        # Convert control character to a _xHHHH_ escape.
+        string = self._escape_control_characters(string)
+
+        # Add attribute to preserve leading or trailing whitespace.
+        if _preserve_whitespace(string):
+            attributes.append(("xml:space", "preserve"))
+
+        # Write any rich strings without further tags.
+        if string.startswith("<r>") and string.endswith("</r>"):
+            self._xml_rich_si_element(string)
+        else:
+            self._xml_si_element(string, attributes)
+
+
+# A metadata class to store Excel strings between worksheets.
+class SharedStringTable:
+    """
+    A class to track Excel shared strings between worksheets.
+
+    """
+
+    def __init__(self):
+        self.count = 0
+        self.unique_count = 0
+        self.string_table = {}
+        self.string_array = []
+
+    def _get_shared_string_index(self, string):
+        """ " Get the index of the string in the Shared String table."""
+        if string not in self.string_table:
+            # String isn't already stored in the table so add it.
+            index = self.unique_count
+            self.string_table[string] = index
+            self.count += 1
+            self.unique_count += 1
+            return index
+
+        # String exists in the table.
+        index = self.string_table[string]
+        self.count += 1
+        return index
+
+    def _get_shared_string(self, index):
+        """ " Get a shared string from the index."""
+        return self.string_array[index]
+
+    def _sort_string_data(self):
+        """ " Sort the shared string data and convert from dict to list."""
+        self.string_array = sorted(self.string_table, key=self.string_table.__getitem__)
+        self.string_table = {}
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/styles.py b/.venv/lib/python3.12/site-packages/xlsxwriter/styles.py
new file mode 100644
index 00000000..a14035a9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/styles.py
@@ -0,0 +1,803 @@
+###############################################################################
+#
+# Styles - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class Styles(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Styles file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.xf_formats = []
+        self.palette = []
+        self.font_count = 0
+        self.num_formats = []
+        self.border_count = 0
+        self.fill_count = 0
+        self.custom_colors = []
+        self.dxf_formats = []
+        self.has_hyperlink = False
+        self.hyperlink_font_id = 0
+        self.has_comments = False
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Add the style sheet.
+        self._write_style_sheet()
+
+        # Write the number formats.
+        self._write_num_fmts()
+
+        # Write the fonts.
+        self._write_fonts()
+
+        # Write the fills.
+        self._write_fills()
+
+        # Write the borders element.
+        self._write_borders()
+
+        # Write the cellStyleXfs element.
+        self._write_cell_style_xfs()
+
+        # Write the cellXfs element.
+        self._write_cell_xfs()
+
+        # Write the cellStyles element.
+        self._write_cell_styles()
+
+        # Write the dxfs element.
+        self._write_dxfs()
+
+        # Write the tableStyles element.
+        self._write_table_styles()
+
+        # Write the colors element.
+        self._write_colors()
+
+        # Close the style sheet tag.
+        self._xml_end_tag("styleSheet")
+
+        # Close the file.
+        self._xml_close()
+
+    def _set_style_properties(self, properties):
+        # Pass in the Format objects and other properties used in the styles.
+
+        self.xf_formats = properties[0]
+        self.palette = properties[1]
+        self.font_count = properties[2]
+        self.num_formats = properties[3]
+        self.border_count = properties[4]
+        self.fill_count = properties[5]
+        self.custom_colors = properties[6]
+        self.dxf_formats = properties[7]
+        self.has_comments = properties[8]
+
+    def _get_palette_color(self, color):
+        # Special handling for automatic color.
+        if color == "Automatic":
+            return color
+
+        # Convert the RGB color.
+        if color[0] == "#":
+            color = color[1:]
+
+        return "FF" + color.upper()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_style_sheet(self):
+        # Write the <styleSheet> element.
+        xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+
+        attributes = [("xmlns", xmlns)]
+        self._xml_start_tag("styleSheet", attributes)
+
+    def _write_num_fmts(self):
+        # Write the <numFmts> element.
+        if not self.num_formats:
+            return
+
+        attributes = [("count", len(self.num_formats))]
+        self._xml_start_tag("numFmts", attributes)
+
+        # Write the numFmts elements.
+        for index, num_format in enumerate(self.num_formats, 164):
+            self._write_num_fmt(index, num_format)
+
+        self._xml_end_tag("numFmts")
+
+    def _write_num_fmt(self, num_fmt_id, format_code):
+        # Write the <numFmt> element.
+        format_codes = {
+            0: "General",
+            1: "0",
+            2: "0.00",
+            3: "#,##0",
+            4: "#,##0.00",
+            5: "($#,##0_);($#,##0)",
+            6: "($#,##0_);[Red]($#,##0)",
+            7: "($#,##0.00_);($#,##0.00)",
+            8: "($#,##0.00_);[Red]($#,##0.00)",
+            9: "0%",
+            10: "0.00%",
+            11: "0.00E+00",
+            12: "# ?/?",
+            13: "# ??/??",
+            14: "m/d/yy",
+            15: "d-mmm-yy",
+            16: "d-mmm",
+            17: "mmm-yy",
+            18: "h:mm AM/PM",
+            19: "h:mm:ss AM/PM",
+            20: "h:mm",
+            21: "h:mm:ss",
+            22: "m/d/yy h:mm",
+            37: "(#,##0_);(#,##0)",
+            38: "(#,##0_);[Red](#,##0)",
+            39: "(#,##0.00_);(#,##0.00)",
+            40: "(#,##0.00_);[Red](#,##0.00)",
+            41: '_(* #,##0_);_(* (#,##0);_(* "-"_);_(_)',
+            42: '_($* #,##0_);_($* (#,##0);_($* "-"_);_(_)',
+            43: '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(_)',
+            44: '_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(_)',
+            45: "mm:ss",
+            46: "[h]:mm:ss",
+            47: "mm:ss.0",
+            48: "##0.0E+0",
+            49: "@",
+        }
+
+        # Set the format code for built-in number formats.
+        if num_fmt_id < 164:
+            format_code = format_codes.get(num_fmt_id, "General")
+
+        attributes = [
+            ("numFmtId", num_fmt_id),
+            ("formatCode", format_code),
+        ]
+
+        self._xml_empty_tag("numFmt", attributes)
+
+    def _write_fonts(self):
+        # Write the <fonts> element.
+        if self.has_comments:
+            # Add extra font for comments.
+            attributes = [("count", self.font_count + 1)]
+        else:
+            attributes = [("count", self.font_count)]
+
+        self._xml_start_tag("fonts", attributes)
+
+        # Write the font elements for xf_format objects that have them.
+        for xf_format in self.xf_formats:
+            if xf_format.has_font:
+                self._write_font(xf_format)
+
+        if self.has_comments:
+            self._write_comment_font()
+
+        self._xml_end_tag("fonts")
+
+    def _write_font(self, xf_format, is_dxf_format=False):
+        # Write the <font> element.
+        self._xml_start_tag("font")
+
+        # The condense and extend elements are mainly used in dxf formats.
+        if xf_format.font_condense:
+            self._write_condense()
+
+        if xf_format.font_extend:
+            self._write_extend()
+
+        if xf_format.bold:
+            self._xml_empty_tag("b")
+
+        if xf_format.italic:
+            self._xml_empty_tag("i")
+
+        if xf_format.font_strikeout:
+            self._xml_empty_tag("strike")
+
+        if xf_format.font_outline:
+            self._xml_empty_tag("outline")
+
+        if xf_format.font_shadow:
+            self._xml_empty_tag("shadow")
+
+        # Handle the underline variants.
+        if xf_format.underline:
+            self._write_underline(xf_format.underline)
+
+        if xf_format.font_script == 1:
+            self._write_vert_align("superscript")
+
+        if xf_format.font_script == 2:
+            self._write_vert_align("subscript")
+
+        if not is_dxf_format:
+            self._xml_empty_tag("sz", [("val", xf_format.font_size)])
+
+        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)
+            if color != "Automatic":
+                self._write_color("rgb", color)
+        elif not is_dxf_format:
+            self._write_color("theme", 1)
+
+        if not is_dxf_format:
+            self._xml_empty_tag("name", [("val", xf_format.font_name)])
+
+            if xf_format.font_family:
+                self._xml_empty_tag("family", [("val", xf_format.font_family)])
+
+            if xf_format.font_charset:
+                self._xml_empty_tag("charset", [("val", xf_format.font_charset)])
+
+            if xf_format.font_name == "Calibri" and not xf_format.hyperlink:
+                self._xml_empty_tag("scheme", [("val", xf_format.font_scheme)])
+
+            if xf_format.hyperlink:
+                self.has_hyperlink = True
+                if self.hyperlink_font_id == 0:
+                    self.hyperlink_font_id = xf_format.font_index
+
+        self._xml_end_tag("font")
+
+    def _write_comment_font(self):
+        # Write the <font> element for comments.
+        self._xml_start_tag("font")
+
+        self._xml_empty_tag("sz", [("val", 8)])
+        self._write_color("indexed", 81)
+        self._xml_empty_tag("name", [("val", "Tahoma")])
+        self._xml_empty_tag("family", [("val", 2)])
+
+        self._xml_end_tag("font")
+
+    def _write_underline(self, underline):
+        # Write the underline font element.
+
+        if underline == 2:
+            attributes = [("val", "double")]
+        elif underline == 33:
+            attributes = [("val", "singleAccounting")]
+        elif underline == 34:
+            attributes = [("val", "doubleAccounting")]
+        else:
+            # Default to single underline.
+            attributes = []
+
+        self._xml_empty_tag("u", attributes)
+
+    def _write_vert_align(self, val):
+        # Write the <vertAlign> font sub-element.
+        attributes = [("val", val)]
+
+        self._xml_empty_tag("vertAlign", attributes)
+
+    def _write_color(self, name, value):
+        # Write the <color> element.
+        attributes = [(name, value)]
+
+        self._xml_empty_tag("color", attributes)
+
+    def _write_fills(self):
+        # Write the <fills> element.
+        attributes = [("count", self.fill_count)]
+
+        self._xml_start_tag("fills", attributes)
+
+        # Write the default fill element.
+        self._write_default_fill("none")
+        self._write_default_fill("gray125")
+
+        # Write the fill elements for xf_format objects that have them.
+        for xf_format in self.xf_formats:
+            if xf_format.has_fill:
+                self._write_fill(xf_format)
+
+        self._xml_end_tag("fills")
+
+    def _write_default_fill(self, pattern_type):
+        # Write the <fill> element for the default fills.
+        self._xml_start_tag("fill")
+        self._xml_empty_tag("patternFill", [("patternType", pattern_type)])
+        self._xml_end_tag("fill")
+
+    def _write_fill(self, xf_format, is_dxf_format=False):
+        # Write the <fill> element.
+        pattern = xf_format.pattern
+        bg_color = xf_format.bg_color
+        fg_color = xf_format.fg_color
+
+        # Colors for dxf formats are handled differently from normal formats
+        # since the normal xf_format reverses the meaning of BG and FG for
+        # solid fills.
+        if is_dxf_format:
+            bg_color = xf_format.dxf_bg_color
+            fg_color = xf_format.dxf_fg_color
+
+        patterns = (
+            "none",
+            "solid",
+            "mediumGray",
+            "darkGray",
+            "lightGray",
+            "darkHorizontal",
+            "darkVertical",
+            "darkDown",
+            "darkUp",
+            "darkGrid",
+            "darkTrellis",
+            "lightHorizontal",
+            "lightVertical",
+            "lightDown",
+            "lightUp",
+            "lightGrid",
+            "lightTrellis",
+            "gray125",
+            "gray0625",
+        )
+
+        # Special handling for pattern only case.
+        if not fg_color and not bg_color and patterns[pattern]:
+            self._write_default_fill(patterns[pattern])
+            return
+
+        self._xml_start_tag("fill")
+
+        # The "none" pattern is handled differently for dxf formats.
+        if is_dxf_format and pattern <= 1:
+            self._xml_start_tag("patternFill")
+        else:
+            self._xml_start_tag("patternFill", [("patternType", patterns[pattern])])
+
+        if fg_color:
+            fg_color = self._get_palette_color(fg_color)
+            if fg_color != "Automatic":
+                self._xml_empty_tag("fgColor", [("rgb", fg_color)])
+
+        if bg_color:
+            bg_color = self._get_palette_color(bg_color)
+            if bg_color != "Automatic":
+                self._xml_empty_tag("bgColor", [("rgb", bg_color)])
+        else:
+            if not is_dxf_format and pattern <= 1:
+                self._xml_empty_tag("bgColor", [("indexed", 64)])
+
+        self._xml_end_tag("patternFill")
+        self._xml_end_tag("fill")
+
+    def _write_borders(self):
+        # Write the <borders> element.
+        attributes = [("count", self.border_count)]
+
+        self._xml_start_tag("borders", attributes)
+
+        # Write the border elements for xf_format objects that have them.
+        for xf_format in self.xf_formats:
+            if xf_format.has_border:
+                self._write_border(xf_format)
+
+        self._xml_end_tag("borders")
+
+    def _write_border(self, xf_format, is_dxf_format=False):
+        # Write the <border> element.
+        attributes = []
+
+        # Diagonal borders add attributes to the <border> element.
+        if xf_format.diag_type == 1:
+            attributes.append(("diagonalUp", 1))
+        elif xf_format.diag_type == 2:
+            attributes.append(("diagonalDown", 1))
+        elif xf_format.diag_type == 3:
+            attributes.append(("diagonalUp", 1))
+            attributes.append(("diagonalDown", 1))
+
+        # Ensure that a default diag border is set if the diag type is set.
+        if xf_format.diag_type and not xf_format.diag_border:
+            xf_format.diag_border = 1
+
+        # Write the start border tag.
+        self._xml_start_tag("border", attributes)
+
+        # Write the <border> sub elements.
+        self._write_sub_border("left", xf_format.left, xf_format.left_color)
+
+        self._write_sub_border("right", xf_format.right, xf_format.right_color)
+
+        self._write_sub_border("top", xf_format.top, xf_format.top_color)
+
+        self._write_sub_border("bottom", xf_format.bottom, xf_format.bottom_color)
+
+        # Condition DXF formats don't allow diagonal borders.
+        if not is_dxf_format:
+            self._write_sub_border(
+                "diagonal", xf_format.diag_border, xf_format.diag_color
+            )
+
+        if is_dxf_format:
+            self._write_sub_border("vertical", None, None)
+            self._write_sub_border("horizontal", None, None)
+
+        self._xml_end_tag("border")
+
+    def _write_sub_border(self, border_type, style, color):
+        # Write the <border> sub elements such as <right>, <top>, etc.
+        attributes = []
+
+        if not style:
+            self._xml_empty_tag(border_type)
+            return
+
+        border_styles = (
+            "none",
+            "thin",
+            "medium",
+            "dashed",
+            "dotted",
+            "thick",
+            "double",
+            "hair",
+            "mediumDashed",
+            "dashDot",
+            "mediumDashDot",
+            "dashDotDot",
+            "mediumDashDotDot",
+            "slantDashDot",
+        )
+
+        attributes.append(("style", border_styles[style]))
+
+        self._xml_start_tag(border_type, attributes)
+
+        if color and color != "Automatic":
+            color = self._get_palette_color(color)
+            self._xml_empty_tag("color", [("rgb", color)])
+        else:
+            self._xml_empty_tag("color", [("auto", 1)])
+
+        self._xml_end_tag(border_type)
+
+    def _write_cell_style_xfs(self):
+        # Write the <cellStyleXfs> element.
+        count = 1
+
+        if self.has_hyperlink:
+            count = 2
+
+        attributes = [("count", count)]
+
+        self._xml_start_tag("cellStyleXfs", attributes)
+        self._write_style_xf()
+
+        if self.has_hyperlink:
+            self._write_style_xf(True, self.hyperlink_font_id)
+
+        self._xml_end_tag("cellStyleXfs")
+
+    def _write_cell_xfs(self):
+        # Write the <cellXfs> element.
+        formats = self.xf_formats
+
+        # Workaround for when the last xf_format is used for the comment font
+        # and shouldn't be used for cellXfs.
+        last_format = formats[-1]
+        if last_format.font_only:
+            formats.pop()
+
+        attributes = [("count", len(formats))]
+        self._xml_start_tag("cellXfs", attributes)
+
+        # Write the xf elements.
+        for xf_format in formats:
+            self._write_xf(xf_format)
+
+        self._xml_end_tag("cellXfs")
+
+    def _write_style_xf(self, has_hyperlink=False, font_id=0):
+        # Write the style <xf> element.
+        num_fmt_id = 0
+        fill_id = 0
+        border_id = 0
+
+        attributes = [
+            ("numFmtId", num_fmt_id),
+            ("fontId", font_id),
+            ("fillId", fill_id),
+            ("borderId", border_id),
+        ]
+
+        if has_hyperlink:
+            attributes.append(("applyNumberFormat", 0))
+            attributes.append(("applyFill", 0))
+            attributes.append(("applyBorder", 0))
+            attributes.append(("applyAlignment", 0))
+            attributes.append(("applyProtection", 0))
+
+            self._xml_start_tag("xf", attributes)
+            self._xml_empty_tag("alignment", [("vertical", "top")])
+            self._xml_empty_tag("protection", [("locked", 0)])
+            self._xml_end_tag("xf")
+
+        else:
+            self._xml_empty_tag("xf", attributes)
+
+    def _write_xf(self, xf_format):
+        # Write the <xf> element.
+        xf_id = xf_format.xf_id
+        font_id = xf_format.font_index
+        fill_id = xf_format.fill_index
+        border_id = xf_format.border_index
+        num_fmt_id = xf_format.num_format_index
+
+        has_checkbox = xf_format.checkbox
+        has_alignment = False
+        has_protection = False
+
+        attributes = [
+            ("numFmtId", num_fmt_id),
+            ("fontId", font_id),
+            ("fillId", fill_id),
+            ("borderId", border_id),
+            ("xfId", xf_id),
+        ]
+
+        if xf_format.quote_prefix:
+            attributes.append(("quotePrefix", 1))
+
+        if xf_format.num_format_index > 0:
+            attributes.append(("applyNumberFormat", 1))
+
+        # Add applyFont attribute if XF format uses a font element.
+        if xf_format.font_index > 0 and not xf_format.hyperlink:
+            attributes.append(("applyFont", 1))
+
+        # Add applyFill attribute if XF format uses a fill element.
+        if xf_format.fill_index > 0:
+            attributes.append(("applyFill", 1))
+
+        # Add applyBorder attribute if XF format uses a border element.
+        if xf_format.border_index > 0:
+            attributes.append(("applyBorder", 1))
+
+        # Check if XF format has alignment properties set.
+        (apply_align, align) = xf_format._get_align_properties()
+
+        # Check if an alignment sub-element should be written.
+        if apply_align and align:
+            has_alignment = True
+
+        # We can also have applyAlignment without a sub-element.
+        if apply_align or xf_format.hyperlink:
+            attributes.append(("applyAlignment", 1))
+
+        # Check for cell protection properties.
+        protection = xf_format._get_protection_properties()
+
+        if protection or xf_format.hyperlink:
+            attributes.append(("applyProtection", 1))
+
+            if not xf_format.hyperlink:
+                has_protection = True
+
+        # Write XF with sub-elements if required.
+        if has_alignment or has_protection or has_checkbox:
+            self._xml_start_tag("xf", attributes)
+
+            if has_alignment:
+                self._xml_empty_tag("alignment", align)
+
+            if has_protection:
+                self._xml_empty_tag("protection", protection)
+
+            if has_checkbox:
+                self._write_xf_format_extensions()
+
+            self._xml_end_tag("xf")
+        else:
+            self._xml_empty_tag("xf", attributes)
+
+    def _write_cell_styles(self):
+        # Write the <cellStyles> element.
+        count = 1
+
+        if self.has_hyperlink:
+            count = 2
+
+        attributes = [("count", count)]
+
+        self._xml_start_tag("cellStyles", attributes)
+
+        if self.has_hyperlink:
+            self._write_cell_style("Hyperlink", 1, 8)
+
+        self._write_cell_style()
+
+        self._xml_end_tag("cellStyles")
+
+    def _write_cell_style(self, name="Normal", xf_id=0, builtin_id=0):
+        # Write the <cellStyle> element.
+        attributes = [
+            ("name", name),
+            ("xfId", xf_id),
+            ("builtinId", builtin_id),
+        ]
+
+        self._xml_empty_tag("cellStyle", attributes)
+
+    def _write_dxfs(self):
+        # Write the <dxfs> element.
+        formats = self.dxf_formats
+        count = len(formats)
+
+        attributes = [("count", len(formats))]
+
+        if count:
+            self._xml_start_tag("dxfs", attributes)
+
+            # Write the font elements for xf_format objects that have them.
+            for dxf_format in self.dxf_formats:
+                self._xml_start_tag("dxf")
+                if dxf_format.has_dxf_font:
+                    self._write_font(dxf_format, True)
+
+                if dxf_format.num_format_index:
+                    self._write_num_fmt(
+                        dxf_format.num_format_index, dxf_format.num_format
+                    )
+
+                if dxf_format.has_dxf_fill:
+                    self._write_fill(dxf_format, True)
+
+                if dxf_format.has_dxf_border:
+                    self._write_border(dxf_format, True)
+
+                if dxf_format.checkbox:
+                    self._write_dxf_format_extensions()
+
+                self._xml_end_tag("dxf")
+
+            self._xml_end_tag("dxfs")
+        else:
+            self._xml_empty_tag("dxfs", attributes)
+
+    def _write_table_styles(self):
+        # Write the <tableStyles> element.
+        count = 0
+        default_table_style = "TableStyleMedium9"
+        default_pivot_style = "PivotStyleLight16"
+
+        attributes = [
+            ("count", count),
+            ("defaultTableStyle", default_table_style),
+            ("defaultPivotStyle", default_pivot_style),
+        ]
+
+        self._xml_empty_tag("tableStyles", attributes)
+
+    def _write_colors(self):
+        # Write the <colors> element.
+        custom_colors = self.custom_colors
+
+        if not custom_colors:
+            return
+
+        self._xml_start_tag("colors")
+        self._write_mru_colors(custom_colors)
+        self._xml_end_tag("colors")
+
+    def _write_mru_colors(self, custom_colors):
+        # Write the <mruColors> element for the most recently used colors.
+
+        # Write the custom custom_colors in reverse order.
+        custom_colors.reverse()
+
+        # Limit the mruColors to the last 10.
+        if len(custom_colors) > 10:
+            custom_colors = custom_colors[0:10]
+
+        self._xml_start_tag("mruColors")
+
+        # Write the custom custom_colors in reverse order.
+        for color in custom_colors:
+            self._write_color("rgb", color)
+
+        self._xml_end_tag("mruColors")
+
+    def _write_condense(self):
+        # Write the <condense> element.
+        attributes = [("val", 0)]
+
+        self._xml_empty_tag("condense", attributes)
+
+    def _write_extend(self):
+        # Write the <extend> element.
+        attributes = [("val", 0)]
+
+        self._xml_empty_tag("extend", attributes)
+
+    def _write_xf_format_extensions(self):
+        # Write the xfComplement <extLst> elements.
+        schema = "http://schemas.microsoft.com/office/spreadsheetml"
+        attributes = [
+            ("uri", "{C7286773-470A-42A8-94C5-96B5CB345126}"),
+            (
+                "xmlns:xfpb",
+                schema + "/2022/featurepropertybag",
+            ),
+        ]
+
+        self._xml_start_tag("extLst")
+        self._xml_start_tag("ext", attributes)
+
+        self._xml_empty_tag("xfpb:xfComplement", [("i", "0")])
+
+        self._xml_end_tag("ext")
+        self._xml_end_tag("extLst")
+
+    def _write_dxf_format_extensions(self):
+        # Write the DXFComplement <extLst> elements.
+        schema = "http://schemas.microsoft.com/office/spreadsheetml"
+        attributes = [
+            ("uri", "{0417FA29-78FA-4A13-93AC-8FF0FAFDF519}"),
+            (
+                "xmlns:xfpb",
+                schema + "/2022/featurepropertybag",
+            ),
+        ]
+
+        self._xml_start_tag("extLst")
+        self._xml_start_tag("ext", attributes)
+
+        self._xml_empty_tag("xfpb:DXFComplement", [("i", "0")])
+
+        self._xml_end_tag("ext")
+        self._xml_end_tag("extLst")
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/table.py b/.venv/lib/python3.12/site-packages/xlsxwriter/table.py
new file mode 100644
index 00000000..f3e757a6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/table.py
@@ -0,0 +1,194 @@
+###############################################################################
+#
+# Table - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from . import xmlwriter
+
+
+class Table(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Table file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+
+        super().__init__()
+
+        self.properties = {}
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the table element.
+        self._write_table()
+
+        # Write the autoFilter element.
+        self._write_auto_filter()
+
+        # Write the tableColumns element.
+        self._write_table_columns()
+
+        # Write the tableStyleInfo element.
+        self._write_table_style_info()
+
+        # Close the table tag.
+        self._xml_end_tag("table")
+
+        # Close the file.
+        self._xml_close()
+
+    def _set_properties(self, properties):
+        # Set the document properties.
+        self.properties = properties
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_table(self):
+        # Write the <table> element.
+        schema = "http://schemas.openxmlformats.org/"
+        xmlns = schema + "spreadsheetml/2006/main"
+        table_id = self.properties["id"]
+        name = self.properties["name"]
+        display_name = self.properties["name"]
+        ref = self.properties["range"]
+        totals_row_shown = self.properties["totals_row_shown"]
+        header_row_count = self.properties["header_row_count"]
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("id", table_id),
+            ("name", name),
+            ("displayName", display_name),
+            ("ref", ref),
+        ]
+
+        if not header_row_count:
+            attributes.append(("headerRowCount", 0))
+
+        if totals_row_shown:
+            attributes.append(("totalsRowCount", 1))
+        else:
+            attributes.append(("totalsRowShown", 0))
+
+        self._xml_start_tag("table", attributes)
+
+    def _write_auto_filter(self):
+        # Write the <autoFilter> element.
+        autofilter = self.properties.get("autofilter", 0)
+
+        if not autofilter:
+            return
+
+        attributes = [
+            (
+                "ref",
+                autofilter,
+            )
+        ]
+
+        self._xml_empty_tag("autoFilter", attributes)
+
+    def _write_table_columns(self):
+        # Write the <tableColumns> element.
+        columns = self.properties["columns"]
+
+        count = len(columns)
+
+        attributes = [("count", count)]
+
+        self._xml_start_tag("tableColumns", attributes)
+
+        for col_data in columns:
+            # Write the tableColumn element.
+            self._write_table_column(col_data)
+
+        self._xml_end_tag("tableColumns")
+
+    def _write_table_column(self, col_data):
+        # Write the <tableColumn> element.
+        attributes = [
+            ("id", col_data["id"]),
+            ("name", col_data["name"]),
+        ]
+
+        if col_data.get("total_string"):
+            attributes.append(("totalsRowLabel", col_data["total_string"]))
+        elif col_data.get("total_function"):
+            attributes.append(("totalsRowFunction", col_data["total_function"]))
+
+        if "format" in col_data and col_data["format"] is not None:
+            attributes.append(("dataDxfId", col_data["format"]))
+
+        if col_data.get("formula") or col_data.get("custom_total"):
+            self._xml_start_tag("tableColumn", attributes)
+
+            if col_data.get("formula"):
+                # Write the calculatedColumnFormula element.
+                self._write_calculated_column_formula(col_data["formula"])
+
+            if col_data.get("custom_total"):
+                # Write the totalsRowFormula element.
+                self._write_totals_row_formula(col_data.get("custom_total"))
+
+            self._xml_end_tag("tableColumn")
+        else:
+            self._xml_empty_tag("tableColumn", attributes)
+
+    def _write_table_style_info(self):
+        # Write the <tableStyleInfo> element.
+        props = self.properties
+        attributes = []
+
+        name = props["style"]
+        show_first_column = 0 + props["show_first_col"]
+        show_last_column = 0 + props["show_last_col"]
+        show_row_stripes = 0 + props["show_row_stripes"]
+        show_column_stripes = 0 + props["show_col_stripes"]
+
+        if name is not None and name != "" and name != "None":
+            attributes.append(("name", name))
+
+        attributes.append(("showFirstColumn", show_first_column))
+        attributes.append(("showLastColumn", show_last_column))
+        attributes.append(("showRowStripes", show_row_stripes))
+        attributes.append(("showColumnStripes", show_column_stripes))
+
+        self._xml_empty_tag("tableStyleInfo", attributes)
+
+    def _write_calculated_column_formula(self, formula):
+        # Write the <calculatedColumnFormula> element.
+        self._xml_data_element("calculatedColumnFormula", formula)
+
+    def _write_totals_row_formula(self, formula):
+        # Write the <totalsRowFormula> element.
+        self._xml_data_element("totalsRowFormula", formula)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/theme.py b/.venv/lib/python3.12/site-packages/xlsxwriter/theme.py
new file mode 100644
index 00000000..71aec0dd
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/theme.py
@@ -0,0 +1,69 @@
+###############################################################################
+#
+# Theme - A class for writing the Excel XLSX Worksheet file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+from io import StringIO
+
+
+class Theme:
+    """
+    A class for writing the Excel XLSX Theme file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+
+    def __init__(self):
+        """
+        Constructor.
+
+        """
+        super().__init__()
+        self.fh = None
+        self.internal_fh = False
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+        self._write_theme_file()
+        if self.internal_fh:
+            self.fh.close()
+
+    def _set_xml_writer(self, filename):
+        # Set the XML writer filehandle for the object.
+        if isinstance(filename, StringIO):
+            self.internal_fh = False
+            self.fh = filename
+        else:
+            self.internal_fh = True
+            # pylint: disable=consider-using-with
+            self.fh = open(filename, mode="w", encoding="utf-8")
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_theme_file(self):
+        # Write a default theme.xml file.
+
+        # pylint: disable=line-too-long
+        default_theme = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="EEECE1"/></a:lt2><a:accent1><a:srgbClr val="4F81BD"/></a:accent1><a:accent2><a:srgbClr val="C0504D"/></a:accent2><a:accent3><a:srgbClr val="9BBB59"/></a:accent3><a:accent4><a:srgbClr val="8064A2"/></a:accent4><a:accent5><a:srgbClr val="4BACC6"/></a:accent5><a:accent6><a:srgbClr val="F79646"/></a:accent6><a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Cambria"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="\uff2d\uff33 \uff30\u30b4\u30b7\u30c3\u30af"/><a:font script="Hang" typeface="\ub9d1\uc740 \uace0\ub515"/><a:font script="Hans" typeface="\u5b8b\u4f53"/><a:font script="Hant" typeface="\u65b0\u7d30\u660e\u9ad4"/><a:font script="Arab" typeface="Times New Roman"/><a:font script="Hebr" typeface="Times New Roman"/><a:font script="Thai" typeface="Tahoma"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="MoolBoran"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Times New Roman"/><a:font script="Uigh" typeface="Microsoft Uighur"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="\uff2d\uff33 \uff30\u30b4\u30b7\u30c3\u30af"/><a:font script="Hang" typeface="\ub9d1\uc740 \uace0\ub515"/><a:font script="Hans" typeface="\u5b8b\u4f53"/><a:font script="Hant" typeface="\u65b0\u7d30\u660e\u9ad4"/><a:font script="Arab" typeface="Arial"/><a:font script="Hebr" typeface="Arial"/><a:font script="Thai" typeface="Tahoma"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="DaunPenh"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Arial"/><a:font script="Uigh" typeface="Microsoft Uighur"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="50000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="35000"><a:schemeClr val="phClr"><a:tint val="37000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:tint val="15000"/><a:satMod val="350000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="1"/></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:shade val="51000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="80000"><a:schemeClr val="phClr"><a:shade val="93000"/><a:satMod val="130000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="94000"/><a:satMod val="135000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="16200000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"><a:shade val="95000"/><a:satMod val="105000"/></a:schemeClr></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="38100" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="38000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="40000" dist="23000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="35000"/></a:srgbClr></a:outerShdw></a:effectLst><a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="0" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d><a:sp3d><a:bevelT w="63500" h="25400"/></a:sp3d></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="40000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="40000"><a:schemeClr val="phClr"><a:tint val="45000"/><a:shade val="99000"/><a:satMod val="350000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="20000"/><a:satMod val="255000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="-80000" r="50000" b="180000"/></a:path></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="80000"/><a:satMod val="300000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="30000"/><a:satMod val="200000"/></a:schemeClr></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>"""  # noqa
+
+        self.fh.write(default_theme)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/utility.py b/.venv/lib/python3.12/site-packages/xlsxwriter/utility.py
new file mode 100644
index 00000000..95a5452c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/utility.py
@@ -0,0 +1,1207 @@
+###############################################################################
+#
+# Worksheet - A class for writing Excel Worksheets.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+import datetime
+import hashlib
+import os
+import re
+from struct import unpack
+from warnings import warn
+
+from .exceptions import UndefinedImageSize, UnsupportedImageFormat
+
+COL_NAMES = {}
+
+CHAR_WIDTHS = {
+    " ": 3,
+    "!": 5,
+    '"': 6,
+    "#": 7,
+    "$": 7,
+    "%": 11,
+    "&": 10,
+    "'": 3,
+    "(": 5,
+    ")": 5,
+    "*": 7,
+    "+": 7,
+    ",": 4,
+    "-": 5,
+    ".": 4,
+    "/": 6,
+    "0": 7,
+    "1": 7,
+    "2": 7,
+    "3": 7,
+    "4": 7,
+    "5": 7,
+    "6": 7,
+    "7": 7,
+    "8": 7,
+    "9": 7,
+    ":": 4,
+    ";": 4,
+    "<": 7,
+    "=": 7,
+    ">": 7,
+    "?": 7,
+    "@": 13,
+    "A": 9,
+    "B": 8,
+    "C": 8,
+    "D": 9,
+    "E": 7,
+    "F": 7,
+    "G": 9,
+    "H": 9,
+    "I": 4,
+    "J": 5,
+    "K": 8,
+    "L": 6,
+    "M": 12,
+    "N": 10,
+    "O": 10,
+    "P": 8,
+    "Q": 10,
+    "R": 8,
+    "S": 7,
+    "T": 7,
+    "U": 9,
+    "V": 9,
+    "W": 13,
+    "X": 8,
+    "Y": 7,
+    "Z": 7,
+    "[": 5,
+    "\\": 6,
+    "]": 5,
+    "^": 7,
+    "_": 7,
+    "`": 4,
+    "a": 7,
+    "b": 8,
+    "c": 6,
+    "d": 8,
+    "e": 8,
+    "f": 5,
+    "g": 7,
+    "h": 8,
+    "i": 4,
+    "j": 4,
+    "k": 7,
+    "l": 4,
+    "m": 12,
+    "n": 8,
+    "o": 8,
+    "p": 8,
+    "q": 8,
+    "r": 5,
+    "s": 6,
+    "t": 5,
+    "u": 8,
+    "v": 7,
+    "w": 11,
+    "x": 7,
+    "y": 7,
+    "z": 6,
+    "{": 5,
+    "|": 7,
+    "}": 5,
+    "~": 7,
+}
+
+# The following is a list of Emojis used to decide if worksheet names require
+# quoting since there is (currently) no native support for matching them in
+# Python regular expressions. It is probably unnecessary to exclude them since
+# the default quoting is safe in Excel even when unnecessary (the reverse isn't
+# true). The Emoji list was generated from:
+#
+# https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AEmoji%3DYes%3A%5D&abb=on&esc=on&g=&i=
+#
+# pylint: disable-next=line-too-long
+EMOJIS = "\u00A9\u00AE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299\U0001F004\U0001F0CF\U0001F170\U0001F171\U0001F17E\U0001F17F\U0001F18E\U0001F191-\U0001F19A\U0001F1E6-\U0001F1FF\U0001F201\U0001F202\U0001F21A\U0001F22F\U0001F232-\U0001F23A\U0001F250\U0001F251\U0001F300-\U0001F321\U0001F324-\U0001F393\U0001F396\U0001F397\U0001F399-\U0001F39B\U0001F39E-\U0001F3F0\U0001F3F3-\U0001F3F5\U0001F3F7-\U0001F4FD\U0001F4FF-\U0001F53D\U0001F549-\U0001F54E\U0001F550-\U0001F567\U0001F56F\U0001F570\U0001F573-\U0001F57A\U0001F587\U0001F58A-\U0001F58D\U0001F590\U0001F595\U0001F596\U0001F5A4\U0001F5A5\U0001F5A8\U0001F5B1\U0001F5B2\U0001F5BC\U0001F5C2-\U0001F5C4\U0001F5D1-\U0001F5D3\U0001F5DC-\U0001F5DE\U0001F5E1\U0001F5E3\U0001F5E8\U0001F5EF\U0001F5F3\U0001F5FA-\U0001F64F\U0001F680-\U0001F6C5\U0001F6CB-\U0001F6D2\U0001F6D5-\U0001F6D7\U0001F6DC-\U0001F6E5\U0001F6E9\U0001F6EB\U0001F6EC\U0001F6F0\U0001F6F3-\U0001F6FC\U0001F7E0-\U0001F7EB\U0001F7F0\U0001F90C-\U0001F93A\U0001F93C-\U0001F945\U0001F947-\U0001F9FF\U0001FA70-\U0001FA7C\U0001FA80-\U0001FA88\U0001FA90-\U0001FABD\U0001FABF-\U0001FAC5\U0001FACE-\U0001FADB\U0001FAE0-\U0001FAE8\U0001FAF0-\U0001FAF8"  # noqa
+
+# Compile performance critical regular expressions.
+RE_LEADING_WHITESPACE = re.compile(r"^\s")
+RE_TRAILING_WHITESPACE = re.compile(r"\s$")
+RE_RANGE_PARTS = re.compile(r"(\$?)([A-Z]{1,3})(\$?)(\d+)")
+RE_QUOTE_RULE1 = re.compile(rf"[^\w\.{EMOJIS}]")
+RE_QUOTE_RULE2 = re.compile(rf"^[\d\.{EMOJIS}]")
+RE_QUOTE_RULE3 = re.compile(r"^([A-Z]{1,3}\d+)$")
+RE_QUOTE_RULE4_ROW = re.compile(r"^R(\d+)")
+RE_QUOTE_RULE4_COLUMN = re.compile(r"^R?C(\d+)")
+
+
+def xl_rowcol_to_cell(row, col, row_abs=False, col_abs=False):
+    """
+    Convert a zero indexed row and column cell reference to a A1 style string.
+
+    Args:
+       row:     The cell row.    Int.
+       col:     The cell column. Int.
+       row_abs: Optional flag to make the row absolute.    Bool.
+       col_abs: Optional flag to make the column absolute. Bool.
+
+    Returns:
+        A1 style string.
+
+    """
+    if row < 0:
+        warn(f"Row number '{row}' must be >= 0")
+        return None
+
+    if col < 0:
+        warn(f"Col number '{col}' must be >= 0")
+        return None
+
+    row += 1  # Change to 1-index.
+    row_abs = "$" if row_abs else ""
+
+    col_str = xl_col_to_name(col, col_abs)
+
+    return col_str + row_abs + str(row)
+
+
+def xl_rowcol_to_cell_fast(row, col):
+    """
+    Optimized version of the xl_rowcol_to_cell function. Only used internally.
+
+    Args:
+       row: The cell row.    Int.
+       col: The cell column. Int.
+
+    Returns:
+        A1 style string.
+
+    """
+    if col in COL_NAMES:
+        col_str = COL_NAMES[col]
+    else:
+        col_str = xl_col_to_name(col)
+        COL_NAMES[col] = col_str
+
+    return col_str + str(row + 1)
+
+
+def xl_col_to_name(col, col_abs=False):
+    """
+    Convert a zero indexed column cell reference to a string.
+
+    Args:
+       col:     The cell column. Int.
+       col_abs: Optional flag to make the column absolute. Bool.
+
+    Returns:
+        Column style string.
+
+    """
+    col_num = col
+    if col_num < 0:
+        warn(f"Col number '{col_num}' must be >= 0")
+        return None
+
+    col_num += 1  # Change to 1-index.
+    col_str = ""
+    col_abs = "$" if col_abs else ""
+
+    while col_num:
+        # Set remainder from 1 .. 26
+        remainder = col_num % 26
+
+        if remainder == 0:
+            remainder = 26
+
+        # Convert the remainder to a character.
+        col_letter = chr(ord("A") + remainder - 1)
+
+        # Accumulate the column letters, right to left.
+        col_str = col_letter + col_str
+
+        # Get the next order of magnitude.
+        col_num = int((col_num - 1) / 26)
+
+    return col_abs + col_str
+
+
+def xl_cell_to_rowcol(cell_str):
+    """
+    Convert a cell reference in A1 notation to a zero indexed row and column.
+
+    Args:
+       cell_str:  A1 style string.
+
+    Returns:
+        row, col: Zero indexed cell row and column indices.
+
+    """
+    if not cell_str:
+        return 0, 0
+
+    match = RE_RANGE_PARTS.match(cell_str)
+    col_str = match.group(2)
+    row_str = match.group(4)
+
+    # Convert base26 column string to number.
+    expn = 0
+    col = 0
+    for char in reversed(col_str):
+        col += (ord(char) - ord("A") + 1) * (26**expn)
+        expn += 1
+
+    # Convert 1-index to zero-index
+    row = int(row_str) - 1
+    col -= 1
+
+    return row, col
+
+
+def xl_cell_to_rowcol_abs(cell_str):
+    """
+    Convert an absolute cell reference in A1 notation to a zero indexed
+    row and column, with True/False values for absolute rows or columns.
+
+    Args:
+       cell_str: A1 style string.
+
+    Returns:
+        row, col, row_abs, col_abs:  Zero indexed cell row and column indices.
+
+    """
+    if not cell_str:
+        return 0, 0, False, False
+
+    match = RE_RANGE_PARTS.match(cell_str)
+
+    col_abs = bool(match.group(1))
+    col_str = match.group(2)
+    row_abs = bool(match.group(3))
+    row_str = match.group(4)
+
+    # Convert base26 column string to number.
+    expn = 0
+    col = 0
+    for char in reversed(col_str):
+        col += (ord(char) - ord("A") + 1) * (26**expn)
+        expn += 1
+
+    # Convert 1-index to zero-index
+    row = int(row_str) - 1
+    col -= 1
+
+    return row, col, row_abs, col_abs
+
+
+def xl_range(first_row, first_col, last_row, last_col):
+    """
+    Convert zero indexed row and col cell references to a A1:B1 range string.
+
+    Args:
+       first_row: The first cell row.    Int.
+       first_col: The first cell column. Int.
+       last_row:  The last cell row.     Int.
+       last_col:  The last cell column.  Int.
+
+    Returns:
+        A1:B1 style range string.
+
+    """
+    range1 = xl_rowcol_to_cell(first_row, first_col)
+    range2 = xl_rowcol_to_cell(last_row, last_col)
+
+    if range1 is None or range2 is None:
+        warn("Row and column numbers must be >= 0")
+        return None
+
+    if range1 == range2:
+        return range1
+
+    return range1 + ":" + range2
+
+
+def xl_range_abs(first_row, first_col, last_row, last_col):
+    """
+    Convert zero indexed row and col cell references to a $A$1:$B$1 absolute
+    range string.
+
+    Args:
+       first_row: The first cell row.    Int.
+       first_col: The first cell column. Int.
+       last_row:  The last cell row.     Int.
+       last_col:  The last cell column.  Int.
+
+    Returns:
+        $A$1:$B$1 style range string.
+
+    """
+    range1 = xl_rowcol_to_cell(first_row, first_col, True, True)
+    range2 = xl_rowcol_to_cell(last_row, last_col, True, True)
+
+    if range1 is None or range2 is None:
+        warn("Row and column numbers must be >= 0")
+        return None
+
+    if range1 == range2:
+        return range1
+
+    return range1 + ":" + range2
+
+
+def xl_range_formula(sheetname, first_row, first_col, last_row, last_col):
+    """
+    Convert worksheet name and zero indexed row and col cell references to
+    a Sheet1!A1:B1 range formula string.
+
+    Args:
+       sheetname: The worksheet name.    String.
+       first_row: The first cell row.    Int.
+       first_col: The first cell column. Int.
+       last_row:  The last cell row.     Int.
+       last_col:  The last cell column.  Int.
+
+    Returns:
+        A1:B1 style range string.
+
+    """
+    cell_range = xl_range_abs(first_row, first_col, last_row, last_col)
+    sheetname = quote_sheetname(sheetname)
+
+    return sheetname + "!" + cell_range
+
+
+def quote_sheetname(sheetname):
+    """
+    Sheetnames used in references should be quoted if they contain any spaces,
+    special characters or if they look like a A1 or RC cell reference. The rules
+    are shown inline below.
+
+    Args:
+       sheetname: The worksheet name. String.
+
+    Returns:
+        A quoted worksheet string.
+
+    """
+    uppercase_sheetname = sheetname.upper()
+    requires_quoting = False
+    col_max = 163_84
+    row_max = 1048576
+
+    # Don't quote sheetname if it is already quoted by the user.
+    if not sheetname.startswith("'"):
+
+        # --------------------------------------------------------------------
+        # Rule 1. Sheet names that contain anything other than \w and "."
+        # characters must be quoted.
+        # --------------------------------------------------------------------
+        if RE_QUOTE_RULE1.search(sheetname):
+            requires_quoting = True
+
+        # --------------------------------------------------------------------
+        # Rule 2. Sheet names that start with a digit or "." must be quoted.
+        # --------------------------------------------------------------------
+        elif RE_QUOTE_RULE2.search(sheetname):
+            requires_quoting = True
+
+        # --------------------------------------------------------------------
+        # Rule 3. Sheet names must not be a valid A1 style cell reference.
+        # Valid means that the row and column range values must also be within
+        # Excel row and column limits.
+        # --------------------------------------------------------------------
+        elif RE_QUOTE_RULE3.match(uppercase_sheetname):
+            match = RE_QUOTE_RULE3.match(uppercase_sheetname)
+            cell = match.group(1)
+            (row, col) = xl_cell_to_rowcol(cell)
+
+            if 0 <= row < row_max and 0 <= col < col_max:
+                requires_quoting = True
+
+        # --------------------------------------------------------------------
+        # Rule 4. Sheet names must not *start* with a valid RC style cell
+        # reference. Other characters after the valid RC reference are ignored
+        # by Excel. Valid means that the row and column range values must also
+        # be within Excel row and column limits.
+        #
+        # Note: references without trailing characters like R12345 or C12345
+        # are caught by Rule 3. Negative references like R-12345 are caught by
+        # Rule 1 due to the dash.
+        # --------------------------------------------------------------------
+
+        # Rule 4a. Check for sheet names that start with R1 style references.
+        elif RE_QUOTE_RULE4_ROW.match(uppercase_sheetname):
+            match = RE_QUOTE_RULE4_ROW.match(uppercase_sheetname)
+            row = int(match.group(1))
+
+            if 0 < row <= row_max:
+                requires_quoting = True
+
+        # Rule 4b. Check for sheet names that start with C1 or RC1 style
+        elif RE_QUOTE_RULE4_COLUMN.match(uppercase_sheetname):
+            match = RE_QUOTE_RULE4_COLUMN.match(uppercase_sheetname)
+            col = int(match.group(1))
+
+            if 0 < col <= col_max:
+                requires_quoting = True
+
+        # Rule 4c. Check for some single R/C references.
+        elif uppercase_sheetname in ("R", "C", "RC"):
+            requires_quoting = True
+
+    if requires_quoting:
+        # Double quote any single quotes.
+        sheetname = sheetname.replace("'", "''")
+
+        # Single quote the sheet name.
+        sheetname = f"'{sheetname}'"
+
+    return sheetname
+
+
+def cell_autofit_width(string):
+    """
+    Calculate the width required to auto-fit a string in a cell.
+
+    Args:
+       string: The string to calculate the cell width for. String.
+
+    Returns:
+        The string autofit width in pixels. Returns 0 if the string is empty.
+
+    """
+    if not string or len(string) == 0:
+        return 0
+
+    # Excel adds an additional 7 pixels of padding to the cell boundary.
+    return xl_pixel_width(string) + 7
+
+
+def xl_pixel_width(string):
+    """
+    Get the pixel width of a string based on individual character widths taken
+    from Excel. UTF8 characters, and other unhandled characters, are given a
+    default width of 8.
+
+    Args:
+       string: The string to calculate the width for. String.
+
+    Returns:
+        The string width in pixels. Note, Excel adds an additional 7 pixels of
+        padding in the cell.
+
+    """
+    length = 0
+    for char in string:
+        length += CHAR_WIDTHS.get(char, 8)
+
+    return length
+
+
+def _xl_color(color):
+    # Used in conjunction with the XlsxWriter *color() methods to convert
+    # a color name into an RGB formatted string. These colors are for
+    # backward compatibility with older versions of Excel.
+    named_colors = {
+        "black": "#000000",
+        "blue": "#0000FF",
+        "brown": "#800000",
+        "cyan": "#00FFFF",
+        "gray": "#808080",
+        "green": "#008000",
+        "lime": "#00FF00",
+        "magenta": "#FF00FF",
+        "navy": "#000080",
+        "orange": "#FF6600",
+        "pink": "#FF00FF",
+        "purple": "#800080",
+        "red": "#FF0000",
+        "silver": "#C0C0C0",
+        "white": "#FFFFFF",
+        "yellow": "#FFFF00",
+    }
+
+    color = named_colors.get(color, color)
+
+    if not re.match("#[0-9a-fA-F]{6}", color):
+        warn(f"Color '{color}' isn't a valid Excel color")
+
+    # Convert the RGB color to the Excel ARGB format.
+    return "FF" + color.lstrip("#").upper()
+
+
+def _get_rgb_color(color):
+    # Convert the user specified color to an RGB color.
+    rgb_color = _xl_color(color)
+
+    # Remove leading FF from RGB color for charts.
+    rgb_color = re.sub(r"^FF", "", rgb_color)
+
+    return rgb_color
+
+
+def _get_sparkline_style(style_id):
+    styles = [
+        {
+            "series": {"theme": "4", "tint": "-0.499984740745262"},
+            "negative": {"theme": "5"},
+            "markers": {"theme": "4", "tint": "-0.499984740745262"},
+            "first": {"theme": "4", "tint": "0.39997558519241921"},
+            "last": {"theme": "4", "tint": "0.39997558519241921"},
+            "high": {"theme": "4"},
+            "low": {"theme": "4"},
+        },  # 0
+        {
+            "series": {"theme": "4", "tint": "-0.499984740745262"},
+            "negative": {"theme": "5"},
+            "markers": {"theme": "4", "tint": "-0.499984740745262"},
+            "first": {"theme": "4", "tint": "0.39997558519241921"},
+            "last": {"theme": "4", "tint": "0.39997558519241921"},
+            "high": {"theme": "4"},
+            "low": {"theme": "4"},
+        },  # 1
+        {
+            "series": {"theme": "5", "tint": "-0.499984740745262"},
+            "negative": {"theme": "6"},
+            "markers": {"theme": "5", "tint": "-0.499984740745262"},
+            "first": {"theme": "5", "tint": "0.39997558519241921"},
+            "last": {"theme": "5", "tint": "0.39997558519241921"},
+            "high": {"theme": "5"},
+            "low": {"theme": "5"},
+        },  # 2
+        {
+            "series": {"theme": "6", "tint": "-0.499984740745262"},
+            "negative": {"theme": "7"},
+            "markers": {"theme": "6", "tint": "-0.499984740745262"},
+            "first": {"theme": "6", "tint": "0.39997558519241921"},
+            "last": {"theme": "6", "tint": "0.39997558519241921"},
+            "high": {"theme": "6"},
+            "low": {"theme": "6"},
+        },  # 3
+        {
+            "series": {"theme": "7", "tint": "-0.499984740745262"},
+            "negative": {"theme": "8"},
+            "markers": {"theme": "7", "tint": "-0.499984740745262"},
+            "first": {"theme": "7", "tint": "0.39997558519241921"},
+            "last": {"theme": "7", "tint": "0.39997558519241921"},
+            "high": {"theme": "7"},
+            "low": {"theme": "7"},
+        },  # 4
+        {
+            "series": {"theme": "8", "tint": "-0.499984740745262"},
+            "negative": {"theme": "9"},
+            "markers": {"theme": "8", "tint": "-0.499984740745262"},
+            "first": {"theme": "8", "tint": "0.39997558519241921"},
+            "last": {"theme": "8", "tint": "0.39997558519241921"},
+            "high": {"theme": "8"},
+            "low": {"theme": "8"},
+        },  # 5
+        {
+            "series": {"theme": "9", "tint": "-0.499984740745262"},
+            "negative": {"theme": "4"},
+            "markers": {"theme": "9", "tint": "-0.499984740745262"},
+            "first": {"theme": "9", "tint": "0.39997558519241921"},
+            "last": {"theme": "9", "tint": "0.39997558519241921"},
+            "high": {"theme": "9"},
+            "low": {"theme": "9"},
+        },  # 6
+        {
+            "series": {"theme": "4", "tint": "-0.249977111117893"},
+            "negative": {"theme": "5"},
+            "markers": {"theme": "5", "tint": "-0.249977111117893"},
+            "first": {"theme": "5", "tint": "-0.249977111117893"},
+            "last": {"theme": "5", "tint": "-0.249977111117893"},
+            "high": {"theme": "5", "tint": "-0.249977111117893"},
+            "low": {"theme": "5", "tint": "-0.249977111117893"},
+        },  # 7
+        {
+            "series": {"theme": "5", "tint": "-0.249977111117893"},
+            "negative": {"theme": "6"},
+            "markers": {"theme": "6", "tint": "-0.249977111117893"},
+            "first": {"theme": "6", "tint": "-0.249977111117893"},
+            "last": {"theme": "6", "tint": "-0.249977111117893"},
+            "high": {"theme": "6", "tint": "-0.249977111117893"},
+            "low": {"theme": "6", "tint": "-0.249977111117893"},
+        },  # 8
+        {
+            "series": {"theme": "6", "tint": "-0.249977111117893"},
+            "negative": {"theme": "7"},
+            "markers": {"theme": "7", "tint": "-0.249977111117893"},
+            "first": {"theme": "7", "tint": "-0.249977111117893"},
+            "last": {"theme": "7", "tint": "-0.249977111117893"},
+            "high": {"theme": "7", "tint": "-0.249977111117893"},
+            "low": {"theme": "7", "tint": "-0.249977111117893"},
+        },  # 9
+        {
+            "series": {"theme": "7", "tint": "-0.249977111117893"},
+            "negative": {"theme": "8"},
+            "markers": {"theme": "8", "tint": "-0.249977111117893"},
+            "first": {"theme": "8", "tint": "-0.249977111117893"},
+            "last": {"theme": "8", "tint": "-0.249977111117893"},
+            "high": {"theme": "8", "tint": "-0.249977111117893"},
+            "low": {"theme": "8", "tint": "-0.249977111117893"},
+        },  # 10
+        {
+            "series": {"theme": "8", "tint": "-0.249977111117893"},
+            "negative": {"theme": "9"},
+            "markers": {"theme": "9", "tint": "-0.249977111117893"},
+            "first": {"theme": "9", "tint": "-0.249977111117893"},
+            "last": {"theme": "9", "tint": "-0.249977111117893"},
+            "high": {"theme": "9", "tint": "-0.249977111117893"},
+            "low": {"theme": "9", "tint": "-0.249977111117893"},
+        },  # 11
+        {
+            "series": {"theme": "9", "tint": "-0.249977111117893"},
+            "negative": {"theme": "4"},
+            "markers": {"theme": "4", "tint": "-0.249977111117893"},
+            "first": {"theme": "4", "tint": "-0.249977111117893"},
+            "last": {"theme": "4", "tint": "-0.249977111117893"},
+            "high": {"theme": "4", "tint": "-0.249977111117893"},
+            "low": {"theme": "4", "tint": "-0.249977111117893"},
+        },  # 12
+        {
+            "series": {"theme": "4"},
+            "negative": {"theme": "5"},
+            "markers": {"theme": "4", "tint": "-0.249977111117893"},
+            "first": {"theme": "4", "tint": "-0.249977111117893"},
+            "last": {"theme": "4", "tint": "-0.249977111117893"},
+            "high": {"theme": "4", "tint": "-0.249977111117893"},
+            "low": {"theme": "4", "tint": "-0.249977111117893"},
+        },  # 13
+        {
+            "series": {"theme": "5"},
+            "negative": {"theme": "6"},
+            "markers": {"theme": "5", "tint": "-0.249977111117893"},
+            "first": {"theme": "5", "tint": "-0.249977111117893"},
+            "last": {"theme": "5", "tint": "-0.249977111117893"},
+            "high": {"theme": "5", "tint": "-0.249977111117893"},
+            "low": {"theme": "5", "tint": "-0.249977111117893"},
+        },  # 14
+        {
+            "series": {"theme": "6"},
+            "negative": {"theme": "7"},
+            "markers": {"theme": "6", "tint": "-0.249977111117893"},
+            "first": {"theme": "6", "tint": "-0.249977111117893"},
+            "last": {"theme": "6", "tint": "-0.249977111117893"},
+            "high": {"theme": "6", "tint": "-0.249977111117893"},
+            "low": {"theme": "6", "tint": "-0.249977111117893"},
+        },  # 15
+        {
+            "series": {"theme": "7"},
+            "negative": {"theme": "8"},
+            "markers": {"theme": "7", "tint": "-0.249977111117893"},
+            "first": {"theme": "7", "tint": "-0.249977111117893"},
+            "last": {"theme": "7", "tint": "-0.249977111117893"},
+            "high": {"theme": "7", "tint": "-0.249977111117893"},
+            "low": {"theme": "7", "tint": "-0.249977111117893"},
+        },  # 16
+        {
+            "series": {"theme": "8"},
+            "negative": {"theme": "9"},
+            "markers": {"theme": "8", "tint": "-0.249977111117893"},
+            "first": {"theme": "8", "tint": "-0.249977111117893"},
+            "last": {"theme": "8", "tint": "-0.249977111117893"},
+            "high": {"theme": "8", "tint": "-0.249977111117893"},
+            "low": {"theme": "8", "tint": "-0.249977111117893"},
+        },  # 17
+        {
+            "series": {"theme": "9"},
+            "negative": {"theme": "4"},
+            "markers": {"theme": "9", "tint": "-0.249977111117893"},
+            "first": {"theme": "9", "tint": "-0.249977111117893"},
+            "last": {"theme": "9", "tint": "-0.249977111117893"},
+            "high": {"theme": "9", "tint": "-0.249977111117893"},
+            "low": {"theme": "9", "tint": "-0.249977111117893"},
+        },  # 18
+        {
+            "series": {"theme": "4", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "4", "tint": "0.79998168889431442"},
+            "first": {"theme": "4", "tint": "-0.249977111117893"},
+            "last": {"theme": "4", "tint": "-0.249977111117893"},
+            "high": {"theme": "4", "tint": "-0.499984740745262"},
+            "low": {"theme": "4", "tint": "-0.499984740745262"},
+        },  # 19
+        {
+            "series": {"theme": "5", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "5", "tint": "0.79998168889431442"},
+            "first": {"theme": "5", "tint": "-0.249977111117893"},
+            "last": {"theme": "5", "tint": "-0.249977111117893"},
+            "high": {"theme": "5", "tint": "-0.499984740745262"},
+            "low": {"theme": "5", "tint": "-0.499984740745262"},
+        },  # 20
+        {
+            "series": {"theme": "6", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "6", "tint": "0.79998168889431442"},
+            "first": {"theme": "6", "tint": "-0.249977111117893"},
+            "last": {"theme": "6", "tint": "-0.249977111117893"},
+            "high": {"theme": "6", "tint": "-0.499984740745262"},
+            "low": {"theme": "6", "tint": "-0.499984740745262"},
+        },  # 21
+        {
+            "series": {"theme": "7", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "7", "tint": "0.79998168889431442"},
+            "first": {"theme": "7", "tint": "-0.249977111117893"},
+            "last": {"theme": "7", "tint": "-0.249977111117893"},
+            "high": {"theme": "7", "tint": "-0.499984740745262"},
+            "low": {"theme": "7", "tint": "-0.499984740745262"},
+        },  # 22
+        {
+            "series": {"theme": "8", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "8", "tint": "0.79998168889431442"},
+            "first": {"theme": "8", "tint": "-0.249977111117893"},
+            "last": {"theme": "8", "tint": "-0.249977111117893"},
+            "high": {"theme": "8", "tint": "-0.499984740745262"},
+            "low": {"theme": "8", "tint": "-0.499984740745262"},
+        },  # 23
+        {
+            "series": {"theme": "9", "tint": "0.39997558519241921"},
+            "negative": {"theme": "0", "tint": "-0.499984740745262"},
+            "markers": {"theme": "9", "tint": "0.79998168889431442"},
+            "first": {"theme": "9", "tint": "-0.249977111117893"},
+            "last": {"theme": "9", "tint": "-0.249977111117893"},
+            "high": {"theme": "9", "tint": "-0.499984740745262"},
+            "low": {"theme": "9", "tint": "-0.499984740745262"},
+        },  # 24
+        {
+            "series": {"theme": "1", "tint": "0.499984740745262"},
+            "negative": {"theme": "1", "tint": "0.249977111117893"},
+            "markers": {"theme": "1", "tint": "0.249977111117893"},
+            "first": {"theme": "1", "tint": "0.249977111117893"},
+            "last": {"theme": "1", "tint": "0.249977111117893"},
+            "high": {"theme": "1", "tint": "0.249977111117893"},
+            "low": {"theme": "1", "tint": "0.249977111117893"},
+        },  # 25
+        {
+            "series": {"theme": "1", "tint": "0.34998626667073579"},
+            "negative": {"theme": "0", "tint": "-0.249977111117893"},
+            "markers": {"theme": "0", "tint": "-0.249977111117893"},
+            "first": {"theme": "0", "tint": "-0.249977111117893"},
+            "last": {"theme": "0", "tint": "-0.249977111117893"},
+            "high": {"theme": "0", "tint": "-0.249977111117893"},
+            "low": {"theme": "0", "tint": "-0.249977111117893"},
+        },  # 26
+        {
+            "series": {"rgb": "FF323232"},
+            "negative": {"rgb": "FFD00000"},
+            "markers": {"rgb": "FFD00000"},
+            "first": {"rgb": "FFD00000"},
+            "last": {"rgb": "FFD00000"},
+            "high": {"rgb": "FFD00000"},
+            "low": {"rgb": "FFD00000"},
+        },  # 27
+        {
+            "series": {"rgb": "FF000000"},
+            "negative": {"rgb": "FF0070C0"},
+            "markers": {"rgb": "FF0070C0"},
+            "first": {"rgb": "FF0070C0"},
+            "last": {"rgb": "FF0070C0"},
+            "high": {"rgb": "FF0070C0"},
+            "low": {"rgb": "FF0070C0"},
+        },  # 28
+        {
+            "series": {"rgb": "FF376092"},
+            "negative": {"rgb": "FFD00000"},
+            "markers": {"rgb": "FFD00000"},
+            "first": {"rgb": "FFD00000"},
+            "last": {"rgb": "FFD00000"},
+            "high": {"rgb": "FFD00000"},
+            "low": {"rgb": "FFD00000"},
+        },  # 29
+        {
+            "series": {"rgb": "FF0070C0"},
+            "negative": {"rgb": "FF000000"},
+            "markers": {"rgb": "FF000000"},
+            "first": {"rgb": "FF000000"},
+            "last": {"rgb": "FF000000"},
+            "high": {"rgb": "FF000000"},
+            "low": {"rgb": "FF000000"},
+        },  # 30
+        {
+            "series": {"rgb": "FF5F5F5F"},
+            "negative": {"rgb": "FFFFB620"},
+            "markers": {"rgb": "FFD70077"},
+            "first": {"rgb": "FF5687C2"},
+            "last": {"rgb": "FF359CEB"},
+            "high": {"rgb": "FF56BE79"},
+            "low": {"rgb": "FFFF5055"},
+        },  # 31
+        {
+            "series": {"rgb": "FF5687C2"},
+            "negative": {"rgb": "FFFFB620"},
+            "markers": {"rgb": "FFD70077"},
+            "first": {"rgb": "FF777777"},
+            "last": {"rgb": "FF359CEB"},
+            "high": {"rgb": "FF56BE79"},
+            "low": {"rgb": "FFFF5055"},
+        },  # 32
+        {
+            "series": {"rgb": "FFC6EFCE"},
+            "negative": {"rgb": "FFFFC7CE"},
+            "markers": {"rgb": "FF8CADD6"},
+            "first": {"rgb": "FFFFDC47"},
+            "last": {"rgb": "FFFFEB9C"},
+            "high": {"rgb": "FF60D276"},
+            "low": {"rgb": "FFFF5367"},
+        },  # 33
+        {
+            "series": {"rgb": "FF00B050"},
+            "negative": {"rgb": "FFFF0000"},
+            "markers": {"rgb": "FF0070C0"},
+            "first": {"rgb": "FFFFC000"},
+            "last": {"rgb": "FFFFC000"},
+            "high": {"rgb": "FF00B050"},
+            "low": {"rgb": "FFFF0000"},
+        },  # 34
+        {
+            "series": {"theme": "3"},
+            "negative": {"theme": "9"},
+            "markers": {"theme": "8"},
+            "first": {"theme": "4"},
+            "last": {"theme": "5"},
+            "high": {"theme": "6"},
+            "low": {"theme": "7"},
+        },  # 35
+        {
+            "series": {"theme": "1"},
+            "negative": {"theme": "9"},
+            "markers": {"theme": "8"},
+            "first": {"theme": "4"},
+            "last": {"theme": "5"},
+            "high": {"theme": "6"},
+            "low": {"theme": "7"},
+        },  # 36
+    ]
+
+    return styles[style_id]
+
+
+def _supported_datetime(dt):
+    # Determine is an argument is a supported datetime object.
+    return isinstance(
+        dt, (datetime.datetime, datetime.date, datetime.time, datetime.timedelta)
+    )
+
+
+def _remove_datetime_timezone(dt_obj, remove_timezone):
+    # Excel doesn't support timezones in datetimes/times so we remove the
+    # tzinfo from the object if the user has specified that option in the
+    # constructor.
+    if remove_timezone:
+        dt_obj = dt_obj.replace(tzinfo=None)
+    else:
+        if dt_obj.tzinfo:
+            raise TypeError(
+                "Excel doesn't support timezones in datetimes. "
+                "Set the tzinfo in the datetime/time object to None or "
+                "use the 'remove_timezone' Workbook() option"
+            )
+
+    return dt_obj
+
+
+def _datetime_to_excel_datetime(dt_obj, date_1904, remove_timezone):
+    # Convert a datetime object to an Excel serial date and time. The integer
+    # part of the number stores the number of days since the epoch and the
+    # fractional part stores the percentage of the day.
+    date_type = dt_obj
+    is_timedelta = False
+
+    if date_1904:
+        # Excel for Mac date epoch.
+        epoch = datetime.datetime(1904, 1, 1)
+    else:
+        # Default Excel epoch.
+        epoch = datetime.datetime(1899, 12, 31)
+
+    # We handle datetime .datetime, .date and .time objects but convert
+    # them to datetime.datetime objects and process them in the same way.
+    if isinstance(dt_obj, datetime.datetime):
+        dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone)
+        delta = dt_obj - epoch
+    elif isinstance(dt_obj, datetime.date):
+        dt_obj = datetime.datetime.fromordinal(dt_obj.toordinal())
+        delta = dt_obj - epoch
+    elif isinstance(dt_obj, datetime.time):
+        dt_obj = datetime.datetime.combine(epoch, dt_obj)
+        dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone)
+        delta = dt_obj - epoch
+    elif isinstance(dt_obj, datetime.timedelta):
+        is_timedelta = True
+        delta = dt_obj
+    else:
+        raise TypeError("Unknown or unsupported datetime type")
+
+    # Convert a Python datetime.datetime value to an Excel date number.
+    excel_time = delta.days + (
+        float(delta.seconds) + float(delta.microseconds) / 1e6
+    ) / (60 * 60 * 24)
+
+    # The following is a workaround for the fact that in Excel a time only
+    # value is represented as 1899-12-31+time whereas in datetime.datetime()
+    # it is 1900-1-1+time so we need to subtract the 1 day difference.
+    if isinstance(date_type, datetime.datetime) and dt_obj.isocalendar() == (
+        1900,
+        1,
+        1,
+    ):
+        excel_time -= 1
+
+    # Account for Excel erroneously treating 1900 as a leap year.
+    if not date_1904 and not is_timedelta and excel_time > 59:
+        excel_time += 1
+
+    return excel_time
+
+
+def _preserve_whitespace(string):
+    # Check if a string has leading or trailing whitespace that requires a
+    # "preserve" attribute.
+    return RE_LEADING_WHITESPACE.search(string) or RE_TRAILING_WHITESPACE.search(string)
+
+
+def _get_image_properties(filename, image_data):
+    # Extract dimension information from the image file.
+    height = 0
+    width = 0
+    x_dpi = 96
+    y_dpi = 96
+
+    if not image_data:
+        # Open the image file and read in the data.
+        with open(filename, "rb") as fh:
+            data = fh.read()
+    else:
+        # Read the image data from the user supplied byte stream.
+        data = image_data.getvalue()
+
+    digest = hashlib.sha256(data).hexdigest()
+
+    # Get the image filename without the path.
+    image_name = os.path.basename(filename)
+
+    # Look for some common image file markers.
+    marker1 = unpack("3s", data[1:4])[0]
+    marker2 = unpack(">H", data[:2])[0]
+    marker3 = unpack("2s", data[:2])[0]
+    marker4 = unpack("<L", data[:4])[0]
+    marker5 = (unpack("4s", data[40:44]))[0]
+    marker6 = unpack("4s", data[:4])[0]
+
+    png_marker = b"PNG"
+    bmp_marker = b"BM"
+    emf_marker = b" EMF"
+    gif_marker = b"GIF8"
+
+    if marker1 == png_marker:
+        (image_type, width, height, x_dpi, y_dpi) = _process_png(data)
+
+    elif marker2 == 0xFFD8:
+        (image_type, width, height, x_dpi, y_dpi) = _process_jpg(data)
+
+    elif marker3 == bmp_marker:
+        (image_type, width, height) = _process_bmp(data)
+
+    elif marker4 == 0x9AC6CDD7:
+        (image_type, width, height, x_dpi, y_dpi) = _process_wmf(data)
+
+    elif marker4 == 1 and marker5 == emf_marker:
+        (image_type, width, height, x_dpi, y_dpi) = _process_emf(data)
+
+    elif marker6 == gif_marker:
+        (image_type, width, height, x_dpi, y_dpi) = _process_gif(data)
+
+    else:
+        raise UnsupportedImageFormat(
+            f"{filename}: Unknown or unsupported image file format."
+        )
+
+    # Check that we found the required data.
+    if not height or not width:
+        raise UndefinedImageSize(f"{filename}: no size data found in image file.")
+
+    if not image_data:
+        fh.close()
+
+    # Set a default dpi for images with 0 dpi.
+    if x_dpi == 0:
+        x_dpi = 96
+    if y_dpi == 0:
+        y_dpi = 96
+
+    return image_type, width, height, image_name, x_dpi, y_dpi, digest
+
+
+def _process_png(data):
+    # Extract width and height information from a PNG file.
+    offset = 8
+    data_length = len(data)
+    end_marker = False
+    width = 0
+    height = 0
+    x_dpi = 96
+    y_dpi = 96
+
+    # Search through the image data to read the height and width in the
+    # IHDR element. Also read the DPI in the pHYs element.
+    while not end_marker and offset < data_length:
+        length = unpack(">I", data[offset + 0 : offset + 4])[0]
+        marker = unpack("4s", data[offset + 4 : offset + 8])[0]
+
+        # Read the image dimensions.
+        if marker == b"IHDR":
+            width = unpack(">I", data[offset + 8 : offset + 12])[0]
+            height = unpack(">I", data[offset + 12 : offset + 16])[0]
+
+        # Read the image DPI.
+        if marker == b"pHYs":
+            x_density = unpack(">I", data[offset + 8 : offset + 12])[0]
+            y_density = unpack(">I", data[offset + 12 : offset + 16])[0]
+            units = unpack("b", data[offset + 16 : offset + 17])[0]
+
+            if units == 1 and x_density > 0 and y_density > 0:
+                x_dpi = x_density * 0.0254
+                y_dpi = y_density * 0.0254
+
+        if marker == b"IEND":
+            end_marker = True
+            continue
+
+        offset = offset + length + 12
+
+    return "png", width, height, x_dpi, y_dpi
+
+
+def _process_jpg(data):
+    # Extract width and height information from a JPEG file.
+    offset = 2
+    data_length = len(data)
+    end_marker = False
+    width = 0
+    height = 0
+    x_dpi = 96
+    y_dpi = 96
+
+    # Search through the image data to read the JPEG markers.
+    while not end_marker and offset < data_length:
+        marker = unpack(">H", data[offset + 0 : offset + 2])[0]
+        length = unpack(">H", data[offset + 2 : offset + 4])[0]
+
+        # Read the height and width in the 0xFFCn elements (except C4, C8
+        # and CC which aren't SOF markers).
+        if (
+            (marker & 0xFFF0) == 0xFFC0
+            and marker != 0xFFC4
+            and marker != 0xFFC8
+            and marker != 0xFFCC
+        ):
+            height = unpack(">H", data[offset + 5 : offset + 7])[0]
+            width = unpack(">H", data[offset + 7 : offset + 9])[0]
+
+        # Read the DPI in the 0xFFE0 element.
+        if marker == 0xFFE0:
+            units = unpack("b", data[offset + 11 : offset + 12])[0]
+            x_density = unpack(">H", data[offset + 12 : offset + 14])[0]
+            y_density = unpack(">H", data[offset + 14 : offset + 16])[0]
+
+            if units == 1:
+                x_dpi = x_density
+                y_dpi = y_density
+
+            if units == 2:
+                x_dpi = x_density * 2.54
+                y_dpi = y_density * 2.54
+
+            # Workaround for incorrect dpi.
+            if x_dpi == 1:
+                x_dpi = 96
+            if y_dpi == 1:
+                y_dpi = 96
+
+        if marker == 0xFFDA:
+            end_marker = True
+            continue
+
+        offset = offset + length + 2
+
+    return "jpeg", width, height, x_dpi, y_dpi
+
+
+def _process_gif(data):
+    # Extract width and height information from a GIF file.
+    x_dpi = 96
+    y_dpi = 96
+
+    width = unpack("<h", data[6:8])[0]
+    height = unpack("<h", data[8:10])[0]
+
+    return "gif", width, height, x_dpi, y_dpi
+
+
+def _process_bmp(data):
+    # Extract width and height information from a BMP file.
+    width = unpack("<L", data[18:22])[0]
+    height = unpack("<L", data[22:26])[0]
+    return "bmp", width, height
+
+
+def _process_wmf(data):
+    # Extract width and height information from a WMF file.
+    x_dpi = 96
+    y_dpi = 96
+
+    # Read the bounding box, measured in logical units.
+    x1 = unpack("<h", data[6:8])[0]
+    y1 = unpack("<h", data[8:10])[0]
+    x2 = unpack("<h", data[10:12])[0]
+    y2 = unpack("<h", data[12:14])[0]
+
+    # Read the number of logical units per inch. Used to scale the image.
+    inch = unpack("<H", data[14:16])[0]
+
+    # Convert to rendered height and width.
+    width = float((x2 - x1) * x_dpi) / inch
+    height = float((y2 - y1) * y_dpi) / inch
+
+    return "wmf", width, height, x_dpi, y_dpi
+
+
+def _process_emf(data):
+    # Extract width and height information from a EMF file.
+
+    # Read the bounding box, measured in logical units.
+    bound_x1 = unpack("<l", data[8:12])[0]
+    bound_y1 = unpack("<l", data[12:16])[0]
+    bound_x2 = unpack("<l", data[16:20])[0]
+    bound_y2 = unpack("<l", data[20:24])[0]
+
+    # Convert the bounds to width and height.
+    width = bound_x2 - bound_x1
+    height = bound_y2 - bound_y1
+
+    # Read the rectangular frame in units of 0.01mm.
+    frame_x1 = unpack("<l", data[24:28])[0]
+    frame_y1 = unpack("<l", data[28:32])[0]
+    frame_x2 = unpack("<l", data[32:36])[0]
+    frame_y2 = unpack("<l", data[36:40])[0]
+
+    # Convert the frame bounds to mm width and height.
+    width_mm = 0.01 * (frame_x2 - frame_x1)
+    height_mm = 0.01 * (frame_y2 - frame_y1)
+
+    # Get the dpi based on the logical size.
+    x_dpi = width * 25.4 / width_mm
+    y_dpi = height * 25.4 / height_mm
+
+    # This is to match Excel's calculation. It is probably to account for
+    # the fact that the bounding box is inclusive-inclusive. Or a bug.
+    width += 1
+    height += 1
+
+    return "emf", width, height, x_dpi, y_dpi
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/vml.py b/.venv/lib/python3.12/site-packages/xlsxwriter/vml.py
new file mode 100644
index 00000000..f97bc210
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/vml.py
@@ -0,0 +1,707 @@
+###############################################################################
+#
+# Vml - A class for writing the Excel XLSX Vml file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Package imports.
+from . import xmlwriter
+
+
+class Vml(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Vml file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+    def _assemble_xml_file(
+        self,
+        data_id,
+        vml_shape_id,
+        comments_data=None,
+        buttons_data=None,
+        header_images_data=None,
+    ):
+        # Assemble and write the XML file.
+        z_index = 1
+
+        self._write_xml_namespace()
+
+        # Write the o:shapelayout element.
+        self._write_shapelayout(data_id)
+
+        if buttons_data:
+            # Write the v:shapetype element.
+            self._write_button_shapetype()
+
+            for button in buttons_data:
+                # Write the v:shape element.
+                vml_shape_id += 1
+                self._write_button_shape(vml_shape_id, z_index, button)
+                z_index += 1
+
+        if comments_data:
+            # Write the v:shapetype element.
+            self._write_comment_shapetype()
+
+            for comment in comments_data:
+                # Write the v:shape element.
+                vml_shape_id += 1
+                self._write_comment_shape(vml_shape_id, z_index, comment)
+                z_index += 1
+
+        if header_images_data:
+            # Write the v:shapetype element.
+            self._write_image_shapetype()
+
+            index = 1
+            for image in header_images_data:
+                # Write the v:shape element.
+                vml_shape_id += 1
+                self._write_image_shape(vml_shape_id, index, image)
+                index += 1
+
+        self._xml_end_tag("xml")
+
+        # Close the XML writer filehandle.
+        self._xml_close()
+
+    def _pixels_to_points(self, vertices):
+        # Convert comment vertices from pixels to points.
+
+        left, top, width, height = vertices[8:12]
+
+        # Scale to pixels.
+        left *= 0.75
+        top *= 0.75
+        width *= 0.75
+        height *= 0.75
+
+        return left, top, width, height
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+    def _write_xml_namespace(self):
+        # Write the <xml> element. This is the root element of VML.
+        schema = "urn:schemas-microsoft-com:"
+        xmlns = schema + "vml"
+        xmlns_o = schema + "office:office"
+        xmlns_x = schema + "office:excel"
+
+        attributes = [
+            ("xmlns:v", xmlns),
+            ("xmlns:o", xmlns_o),
+            ("xmlns:x", xmlns_x),
+        ]
+
+        self._xml_start_tag("xml", attributes)
+
+    def _write_shapelayout(self, data_id):
+        # Write the <o:shapelayout> element.
+        attributes = [("v:ext", "edit")]
+
+        self._xml_start_tag("o:shapelayout", attributes)
+
+        # Write the o:idmap element.
+        self._write_idmap(data_id)
+
+        self._xml_end_tag("o:shapelayout")
+
+    def _write_idmap(self, data_id):
+        # Write the <o:idmap> element.
+        attributes = [
+            ("v:ext", "edit"),
+            ("data", data_id),
+        ]
+
+        self._xml_empty_tag("o:idmap", attributes)
+
+    def _write_comment_shapetype(self):
+        # Write the <v:shapetype> element.
+        shape_id = "_x0000_t202"
+        coordsize = "21600,21600"
+        spt = 202
+        path = "m,l,21600r21600,l21600,xe"
+
+        attributes = [
+            ("id", shape_id),
+            ("coordsize", coordsize),
+            ("o:spt", spt),
+            ("path", path),
+        ]
+
+        self._xml_start_tag("v:shapetype", attributes)
+
+        # Write the v:stroke element.
+        self._write_stroke()
+
+        # Write the v:path element.
+        self._write_comment_path("t", "rect")
+
+        self._xml_end_tag("v:shapetype")
+
+    def _write_button_shapetype(self):
+        # Write the <v:shapetype> element.
+        shape_id = "_x0000_t201"
+        coordsize = "21600,21600"
+        spt = 201
+        path = "m,l,21600r21600,l21600,xe"
+
+        attributes = [
+            ("id", shape_id),
+            ("coordsize", coordsize),
+            ("o:spt", spt),
+            ("path", path),
+        ]
+
+        self._xml_start_tag("v:shapetype", attributes)
+
+        # Write the v:stroke element.
+        self._write_stroke()
+
+        # Write the v:path element.
+        self._write_button_path()
+
+        # Write the o:lock element.
+        self._write_shapetype_lock()
+
+        self._xml_end_tag("v:shapetype")
+
+    def _write_image_shapetype(self):
+        # Write the <v:shapetype> element.
+        shape_id = "_x0000_t75"
+        coordsize = "21600,21600"
+        spt = 75
+        o_preferrelative = "t"
+        path = "m@4@5l@4@11@9@11@9@5xe"
+        filled = "f"
+        stroked = "f"
+
+        attributes = [
+            ("id", shape_id),
+            ("coordsize", coordsize),
+            ("o:spt", spt),
+            ("o:preferrelative", o_preferrelative),
+            ("path", path),
+            ("filled", filled),
+            ("stroked", stroked),
+        ]
+
+        self._xml_start_tag("v:shapetype", attributes)
+
+        # Write the v:stroke element.
+        self._write_stroke()
+
+        # Write the v:formulas element.
+        self._write_formulas()
+
+        # Write the v:path element.
+        self._write_image_path()
+
+        # Write the o:lock element.
+        self._write_aspect_ratio_lock()
+
+        self._xml_end_tag("v:shapetype")
+
+    def _write_stroke(self):
+        # Write the <v:stroke> element.
+        joinstyle = "miter"
+
+        attributes = [("joinstyle", joinstyle)]
+
+        self._xml_empty_tag("v:stroke", attributes)
+
+    def _write_comment_path(self, gradientshapeok, connecttype):
+        # Write the <v:path> element.
+        attributes = []
+
+        if gradientshapeok:
+            attributes.append(("gradientshapeok", "t"))
+
+        attributes.append(("o:connecttype", connecttype))
+
+        self._xml_empty_tag("v:path", attributes)
+
+    def _write_button_path(self):
+        # Write the <v:path> element.
+        shadowok = "f"
+        extrusionok = "f"
+        strokeok = "f"
+        fillok = "f"
+        connecttype = "rect"
+
+        attributes = [
+            ("shadowok", shadowok),
+            ("o:extrusionok", extrusionok),
+            ("strokeok", strokeok),
+            ("fillok", fillok),
+            ("o:connecttype", connecttype),
+        ]
+
+        self._xml_empty_tag("v:path", attributes)
+
+    def _write_image_path(self):
+        # Write the <v:path> element.
+        extrusionok = "f"
+        gradientshapeok = "t"
+        connecttype = "rect"
+
+        attributes = [
+            ("o:extrusionok", extrusionok),
+            ("gradientshapeok", gradientshapeok),
+            ("o:connecttype", connecttype),
+        ]
+
+        self._xml_empty_tag("v:path", attributes)
+
+    def _write_shapetype_lock(self):
+        # Write the <o:lock> element.
+        ext = "edit"
+        shapetype = "t"
+
+        attributes = [
+            ("v:ext", ext),
+            ("shapetype", shapetype),
+        ]
+
+        self._xml_empty_tag("o:lock", attributes)
+
+    def _write_rotation_lock(self):
+        # Write the <o:lock> element.
+        ext = "edit"
+        rotation = "t"
+
+        attributes = [
+            ("v:ext", ext),
+            ("rotation", rotation),
+        ]
+
+        self._xml_empty_tag("o:lock", attributes)
+
+    def _write_aspect_ratio_lock(self):
+        # Write the <o:lock> element.
+        ext = "edit"
+        aspectratio = "t"
+
+        attributes = [
+            ("v:ext", ext),
+            ("aspectratio", aspectratio),
+        ]
+
+        self._xml_empty_tag("o:lock", attributes)
+
+    def _write_comment_shape(self, shape_id, z_index, comment):
+        # Write the <v:shape> element.
+        shape_type = "#_x0000_t202"
+        insetmode = "auto"
+        visibility = "hidden"
+
+        # Set the shape index.
+        shape_id = "_x0000_s" + str(shape_id)
+
+        # Get the comment parameters
+        row = comment[0]
+        col = comment[1]
+        visible = comment[4]
+        fillcolor = comment[5]
+        vertices = comment[9]
+
+        (left, top, width, height) = self._pixels_to_points(vertices)
+
+        # Set the visibility.
+        if visible:
+            visibility = "visible"
+
+        style = (
+            f"position:absolute;"
+            f"margin-left:{left:.15g}pt;"
+            f"margin-top:{top:.15g}pt;"
+            f"width:{width:.15g}pt;"
+            f"height:{height:.15g}pt;"
+            f"z-index:{z_index};"
+            f"visibility:{visibility}"
+        )
+
+        attributes = [
+            ("id", shape_id),
+            ("type", shape_type),
+            ("style", style),
+            ("fillcolor", fillcolor),
+            ("o:insetmode", insetmode),
+        ]
+
+        self._xml_start_tag("v:shape", attributes)
+
+        # Write the v:fill element.
+        self._write_comment_fill()
+
+        # Write the v:shadow element.
+        self._write_shadow()
+
+        # Write the v:path element.
+        self._write_comment_path(None, "none")
+
+        # Write the v:textbox element.
+        self._write_comment_textbox()
+
+        # Write the x:ClientData element.
+        self._write_comment_client_data(row, col, visible, vertices)
+
+        self._xml_end_tag("v:shape")
+
+    def _write_button_shape(self, shape_id, z_index, button):
+        # Write the <v:shape> element.
+        shape_type = "#_x0000_t201"
+
+        # Set the shape index.
+        shape_id = "_x0000_s" + str(shape_id)
+
+        # Get the button parameters.
+        # row = button["_row"]
+        # col = button["_col"]
+        vertices = button["vertices"]
+
+        (left, top, width, height) = self._pixels_to_points(vertices)
+
+        style = (
+            f"position:absolute;"
+            f"margin-left:{left:.15g}pt;"
+            f"margin-top:{top:.15g}pt;"
+            f"width:{width:.15g}pt;"
+            f"height:{height:.15g}pt;"
+            f"z-index:{z_index};"
+            f"mso-wrap-style:tight"
+        )
+
+        attributes = [
+            ("id", shape_id),
+            ("type", shape_type),
+        ]
+
+        if button.get("description"):
+            attributes.append(("alt", button["description"]))
+
+        attributes.append(("style", style))
+        attributes.append(("o:button", "t"))
+        attributes.append(("fillcolor", "buttonFace [67]"))
+        attributes.append(("strokecolor", "windowText [64]"))
+        attributes.append(("o:insetmode", "auto"))
+
+        self._xml_start_tag("v:shape", attributes)
+
+        # Write the v:fill element.
+        self._write_button_fill()
+
+        # Write the o:lock element.
+        self._write_rotation_lock()
+
+        # Write the v:textbox element.
+        self._write_button_textbox(button["font"])
+
+        # Write the x:ClientData element.
+        self._write_button_client_data(button)
+
+        self._xml_end_tag("v:shape")
+
+    def _write_image_shape(self, shape_id, z_index, image_data):
+        # Write the <v:shape> element.
+        shape_type = "#_x0000_t75"
+
+        # Set the shape index.
+        shape_id = "_x0000_s" + str(shape_id)
+
+        # Get the image parameters
+        width = image_data[0]
+        height = image_data[1]
+        name = image_data[2]
+        position = image_data[3]
+        x_dpi = image_data[4]
+        y_dpi = image_data[5]
+        ref_id = image_data[6]
+
+        # Scale the height/width by the resolution, relative to 72dpi.
+        width = width * 72.0 / x_dpi
+        height = height * 72.0 / y_dpi
+
+        # Excel uses a rounding based around 72 and 96 dpi.
+        width = 72.0 / 96 * int(width * 96.0 / 72 + 0.25)
+        height = 72.0 / 96 * int(height * 96.0 / 72 + 0.25)
+
+        style = (
+            f"position:absolute;"
+            f"margin-left:0;"
+            f"margin-top:0;"
+            f"width:{width:.15g}pt;"
+            f"height:{height:.15g}pt;"
+            f"z-index:{z_index}"
+        )
+
+        attributes = [
+            ("id", position),
+            ("o:spid", shape_id),
+            ("type", shape_type),
+            ("style", style),
+        ]
+
+        self._xml_start_tag("v:shape", attributes)
+
+        # Write the v:imagedata element.
+        self._write_imagedata(ref_id, name)
+
+        # Write the o:lock element.
+        self._write_rotation_lock()
+
+        self._xml_end_tag("v:shape")
+
+    def _write_comment_fill(self):
+        # Write the <v:fill> element.
+        color_2 = "#ffffe1"
+
+        attributes = [("color2", color_2)]
+
+        self._xml_empty_tag("v:fill", attributes)
+
+    def _write_button_fill(self):
+        # Write the <v:fill> element.
+        color_2 = "buttonFace [67]"
+        detectmouseclick = "t"
+
+        attributes = [
+            ("color2", color_2),
+            ("o:detectmouseclick", detectmouseclick),
+        ]
+
+        self._xml_empty_tag("v:fill", attributes)
+
+    def _write_shadow(self):
+        # Write the <v:shadow> element.
+        on = "t"
+        color = "black"
+        obscured = "t"
+
+        attributes = [
+            ("on", on),
+            ("color", color),
+            ("obscured", obscured),
+        ]
+
+        self._xml_empty_tag("v:shadow", attributes)
+
+    def _write_comment_textbox(self):
+        # Write the <v:textbox> element.
+        style = "mso-direction-alt:auto"
+
+        attributes = [("style", style)]
+
+        self._xml_start_tag("v:textbox", attributes)
+
+        # Write the div element.
+        self._write_div("left")
+
+        self._xml_end_tag("v:textbox")
+
+    def _write_button_textbox(self, font):
+        # Write the <v:textbox> element.
+        style = "mso-direction-alt:auto"
+
+        attributes = [("style", style), ("o:singleclick", "f")]
+
+        self._xml_start_tag("v:textbox", attributes)
+
+        # Write the div element.
+        self._write_div("center", font)
+
+        self._xml_end_tag("v:textbox")
+
+    def _write_div(self, align, font=None):
+        # Write the <div> element.
+
+        style = "text-align:" + align
+
+        attributes = [("style", style)]
+
+        self._xml_start_tag("div", attributes)
+
+        if font:
+            # Write the font element.
+            self._write_font(font)
+
+        self._xml_end_tag("div")
+
+    def _write_font(self, font):
+        # Write the <font> element.
+        caption = font["caption"]
+        face = "Calibri"
+        size = 220
+        color = "#000000"
+
+        attributes = [
+            ("face", face),
+            ("size", size),
+            ("color", color),
+        ]
+
+        self._xml_data_element("font", caption, attributes)
+
+    def _write_comment_client_data(self, row, col, visible, vertices):
+        # Write the <x:ClientData> element.
+        object_type = "Note"
+
+        attributes = [("ObjectType", object_type)]
+
+        self._xml_start_tag("x:ClientData", attributes)
+
+        # Write the x:MoveWithCells element.
+        self._write_move_with_cells()
+
+        # Write the x:SizeWithCells element.
+        self._write_size_with_cells()
+
+        # Write the x:Anchor element.
+        self._write_anchor(vertices)
+
+        # Write the x:AutoFill element.
+        self._write_auto_fill()
+
+        # Write the x:Row element.
+        self._write_row(row)
+
+        # Write the x:Column element.
+        self._write_column(col)
+
+        # Write the x:Visible element.
+        if visible:
+            self._write_visible()
+
+        self._xml_end_tag("x:ClientData")
+
+    def _write_button_client_data(self, button):
+        # Write the <x:ClientData> element.
+        macro = button["macro"]
+        vertices = button["vertices"]
+
+        object_type = "Button"
+
+        attributes = [("ObjectType", object_type)]
+
+        self._xml_start_tag("x:ClientData", attributes)
+
+        # Write the x:Anchor element.
+        self._write_anchor(vertices)
+
+        # Write the x:PrintObject element.
+        self._write_print_object()
+
+        # Write the x:AutoFill element.
+        self._write_auto_fill()
+
+        # Write the x:FmlaMacro element.
+        self._write_fmla_macro(macro)
+
+        # Write the x:TextHAlign element.
+        self._write_text_halign()
+
+        # Write the x:TextVAlign element.
+        self._write_text_valign()
+
+        self._xml_end_tag("x:ClientData")
+
+    def _write_move_with_cells(self):
+        # Write the <x:MoveWithCells> element.
+        self._xml_empty_tag("x:MoveWithCells")
+
+    def _write_size_with_cells(self):
+        # Write the <x:SizeWithCells> element.
+        self._xml_empty_tag("x:SizeWithCells")
+
+    def _write_visible(self):
+        # Write the <x:Visible> element.
+        self._xml_empty_tag("x:Visible")
+
+    def _write_anchor(self, vertices):
+        # Write the <x:Anchor> element.
+        (col_start, row_start, x1, y1, col_end, row_end, x2, y2) = vertices[:8]
+
+        strings = [col_start, x1, row_start, y1, col_end, x2, row_end, y2]
+        strings = [str(i) for i in strings]
+
+        data = ", ".join(strings)
+
+        self._xml_data_element("x:Anchor", data)
+
+    def _write_auto_fill(self):
+        # Write the <x:AutoFill> element.
+        data = "False"
+
+        self._xml_data_element("x:AutoFill", data)
+
+    def _write_row(self, data):
+        # Write the <x:Row> element.
+        self._xml_data_element("x:Row", data)
+
+    def _write_column(self, data):
+        # Write the <x:Column> element.
+        self._xml_data_element("x:Column", data)
+
+    def _write_print_object(self):
+        # Write the <x:PrintObject> element.
+        self._xml_data_element("x:PrintObject", "False")
+
+    def _write_text_halign(self):
+        # Write the <x:TextHAlign> element.
+        self._xml_data_element("x:TextHAlign", "Center")
+
+    def _write_text_valign(self):
+        # Write the <x:TextVAlign> element.
+        self._xml_data_element("x:TextVAlign", "Center")
+
+    def _write_fmla_macro(self, data):
+        # Write the <x:FmlaMacro> element.
+        self._xml_data_element("x:FmlaMacro", data)
+
+    def _write_imagedata(self, ref_id, o_title):
+        # Write the <v:imagedata> element.
+        attributes = [
+            ("o:relid", "rId" + str(ref_id)),
+            ("o:title", o_title),
+        ]
+
+        self._xml_empty_tag("v:imagedata", attributes)
+
+    def _write_formulas(self):
+        # Write the <v:formulas> element.
+        self._xml_start_tag("v:formulas")
+
+        # Write the v:f elements.
+        self._write_formula("if lineDrawn pixelLineWidth 0")
+        self._write_formula("sum @0 1 0")
+        self._write_formula("sum 0 0 @1")
+        self._write_formula("prod @2 1 2")
+        self._write_formula("prod @3 21600 pixelWidth")
+        self._write_formula("prod @3 21600 pixelHeight")
+        self._write_formula("sum @0 0 1")
+        self._write_formula("prod @6 1 2")
+        self._write_formula("prod @7 21600 pixelWidth")
+        self._write_formula("sum @8 21600 0")
+        self._write_formula("prod @7 21600 pixelHeight")
+        self._write_formula("sum @10 21600 0")
+
+        self._xml_end_tag("v:formulas")
+
+    def _write_formula(self, eqn):
+        # Write the <v:f> element.
+        attributes = [("eqn", eqn)]
+
+        self._xml_empty_tag("v:f", attributes)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/workbook.py b/.venv/lib/python3.12/site-packages/xlsxwriter/workbook.py
new file mode 100644
index 00000000..fc6aa5a3
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/workbook.py
@@ -0,0 +1,1856 @@
+###############################################################################
+#
+# Workbook - A class for writing the Excel XLSX Workbook file.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# Standard packages.
+import operator
+import os
+import re
+import time
+from datetime import datetime, timezone
+from decimal import Decimal
+from fractions import Fraction
+from warnings import warn
+from zipfile import ZIP_DEFLATED, LargeZipFile, ZipFile, ZipInfo
+
+# Package imports.
+from . import xmlwriter
+from .chart_area import ChartArea
+from .chart_bar import ChartBar
+from .chart_column import ChartColumn
+from .chart_doughnut import ChartDoughnut
+from .chart_line import ChartLine
+from .chart_pie import ChartPie
+from .chart_radar import ChartRadar
+from .chart_scatter import ChartScatter
+from .chart_stock import ChartStock
+from .chartsheet import Chartsheet
+from .exceptions import (
+    DuplicateWorksheetName,
+    FileCreateError,
+    FileSizeError,
+    InvalidWorksheetName,
+)
+from .format import Format
+from .packager import Packager
+from .sharedstrings import SharedStringTable
+from .utility import _get_image_properties, xl_cell_to_rowcol
+from .worksheet import Worksheet
+
+
+class Workbook(xmlwriter.XMLwriter):
+    """
+    A class for writing the Excel XLSX Workbook file.
+
+
+    """
+
+    ###########################################################################
+    #
+    # Public API.
+    #
+    ###########################################################################
+    chartsheet_class = Chartsheet
+    worksheet_class = Worksheet
+
+    def __init__(self, filename=None, options=None):
+        """
+        Constructor.
+
+        """
+        if options is None:
+            options = {}
+
+        super().__init__()
+
+        self.filename = filename
+
+        self.tmpdir = options.get("tmpdir", None)
+        self.date_1904 = options.get("date_1904", False)
+        self.strings_to_numbers = options.get("strings_to_numbers", False)
+        self.strings_to_formulas = options.get("strings_to_formulas", True)
+        self.strings_to_urls = options.get("strings_to_urls", True)
+        self.nan_inf_to_errors = options.get("nan_inf_to_errors", False)
+        self.default_date_format = options.get("default_date_format", None)
+        self.constant_memory = options.get("constant_memory", False)
+        self.in_memory = options.get("in_memory", False)
+        self.excel2003_style = options.get("excel2003_style", False)
+        self.remove_timezone = options.get("remove_timezone", False)
+        self.use_future_functions = options.get("use_future_functions", False)
+        self.default_format_properties = options.get("default_format_properties", {})
+
+        self.max_url_length = options.get("max_url_length", 2079)
+        if self.max_url_length < 255:
+            self.max_url_length = 2079
+
+        if options.get("use_zip64"):
+            self.allow_zip64 = True
+        else:
+            self.allow_zip64 = False
+
+        self.worksheet_meta = WorksheetMeta()
+        self.selected = 0
+        self.fileclosed = 0
+        self.filehandle = None
+        self.internal_fh = 0
+        self.sheet_name = "Sheet"
+        self.chart_name = "Chart"
+        self.sheetname_count = 0
+        self.chartname_count = 0
+        self.worksheets_objs = []
+        self.charts = []
+        self.drawings = []
+        self.sheetnames = {}
+        self.formats = []
+        self.xf_formats = []
+        self.xf_format_indices = {}
+        self.dxf_formats = []
+        self.dxf_format_indices = {}
+        self.palette = []
+        self.font_count = 0
+        self.num_formats = []
+        self.defined_names = []
+        self.named_ranges = []
+        self.custom_colors = []
+        self.doc_properties = {}
+        self.custom_properties = []
+        self.createtime = datetime.now(timezone.utc)
+        self.num_vml_files = 0
+        self.num_comment_files = 0
+        self.x_window = 240
+        self.y_window = 15
+        self.window_width = 16095
+        self.window_height = 9660
+        self.tab_ratio = 600
+        self.str_table = SharedStringTable()
+        self.vba_project = None
+        self.vba_project_is_stream = False
+        self.vba_project_signature = None
+        self.vba_project_signature_is_stream = False
+        self.vba_codename = None
+        self.image_types = {}
+        self.images = []
+        self.border_count = 0
+        self.fill_count = 0
+        self.drawing_count = 0
+        self.calc_mode = "auto"
+        self.calc_on_load = True
+        self.calc_id = 124519
+        self.has_comments = False
+        self.read_only = 0
+        self.has_metadata = False
+        self.has_embedded_images = False
+        self.has_dynamic_functions = False
+        self.has_embedded_descriptions = False
+        self.embedded_images = EmbeddedImages()
+        self.feature_property_bags = set()
+
+        # We can't do 'constant_memory' mode while doing 'in_memory' mode.
+        if self.in_memory:
+            self.constant_memory = False
+
+        # Add the default cell format.
+        if self.excel2003_style:
+            self.add_format({"xf_index": 0, "font_family": 0})
+        else:
+            self.add_format({"xf_index": 0})
+
+        # Add a default URL format.
+        self.default_url_format = self.add_format({"hyperlink": True})
+
+        # Add the default date format.
+        if self.default_date_format is not None:
+            self.default_date_format = self.add_format(
+                {"num_format": self.default_date_format}
+            )
+
+    def __enter__(self):
+        """Return self object to use with "with" statement."""
+        return self
+
+    def __exit__(self, type, value, traceback):
+        # pylint: disable=redefined-builtin
+        """Close workbook when exiting "with" statement."""
+        self.close()
+
+    def add_worksheet(self, name=None, worksheet_class=None):
+        """
+        Add a new worksheet to the Excel workbook.
+
+        Args:
+            name: The worksheet name. Defaults to 'Sheet1', etc.
+
+        Returns:
+            Reference to a worksheet object.
+
+        """
+        if worksheet_class is None:
+            worksheet_class = self.worksheet_class
+
+        return self._add_sheet(name, worksheet_class=worksheet_class)
+
+    def add_chartsheet(self, name=None, chartsheet_class=None):
+        """
+        Add a new chartsheet to the Excel workbook.
+
+        Args:
+            name: The chartsheet name. Defaults to 'Sheet1', etc.
+
+        Returns:
+            Reference to a chartsheet object.
+
+        """
+        if chartsheet_class is None:
+            chartsheet_class = self.chartsheet_class
+
+        return self._add_sheet(name, worksheet_class=chartsheet_class)
+
+    def add_format(self, properties=None):
+        """
+        Add a new Format to the Excel Workbook.
+
+        Args:
+            properties: The format properties.
+
+        Returns:
+            Reference to a Format object.
+
+        """
+        format_properties = self.default_format_properties.copy()
+
+        if self.excel2003_style:
+            format_properties = {"font_name": "Arial", "font_size": 10, "theme": 1 * -1}
+
+        if properties:
+            format_properties.update(properties)
+
+        xf_format = Format(
+            format_properties, self.xf_format_indices, self.dxf_format_indices
+        )
+
+        # Store the format reference.
+        self.formats.append(xf_format)
+
+        return xf_format
+
+    def add_chart(self, options):
+        """
+        Create a chart object.
+
+        Args:
+            options: The chart type and subtype options.
+
+        Returns:
+            Reference to a Chart object.
+
+        """
+
+        # Type must be specified so we can create the required chart instance.
+        chart_type = options.get("type")
+        if chart_type is None:
+            warn("Chart type must be defined in add_chart()")
+            return None
+
+        if chart_type == "area":
+            chart = ChartArea(options)
+        elif chart_type == "bar":
+            chart = ChartBar(options)
+        elif chart_type == "column":
+            chart = ChartColumn(options)
+        elif chart_type == "doughnut":
+            chart = ChartDoughnut()
+        elif chart_type == "line":
+            chart = ChartLine(options)
+        elif chart_type == "pie":
+            chart = ChartPie()
+        elif chart_type == "radar":
+            chart = ChartRadar(options)
+        elif chart_type == "scatter":
+            chart = ChartScatter(options)
+        elif chart_type == "stock":
+            chart = ChartStock()
+        else:
+            warn(f"Unknown chart type '{chart_type}' in add_chart()")
+            return None
+
+        # Set the embedded chart name if present.
+        if "name" in options:
+            chart.chart_name = options["name"]
+
+        chart.embedded = True
+        chart.date_1904 = self.date_1904
+        chart.remove_timezone = self.remove_timezone
+
+        self.charts.append(chart)
+
+        return chart
+
+    def add_vba_project(self, vba_project, is_stream=False):
+        """
+        Add a vbaProject binary to the Excel workbook.
+
+        Args:
+            vba_project: The vbaProject binary file name.
+            is_stream:   vba_project is an in memory byte stream.
+
+        Returns:
+            0 on success.
+
+        """
+        if not is_stream and not os.path.exists(vba_project):
+            warn(f"VBA project binary file '{vba_project}' not found.")
+            return -1
+
+        if self.vba_codename is None:
+            self.vba_codename = "ThisWorkbook"
+
+        self.vba_project = vba_project
+        self.vba_project_is_stream = is_stream
+
+        return 0
+
+    def add_signed_vba_project(
+        self, vba_project, signature, project_is_stream=False, signature_is_stream=False
+    ):
+        """
+        Add a vbaProject binary and a vbaProjectSignature binary to the
+        Excel workbook.
+
+        Args:
+            vba_project:           The vbaProject binary file name.
+            signature:             The vbaProjectSignature binary file name.
+            project_is_stream:     vba_project is an in memory byte stream.
+            signature_is_stream:   signature is an in memory byte stream.
+
+        Returns:
+            0 on success.
+
+        """
+        if self.add_vba_project(vba_project, project_is_stream) == -1:
+            return -1
+
+        if not signature_is_stream and not os.path.exists(signature):
+            warn(f"VBA project signature binary file '{signature}' not found.")
+            return -1
+
+        self.vba_project_signature = signature
+        self.vba_project_signature_is_stream = signature_is_stream
+
+        return 0
+
+    def close(self):
+        """
+        Call finalization code and close file.
+
+        Args:
+            None.
+
+        Returns:
+            Nothing.
+
+        """
+        # pylint: disable=raise-missing-from
+        if not self.fileclosed:
+            try:
+                self._store_workbook()
+            except IOError as e:
+                raise FileCreateError(e)
+            except LargeZipFile:
+                raise FileSizeError(
+                    "Filesize would require ZIP64 extensions. "
+                    "Use workbook.use_zip64()."
+                )
+
+            self.fileclosed = True
+
+            # Ensure all constant_memory temp files are closed.
+            if self.constant_memory:
+                for worksheet in self.worksheets():
+                    worksheet._opt_close()
+
+        else:
+            warn("Calling close() on already closed file.")
+
+    def set_size(self, width, height):
+        """
+        Set the size of a workbook window.
+
+        Args:
+            width:  Width  of the window in pixels.
+            height: Height of the window in pixels.
+
+        Returns:
+            Nothing.
+
+        """
+        # Convert the width/height to twips at 96 dpi.
+        if width:
+            self.window_width = int(width * 1440 / 96)
+        else:
+            self.window_width = 16095
+
+        if height:
+            self.window_height = int(height * 1440 / 96)
+        else:
+            self.window_height = 9660
+
+    def set_tab_ratio(self, tab_ratio=None):
+        """
+        Set the ratio between worksheet tabs and the horizontal slider.
+
+        Args:
+            tab_ratio: The tab ratio, 0 <= tab_ratio <= 100
+
+        Returns:
+            Nothing.
+
+        """
+        if tab_ratio is None:
+            return
+
+        if tab_ratio < 0 or tab_ratio > 100:
+            warn(f"Tab ratio '{tab_ratio}' outside: 0 <= tab_ratio <= 100")
+        else:
+            self.tab_ratio = int(tab_ratio * 10)
+
+    def set_properties(self, properties):
+        """
+        Set the document properties such as Title, Author etc.
+
+        Args:
+            properties: Dictionary of document properties.
+
+        Returns:
+            Nothing.
+
+        """
+        self.doc_properties = properties
+
+    def set_custom_property(self, name, value, property_type=None):
+        """
+        Set a custom document property.
+
+        Args:
+            name:          The name of the custom property.
+            value:         The value of the custom property.
+            property_type: The type of the custom property. Optional.
+
+        Returns:
+            0 on success.
+
+        """
+        if name is None or value is None:
+            warn(
+                "The name and value parameters must be non-None in "
+                "set_custom_property()"
+            )
+            return -1
+
+        if property_type is None:
+            # Determine the property type from the Python type.
+            if isinstance(value, bool):
+                property_type = "bool"
+            elif isinstance(value, datetime):
+                property_type = "date"
+            elif isinstance(value, int):
+                property_type = "number_int"
+            elif isinstance(value, (float, int, Decimal, Fraction)):
+                property_type = "number"
+            else:
+                property_type = "text"
+
+        if property_type == "date":
+            value = value.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+        if property_type == "text" and len(value) > 255:
+            warn(
+                f"Length of 'value' parameter exceeds Excel's limit of 255 "
+                f"characters in set_custom_property(): '{value}'"
+            )
+
+        if len(name) > 255:
+            warn(
+                f"Length of 'name' parameter exceeds Excel's limit of 255 "
+                f"characters in set_custom_property(): '{name}'"
+            )
+
+        self.custom_properties.append((name, value, property_type))
+
+        return 0
+
+    def set_calc_mode(self, mode, calc_id=None):
+        """
+        Set the Excel calculation mode for the workbook.
+
+        Args:
+            mode: String containing one of:
+                * manual
+                * auto_except_tables
+                * auto
+
+        Returns:
+            Nothing.
+
+        """
+        self.calc_mode = mode
+
+        if mode == "manual":
+            self.calc_on_load = False
+        elif mode == "auto_except_tables":
+            self.calc_mode = "autoNoTable"
+
+        # Leave undocumented for now. Rarely required.
+        if calc_id:
+            self.calc_id = calc_id
+
+    def define_name(self, name, formula):
+        # Create a defined name in Excel. We handle global/workbook level
+        # names and local/worksheet names.
+        """
+        Create a defined name in the workbook.
+
+        Args:
+            name:    The defined name.
+            formula: The cell or range that the defined name refers to.
+
+        Returns:
+            0 on success.
+
+        """
+        sheet_index = None
+        sheetname = ""
+
+        # Remove the = sign from the formula if it exists.
+        if formula.startswith("="):
+            formula = formula.lstrip("=")
+
+        # Local defined names are formatted like "Sheet1!name".
+        sheet_parts = re.compile(r"^([^!]+)!([^!]+)$")
+        match = sheet_parts.match(name)
+
+        if match:
+            sheetname = match.group(1)
+            name = match.group(2)
+            sheet_index = self._get_sheet_index(sheetname)
+
+            # Warn if the sheet index wasn't found.
+            if sheet_index is None:
+                warn(f"Unknown sheet name '{sheetname}' in defined_name()")
+                return -1
+        else:
+            # Use -1 to indicate global names.
+            sheet_index = -1
+
+        # Warn if the defined 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 defined_name(): '{name}'")
+            return -1
+
+        # Warn if the defined 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 defined_name(): '{name}'")
+            return -1
+
+        # 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 defined_name()")
+            return -1
+
+        self.defined_names.append([name, sheet_index, formula, False])
+
+        return 0
+
+    def worksheets(self):
+        """
+        Return a list of the worksheet objects in the workbook.
+
+        Args:
+            None.
+
+        Returns:
+            A list of worksheet objects.
+
+        """
+        return self.worksheets_objs
+
+    def get_worksheet_by_name(self, name):
+        """
+        Return a worksheet object in the workbook using the sheetname.
+
+        Args:
+            name: The name of the worksheet.
+
+        Returns:
+            A worksheet object or None.
+
+        """
+        return self.sheetnames.get(name)
+
+    def get_default_url_format(self):
+        """
+        Get the default url format used when a user defined format isn't
+        specified with write_url(). The format is the hyperlink style defined
+        by Excel for the default theme.
+
+        Args:
+            None.
+
+        Returns:
+            A format object.
+
+        """
+        return self.default_url_format
+
+    def use_zip64(self):
+        """
+        Allow ZIP64 extensions when writing xlsx file zip container.
+
+        Args:
+            None.
+
+        Returns:
+            Nothing.
+
+        """
+        self.allow_zip64 = True
+
+    def set_vba_name(self, name=None):
+        """
+        Set the VBA name for the workbook. By default the workbook is referred
+        to as ThisWorkbook in VBA.
+
+        Args:
+            name: The VBA name for the workbook.
+
+        Returns:
+            Nothing.
+
+        """
+        if name is not None:
+            self.vba_codename = name
+        else:
+            self.vba_codename = "ThisWorkbook"
+
+    def read_only_recommended(self):
+        """
+        Set the Excel "Read-only recommended" option when saving a file.
+
+        Args:
+            None.
+
+        Returns:
+            Nothing.
+
+        """
+        self.read_only = 2
+
+    ###########################################################################
+    #
+    # Private API.
+    #
+    ###########################################################################
+
+    def _assemble_xml_file(self):
+        # Assemble and write the XML file.
+
+        # Prepare format object for passing to Style.pm.
+        self._prepare_format_properties()
+
+        # Write the XML declaration.
+        self._xml_declaration()
+
+        # Write the workbook element.
+        self._write_workbook()
+
+        # Write the fileVersion element.
+        self._write_file_version()
+
+        # Write the fileSharing element.
+        self._write_file_sharing()
+
+        # Write the workbookPr element.
+        self._write_workbook_pr()
+
+        # Write the bookViews element.
+        self._write_book_views()
+
+        # Write the sheets element.
+        self._write_sheets()
+
+        # Write the workbook defined names.
+        self._write_defined_names()
+
+        # Write the calcPr element.
+        self._write_calc_pr()
+
+        # Close the workbook tag.
+        self._xml_end_tag("workbook")
+
+        # Close the file.
+        self._xml_close()
+
+    def _store_workbook(self):
+        # pylint: disable=consider-using-with
+        # Create the xlsx/zip file.
+        try:
+            xlsx_file = ZipFile(
+                self.filename,
+                "w",
+                compression=ZIP_DEFLATED,
+                allowZip64=self.allow_zip64,
+            )
+        except IOError as e:
+            raise e
+
+        # Assemble worksheets into a workbook.
+        packager = self._get_packager()
+
+        # Add a default worksheet if non have been added.
+        if not self.worksheets():
+            self.add_worksheet()
+
+        # Ensure that at least one worksheet has been selected.
+        if self.worksheet_meta.activesheet == 0:
+            self.worksheets_objs[0].selected = 1
+            self.worksheets_objs[0].hidden = 0
+
+        # Set the active sheet.
+        for sheet in self.worksheets():
+            if sheet.index == self.worksheet_meta.activesheet:
+                sheet.active = 1
+
+        # Set the sheet vba_codename the workbook has a vbaProject binary.
+        if self.vba_project:
+            for sheet in self.worksheets():
+                if sheet.vba_codename is None:
+                    sheet.set_vba_name()
+
+        # Convert the SST strings data structure.
+        self._prepare_sst_string_data()
+
+        # Prepare the worksheet VML elements such as comments and buttons.
+        self._prepare_vml()
+
+        # Set the defined names for the worksheets such as Print Titles.
+        self._prepare_defined_names()
+
+        # Prepare the drawings, charts and images.
+        self._prepare_drawings()
+
+        # Add cached data to charts.
+        self._add_chart_data()
+
+        # Prepare the worksheet tables.
+        self._prepare_tables()
+
+        # Prepare the metadata file links.
+        self._prepare_metadata()
+
+        # Package the workbook.
+        packager._add_workbook(self)
+        packager._set_tmpdir(self.tmpdir)
+        packager._set_in_memory(self.in_memory)
+        xml_files = packager._create_package()
+
+        # Free up the Packager object.
+        packager = None
+
+        # Add XML sub-files to the Zip file with their Excel filename.
+        for file_id, file_data in enumerate(xml_files):
+            os_filename, xml_filename, is_binary = file_data
+
+            if self.in_memory:
+                # Set sub-file timestamp to Excel's timestamp of 1/1/1980.
+                zipinfo = ZipInfo(xml_filename, (1980, 1, 1, 0, 0, 0))
+
+                # Copy compression type from parent ZipFile.
+                zipinfo.compress_type = xlsx_file.compression
+
+                if is_binary:
+                    xlsx_file.writestr(zipinfo, os_filename.getvalue())
+                else:
+                    xlsx_file.writestr(zipinfo, os_filename.getvalue().encode("utf-8"))
+            else:
+                # The sub-files are tempfiles on disk, i.e, not in memory.
+
+                # Set sub-file timestamp to 31/1/1980 due to portability
+                # issues setting it to Excel's timestamp of 1/1/1980.
+                timestamp = time.mktime((1980, 1, 31, 0, 0, 0, 0, 0, -1))
+                os.utime(os_filename, (timestamp, timestamp))
+
+                try:
+                    xlsx_file.write(os_filename, xml_filename)
+                    os.remove(os_filename)
+                except LargeZipFile as e:
+                    # Close open temp files on zipfile.LargeZipFile exception.
+                    for i in range(file_id, len(xml_files) - 1):
+                        os.remove(xml_files[i][0])
+                    raise e
+
+        xlsx_file.close()
+
+    def _add_sheet(self, name, worksheet_class=None):
+        # Utility for shared code in add_worksheet() and add_chartsheet().
+
+        if worksheet_class:
+            worksheet = worksheet_class()
+        else:
+            worksheet = self.worksheet_class()
+
+        sheet_index = len(self.worksheets_objs)
+        name = self._check_sheetname(name, isinstance(worksheet, Chartsheet))
+
+        # Initialization data to pass to the worksheet.
+        init_data = {
+            "name": name,
+            "index": sheet_index,
+            "str_table": self.str_table,
+            "worksheet_meta": self.worksheet_meta,
+            "constant_memory": self.constant_memory,
+            "tmpdir": self.tmpdir,
+            "date_1904": self.date_1904,
+            "strings_to_numbers": self.strings_to_numbers,
+            "strings_to_formulas": self.strings_to_formulas,
+            "strings_to_urls": self.strings_to_urls,
+            "nan_inf_to_errors": self.nan_inf_to_errors,
+            "default_date_format": self.default_date_format,
+            "default_url_format": self.default_url_format,
+            "workbook_add_format": self.add_format,
+            "excel2003_style": self.excel2003_style,
+            "remove_timezone": self.remove_timezone,
+            "max_url_length": self.max_url_length,
+            "use_future_functions": self.use_future_functions,
+            "embedded_images": self.embedded_images,
+        }
+
+        worksheet._initialize(init_data)
+
+        self.worksheets_objs.append(worksheet)
+        self.sheetnames[name] = worksheet
+
+        return worksheet
+
+    def _check_sheetname(self, sheetname, is_chartsheet=False):
+        # Check for valid worksheet names. We check the length, if it contains
+        # any invalid chars and if the sheetname is unique in the workbook.
+        invalid_char = re.compile(r"[\[\]:*?/\\]")
+
+        # Increment the Sheet/Chart number used for default sheet names below.
+        if is_chartsheet:
+            self.chartname_count += 1
+        else:
+            self.sheetname_count += 1
+
+        # Supply default Sheet/Chart sheetname if none has been defined.
+        if sheetname is None or sheetname == "":
+            if is_chartsheet:
+                sheetname = self.chart_name + str(self.chartname_count)
+            else:
+                sheetname = self.sheet_name + str(self.sheetname_count)
+
+        # Check that sheet sheetname is <= 31. Excel limit.
+        if len(sheetname) > 31:
+            raise InvalidWorksheetName(
+                f"Excel worksheet name '{sheetname}' must be <= 31 chars."
+            )
+
+        # Check that sheetname doesn't contain any invalid characters.
+        if invalid_char.search(sheetname):
+            raise InvalidWorksheetName(
+                f"Invalid Excel character '[]:*?/\\' in sheetname '{sheetname}'."
+            )
+
+        # Check that sheetname doesn't start or end with an apostrophe.
+        if sheetname.startswith("'") or sheetname.endswith("'"):
+            raise InvalidWorksheetName(
+                f'Sheet name cannot start or end with an apostrophe "{sheetname}".'
+            )
+
+        # Check that the worksheet name doesn't already exist since this is a
+        # fatal Excel error. The check must be case insensitive like Excel.
+        for worksheet in self.worksheets():
+            if sheetname.lower() == worksheet.name.lower():
+                raise DuplicateWorksheetName(
+                    f"Sheetname '{sheetname}', with case ignored, is already in use."
+                )
+
+        return sheetname
+
+    def _prepare_format_properties(self):
+        # Prepare all Format properties prior to passing them to styles.py.
+
+        # Separate format objects into XF and DXF formats.
+        self._prepare_formats()
+
+        # Set the font index for the format objects.
+        self._prepare_fonts()
+
+        # Set the number format index for the format objects.
+        self._prepare_num_formats()
+
+        # Set the border index for the format objects.
+        self._prepare_borders()
+
+        # Set the fill index for the format objects.
+        self._prepare_fills()
+
+    def _prepare_formats(self):
+        # Iterate through the XF Format objects and separate them into
+        # XF and DXF formats. The XF and DF formats then need to be sorted
+        # back into index order rather than creation order.
+        xf_formats = []
+        dxf_formats = []
+
+        # Sort into XF and DXF formats.
+        for xf_format in self.formats:
+            if xf_format.xf_index is not None:
+                xf_formats.append(xf_format)
+
+            if xf_format.dxf_index is not None:
+                dxf_formats.append(xf_format)
+
+        # Pre-extend the format lists.
+        self.xf_formats = [None] * len(xf_formats)
+        self.dxf_formats = [None] * len(dxf_formats)
+
+        # Rearrange formats into index order.
+        for xf_format in xf_formats:
+            index = xf_format.xf_index
+            self.xf_formats[index] = xf_format
+
+        for dxf_format in dxf_formats:
+            index = dxf_format.dxf_index
+            self.dxf_formats[index] = dxf_format
+
+    def _set_default_xf_indices(self):
+        # Set the default index for each format. Only used for testing.
+
+        formats = list(self.formats)
+
+        # Delete the default url format.
+        del formats[1]
+
+        # Skip the default date format if set.
+        if self.default_date_format is not None:
+            del formats[1]
+
+        # Set the remaining formats.
+        for xf_format in formats:
+            xf_format._get_xf_index()
+
+    def _prepare_fonts(self):
+        # Iterate through the XF Format objects and give them an index to
+        # non-default font elements.
+        fonts = {}
+        index = 0
+
+        for xf_format in self.xf_formats:
+            key = xf_format._get_font_key()
+            if key in fonts:
+                # Font has already been used.
+                xf_format.font_index = fonts[key]
+                xf_format.has_font = 0
+            else:
+                # This is a new font.
+                fonts[key] = index
+                xf_format.font_index = index
+                xf_format.has_font = 1
+                index += 1
+
+        self.font_count = index
+
+        # For DXF formats we only need to check if the properties have changed.
+        for xf_format in self.dxf_formats:
+            # The only font properties that can change for a DXF format are:
+            # color, bold, italic, underline and strikethrough.
+            if (
+                xf_format.font_color
+                or xf_format.bold
+                or xf_format.italic
+                or xf_format.underline
+                or xf_format.font_strikeout
+            ):
+                xf_format.has_dxf_font = 1
+
+    def _prepare_num_formats(self):
+        # User defined records in Excel start from index 0xA4.
+        unique_num_formats = {}
+        num_formats = []
+        index = 164
+
+        for xf_format in self.xf_formats + self.dxf_formats:
+            num_format = xf_format.num_format
+
+            # Check if num_format is an index to a built-in number format.
+            if not isinstance(num_format, str):
+                num_format = int(num_format)
+
+                # Number format '0' is indexed as 1 in Excel.
+                if num_format == 0:
+                    num_format = 1
+
+                xf_format.num_format_index = num_format
+                continue
+
+            if num_format == "0":
+                # Number format '0' is indexed as 1 in Excel.
+                xf_format.num_format_index = 1
+                continue
+
+            if num_format == "General":
+                # The 'General' format has an number format index of 0.
+                xf_format.num_format_index = 0
+                continue
+
+            if num_format in unique_num_formats:
+                # Number xf_format has already been used.
+                xf_format.num_format_index = unique_num_formats[num_format]
+            else:
+                # Add a new number xf_format.
+                unique_num_formats[num_format] = index
+                xf_format.num_format_index = index
+                index += 1
+
+                # Only increase font count for XF formats (not DXF formats).
+                if xf_format.xf_index:
+                    num_formats.append(num_format)
+
+        self.num_formats = num_formats
+
+    def _prepare_borders(self):
+        # Iterate through the XF Format objects and give them an index to
+        # non-default border elements.
+        borders = {}
+        index = 0
+
+        for xf_format in self.xf_formats:
+            key = xf_format._get_border_key()
+
+            if key in borders:
+                # Border has already been used.
+                xf_format.border_index = borders[key]
+                xf_format.has_border = 0
+            else:
+                # This is a new border.
+                borders[key] = index
+                xf_format.border_index = index
+                xf_format.has_border = 1
+                index += 1
+
+        self.border_count = index
+
+        # For DXF formats we only need to check if the properties have changed.
+        has_border = re.compile(r"[^0:]")
+
+        for xf_format in self.dxf_formats:
+            key = xf_format._get_border_key()
+
+            if has_border.search(key):
+                xf_format.has_dxf_border = 1
+
+    def _prepare_fills(self):
+        # Iterate through the XF Format objects and give them an index to
+        # non-default fill elements.
+        # The user defined fill properties start from 2 since there are 2
+        # default fills: patternType="none" and patternType="gray125".
+        fills = {}
+        index = 2  # Start from 2. See above.
+
+        # Add the default fills.
+        fills["0:0:0"] = 0
+        fills["17:0:0"] = 1
+
+        # Store the DXF colors separately since them may be reversed below.
+        for xf_format in self.dxf_formats:
+            if xf_format.pattern or xf_format.bg_color or xf_format.fg_color:
+                xf_format.has_dxf_fill = 1
+                xf_format.dxf_bg_color = xf_format.bg_color
+                xf_format.dxf_fg_color = xf_format.fg_color
+
+        for xf_format in self.xf_formats:
+            # The following logical statements jointly take care of special
+            # cases in relation to cell colors and patterns:
+            # 1. For a solid fill (_pattern == 1) Excel reverses the role of
+            # foreground and background colors, and
+            # 2. If the user specifies a foreground or background color
+            # without a pattern they probably wanted a solid fill, so we fill
+            # in the defaults.
+            if (
+                xf_format.pattern == 1
+                and xf_format.bg_color != 0
+                and xf_format.fg_color != 0
+            ):
+                tmp = xf_format.fg_color
+                xf_format.fg_color = xf_format.bg_color
+                xf_format.bg_color = tmp
+
+            if (
+                xf_format.pattern <= 1
+                and xf_format.bg_color != 0
+                and xf_format.fg_color == 0
+            ):
+                xf_format.fg_color = xf_format.bg_color
+                xf_format.bg_color = 0
+                xf_format.pattern = 1
+
+            if (
+                xf_format.pattern <= 1
+                and xf_format.bg_color == 0
+                and xf_format.fg_color != 0
+            ):
+                xf_format.pattern = 1
+
+            key = xf_format._get_fill_key()
+
+            if key in fills:
+                # Fill has already been used.
+                xf_format.fill_index = fills[key]
+                xf_format.has_fill = 0
+            else:
+                # This is a new fill.
+                fills[key] = index
+                xf_format.fill_index = index
+                xf_format.has_fill = 1
+                index += 1
+
+        self.fill_count = index
+
+    def _has_feature_property_bags(self):
+        # Check for any format properties that require a feature bag. Currently
+        # this only applies to checkboxes.
+        if not self.feature_property_bags:
+            for xf_format in self.formats:
+                if xf_format.checkbox:
+                    self.feature_property_bags.add("XFComplements")
+
+                if xf_format.dxf_index is not None and xf_format.checkbox:
+                    self.feature_property_bags.add("DXFComplements")
+
+        return self.feature_property_bags
+
+    def _prepare_defined_names(self):
+        # Iterate through the worksheets and store any defined names in
+        # addition to any user defined names. Stores the defined names
+        # for the Workbook.xml and the named ranges for App.xml.
+        defined_names = self.defined_names
+
+        for sheet in self.worksheets():
+            # Check for Print Area settings.
+            if sheet.autofilter_area:
+                hidden = 1
+                sheet_range = sheet.autofilter_area
+                # Store the defined names.
+                defined_names.append(
+                    ["_xlnm._FilterDatabase", sheet.index, sheet_range, hidden]
+                )
+
+            # Check for Print Area settings.
+            if sheet.print_area_range:
+                hidden = 0
+                sheet_range = sheet.print_area_range
+                # Store the defined names.
+                defined_names.append(
+                    ["_xlnm.Print_Area", sheet.index, sheet_range, hidden]
+                )
+
+            # Check for repeat rows/cols referred to as Print Titles.
+            if sheet.repeat_col_range or sheet.repeat_row_range:
+                hidden = 0
+                sheet_range = ""
+                if sheet.repeat_col_range and sheet.repeat_row_range:
+                    sheet_range = sheet.repeat_col_range + "," + sheet.repeat_row_range
+                else:
+                    sheet_range = sheet.repeat_col_range + sheet.repeat_row_range
+                # Store the defined names.
+                defined_names.append(
+                    ["_xlnm.Print_Titles", sheet.index, sheet_range, hidden]
+                )
+
+        defined_names = self._sort_defined_names(defined_names)
+        self.defined_names = defined_names
+        self.named_ranges = self._extract_named_ranges(defined_names)
+
+    def _sort_defined_names(self, names):
+        # Sort the list of list of internal and user defined names in
+        # the same order as used by Excel.
+
+        # Add a normalize name string to each list for sorting.
+        for name_list in names:
+            (defined_name, _, sheet_name, _) = name_list
+
+            # Normalize the defined name by removing any leading '_xmln.'
+            # from internal names and lowercasing the string.
+            defined_name = defined_name.replace("_xlnm.", "").lower()
+
+            # Normalize the sheetname by removing the leading quote and
+            # lowercasing the string.
+            sheet_name = sheet_name.lstrip("'").lower()
+
+            name_list.append(defined_name + "::" + sheet_name)
+
+        # Sort based on the normalized key.
+        names.sort(key=operator.itemgetter(4))
+
+        # Remove the extra key used for sorting.
+        for name_list in names:
+            name_list.pop()
+
+        return names
+
+    def _prepare_drawings(self):
+        # Iterate through the worksheets and set up chart and image drawings.
+        chart_ref_id = 0
+        ref_id = 0
+        drawing_id = 0
+        image_ids = {}
+        header_image_ids = {}
+        background_ids = {}
+
+        # Store the image types for any embedded images.
+        for image_data in self.embedded_images.images:
+            image_type = image_data[1]
+            self.image_types[image_type] = True
+            if image_data[3]:
+                self.has_embedded_descriptions = True
+
+        image_ref_id = len(self.embedded_images.images)
+
+        for sheet in self.worksheets():
+            chart_count = len(sheet.charts)
+            image_count = len(sheet.images)
+            shape_count = len(sheet.shapes)
+
+            header_image_count = len(sheet.header_images)
+            footer_image_count = len(sheet.footer_images)
+            has_background = sheet.background_image
+            has_drawing = False
+
+            if not (
+                chart_count
+                or image_count
+                or shape_count
+                or header_image_count
+                or footer_image_count
+                or has_background
+            ):
+                continue
+
+            # Don't increase the drawing_id header/footer images.
+            if chart_count or image_count or shape_count:
+                drawing_id += 1
+                has_drawing = True
+
+            # Prepare the background images.
+            if sheet.background_image:
+                if sheet.background_bytes:
+                    filename = ""
+                    image_data = sheet.background_image
+                else:
+                    filename = sheet.background_image
+                    image_data = None
+
+                (
+                    image_type,
+                    _,
+                    _,
+                    _,
+                    _,
+                    _,
+                    digest,
+                ) = _get_image_properties(filename, image_data)
+
+                self.image_types[image_type] = True
+
+                if digest in background_ids:
+                    ref_id = background_ids[digest]
+                else:
+                    image_ref_id += 1
+                    ref_id = image_ref_id
+                    background_ids[digest] = image_ref_id
+                    self.images.append([filename, image_type, image_data])
+
+                sheet._prepare_background(ref_id, image_type)
+
+            # Prepare the worksheet images.
+            for index in range(image_count):
+                filename = sheet.images[index][2]
+                image_data = sheet.images[index][10]
+                (
+                    image_type,
+                    width,
+                    height,
+                    name,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                ) = _get_image_properties(filename, image_data)
+
+                self.image_types[image_type] = True
+
+                if digest in image_ids:
+                    ref_id = image_ids[digest]
+                else:
+                    image_ref_id += 1
+                    ref_id = image_ref_id
+                    image_ids[digest] = image_ref_id
+                    self.images.append([filename, image_type, image_data])
+
+                sheet._prepare_image(
+                    index,
+                    ref_id,
+                    drawing_id,
+                    width,
+                    height,
+                    name,
+                    image_type,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                )
+
+            # Prepare the worksheet charts.
+            for index in range(chart_count):
+                chart_ref_id += 1
+                sheet._prepare_chart(index, chart_ref_id, drawing_id)
+
+            # Prepare the worksheet shapes.
+            for index in range(shape_count):
+                sheet._prepare_shape(index, drawing_id)
+
+            # Prepare the header images.
+            for index in range(header_image_count):
+                filename = sheet.header_images[index][0]
+                image_data = sheet.header_images[index][1]
+                position = sheet.header_images[index][2]
+
+                (
+                    image_type,
+                    width,
+                    height,
+                    name,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                ) = _get_image_properties(filename, image_data)
+
+                self.image_types[image_type] = True
+
+                if digest in header_image_ids:
+                    ref_id = header_image_ids[digest]
+                else:
+                    image_ref_id += 1
+                    ref_id = image_ref_id
+                    header_image_ids[digest] = image_ref_id
+                    self.images.append([filename, image_type, image_data])
+
+                sheet._prepare_header_image(
+                    ref_id,
+                    width,
+                    height,
+                    name,
+                    image_type,
+                    position,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                )
+
+            # Prepare the footer images.
+            for index in range(footer_image_count):
+                filename = sheet.footer_images[index][0]
+                image_data = sheet.footer_images[index][1]
+                position = sheet.footer_images[index][2]
+
+                (
+                    image_type,
+                    width,
+                    height,
+                    name,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                ) = _get_image_properties(filename, image_data)
+
+                self.image_types[image_type] = True
+
+                if digest in header_image_ids:
+                    ref_id = header_image_ids[digest]
+                else:
+                    image_ref_id += 1
+                    ref_id = image_ref_id
+                    header_image_ids[digest] = image_ref_id
+                    self.images.append([filename, image_type, image_data])
+
+                sheet._prepare_header_image(
+                    ref_id,
+                    width,
+                    height,
+                    name,
+                    image_type,
+                    position,
+                    x_dpi,
+                    y_dpi,
+                    digest,
+                )
+
+            if has_drawing:
+                drawing = sheet.drawing
+                self.drawings.append(drawing)
+
+        # Remove charts that were created but not inserted into worksheets.
+        for chart in self.charts[:]:
+            if chart.id == -1:
+                self.charts.remove(chart)
+
+        # Sort the workbook charts references into the order that the were
+        # written to the worksheets above.
+        self.charts = sorted(self.charts, key=lambda chart: chart.id)
+
+        self.drawing_count = drawing_id
+
+    def _extract_named_ranges(self, defined_names):
+        # Extract the named ranges from the sorted list of defined names.
+        # These are used in the App.xml file.
+        named_ranges = []
+
+        for defined_name in defined_names:
+            name = defined_name[0]
+            index = defined_name[1]
+            sheet_range = defined_name[2]
+
+            # Skip autoFilter ranges.
+            if name == "_xlnm._FilterDatabase":
+                continue
+
+            # We are only interested in defined names with ranges.
+            if "!" in sheet_range:
+                sheet_name, _ = sheet_range.split("!", 1)
+
+                # Match Print_Area and Print_Titles xlnm types.
+                if name.startswith("_xlnm."):
+                    xlnm_type = name.replace("_xlnm.", "")
+                    name = sheet_name + "!" + xlnm_type
+                elif index != -1:
+                    name = sheet_name + "!" + name
+
+                named_ranges.append(name)
+
+        return named_ranges
+
+    def _get_sheet_index(self, sheetname):
+        # Convert a sheet name to its index. Return None otherwise.
+        sheetname = sheetname.strip("'")
+
+        if sheetname in self.sheetnames:
+            return self.sheetnames[sheetname].index
+
+        return None
+
+    def _prepare_vml(self):
+        # Iterate through the worksheets and set up the VML objects.
+        comment_id = 0
+        vml_drawing_id = 0
+        vml_data_id = 1
+        vml_header_id = 0
+        vml_shape_id = 1024
+        vml_files = 0
+        comment_files = 0
+
+        for sheet in self.worksheets():
+            if not sheet.has_vml and not sheet.has_header_vml:
+                continue
+
+            vml_files += 1
+
+            if sheet.has_vml:
+                if sheet.has_comments:
+                    comment_files += 1
+                    comment_id += 1
+                    self.has_comments = True
+
+                vml_drawing_id += 1
+
+                count = sheet._prepare_vml_objects(
+                    vml_data_id, vml_shape_id, vml_drawing_id, comment_id
+                )
+
+                # Each VML should start with a shape id incremented by 1024.
+                vml_data_id += 1 * int((1024 + count) / 1024)
+                vml_shape_id += 1024 * int((1024 + count) / 1024)
+
+            if sheet.has_header_vml:
+                vml_header_id += 1
+                vml_drawing_id += 1
+                sheet._prepare_header_vml_objects(vml_header_id, vml_drawing_id)
+
+            self.num_vml_files = vml_files
+            self.num_comment_files = comment_files
+
+    def _prepare_tables(self):
+        # Set the table ids for the worksheet tables.
+        table_id = 0
+        seen = {}
+
+        for sheet in self.worksheets():
+            table_count = len(sheet.tables)
+
+            if not table_count:
+                continue
+
+            sheet._prepare_tables(table_id + 1, seen)
+            table_id += table_count
+
+    def _prepare_metadata(self):
+        # Set the metadata rel link.
+        self.has_embedded_images = self.embedded_images.has_images()
+        self.has_metadata = self.has_embedded_images
+
+        for sheet in self.worksheets():
+            if sheet.has_dynamic_arrays:
+                self.has_metadata = True
+                self.has_dynamic_functions = True
+
+    def _add_chart_data(self):
+        # Add "cached" data to charts to provide the numCache and strCache
+        # data for series and title/axis ranges.
+        worksheets = {}
+        seen_ranges = {}
+        charts = []
+
+        # Map worksheet names to worksheet objects.
+        for worksheet in self.worksheets():
+            worksheets[worksheet.name] = worksheet
+
+        # Build a list of the worksheet charts including any combined charts.
+        for chart in self.charts:
+            charts.append(chart)
+            if chart.combined:
+                charts.append(chart.combined)
+
+        for chart in charts:
+            for c_range in chart.formula_ids.keys():
+                r_id = chart.formula_ids[c_range]
+
+                # Skip if the series has user defined data.
+                if chart.formula_data[r_id] is not None:
+                    if c_range not in seen_ranges or seen_ranges[c_range] is None:
+                        data = chart.formula_data[r_id]
+                        seen_ranges[c_range] = data
+                    continue
+
+                # Check to see if the data is already cached locally.
+                if c_range in seen_ranges:
+                    chart.formula_data[r_id] = seen_ranges[c_range]
+                    continue
+
+                # Convert the range formula to a sheet name and cell range.
+                (sheetname, cells) = self._get_chart_range(c_range)
+
+                # Skip if we couldn't parse the formula.
+                if sheetname is None:
+                    continue
+
+                # Handle non-contiguous ranges like:
+                #     (Sheet1!$A$1:$A$2,Sheet1!$A$4:$A$5).
+                # We don't try to parse them. We just return an empty list.
+                if sheetname.startswith("("):
+                    chart.formula_data[r_id] = []
+                    seen_ranges[c_range] = []
+                    continue
+
+                # Warn if the name is unknown since it indicates a user error
+                # in a chart series formula.
+                if sheetname not in worksheets:
+                    warn(
+                        f"Unknown worksheet reference '{sheetname}' in range "
+                        f"'{c_range}' passed to add_series()"
+                    )
+                    chart.formula_data[r_id] = []
+                    seen_ranges[c_range] = []
+                    continue
+
+                # Find the worksheet object based on the sheet name.
+                worksheet = worksheets[sheetname]
+
+                # Get the data from the worksheet table.
+                data = worksheet._get_range_data(*cells)
+
+                # Add the data to the chart.
+                chart.formula_data[r_id] = data
+
+                # Store range data locally to avoid lookup if seen again.
+                seen_ranges[c_range] = data
+
+    def _get_chart_range(self, c_range):
+        # Convert a range formula such as Sheet1!$B$1:$B$5 into a sheet name
+        # and cell range such as ( 'Sheet1', 0, 1, 4, 1 ).
+
+        # Split the range formula into sheetname and cells at the last '!'.
+        pos = c_range.rfind("!")
+        if pos > 0:
+            sheetname = c_range[:pos]
+            cells = c_range[pos + 1 :]
+        else:
+            return None, None
+
+        # Split the cell range into 2 cells or else use single cell for both.
+        if cells.find(":") > 0:
+            (cell_1, cell_2) = cells.split(":", 1)
+        else:
+            (cell_1, cell_2) = (cells, cells)
+
+        # Remove leading/trailing quotes and convert escaped quotes to single.
+        sheetname = sheetname.strip("'")
+        sheetname = sheetname.replace("''", "'")
+
+        try:
+            # Get the row, col values from the Excel ranges. We do this in a
+            # try block for ranges that can't be parsed such as defined names.
+            (row_start, col_start) = xl_cell_to_rowcol(cell_1)
+            (row_end, col_end) = xl_cell_to_rowcol(cell_2)
+        except AttributeError:
+            return None, None
+
+        # We only handle 1D ranges.
+        if row_start != row_end and col_start != col_end:
+            return None, None
+
+        return sheetname, [row_start, col_start, row_end, col_end]
+
+    def _prepare_sst_string_data(self):
+        # Convert the SST string data from a dict to a list.
+        self.str_table._sort_string_data()
+
+    def _get_packager(self):
+        # Get and instance of the Packager class to create the xlsx package.
+        # This allows the default packager to be over-ridden.
+        return Packager()
+
+    ###########################################################################
+    #
+    # XML methods.
+    #
+    ###########################################################################
+
+    def _write_workbook(self):
+        # Write <workbook> element.
+
+        schema = "http://schemas.openxmlformats.org"
+        xmlns = schema + "/spreadsheetml/2006/main"
+        xmlns_r = schema + "/officeDocument/2006/relationships"
+
+        attributes = [
+            ("xmlns", xmlns),
+            ("xmlns:r", xmlns_r),
+        ]
+
+        self._xml_start_tag("workbook", attributes)
+
+    def _write_file_version(self):
+        # Write the <fileVersion> element.
+
+        app_name = "xl"
+        last_edited = 4
+        lowest_edited = 4
+        rup_build = 4505
+
+        attributes = [
+            ("appName", app_name),
+            ("lastEdited", last_edited),
+            ("lowestEdited", lowest_edited),
+            ("rupBuild", rup_build),
+        ]
+
+        if self.vba_project:
+            attributes.append(("codeName", "{37E998C4-C9E5-D4B9-71C8-EB1FF731991C}"))
+
+        self._xml_empty_tag("fileVersion", attributes)
+
+    def _write_file_sharing(self):
+        # Write the <fileSharing> element.
+        if self.read_only == 0:
+            return
+
+        attributes = [("readOnlyRecommended", 1)]
+
+        self._xml_empty_tag("fileSharing", attributes)
+
+    def _write_workbook_pr(self):
+        # Write <workbookPr> element.
+        default_theme_version = 124226
+        attributes = []
+
+        if self.vba_codename:
+            attributes.append(("codeName", self.vba_codename))
+        if self.date_1904:
+            attributes.append(("date1904", 1))
+
+        attributes.append(("defaultThemeVersion", default_theme_version))
+
+        self._xml_empty_tag("workbookPr", attributes)
+
+    def _write_book_views(self):
+        # Write <bookViews> element.
+        self._xml_start_tag("bookViews")
+        self._write_workbook_view()
+        self._xml_end_tag("bookViews")
+
+    def _write_workbook_view(self):
+        # Write <workbookView> element.
+        attributes = [
+            ("xWindow", self.x_window),
+            ("yWindow", self.y_window),
+            ("windowWidth", self.window_width),
+            ("windowHeight", self.window_height),
+        ]
+
+        # Store the tabRatio attribute when it isn't the default.
+        if self.tab_ratio != 600:
+            attributes.append(("tabRatio", self.tab_ratio))
+
+        # Store the firstSheet attribute when it isn't the default.
+        if self.worksheet_meta.firstsheet > 0:
+            firstsheet = self.worksheet_meta.firstsheet + 1
+            attributes.append(("firstSheet", firstsheet))
+
+        # Store the activeTab attribute when it isn't the first sheet.
+        if self.worksheet_meta.activesheet > 0:
+            attributes.append(("activeTab", self.worksheet_meta.activesheet))
+
+        self._xml_empty_tag("workbookView", attributes)
+
+    def _write_sheets(self):
+        # Write <sheets> element.
+        self._xml_start_tag("sheets")
+
+        id_num = 1
+        for worksheet in self.worksheets():
+            self._write_sheet(worksheet.name, id_num, worksheet.hidden)
+            id_num += 1
+
+        self._xml_end_tag("sheets")
+
+    def _write_sheet(self, name, sheet_id, hidden):
+        # Write <sheet> element.
+        attributes = [
+            ("name", name),
+            ("sheetId", sheet_id),
+        ]
+
+        if hidden == 1:
+            attributes.append(("state", "hidden"))
+        elif hidden == 2:
+            attributes.append(("state", "veryHidden"))
+
+        attributes.append(("r:id", "rId" + str(sheet_id)))
+
+        self._xml_empty_tag("sheet", attributes)
+
+    def _write_calc_pr(self):
+        # Write the <calcPr> element.
+        attributes = [("calcId", self.calc_id)]
+
+        if self.calc_mode == "manual":
+            attributes.append(("calcMode", self.calc_mode))
+            attributes.append(("calcOnSave", "0"))
+        elif self.calc_mode == "autoNoTable":
+            attributes.append(("calcMode", self.calc_mode))
+
+        if self.calc_on_load:
+            attributes.append(("fullCalcOnLoad", "1"))
+
+        self._xml_empty_tag("calcPr", attributes)
+
+    def _write_defined_names(self):
+        # Write the <definedNames> element.
+        if not self.defined_names:
+            return
+
+        self._xml_start_tag("definedNames")
+
+        for defined_name in self.defined_names:
+            self._write_defined_name(defined_name)
+
+        self._xml_end_tag("definedNames")
+
+    def _write_defined_name(self, defined_name):
+        # Write the <definedName> element.
+        name = defined_name[0]
+        sheet_id = defined_name[1]
+        sheet_range = defined_name[2]
+        hidden = defined_name[3]
+
+        attributes = [("name", name)]
+
+        if sheet_id != -1:
+            attributes.append(("localSheetId", sheet_id))
+        if hidden:
+            attributes.append(("hidden", 1))
+
+        self._xml_data_element("definedName", sheet_range, attributes)
+
+
+# A metadata class to share data between worksheets.
+class WorksheetMeta:
+    """
+    A class to track worksheets data such as the active sheet and the
+    first sheet.
+
+    """
+
+    def __init__(self):
+        self.activesheet = 0
+        self.firstsheet = 0
+
+
+# A helper class to share embedded images between worksheets.
+class EmbeddedImages:
+    """
+    A class to track duplicate embedded images between worksheets.
+
+    """
+
+    def __init__(self):
+        self.images = []
+        self.image_indexes = {}
+
+    def get_image_index(self, image, digest):
+        """
+        Get the index of an embedded image.
+
+        Args:
+            image: The image to lookup.
+            digest: The digest of the image.
+
+        Returns:
+            The image index.
+
+        """
+        image_index = self.image_indexes.get(digest)
+
+        if image_index is None:
+            self.images.append(image)
+            image_index = len(self.images)
+            self.image_indexes[digest] = image_index
+
+        return image_index
+
+    def has_images(self):
+        """
+        Check if the worksheet has embedded images.
+
+        Args:
+            None.
+
+        Returns:
+            Boolean.
+
+        """
+        return len(self.images) > 0
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)
diff --git a/.venv/lib/python3.12/site-packages/xlsxwriter/xmlwriter.py b/.venv/lib/python3.12/site-packages/xlsxwriter/xmlwriter.py
new file mode 100644
index 00000000..60593c90
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/xlsxwriter/xmlwriter.py
@@ -0,0 +1,235 @@
+###############################################################################
+#
+# XMLwriter - A base class for XlsxWriter classes.
+#
+# Used in conjunction with XlsxWriter.
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
+#
+
+# pylint: disable=dangerous-default-value
+
+# Standard packages.
+import re
+from io import StringIO
+
+# Compile performance critical regular expressions.
+re_control_chars_1 = re.compile("(_x[0-9a-fA-F]{4}_)")
+re_control_chars_2 = re.compile(r"([\x00-\x08\x0b-\x1f])")
+xml_escapes = re.compile('["&<>\n]')
+
+
+class XMLwriter:
+    """
+    Simple XML writer class.
+
+    """
+
+    def __init__(self):
+        self.fh = None
+        self.internal_fh = False
+
+    def _set_filehandle(self, filehandle):
+        # Set the writer filehandle directly. Mainly for testing.
+        self.fh = filehandle
+        self.internal_fh = False
+
+    def _set_xml_writer(self, filename):
+        # Set the XML writer filehandle for the object.
+        if isinstance(filename, StringIO):
+            self.internal_fh = False
+            self.fh = filename
+        else:
+            self.internal_fh = True
+            # pylint: disable-next=consider-using-with
+            self.fh = open(filename, "w", encoding="utf-8")
+
+    def _xml_close(self):
+        # Close the XML filehandle if we created it.
+        if self.internal_fh:
+            self.fh.close()
+
+    def _xml_declaration(self):
+        # Write the XML declaration.
+        self.fh.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
+
+    def _xml_start_tag(self, tag, attributes=[]):
+        # Write an XML start tag with optional attributes.
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            tag += f' {key}="{value}"'
+
+        self.fh.write(f"<{tag}>")
+
+    def _xml_start_tag_unencoded(self, tag, attributes=[]):
+        # Write an XML start tag with optional, unencoded, attributes.
+        # This is a minor speed optimization for elements that don't
+        # need encoding.
+        for key, value in attributes:
+            tag += f' {key}="{value}"'
+
+        self.fh.write(f"<{tag}>")
+
+    def _xml_end_tag(self, tag):
+        # Write an XML end tag.
+        self.fh.write(f"</{tag}>")
+
+    def _xml_empty_tag(self, tag, attributes=[]):
+        # Write an empty XML tag with optional attributes.
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            tag += f' {key}="{value}"'
+
+        self.fh.write(f"<{tag}/>")
+
+    def _xml_empty_tag_unencoded(self, tag, attributes=[]):
+        # Write an empty XML tag with optional, unencoded, attributes.
+        # This is a minor speed optimization for elements that don't
+        # need encoding.
+        for key, value in attributes:
+            tag += f' {key}="{value}"'
+
+        self.fh.write(f"<{tag}/>")
+
+    def _xml_data_element(self, tag, data, attributes=[]):
+        # Write an XML element containing data with optional attributes.
+        end_tag = tag
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            tag += f' {key}="{value}"'
+
+        data = self._escape_data(data)
+        data = self._escape_control_characters(data)
+
+        self.fh.write(f"<{tag}>{data}</{end_tag}>")
+
+    def _xml_string_element(self, index, attributes=[]):
+        # Optimized tag writer for <c> cell string elements in the inner loop.
+        attr = ""
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        self.fh.write(f'<c{attr} t="s"><v>{index}</v></c>')
+
+    def _xml_si_element(self, string, attributes=[]):
+        # Optimized tag writer for shared strings <si> elements.
+        attr = ""
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        string = self._escape_data(string)
+
+        self.fh.write(f"<si><t{attr}>{string}</t></si>")
+
+    def _xml_rich_si_element(self, string):
+        # Optimized tag writer for shared strings <si> rich string elements.
+
+        self.fh.write(f"<si>{string}</si>")
+
+    def _xml_number_element(self, number, attributes=[]):
+        # Optimized tag writer for <c> cell number elements in the inner loop.
+        attr = ""
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        self.fh.write(f"<c{attr}><v>{number:.16G}</v></c>")
+
+    def _xml_formula_element(self, formula, result, attributes=[]):
+        # Optimized tag writer for <c> cell formula elements in the inner loop.
+        attr = ""
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        formula = self._escape_data(formula)
+        result = self._escape_data(result)
+        self.fh.write(f"<c{attr}><f>{formula}</f><v>{result}</v></c>")
+
+    def _xml_inline_string(self, string, preserve, attributes=[]):
+        # Optimized tag writer for inlineStr cell elements in the inner loop.
+        attr = ""
+        t_attr = ""
+
+        # Set the <t> attribute to preserve whitespace.
+        if preserve:
+            t_attr = ' xml:space="preserve"'
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        string = self._escape_data(string)
+
+        self.fh.write(f'<c{attr} t="inlineStr"><is><t{t_attr}>{string}</t></is></c>')
+
+    def _xml_rich_inline_string(self, string, attributes=[]):
+        # Optimized tag writer for rich inlineStr in the inner loop.
+        attr = ""
+
+        for key, value in attributes:
+            value = self._escape_attributes(value)
+            attr += f' {key}="{value}"'
+
+        self.fh.write(f'<c{attr} t="inlineStr"><is>{string}</is></c>')
+
+    def _escape_attributes(self, attribute):
+        # Escape XML characters in attributes.
+        try:
+            if not xml_escapes.search(attribute):
+                return attribute
+        except TypeError:
+            return attribute
+
+        attribute = (
+            attribute.replace("&", "&amp;")
+            .replace('"', "&quot;")
+            .replace("<", "&lt;")
+            .replace(">", "&gt;")
+            .replace("\n", "&#xA;")
+        )
+        return attribute
+
+    def _escape_data(self, data):
+        # Escape XML characters in data sections of tags.  Note, this
+        # is different from _escape_attributes() in that double quotes
+        # are not escaped by Excel.
+        try:
+            if not xml_escapes.search(data):
+                return data
+        except TypeError:
+            return data
+
+        data = data.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+        return data
+
+    @staticmethod
+    def _escape_control_characters(data):
+        # Excel escapes control characters with _xHHHH_ and also escapes any
+        # literal strings of that type by encoding the leading underscore.
+        # So "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
+        # The following substitutions deal with those cases.
+        try:
+            # Escape the escape.
+            data = re_control_chars_1.sub(r"_x005F\1", data)
+        except TypeError:
+            return data
+
+        # Convert control character to the _xHHHH_ escape.
+        data = re_control_chars_2.sub(
+            lambda match: f"_x{ord(match.group(1)):04X}_", data
+        )
+
+        # Escapes non characters in strings.
+        data = data.replace("\uFFFE", "_xFFFE_").replace("\uFFFF", "_xFFFF_")
+
+        return data