about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/openpyxl/worksheet
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/openpyxl/worksheet')
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/__init__.py1
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/_read_only.py190
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/_reader.py472
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/_write_only.py160
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/_writer.py390
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_range.py512
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_watch.py34
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/controls.py107
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/copier.py70
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/custom.py35
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/datavalidation.py202
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/dimensions.py306
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/drawing.py14
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/errors.py93
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/filters.py486
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/formula.py51
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/header_footer.py270
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/hyperlink.py46
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/merge.py141
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/ole.py133
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/page.py174
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/pagebreak.py94
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/picture.py8
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/print_settings.py184
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/properties.py97
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/protection.py120
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/related.py17
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/scenario.py105
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/smart_tag.py78
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/table.py385
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/views.py155
-rw-r--r--.venv/lib/python3.12/site-packages/openpyxl/worksheet/worksheet.py907
32 files changed, 6037 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/__init__.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/__init__.py
new file mode 100644
index 00000000..ab6cdead
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/__init__.py
@@ -0,0 +1 @@
+# Copyright (c) 2010-2024 openpyxl
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_read_only.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_read_only.py
new file mode 100644
index 00000000..95852f21
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_read_only.py
@@ -0,0 +1,190 @@
+# Copyright (c) 2010-2024 openpyxl
+
+""" Read worksheets on-demand
+"""
+
+from .worksheet import Worksheet
+from openpyxl.cell.read_only import ReadOnlyCell, EMPTY_CELL
+from openpyxl.utils import get_column_letter
+
+from ._reader import WorkSheetParser
+from openpyxl.workbook.defined_name import DefinedNameDict
+
+
+def read_dimension(source):
+    parser = WorkSheetParser(source, [])
+    return parser.parse_dimensions()
+
+
+class ReadOnlyWorksheet:
+
+    _min_column = 1
+    _min_row = 1
+    _max_column = _max_row = None
+
+    # from Standard Worksheet
+    # Methods from Worksheet
+    cell = Worksheet.cell
+    iter_rows = Worksheet.iter_rows
+    values = Worksheet.values
+    rows = Worksheet.rows
+    __getitem__ = Worksheet.__getitem__
+    __iter__ = Worksheet.__iter__
+
+
+    def __init__(self, parent_workbook, title, worksheet_path, shared_strings):
+        self.parent = parent_workbook
+        self.title = title
+        self.sheet_state = 'visible'
+        self._current_row = None
+        self._worksheet_path = worksheet_path
+        self._shared_strings = shared_strings
+        self._get_size()
+        self.defined_names = DefinedNameDict()
+
+
+    def _get_size(self):
+        src = self._get_source()
+        parser = WorkSheetParser(src, [])
+        dimensions = parser.parse_dimensions()
+        src.close()
+        if dimensions is not None:
+            self._min_column, self._min_row, self._max_column, self._max_row = dimensions
+
+
+    def _get_source(self):
+        """Parse xml source on demand, must close after use"""
+        return self.parent._archive.open(self._worksheet_path)
+
+
+    def _cells_by_row(self, min_col, min_row, max_col, max_row, values_only=False):
+        """
+        The source worksheet file may have columns or rows missing.
+        Missing cells will be created.
+        """
+        filler = EMPTY_CELL
+        if values_only:
+            filler = None
+
+        max_col = max_col or self.max_column
+        max_row = max_row or self.max_row
+        empty_row = []
+        if max_col is not None:
+            empty_row = (filler,) * (max_col + 1 - min_col)
+
+        counter = min_row
+        idx = 1
+        with self._get_source() as src:
+            parser = WorkSheetParser(src,
+                                     self._shared_strings,
+                                     data_only=self.parent.data_only,
+                                     epoch=self.parent.epoch,
+                                     date_formats=self.parent._date_formats,
+                                     timedelta_formats=self.parent._timedelta_formats)
+
+            for idx, row in parser.parse():
+                if max_row is not None and idx > max_row:
+                    break
+
+                # some rows are missing
+                for _ in range(counter, idx):
+                    counter += 1
+                    yield empty_row
+
+                # return cells from a row
+                if counter <= idx:
+                    row = self._get_row(row, min_col, max_col, values_only)
+                    counter += 1
+                    yield row
+
+        if max_row is not None and max_row < idx:
+            for _ in range(counter, max_row+1):
+                yield empty_row
+
+
+    def _get_row(self, row, min_col=1, max_col=None, values_only=False):
+        """
+        Make sure a row contains always the same number of cells or values
+        """
+        if not row and not max_col: # in case someone wants to force rows where there aren't any
+            return ()
+
+        max_col = max_col or  row[-1]['column']
+        row_width = max_col + 1 - min_col
+
+        new_row = [EMPTY_CELL] * row_width
+        if values_only:
+            new_row = [None] * row_width
+
+        for cell in row:
+            counter = cell['column']
+            if min_col <= counter <= max_col:
+                idx = counter - min_col # position in list of cells returned
+                new_row[idx] = cell['value']
+                if not values_only:
+                    new_row[idx] = ReadOnlyCell(self, **cell)
+
+        return tuple(new_row)
+
+
+    def _get_cell(self, row, column):
+        """Cells are returned by a generator which can be empty"""
+        for row in self._cells_by_row(column, row, column, row):
+            if row:
+                return row[0]
+        return EMPTY_CELL
+
+
+    def calculate_dimension(self, force=False):
+        if not all([self.max_column, self.max_row]):
+            if force:
+                self._calculate_dimension()
+            else:
+                raise ValueError("Worksheet is unsized, use calculate_dimension(force=True)")
+        return f"{get_column_letter(self.min_column)}{self.min_row}:{get_column_letter(self.max_column)}{self.max_row}"
+
+
+    def _calculate_dimension(self):
+        """
+        Loop through all the cells to get the size of a worksheet.
+        Do this only if it is explicitly requested.
+        """
+
+        max_col = 0
+        for r in self.rows:
+            if not r:
+                continue
+            cell = r[-1]
+            max_col = max(max_col, cell.column)
+
+        self._max_row = cell.row
+        self._max_column = max_col
+
+
+    def reset_dimensions(self):
+        """
+        Remove worksheet dimensions if these are incorrect in the worksheet source.
+        NB. This probably indicates a bug in the library or application that created
+        the workbook.
+        """
+        self._max_row = self._max_column = None
+
+
+    @property
+    def min_row(self):
+        return self._min_row
+
+
+    @property
+    def max_row(self):
+        return self._max_row
+
+
+    @property
+    def min_column(self):
+        return self._min_column
+
+
+    @property
+    def max_column(self):
+        return self._max_column
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_reader.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_reader.py
new file mode 100644
index 00000000..38240f03
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_reader.py
@@ -0,0 +1,472 @@
+# Copyright (c) 2010-2024 openpyxl
+
+"""Reader for a single worksheet."""
+from copy import copy
+from warnings import warn
+
+# compatibility imports
+from openpyxl.xml.functions import iterparse
+
+# package imports
+from openpyxl.cell import Cell, MergedCell
+from openpyxl.cell.text import Text
+from openpyxl.worksheet.dimensions import (
+    ColumnDimension,
+    RowDimension,
+    SheetFormatProperties,
+)
+
+from openpyxl.xml.constants import (
+    SHEET_MAIN_NS,
+    EXT_TYPES,
+)
+from openpyxl.formatting.formatting import ConditionalFormatting
+from openpyxl.formula.translate import Translator
+from openpyxl.utils import (
+    get_column_letter,
+    coordinate_to_tuple,
+    )
+from openpyxl.utils.datetime import from_excel, from_ISO8601, WINDOWS_EPOCH
+from openpyxl.descriptors.excel import ExtensionList
+from openpyxl.cell.rich_text import CellRichText
+
+from .formula import DataTableFormula, ArrayFormula
+from .filters import AutoFilter
+from .header_footer import HeaderFooter
+from .hyperlink import HyperlinkList
+from .merge import MergeCells
+from .page import PageMargins, PrintOptions, PrintPageSetup
+from .pagebreak import RowBreak, ColBreak
+from .protection import SheetProtection
+from .scenario import ScenarioList
+from .views import SheetViewList
+from .datavalidation import DataValidationList
+from .table import TablePartList
+from .properties import WorksheetProperties
+from .dimensions import SheetDimension
+from .related import Related
+
+
+CELL_TAG = '{%s}c' % SHEET_MAIN_NS
+VALUE_TAG = '{%s}v' % SHEET_MAIN_NS
+FORMULA_TAG = '{%s}f' % SHEET_MAIN_NS
+MERGE_TAG = '{%s}mergeCells' % SHEET_MAIN_NS
+INLINE_STRING = "{%s}is" % SHEET_MAIN_NS
+COL_TAG = '{%s}col' % SHEET_MAIN_NS
+ROW_TAG = '{%s}row' % SHEET_MAIN_NS
+CF_TAG = '{%s}conditionalFormatting' % SHEET_MAIN_NS
+LEGACY_TAG = '{%s}legacyDrawing' % SHEET_MAIN_NS
+PROT_TAG = '{%s}sheetProtection' % SHEET_MAIN_NS
+EXT_TAG = "{%s}extLst" % SHEET_MAIN_NS
+HYPERLINK_TAG = "{%s}hyperlinks" % SHEET_MAIN_NS
+TABLE_TAG = "{%s}tableParts" % SHEET_MAIN_NS
+PRINT_TAG = '{%s}printOptions' % SHEET_MAIN_NS
+MARGINS_TAG = '{%s}pageMargins' % SHEET_MAIN_NS
+PAGE_TAG = '{%s}pageSetup' % SHEET_MAIN_NS
+HEADER_TAG = '{%s}headerFooter' % SHEET_MAIN_NS
+FILTER_TAG = '{%s}autoFilter' % SHEET_MAIN_NS
+VALIDATION_TAG = '{%s}dataValidations' % SHEET_MAIN_NS
+PROPERTIES_TAG = '{%s}sheetPr' % SHEET_MAIN_NS
+VIEWS_TAG = '{%s}sheetViews' % SHEET_MAIN_NS
+FORMAT_TAG = '{%s}sheetFormatPr' % SHEET_MAIN_NS
+ROW_BREAK_TAG = '{%s}rowBreaks' % SHEET_MAIN_NS
+COL_BREAK_TAG = '{%s}colBreaks' % SHEET_MAIN_NS
+SCENARIOS_TAG = '{%s}scenarios' % SHEET_MAIN_NS
+DATA_TAG = '{%s}sheetData' % SHEET_MAIN_NS
+DIMENSION_TAG = '{%s}dimension' % SHEET_MAIN_NS
+CUSTOM_VIEWS_TAG = '{%s}customSheetViews' % SHEET_MAIN_NS
+
+
+def _cast_number(value):
+    "Convert numbers as string to an int or float"
+    if "." in value or "E" in value or "e" in value:
+        return float(value)
+    return int(value)
+
+
+def parse_richtext_string(element):
+    """
+    Parse inline string and preserve rich text formatting
+    """
+    value = CellRichText.from_tree(element) or ""
+    if len(value) == 1 and isinstance(value[0], str):
+        value = value[0]
+    return value
+
+
+class WorkSheetParser:
+
+    def __init__(self, src, shared_strings, data_only=False,
+                 epoch=WINDOWS_EPOCH, date_formats=set(),
+                 timedelta_formats=set(), rich_text=False):
+        self.min_row = self.min_col = None
+        self.epoch = epoch
+        self.source = src
+        self.shared_strings = shared_strings
+        self.data_only = data_only
+        self.shared_formulae = {}
+        self.row_counter = self.col_counter = 0
+        self.tables = TablePartList()
+        self.date_formats = date_formats
+        self.timedelta_formats = timedelta_formats
+        self.row_dimensions = {}
+        self.column_dimensions = {}
+        self.number_formats = []
+        self.keep_vba = False
+        self.hyperlinks = HyperlinkList()
+        self.formatting = []
+        self.legacy_drawing = None
+        self.merged_cells = None
+        self.row_breaks = RowBreak()
+        self.col_breaks = ColBreak()
+        self.rich_text = rich_text
+
+
+    def parse(self):
+        dispatcher = {
+            COL_TAG: self.parse_column_dimensions,
+            PROT_TAG: self.parse_sheet_protection,
+            EXT_TAG: self.parse_extensions,
+            CF_TAG: self.parse_formatting,
+            LEGACY_TAG: self.parse_legacy,
+            ROW_BREAK_TAG: self.parse_row_breaks,
+            COL_BREAK_TAG: self.parse_col_breaks,
+            CUSTOM_VIEWS_TAG: self.parse_custom_views,
+                      }
+
+        properties = {
+            PRINT_TAG: ('print_options', PrintOptions),
+            MARGINS_TAG: ('page_margins', PageMargins),
+            PAGE_TAG: ('page_setup', PrintPageSetup),
+            HEADER_TAG: ('HeaderFooter', HeaderFooter),
+            FILTER_TAG: ('auto_filter', AutoFilter),
+            VALIDATION_TAG: ('data_validations', DataValidationList),
+            PROPERTIES_TAG: ('sheet_properties', WorksheetProperties),
+            VIEWS_TAG: ('views', SheetViewList),
+            FORMAT_TAG: ('sheet_format', SheetFormatProperties),
+            SCENARIOS_TAG: ('scenarios', ScenarioList),
+            TABLE_TAG: ('tables', TablePartList),
+            HYPERLINK_TAG: ('hyperlinks', HyperlinkList),
+            MERGE_TAG: ('merged_cells', MergeCells),
+
+        }
+
+        it = iterparse(self.source) # add a finaliser to close the source when this becomes possible
+
+        for _, element in it:
+            tag_name = element.tag
+            if tag_name in dispatcher:
+                dispatcher[tag_name](element)
+                element.clear()
+            elif tag_name in properties:
+                prop = properties[tag_name]
+                obj = prop[1].from_tree(element)
+                setattr(self, prop[0], obj)
+                element.clear()
+            elif tag_name == ROW_TAG:
+                row = self.parse_row(element)
+                element.clear()
+                yield row
+
+
+    def parse_dimensions(self):
+        """
+        Get worksheet dimensions if they are provided.
+        """
+        it = iterparse(self.source)
+
+        for _event, element in it:
+            if element.tag == DIMENSION_TAG:
+                dim = SheetDimension.from_tree(element)
+                return dim.boundaries
+
+            elif element.tag == DATA_TAG:
+                # Dimensions missing
+                break
+            element.clear()
+
+
+    def parse_cell(self, element):
+        data_type = element.get('t', 'n')
+        coordinate = element.get('r')
+        style_id = element.get('s', 0)
+        if style_id:
+            style_id = int(style_id)
+
+        if data_type == "inlineStr":
+            value = None
+        else:
+            value = element.findtext(VALUE_TAG, None) or None
+
+        if coordinate:
+            row, column = coordinate_to_tuple(coordinate)
+            self.col_counter = column
+        else:
+            self.col_counter += 1
+            row, column = self.row_counter, self.col_counter
+
+        if not self.data_only and element.find(FORMULA_TAG) is not None:
+            data_type = 'f'
+            value = self.parse_formula(element)
+
+        elif value is not None:
+            if data_type == 'n':
+                value = _cast_number(value)
+                if style_id in self.date_formats:
+                    data_type = 'd'
+                    try:
+                        value = from_excel(
+                            value, self.epoch, timedelta=style_id in self.timedelta_formats
+                        )
+                    except (OverflowError, ValueError):
+                        msg = f"""Cell {coordinate} is marked as a date but the serial value {value} is outside the limits for dates. The cell will be treated as an error."""
+                        warn(msg)
+                        data_type = "e"
+                        value = "#VALUE!"
+            elif data_type == 's':
+                value = self.shared_strings[int(value)]
+            elif data_type == 'b':
+                value = bool(int(value))
+            elif data_type == "str":
+                data_type = "s"
+            elif data_type == 'd':
+                value = from_ISO8601(value)
+
+        elif data_type == 'inlineStr':
+                child = element.find(INLINE_STRING)
+                if child is not None:
+                    data_type = 's'
+                    if self.rich_text:
+                        value = parse_richtext_string(child)
+                    else:
+                        value = Text.from_tree(child).content
+
+        return {'row':row, 'column':column, 'value':value, 'data_type':data_type, 'style_id':style_id}
+
+
+    def parse_formula(self, element):
+        """
+        possible formulae types: shared, array, datatable
+        """
+        formula = element.find(FORMULA_TAG)
+        formula_type = formula.get('t')
+        coordinate = element.get('r')
+        value = "="
+        if formula.text is not None:
+            value += formula.text
+
+        if formula_type == "array":
+            value = ArrayFormula(ref=formula.get('ref'), text=value)
+
+        elif formula_type == "shared":
+            idx = formula.get('si')
+            if idx in self.shared_formulae:
+                trans = self.shared_formulae[idx]
+                value = trans.translate_formula(coordinate)
+            elif value != "=":
+                self.shared_formulae[idx] = Translator(value, coordinate)
+
+        elif formula_type == "dataTable":
+            value = DataTableFormula(**formula.attrib)
+
+        return value
+
+
+    def parse_column_dimensions(self, col):
+        attrs = dict(col.attrib)
+        column = get_column_letter(int(attrs['min']))
+        attrs['index'] = column
+        self.column_dimensions[column] = attrs
+
+
+    def parse_row(self, row):
+        attrs = dict(row.attrib)
+
+        if "r" in attrs:
+            try:
+                self.row_counter = int(attrs['r'])
+            except ValueError:
+                val = float(attrs['r'])
+                if val.is_integer():
+                    self.row_counter = int(val)
+                else:
+                    raise ValueError(f"{attrs['r']} is not a valid row number")
+        else:
+            self.row_counter += 1
+        self.col_counter = 0
+
+        keys = {k for k in attrs if not k.startswith('{')}
+        if keys - {'r', 'spans'}:
+            # don't create dimension objects unless they have relevant information
+            self.row_dimensions[str(self.row_counter)] = attrs
+
+        cells = [self.parse_cell(el) for el in row]
+        return self.row_counter, cells
+
+
+    def parse_formatting(self, element):
+        try:
+            cf = ConditionalFormatting.from_tree(element)
+            self.formatting.append(cf)
+        except TypeError as e:
+            msg = f"Failed to load a conditional formatting rule. It will be discarded. Cause: {e}"
+            warn(msg)
+
+
+    def parse_sheet_protection(self, element):
+        protection = SheetProtection.from_tree(element)
+        password = element.get("password")
+        if password is not None:
+            protection.set_password(password, True)
+        self.protection = protection
+
+
+    def parse_extensions(self, element):
+        extLst = ExtensionList.from_tree(element)
+        for e in extLst.ext:
+            ext_type = EXT_TYPES.get(e.uri.upper(), "Unknown")
+            msg = "{0} extension is not supported and will be removed".format(ext_type)
+            warn(msg)
+
+
+    def parse_legacy(self, element):
+        obj = Related.from_tree(element)
+        self.legacy_drawing = obj.id
+
+
+    def parse_row_breaks(self, element):
+        brk = RowBreak.from_tree(element)
+        self.row_breaks = brk
+
+
+    def parse_col_breaks(self, element):
+        brk = ColBreak.from_tree(element)
+        self.col_breaks = brk
+
+
+    def parse_custom_views(self, element):
+        # clear page_breaks to avoid duplication which Excel doesn't like
+        # basically they're ignored in custom views
+        self.row_breaks = RowBreak()
+        self.col_breaks = ColBreak()
+
+
+class WorksheetReader:
+    """
+    Create a parser and apply it to a workbook
+    """
+
+    def __init__(self, ws, xml_source, shared_strings, data_only, rich_text):
+        self.ws = ws
+        self.parser = WorkSheetParser(xml_source, shared_strings,
+                data_only, ws.parent.epoch, ws.parent._date_formats,
+                ws.parent._timedelta_formats, rich_text)
+        self.tables = []
+
+
+    def bind_cells(self):
+        for idx, row in self.parser.parse():
+            for cell in row:
+                style = self.ws.parent._cell_styles[cell['style_id']]
+                c = Cell(self.ws, row=cell['row'], column=cell['column'], style_array=style)
+                c._value = cell['value']
+                c.data_type = cell['data_type']
+                self.ws._cells[(cell['row'], cell['column'])] = c
+
+        if self.ws._cells:
+            self.ws._current_row = self.ws.max_row # use cells not row dimensions
+
+
+    def bind_formatting(self):
+        for cf in self.parser.formatting:
+            for rule in cf.rules:
+                if rule.dxfId is not None:
+                    rule.dxf = self.ws.parent._differential_styles[rule.dxfId]
+                self.ws.conditional_formatting[cf] = rule
+
+
+    def bind_tables(self):
+        for t in self.parser.tables.tablePart:
+            rel = self.ws._rels.get(t.id)
+            self.tables.append(rel.Target)
+
+
+    def bind_merged_cells(self):
+        from openpyxl.worksheet.cell_range import MultiCellRange
+        from openpyxl.worksheet.merge import MergedCellRange
+        if not self.parser.merged_cells:
+            return
+
+        ranges = []
+        for cr in self.parser.merged_cells.mergeCell:
+            mcr = MergedCellRange(self.ws, cr.ref)
+            self.ws._clean_merge_range(mcr)
+            ranges.append(mcr)
+        self.ws.merged_cells = MultiCellRange(ranges)
+
+
+    def bind_hyperlinks(self):
+        for link in self.parser.hyperlinks.hyperlink:
+            if link.id:
+                rel = self.ws._rels.get(link.id)
+                link.target = rel.Target
+            if ":" in link.ref:
+                # range of cells
+                for row in self.ws[link.ref]:
+                    for cell in row:
+                        try:
+                            cell.hyperlink = copy(link)
+                        except AttributeError:
+                            pass
+            else:
+                cell = self.ws[link.ref]
+                if isinstance(cell, MergedCell):
+                    cell = self.normalize_merged_cell_link(cell.coordinate)
+                cell.hyperlink = link
+
+    def normalize_merged_cell_link(self, coord):
+        """
+        Returns the appropriate cell to which a hyperlink, which references a merged cell at the specified coordinates,
+        should be bound.
+        """
+        for rng in self.ws.merged_cells:
+            if coord in rng:
+                return self.ws.cell(*rng.top[0])
+
+    def bind_col_dimensions(self):
+        for col, cd in self.parser.column_dimensions.items():
+            if 'style' in cd:
+                key = int(cd['style'])
+                cd['style'] = self.ws.parent._cell_styles[key]
+            self.ws.column_dimensions[col] = ColumnDimension(self.ws, **cd)
+
+
+    def bind_row_dimensions(self):
+        for row, rd in self.parser.row_dimensions.items():
+            if 's' in rd:
+                key = int(rd['s'])
+                rd['s'] = self.ws.parent._cell_styles[key]
+            self.ws.row_dimensions[int(row)] = RowDimension(self.ws, **rd)
+
+
+    def bind_properties(self):
+        for k in ('print_options', 'page_margins', 'page_setup',
+                  'HeaderFooter', 'auto_filter', 'data_validations',
+                  'sheet_properties', 'views', 'sheet_format',
+                  'row_breaks', 'col_breaks', 'scenarios', 'legacy_drawing',
+                  'protection',
+                  ):
+            v = getattr(self.parser, k, None)
+            if v is not None:
+                setattr(self.ws, k, v)
+
+
+    def bind_all(self):
+        self.bind_cells()
+        self.bind_merged_cells()
+        self.bind_hyperlinks()
+        self.bind_formatting()
+        self.bind_col_dimensions()
+        self.bind_row_dimensions()
+        self.bind_tables()
+        self.bind_properties()
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_write_only.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_write_only.py
new file mode 100644
index 00000000..0b1d027d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_write_only.py
@@ -0,0 +1,160 @@
+# Copyright (c) 2010-2024 openpyxl
+
+
+"""Write worksheets to xml representations in an optimized way"""
+
+from inspect import isgenerator
+
+from openpyxl.cell import Cell, WriteOnlyCell
+from openpyxl.workbook.child import _WorkbookChild
+from .worksheet import Worksheet
+from openpyxl.utils.exceptions import WorkbookAlreadySaved
+
+from ._writer import WorksheetWriter
+
+
+class WriteOnlyWorksheet(_WorkbookChild):
+    """
+    Streaming worksheet. Optimised to reduce memory by writing rows just in
+    time.
+    Cells can be styled and have comments Styles for rows and columns
+    must be applied before writing cells
+    """
+
+    __saved = False
+    _writer = None
+    _rows = None
+    _rel_type = Worksheet._rel_type
+    _path = Worksheet._path
+    mime_type = Worksheet.mime_type
+
+    # copy methods from Standard worksheet
+    _add_row = Worksheet._add_row
+    _add_column = Worksheet._add_column
+    add_chart = Worksheet.add_chart
+    add_image = Worksheet.add_image
+    add_table = Worksheet.add_table
+    tables = Worksheet.tables
+    print_titles = Worksheet.print_titles
+    print_title_cols = Worksheet.print_title_cols
+    print_title_rows = Worksheet.print_title_rows
+    freeze_panes = Worksheet.freeze_panes
+    print_area = Worksheet.print_area
+    sheet_view = Worksheet.sheet_view
+    _setup = Worksheet._setup
+
+    def __init__(self, parent, title):
+        super().__init__(parent, title)
+        self._max_col = 0
+        self._max_row = 0
+        self._setup()
+
+    @property
+    def closed(self):
+        return self.__saved
+
+
+    def _write_rows(self):
+        """
+        Send rows to the writer's stream
+        """
+        try:
+            xf = self._writer.xf.send(True)
+        except StopIteration:
+            self._already_saved()
+
+        with xf.element("sheetData"):
+            row_idx = 1
+            try:
+                while True:
+                    row = (yield)
+                    row = self._values_to_row(row, row_idx)
+                    self._writer.write_row(xf, row, row_idx)
+                    row_idx += 1
+            except GeneratorExit:
+                pass
+
+        self._writer.xf.send(None)
+
+
+    def _get_writer(self):
+        if self._writer is None:
+            self._writer = WorksheetWriter(self)
+            self._writer.write_top()
+
+
+    def close(self):
+        if self.__saved:
+            self._already_saved()
+
+        self._get_writer()
+
+        if self._rows is None:
+            self._writer.write_rows()
+        else:
+            self._rows.close()
+
+        self._writer.write_tail()
+
+        self._writer.close()
+        self.__saved = True
+
+
+    def append(self, row):
+        """
+        :param row: iterable containing values to append
+        :type row: iterable
+        """
+
+        if (not isgenerator(row) and
+            not isinstance(row, (list, tuple, range))
+            ):
+            self._invalid_row(row)
+
+        self._get_writer()
+
+        if self._rows is None:
+            self._rows = self._write_rows()
+            next(self._rows)
+
+        self._rows.send(row)
+
+
+    def _values_to_row(self, values, row_idx):
+        """
+        Convert whatever has been appended into a form suitable for work_rows
+        """
+        cell = WriteOnlyCell(self)
+
+        for col_idx, value in enumerate(values, 1):
+            if value is None:
+                continue
+            try:
+                cell.value = value
+            except ValueError:
+                if isinstance(value, Cell):
+                    cell = value
+                else:
+                    raise ValueError
+
+            cell.column = col_idx
+            cell.row = row_idx
+
+            if cell.hyperlink is not None:
+                cell.hyperlink.ref = cell.coordinate
+
+            yield cell
+
+            # reset cell if style applied
+            if cell.has_style or cell.hyperlink:
+                cell = WriteOnlyCell(self)
+
+
+    def _already_saved(self):
+        raise WorkbookAlreadySaved('Workbook has already been saved and cannot be modified or saved anymore.')
+
+
+    def _invalid_row(self, iterable):
+        raise TypeError('Value must be a list, tuple, range or a generator Supplied value is {0}'.format(
+            type(iterable))
+                        )
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_writer.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_writer.py
new file mode 100644
index 00000000..df381d2b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/_writer.py
@@ -0,0 +1,390 @@
+# Copyright (c) 2010-2024 openpyxl
+
+import atexit
+from collections import defaultdict
+from io import BytesIO
+import os
+from tempfile import NamedTemporaryFile
+from warnings import warn
+
+from openpyxl.xml.functions import xmlfile
+from openpyxl.xml.constants import SHEET_MAIN_NS
+
+from openpyxl.comments.comment_sheet import CommentRecord
+from openpyxl.packaging.relationship import Relationship, RelationshipList
+from openpyxl.styles.differential import DifferentialStyle
+
+from .dimensions import SheetDimension
+from .hyperlink import HyperlinkList
+from .merge import MergeCell, MergeCells
+from .related import Related
+from .table import TablePartList
+
+from openpyxl.cell._writer import write_cell
+
+
+ALL_TEMP_FILES = []
+
+@atexit.register
+def _openpyxl_shutdown():
+    for path in ALL_TEMP_FILES:
+        if os.path.exists(path):
+            os.remove(path)
+
+
+def create_temporary_file(suffix=''):
+    fobj = NamedTemporaryFile(mode='w+', suffix=suffix,
+                              prefix='openpyxl.', delete=False)
+    filename = fobj.name
+    fobj.close()
+    ALL_TEMP_FILES.append(filename)
+    return filename
+
+
+class WorksheetWriter:
+
+
+    def __init__(self, ws, out=None):
+        self.ws = ws
+        self.ws._hyperlinks = []
+        self.ws._comments = []
+        if out is None:
+            out = create_temporary_file()
+        self.out = out
+        self._rels = RelationshipList()
+        self.xf = self.get_stream()
+        next(self.xf) # start generator
+
+
+    def write_properties(self):
+        props = self.ws.sheet_properties
+        self.xf.send(props.to_tree())
+
+
+    def write_dimensions(self):
+        """
+        Write worksheet size if known
+        """
+        ref = getattr(self.ws, 'calculate_dimension', None)
+        if ref:
+            dim = SheetDimension(ref())
+            self.xf.send(dim.to_tree())
+
+
+    def write_format(self):
+        self.ws.sheet_format.outlineLevelCol = self.ws.column_dimensions.max_outline
+        fmt = self.ws.sheet_format
+        self.xf.send(fmt.to_tree())
+
+
+    def write_views(self):
+        views = self.ws.views
+        self.xf.send(views.to_tree())
+
+
+    def write_cols(self):
+        cols = self.ws.column_dimensions
+        self.xf.send(cols.to_tree())
+
+
+    def write_top(self):
+        """
+        Write all elements up to rows:
+        properties
+        dimensions
+        views
+        format
+        cols
+        """
+        self.write_properties()
+        self.write_dimensions()
+        self.write_views()
+        self.write_format()
+        self.write_cols()
+
+
+    def rows(self):
+        """Return all rows, and any cells that they contain"""
+        # order cells by row
+        rows = defaultdict(list)
+        for (row, col), cell in sorted(self.ws._cells.items()):
+            rows[row].append(cell)
+
+        # add empty rows if styling has been applied
+        for row in self.ws.row_dimensions.keys() - rows.keys():
+            rows[row] = []
+
+        return sorted(rows.items())
+
+
+    def write_rows(self):
+        xf = self.xf.send(True)
+
+        with xf.element("sheetData"):
+            for row_idx, row in self.rows():
+                self.write_row(xf, row, row_idx)
+
+        self.xf.send(None) # return control to generator
+
+
+    def write_row(self, xf, row, row_idx):
+        attrs = {'r': f"{row_idx}"}
+        dims = self.ws.row_dimensions
+        attrs.update(dims.get(row_idx, {}))
+
+        with xf.element("row", attrs):
+
+            for cell in row:
+                if cell._comment is not None:
+                    comment = CommentRecord.from_cell(cell)
+                    self.ws._comments.append(comment)
+                if (
+                    cell._value is None
+                    and not cell.has_style
+                    and not cell._comment
+                    ):
+                    continue
+                write_cell(xf, self.ws, cell, cell.has_style)
+
+
+    def write_protection(self):
+        prot = self.ws.protection
+        if prot:
+            self.xf.send(prot.to_tree())
+
+
+    def write_scenarios(self):
+        scenarios = self.ws.scenarios
+        if scenarios:
+            self.xf.send(scenarios.to_tree())
+
+
+    def write_filter(self):
+        flt = self.ws.auto_filter
+        if flt:
+            self.xf.send(flt.to_tree())
+
+
+    def write_sort(self):
+        """
+        As per discusion with the OOXML Working Group global sort state is not required.
+        openpyxl never reads it from existing files
+        """
+        pass
+
+
+    def write_merged_cells(self):
+        merged = self.ws.merged_cells
+        if merged:
+            cells = [MergeCell(str(ref)) for ref in self.ws.merged_cells]
+            self.xf.send(MergeCells(mergeCell=cells).to_tree())
+
+
+    def write_formatting(self):
+        df = DifferentialStyle()
+        wb = self.ws.parent
+        for cf in self.ws.conditional_formatting:
+            for rule in cf.rules:
+                if rule.dxf and rule.dxf != df:
+                    rule.dxfId = wb._differential_styles.add(rule.dxf)
+            self.xf.send(cf.to_tree())
+
+
+    def write_validations(self):
+        dv = self.ws.data_validations
+        if dv:
+            self.xf.send(dv.to_tree())
+
+
+    def write_hyperlinks(self):
+
+        links = self.ws._hyperlinks
+
+        for link in links:
+            if link.target:
+                rel = Relationship(type="hyperlink", TargetMode="External", Target=link.target)
+                self._rels.append(rel)
+                link.id = rel.id
+
+        if links:
+            self.xf.send(HyperlinkList(links).to_tree())
+
+
+    def write_print(self):
+        print_options = self.ws.print_options
+        if print_options:
+            self.xf.send(print_options.to_tree())
+
+
+    def write_margins(self):
+        margins = self.ws.page_margins
+        if margins:
+            self.xf.send(margins.to_tree())
+
+
+    def write_page(self):
+        setup = self.ws.page_setup
+        if setup:
+            self.xf.send(setup.to_tree())
+
+
+    def write_header(self):
+        hf = self.ws.HeaderFooter
+        if hf:
+            self.xf.send(hf.to_tree())
+
+
+    def write_breaks(self):
+        brks = (self.ws.row_breaks, self.ws.col_breaks)
+        for brk in brks:
+            if brk:
+                self.xf.send(brk.to_tree())
+
+
+    def write_drawings(self):
+        if self.ws._charts or self.ws._images:
+            rel = Relationship(type="drawing", Target="")
+            self._rels.append(rel)
+            drawing = Related()
+            drawing.id = rel.id
+            self.xf.send(drawing.to_tree("drawing"))
+
+
+    def write_legacy(self):
+        """
+        Comments & VBA controls use VML and require an additional element
+        that is no longer in the specification.
+        """
+        if (self.ws.legacy_drawing is not None or self.ws._comments):
+            legacy = Related(id="anysvml")
+            self.xf.send(legacy.to_tree("legacyDrawing"))
+
+
+    def write_tables(self):
+        tables = TablePartList()
+
+        for table in self.ws.tables.values():
+            if not table.tableColumns:
+                table._initialise_columns()
+                if table.headerRowCount:
+                    try:
+                        row = self.ws[table.ref][0]
+                        for cell, col in zip(row, table.tableColumns):
+                            if cell.data_type != "s":
+                                warn("File may not be readable: column headings must be strings.")
+                            col.name = str(cell.value)
+                    except TypeError:
+                        warn("Column headings are missing, file may not be readable")
+            rel = Relationship(Type=table._rel_type, Target="")
+            self._rels.append(rel)
+            table._rel_id = rel.Id
+            tables.append(Related(id=rel.Id))
+
+        if tables:
+            self.xf.send(tables.to_tree())
+
+
+    def get_stream(self):
+        with xmlfile(self.out) as xf:
+            with xf.element("worksheet", xmlns=SHEET_MAIN_NS):
+                try:
+                    while True:
+                        el = (yield)
+                        if el is True:
+                            yield xf
+                        elif el is None: # et_xmlfile chokes
+                            continue
+                        else:
+                            xf.write(el)
+                except GeneratorExit:
+                    pass
+
+
+    def write_tail(self):
+        """
+        Write all elements after the rows
+        calc properties
+        protection
+        protected ranges #
+        scenarios
+        filters
+        sorts # always ignored
+        data consolidation #
+        custom views #
+        merged cells
+        phonetic properties #
+        conditional formatting
+        data validation
+        hyperlinks
+        print options
+        page margins
+        page setup
+        header
+        row breaks
+        col breaks
+        custom properties #
+        cell watches #
+        ignored errors #
+        smart tags #
+        drawing
+        drawingHF #
+        background #
+        OLE objects #
+        controls #
+        web publishing #
+        tables
+        """
+        self.write_protection()
+        self.write_scenarios()
+        self.write_filter()
+        self.write_merged_cells()
+        self.write_formatting()
+        self.write_validations()
+        self.write_hyperlinks()
+        self.write_print()
+        self.write_margins()
+        self.write_page()
+        self.write_header()
+        self.write_breaks()
+        self.write_drawings()
+        self.write_legacy()
+        self.write_tables()
+
+
+    def write(self):
+        """
+        High level
+        """
+        self.write_top()
+        self.write_rows()
+        self.write_tail()
+        self.close()
+
+
+    def close(self):
+        """
+        Close the context manager
+        """
+        if self.xf:
+            self.xf.close()
+
+
+    def read(self):
+        """
+        Close the context manager and return serialised XML
+        """
+        self.close()
+        if isinstance(self.out, BytesIO):
+            return self.out.getvalue()
+        with open(self.out, "rb") as src:
+            out = src.read()
+
+        return out
+
+
+    def cleanup(self):
+        """
+        Remove tempfile
+        """
+        os.remove(self.out)
+        ALL_TEMP_FILES.remove(self.out)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_range.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_range.py
new file mode 100644
index 00000000..2fbf5e22
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_range.py
@@ -0,0 +1,512 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from copy import copy
+from operator import attrgetter
+
+from openpyxl.descriptors import Strict
+from openpyxl.descriptors import MinMax
+from openpyxl.descriptors.sequence import UniqueSequence
+from openpyxl.descriptors.serialisable import Serialisable
+
+from openpyxl.utils import (
+    range_boundaries,
+    range_to_tuple,
+    get_column_letter,
+    quote_sheetname,
+)
+
+class CellRange(Serialisable):
+    """
+    Represents a range in a sheet: title and coordinates.
+
+    This object is used to perform operations on ranges, like:
+
+    - shift, expand or shrink
+    - union/intersection with another sheet range,
+
+    We can check whether a range is:
+
+    - equal or not equal to another,
+    - disjoint of another,
+    - contained in another.
+
+    We can get:
+
+    - the size of a range.
+    - the range bounds (vertices)
+    - the coordinates,
+    - the string representation,
+
+    """
+
+    min_col = MinMax(min=1, max=18278, expected_type=int)
+    min_row = MinMax(min=1, max=1048576, expected_type=int)
+    max_col = MinMax(min=1, max=18278, expected_type=int)
+    max_row = MinMax(min=1, max=1048576, expected_type=int)
+
+
+    def __init__(self, range_string=None, min_col=None, min_row=None,
+                 max_col=None, max_row=None, title=None):
+        if range_string is not None:
+            if "!" in range_string:
+                title, (min_col, min_row, max_col, max_row) = range_to_tuple(range_string)
+            else:
+                min_col, min_row, max_col, max_row = range_boundaries(range_string)
+
+        self.min_col = min_col
+        self.min_row = min_row
+        self.max_col = max_col
+        self.max_row = max_row
+        self.title = title
+
+        if min_col > max_col:
+            fmt = "{max_col} must be greater than {min_col}"
+            raise ValueError(fmt.format(min_col=min_col, max_col=max_col))
+        if min_row > max_row:
+            fmt = "{max_row} must be greater than {min_row}"
+            raise ValueError(fmt.format(min_row=min_row, max_row=max_row))
+
+
+    @property
+    def bounds(self):
+        """
+        Vertices of the range as a tuple
+        """
+        return self.min_col, self.min_row, self.max_col, self.max_row
+
+
+    @property
+    def coord(self):
+        """
+        Excel-style representation of the range
+        """
+        fmt = "{min_col}{min_row}:{max_col}{max_row}"
+        if (self.min_col == self.max_col
+            and self.min_row == self.max_row):
+            fmt = "{min_col}{min_row}"
+
+        return fmt.format(
+            min_col=get_column_letter(self.min_col),
+            min_row=self.min_row,
+            max_col=get_column_letter(self.max_col),
+            max_row=self.max_row
+        )
+
+    @property
+    def rows(self):
+        """
+        Return cell coordinates as rows
+        """
+        for row in range(self.min_row, self.max_row+1):
+            yield [(row, col) for col in range(self.min_col, self.max_col+1)]
+
+
+    @property
+    def cols(self):
+        """
+        Return cell coordinates as columns
+        """
+        for col in range(self.min_col, self.max_col+1):
+            yield [(row, col) for row in range(self.min_row, self.max_row+1)]
+
+
+    @property
+    def cells(self):
+        from itertools import product
+        return product(range(self.min_row, self.max_row+1), range(self.min_col, self.max_col+1))
+
+
+    def _check_title(self, other):
+        """
+        Check whether comparisons between ranges are possible.
+        Cannot compare ranges from different worksheets
+        Skip if the range passed in has no title.
+        """
+        if not isinstance(other, CellRange):
+            raise TypeError(repr(type(other)))
+
+        if other.title and self.title != other.title:
+            raise ValueError("Cannot work with ranges from different worksheets")
+
+
+    def __repr__(self):
+        fmt = u"<{cls} {coord}>"
+        if self.title:
+            fmt = u"<{cls} {title!r}!{coord}>"
+        return fmt.format(cls=self.__class__.__name__, title=self.title, coord=self.coord)
+
+
+    def __hash__(self):
+        return hash((self.min_row, self.min_col, self.max_row, self.max_col))
+
+
+    def __str__(self):
+        fmt = "{coord}"
+        title = self.title
+        if title:
+            fmt = u"{title}!{coord}"
+            title = quote_sheetname(title)
+        return fmt.format(title=title, coord=self.coord)
+
+
+    def __copy__(self):
+        return self.__class__(min_col=self.min_col, min_row=self.min_row,
+                              max_col=self.max_col, max_row=self.max_row,
+                              title=self.title)
+
+
+    def shift(self, col_shift=0, row_shift=0):
+        """
+        Shift the focus of the range according to the shift values (*col_shift*, *row_shift*).
+
+        :type col_shift: int
+        :param col_shift: number of columns to be moved by, can be negative
+        :type row_shift: int
+        :param row_shift: number of rows to be moved by, can be negative
+        :raise: :class:`ValueError` if any row or column index < 1
+        """
+
+        if (self.min_col + col_shift <= 0
+            or self.min_row + row_shift <= 0):
+            raise ValueError("Invalid shift value: col_shift={0}, row_shift={1}".format(col_shift, row_shift))
+        self.min_col += col_shift
+        self.min_row += row_shift
+        self.max_col += col_shift
+        self.max_row += row_shift
+
+
+    def __ne__(self, other):
+        """
+        Test whether the ranges are not equal.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* != *other*.
+        """
+        try:
+            self._check_title(other)
+        except ValueError:
+            return True
+
+        return (
+            other.min_row != self.min_row
+            or self.max_row != other.max_row
+            or other.min_col != self.min_col
+            or self.max_col != other.max_col
+        )
+
+
+    def __eq__(self, other):
+        """
+        Test whether the ranges are equal.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* == *other*.
+        """
+        return not self.__ne__(other)
+
+
+    def issubset(self, other):
+        """
+        Test whether every cell in this range is also in *other*.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* <= *other*.
+        """
+        self._check_title(other)
+
+        return other.__superset(self)
+
+    __le__ = issubset
+
+
+    def __lt__(self, other):
+        """
+        Test whether *other* contains every cell of this range, and more.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* < *other*.
+        """
+        return self.__le__(other) and self.__ne__(other)
+
+
+    def __superset(self, other):
+        return (
+            (self.min_row <= other.min_row <= other.max_row <= self.max_row)
+            and
+            (self.min_col <= other.min_col <= other.max_col <= self.max_col)
+        )
+
+
+    def issuperset(self, other):
+        """
+        Test whether every cell in *other* is in this range.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* >= *other* (or *other* in *range*).
+        """
+        self._check_title(other)
+
+        return self.__superset(other)
+
+    __ge__ = issuperset
+
+
+    def __contains__(self, coord):
+        """
+        Check whether the range contains a particular cell coordinate
+        """
+        cr = self.__class__(coord)
+        return self.__superset(cr)
+
+
+    def __gt__(self, other):
+        """
+        Test whether this range contains every cell in *other*, and more.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range
+        :return: ``True`` if *range* > *other*.
+        """
+        return self.__ge__(other) and self.__ne__(other)
+
+
+    def isdisjoint(self, other):
+        """
+        Return ``True`` if this range has no cell in common with *other*.
+        Ranges are disjoint if and only if their intersection is the empty range.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range.
+        :return: ``True`` if the range has no cells in common with other.
+        """
+        self._check_title(other)
+
+        # Sort by top-left vertex
+        if self.bounds > other.bounds:
+            self, other = other, self
+
+        return (self.max_col < other.min_col
+                or self.max_row < other.min_row
+                or other.max_row < self.min_row)
+
+
+    def intersection(self, other):
+        """
+        Return a new range with cells common to this range and *other*
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range.
+        :return: the intersecting sheet range.
+        :raise: :class:`ValueError` if the *other* range doesn't intersect
+            with this range.
+        """
+        if self.isdisjoint(other):
+            raise ValueError("Range {0} doesn't intersect {0}".format(self, other))
+
+        min_row = max(self.min_row, other.min_row)
+        max_row = min(self.max_row, other.max_row)
+        min_col = max(self.min_col, other.min_col)
+        max_col = min(self.max_col, other.max_col)
+
+        return CellRange(min_col=min_col, min_row=min_row, max_col=max_col,
+                         max_row=max_row)
+
+    __and__ = intersection
+
+
+    def union(self, other):
+        """
+        Return the minimal superset of this range and *other*. This new range
+        will contain all cells from this range, *other*, and any additional
+        cells required to form a rectangular ``CellRange``.
+
+        :type other: openpyxl.worksheet.cell_range.CellRange
+        :param other: Other sheet range.
+        :return: a ``CellRange`` that is a superset of this and *other*.
+        """
+        self._check_title(other)
+
+        min_row = min(self.min_row, other.min_row)
+        max_row = max(self.max_row, other.max_row)
+        min_col = min(self.min_col, other.min_col)
+        max_col = max(self.max_col, other.max_col)
+        return CellRange(min_col=min_col, min_row=min_row, max_col=max_col,
+                         max_row=max_row, title=self.title)
+
+    __or__ = union
+
+
+    def __iter__(self):
+        """
+        For use as a dictionary elsewhere in the library.
+        """
+        for x in self.__attrs__:
+            if x == "title":
+                continue
+            v = getattr(self, x)
+            yield x, v
+
+
+    def expand(self, right=0, down=0, left=0, up=0):
+        """
+        Expand the range by the dimensions provided.
+
+        :type right: int
+        :param right: expand range to the right by this number of cells
+        :type down: int
+        :param down: expand range down by this number of cells
+        :type left: int
+        :param left: expand range to the left by this number of cells
+        :type up: int
+        :param up: expand range up by this number of cells
+        """
+        self.min_col -= left
+        self.min_row -= up
+        self.max_col += right
+        self.max_row += down
+
+
+    def shrink(self, right=0, bottom=0, left=0, top=0):
+        """
+        Shrink the range by the dimensions provided.
+
+        :type right: int
+        :param right: shrink range from the right by this number of cells
+        :type down: int
+        :param down: shrink range from the top by this number of cells
+        :type left: int
+        :param left: shrink range from the left by this number of cells
+        :type up: int
+        :param up: shrink range from the bottom by this number of cells
+        """
+        self.min_col += left
+        self.min_row += top
+        self.max_col -= right
+        self.max_row -= bottom
+
+
+    @property
+    def size(self):
+        """ Return the size of the range as a dictionary of rows and columns. """
+        cols = self.max_col + 1 - self.min_col
+        rows = self.max_row + 1 - self.min_row
+        return {'columns':cols, 'rows':rows}
+
+
+    @property
+    def top(self):
+        """A list of cell coordinates that comprise the top of the range"""
+        return [(self.min_row, col) for col in range(self.min_col, self.max_col+1)]
+
+
+    @property
+    def bottom(self):
+        """A list of cell coordinates that comprise the bottom of the range"""
+        return [(self.max_row, col) for col in range(self.min_col, self.max_col+1)]
+
+
+    @property
+    def left(self):
+        """A list of cell coordinates that comprise the left-side of the range"""
+        return [(row, self.min_col) for row in range(self.min_row, self.max_row+1)]
+
+
+    @property
+    def right(self):
+        """A list of cell coordinates that comprise the right-side of the range"""
+        return [(row, self.max_col) for row in range(self.min_row, self.max_row+1)]
+
+
+class MultiCellRange(Strict):
+
+
+    ranges = UniqueSequence(expected_type=CellRange)
+
+
+    def __init__(self, ranges=set()):
+        if isinstance(ranges, str):
+            ranges = [CellRange(r) for r in ranges.split()]
+        self.ranges = set(ranges)
+
+
+    def __contains__(self, coord):
+        if isinstance(coord, str):
+            coord = CellRange(coord)
+        for r in self.ranges:
+            if coord <= r:
+                return True
+        return False
+
+
+    def __repr__(self):
+        ranges = " ".join([str(r) for r in self.sorted()])
+        return f"<{self.__class__.__name__} [{ranges}]>"
+
+
+    def __str__(self):
+        ranges = u" ".join([str(r) for r in self.sorted()])
+        return ranges
+
+
+    def __hash__(self):
+        return hash(str(self))
+
+
+    def sorted(self):
+        """
+        Return a sorted list of items
+        """
+        return sorted(self.ranges, key=attrgetter('min_col', 'min_row', 'max_col', 'max_row'))
+
+
+    def add(self, coord):
+        """
+        Add a cell coordinate or CellRange
+        """
+        cr = coord
+        if isinstance(coord, str):
+            cr = CellRange(coord)
+        elif not isinstance(coord, CellRange):
+            raise ValueError("You can only add CellRanges")
+        if cr not in self:
+            self.ranges.add(cr)
+
+
+    def __iadd__(self, coord):
+        self.add(coord)
+        return self
+
+
+    def __eq__(self, other):
+        if  isinstance(other, str):
+            other = self.__class__(other)
+        return self.ranges == other.ranges
+
+
+    def __ne__(self, other):
+        return not self == other
+
+
+    def __bool__(self):
+        return bool(self.ranges)
+
+
+    def remove(self, coord):
+        if not isinstance(coord, CellRange):
+            coord = CellRange(coord)
+        self.ranges.remove(coord)
+
+
+    def __iter__(self):
+        for cr in self.ranges:
+            yield cr
+
+
+    def __copy__(self):
+        ranges = {copy(r) for r in self.ranges}
+        return MultiCellRange(ranges)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_watch.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_watch.py
new file mode 100644
index 00000000..dea89caf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/cell_watch.py
@@ -0,0 +1,34 @@
+#Autogenerated schema
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Sequence,
+    String,
+)
+
+# could be done using a nestedSequence
+
+class CellWatch(Serialisable):
+
+    tagname = "cellWatch"
+
+    r = String()
+
+    def __init__(self,
+                 r=None,
+                ):
+        self.r = r
+
+
+class CellWatches(Serialisable):
+
+    tagname = "cellWatches"
+
+    cellWatch = Sequence(expected_type=CellWatch)
+
+    __elements__ = ('cellWatch',)
+
+    def __init__(self,
+                 cellWatch=(),
+                ):
+        self.cellWatch = cellWatch
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/controls.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/controls.py
new file mode 100644
index 00000000..f1fd1c9e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/controls.py
@@ -0,0 +1,107 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Typed,
+    Bool,
+    Integer,
+    String,
+    Sequence,
+)
+
+from openpyxl.descriptors.excel import Relation
+from .ole import ObjectAnchor
+
+
+class ControlProperty(Serialisable):
+
+    tagname = "controlPr"
+
+    anchor = Typed(expected_type=ObjectAnchor, )
+    locked = Bool(allow_none=True)
+    defaultSize = Bool(allow_none=True)
+    _print = Bool(allow_none=True)
+    disabled = Bool(allow_none=True)
+    recalcAlways = Bool(allow_none=True)
+    uiObject = Bool(allow_none=True)
+    autoFill = Bool(allow_none=True)
+    autoLine = Bool(allow_none=True)
+    autoPict = Bool(allow_none=True)
+    macro = String(allow_none=True)
+    altText = String(allow_none=True)
+    linkedCell = String(allow_none=True)
+    listFillRange = String(allow_none=True)
+    cf = String(allow_none=True)
+    id = Relation(allow_none=True)
+
+    __elements__ = ('anchor',)
+
+    def __init__(self,
+                 anchor=None,
+                 locked=True,
+                 defaultSize=True,
+                 _print=True,
+                 disabled=False,
+                 recalcAlways=False,
+                 uiObject=False,
+                 autoFill=True,
+                 autoLine=True,
+                 autoPict=True,
+                 macro=None,
+                 altText=None,
+                 linkedCell=None,
+                 listFillRange=None,
+                 cf='pict',
+                 id=None,
+                ):
+        self.anchor = anchor
+        self.locked = locked
+        self.defaultSize = defaultSize
+        self._print = _print
+        self.disabled = disabled
+        self.recalcAlways = recalcAlways
+        self.uiObject = uiObject
+        self.autoFill = autoFill
+        self.autoLine = autoLine
+        self.autoPict = autoPict
+        self.macro = macro
+        self.altText = altText
+        self.linkedCell = linkedCell
+        self.listFillRange = listFillRange
+        self.cf = cf
+        self.id = id
+
+
+class Control(Serialisable):
+
+    tagname = "control"
+
+    controlPr = Typed(expected_type=ControlProperty, allow_none=True)
+    shapeId = Integer()
+    name = String(allow_none=True)
+
+    __elements__ = ('controlPr',)
+
+    def __init__(self,
+                 controlPr=None,
+                 shapeId=None,
+                 name=None,
+                ):
+        self.controlPr = controlPr
+        self.shapeId = shapeId
+        self.name = name
+
+
+class Controls(Serialisable):
+
+    tagname = "controls"
+
+    control = Sequence(expected_type=Control)
+
+    __elements__ = ('control',)
+
+    def __init__(self,
+                 control=(),
+                ):
+        self.control = control
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/copier.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/copier.py
new file mode 100644
index 00000000..f6601540
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/copier.py
@@ -0,0 +1,70 @@
+# Copyright (c) 2010-2024 openpyxl
+
+#standard lib imports
+from copy import copy
+
+from .worksheet import Worksheet
+
+
+class WorksheetCopy:
+    """
+    Copy the values, styles, dimensions, merged cells, margins, and
+    print/page setup from one worksheet to another within the same
+    workbook.
+    """
+
+    def __init__(self, source_worksheet, target_worksheet):
+        self.source = source_worksheet
+        self.target = target_worksheet
+        self._verify_resources()
+
+
+    def _verify_resources(self):
+
+        if (not isinstance(self.source, Worksheet)
+            and not isinstance(self.target, Worksheet)):
+            raise TypeError("Can only copy worksheets")
+
+        if self.source is self.target:
+            raise ValueError("Cannot copy a worksheet to itself")
+
+        if self.source.parent != self.target.parent:
+            raise ValueError('Cannot copy between worksheets from different workbooks')
+
+
+    def copy_worksheet(self):
+        self._copy_cells()
+        self._copy_dimensions()
+
+        self.target.sheet_format = copy(self.source.sheet_format)
+        self.target.sheet_properties = copy(self.source.sheet_properties)
+        self.target.merged_cells = copy(self.source.merged_cells)
+        self.target.page_margins = copy(self.source.page_margins)
+        self.target.page_setup = copy(self.source.page_setup)
+        self.target.print_options = copy(self.source.print_options)
+
+
+    def _copy_cells(self):
+        for (row, col), source_cell  in self.source._cells.items():
+            target_cell = self.target.cell(column=col, row=row)
+
+            target_cell._value = source_cell._value
+            target_cell.data_type = source_cell.data_type
+
+            if source_cell.has_style:
+                target_cell._style = copy(source_cell._style)
+
+            if source_cell.hyperlink:
+                target_cell._hyperlink = copy(source_cell.hyperlink)
+
+            if source_cell.comment:
+                target_cell.comment = copy(source_cell.comment)
+
+
+    def _copy_dimensions(self):
+        for attr in ('row_dimensions', 'column_dimensions'):
+            src = getattr(self.source, attr)
+            target = getattr(self.target, attr)
+            for key, dim in src.items():
+                target[key] = copy(dim)
+                target[key].worksheet = self.target
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/custom.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/custom.py
new file mode 100644
index 00000000..b3af5c91
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/custom.py
@@ -0,0 +1,35 @@
+#Autogenerated schema
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    String,
+    Sequence,
+)
+
+# can be done with a nested sequence
+
+
+class CustomProperty(Serialisable):
+
+    tagname = "customProperty"
+
+    name = String()
+
+    def __init__(self,
+                 name=None,
+                ):
+        self.name = name
+
+
+class CustomProperties(Serialisable):
+
+    tagname = "customProperties"
+
+    customPr = Sequence(expected_type=CustomProperty)
+
+    __elements__ = ('customPr',)
+
+    def __init__(self,
+                 customPr=(),
+                ):
+        self.customPr = customPr
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/datavalidation.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/datavalidation.py
new file mode 100644
index 00000000..f5077d97
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/datavalidation.py
@@ -0,0 +1,202 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from collections import defaultdict
+from itertools import chain
+from operator import itemgetter
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Bool,
+    NoneSet,
+    String,
+    Sequence,
+    Alias,
+    Integer,
+    Convertible,
+)
+from openpyxl.descriptors.nested import NestedText
+
+from openpyxl.utils import (
+    rows_from_range,
+    coordinate_to_tuple,
+    get_column_letter,
+)
+
+
+def collapse_cell_addresses(cells, input_ranges=()):
+    """ Collapse a collection of cell co-ordinates down into an optimal
+        range or collection of ranges.
+
+        E.g. Cells A1, A2, A3, B1, B2 and B3 should have the data-validation
+        object applied, attempt to collapse down to a single range, A1:B3.
+
+        Currently only collapsing contiguous vertical ranges (i.e. above
+        example results in A1:A3 B1:B3).
+    """
+
+    ranges = list(input_ranges)
+
+    # convert cell into row, col tuple
+    raw_coords = (coordinate_to_tuple(cell) for cell in cells)
+
+    # group by column in order
+    grouped_coords = defaultdict(list)
+    for row, col in sorted(raw_coords, key=itemgetter(1)):
+        grouped_coords[col].append(row)
+
+    # create range string from first and last row in column
+    for col, cells in grouped_coords.items():
+        col = get_column_letter(col)
+        fmt = "{0}{1}:{2}{3}"
+        if len(cells) == 1:
+            fmt = "{0}{1}"
+        r = fmt.format(col, min(cells), col, max(cells))
+        ranges.append(r)
+
+    return " ".join(ranges)
+
+
+def expand_cell_ranges(range_string):
+    """
+    Expand cell ranges to a sequence of addresses.
+    Reverse of collapse_cell_addresses
+    Eg. converts "A1:A2 B1:B2" to (A1, A2, B1, B2)
+    """
+    # expand ranges to rows and then flatten
+    rows = (rows_from_range(rs) for rs in range_string.split()) # list of rows
+    cells = (chain(*row) for row in rows) # flatten rows
+    return set(chain(*cells))
+
+
+from .cell_range import MultiCellRange
+
+
+class DataValidation(Serialisable):
+
+    tagname = "dataValidation"
+
+    sqref = Convertible(expected_type=MultiCellRange)
+    cells = Alias("sqref")
+    ranges = Alias("sqref")
+
+    showDropDown = Bool(allow_none=True)
+    hide_drop_down = Alias('showDropDown')
+    showInputMessage = Bool(allow_none=True)
+    showErrorMessage = Bool(allow_none=True)
+    allowBlank = Bool(allow_none=True)
+    allow_blank = Alias('allowBlank')
+
+    errorTitle = String(allow_none = True)
+    error = String(allow_none = True)
+    promptTitle = String(allow_none = True)
+    prompt = String(allow_none = True)
+    formula1 = NestedText(allow_none=True, expected_type=str)
+    formula2 = NestedText(allow_none=True, expected_type=str)
+
+    type = NoneSet(values=("whole", "decimal", "list", "date", "time",
+                           "textLength", "custom"))
+    errorStyle = NoneSet(values=("stop", "warning", "information"))
+    imeMode = NoneSet(values=("noControl", "off", "on", "disabled",
+                              "hiragana", "fullKatakana", "halfKatakana", "fullAlpha","halfAlpha",
+                              "fullHangul", "halfHangul"))
+    operator = NoneSet(values=("between", "notBetween", "equal", "notEqual",
+                               "lessThan", "lessThanOrEqual", "greaterThan", "greaterThanOrEqual"))
+    validation_type = Alias('type')
+
+    def __init__(self,
+                 type=None,
+                 formula1=None,
+                 formula2=None,
+                 showErrorMessage=False,
+                 showInputMessage=False,
+                 showDropDown=False,
+                 allowBlank=False,
+                 sqref=(),
+                 promptTitle=None,
+                 errorStyle=None,
+                 error=None,
+                 prompt=None,
+                 errorTitle=None,
+                 imeMode=None,
+                 operator=None,
+                 allow_blank=None,
+                 ):
+        self.sqref = sqref
+        self.showDropDown = showDropDown
+        self.imeMode = imeMode
+        self.operator = operator
+        self.formula1 = formula1
+        self.formula2 = formula2
+        if allow_blank is not None:
+            allowBlank = allow_blank
+        self.allowBlank = allowBlank
+        self.showErrorMessage = showErrorMessage
+        self.showInputMessage = showInputMessage
+        self.type = type
+        self.promptTitle = promptTitle
+        self.errorStyle = errorStyle
+        self.error = error
+        self.prompt = prompt
+        self.errorTitle = errorTitle
+
+
+    def add(self, cell):
+        """Adds a cell or cell coordinate to this validator"""
+        if hasattr(cell, "coordinate"):
+            cell = cell.coordinate
+        self.sqref += cell
+
+
+    def __contains__(self, cell):
+        if hasattr(cell, "coordinate"):
+            cell = cell.coordinate
+        return cell in self.sqref
+
+
+class DataValidationList(Serialisable):
+
+    tagname = "dataValidations"
+
+    disablePrompts = Bool(allow_none=True)
+    xWindow = Integer(allow_none=True)
+    yWindow = Integer(allow_none=True)
+    dataValidation = Sequence(expected_type=DataValidation)
+
+    __elements__ = ('dataValidation',)
+    __attrs__ = ('disablePrompts', 'xWindow', 'yWindow', 'count')
+
+    def __init__(self,
+                 disablePrompts=None,
+                 xWindow=None,
+                 yWindow=None,
+                 count=None,
+                 dataValidation=(),
+                ):
+        self.disablePrompts = disablePrompts
+        self.xWindow = xWindow
+        self.yWindow = yWindow
+        self.dataValidation = dataValidation
+
+
+    @property
+    def count(self):
+        return len(self)
+
+
+    def __len__(self):
+        return len(self.dataValidation)
+
+
+    def append(self, dv):
+        self.dataValidation.append(dv)
+
+
+    def to_tree(self, tagname=None):
+        """
+        Need to skip validations that have no cell ranges
+        """
+        ranges = self.dataValidation # copy
+        self.dataValidation = [r for r in self.dataValidation if bool(r.sqref)]
+        xml = super().to_tree(tagname)
+        self.dataValidation = ranges
+        return xml
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/dimensions.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/dimensions.py
new file mode 100644
index 00000000..482717a1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/dimensions.py
@@ -0,0 +1,306 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from copy import copy
+
+from openpyxl.compat import safe_string
+from openpyxl.utils import (
+    get_column_letter,
+    get_column_interval,
+    column_index_from_string,
+    range_boundaries,
+)
+from openpyxl.utils.units import DEFAULT_COLUMN_WIDTH
+from openpyxl.descriptors import (
+    Integer,
+    Float,
+    Bool,
+    Strict,
+    String,
+    Alias,
+)
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.styles.styleable import StyleableObject
+from openpyxl.utils.bound_dictionary import BoundDictionary
+from openpyxl.xml.functions import Element
+
+
+class Dimension(Strict, StyleableObject):
+    """Information about the display properties of a row or column."""
+    __fields__ = ('hidden',
+                 'outlineLevel',
+                 'collapsed',)
+
+    index = Integer()
+    hidden = Bool()
+    outlineLevel = Integer(allow_none=True)
+    outline_level = Alias('outlineLevel')
+    collapsed = Bool()
+    style = Alias('style_id')
+
+
+    def __init__(self, index, hidden, outlineLevel,
+                 collapsed, worksheet, visible=True, style=None):
+        super().__init__(sheet=worksheet, style_array=style)
+        self.index = index
+        self.hidden = hidden
+        self.outlineLevel = outlineLevel
+        self.collapsed = collapsed
+
+
+    def __iter__(self):
+        for key in self.__fields__:
+            value = getattr(self, key, None)
+            if value:
+                yield key, safe_string(value)
+
+
+    def __copy__(self):
+        cp = self.__new__(self.__class__)
+        attrib = self.__dict__
+        attrib['worksheet'] = self.parent
+        cp.__init__(**attrib)
+        cp._style = copy(self._style)
+        return cp
+
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__} Instance, Attributes={dict(self)}>"
+
+
+class RowDimension(Dimension):
+    """Information about the display properties of a row."""
+
+    __fields__ = Dimension.__fields__ + ('ht', 'customFormat', 'customHeight', 's',
+                                         'thickBot', 'thickTop')
+    r = Alias('index')
+    s = Alias('style_id')
+    ht = Float(allow_none=True)
+    height = Alias('ht')
+    thickBot = Bool()
+    thickTop = Bool()
+
+    def __init__(self,
+                 worksheet,
+                 index=0,
+                 ht=None,
+                 customHeight=None, # do not write
+                 s=None,
+                 customFormat=None, # do not write
+                 hidden=False,
+                 outlineLevel=0,
+                 outline_level=None,
+                 collapsed=False,
+                 visible=None,
+                 height=None,
+                 r=None,
+                 spans=None,
+                 thickBot=None,
+                 thickTop=None,
+                 **kw
+                 ):
+        if r is not None:
+            index = r
+        if height is not None:
+            ht = height
+        self.ht = ht
+        if visible is not None:
+            hidden = not visible
+        if outline_level is not None:
+            outlineLevel = outline_level
+        self.thickBot = thickBot
+        self.thickTop = thickTop
+        super().__init__(index, hidden, outlineLevel,
+                                           collapsed, worksheet, style=s)
+
+    @property
+    def customFormat(self):
+        """Always true if there is a style for the row"""
+        return self.has_style
+
+    @property
+    def customHeight(self):
+        """Always true if there is a height for the row"""
+        return self.ht is not None
+
+
+class ColumnDimension(Dimension):
+    """Information about the display properties of a column."""
+
+    width = Float()
+    bestFit = Bool()
+    auto_size = Alias('bestFit')
+    index = String()
+    min = Integer(allow_none=True)
+    max = Integer(allow_none=True)
+    collapsed = Bool()
+
+    __fields__ = Dimension.__fields__ + ('width', 'bestFit', 'customWidth', 'style',
+                                         'min', 'max')
+
+    def __init__(self,
+                 worksheet,
+                 index='A',
+                 width=DEFAULT_COLUMN_WIDTH,
+                 bestFit=False,
+                 hidden=False,
+                 outlineLevel=0,
+                 outline_level=None,
+                 collapsed=False,
+                 style=None,
+                 min=None,
+                 max=None,
+                 customWidth=False, # do not write
+                 visible=None,
+                 auto_size=None,):
+        self.width = width
+        self.min = min
+        self.max = max
+        if visible is not None:
+            hidden = not visible
+        if auto_size is not None:
+            bestFit = auto_size
+        self.bestFit = bestFit
+        if outline_level is not None:
+            outlineLevel = outline_level
+        self.collapsed = collapsed
+        super().__init__(index, hidden, outlineLevel,
+                                              collapsed, worksheet, style=style)
+
+
+    @property
+    def customWidth(self):
+        """Always true if there is a width for the column"""
+        return bool(self.width)
+
+
+    def reindex(self):
+        """
+        Set boundaries for column definition
+        """
+        if not all([self.min, self.max]):
+            self.min = self.max = column_index_from_string(self.index)
+
+    @property
+    def range(self):
+        """Return the range of cells actually covered"""
+        return f"{get_column_letter(self.min)}:{get_column_letter(self.max)}"
+
+
+    def to_tree(self):
+        attrs = dict(self)
+        if attrs.keys() != {'min', 'max'}:
+            return Element("col", **attrs)
+
+
+class DimensionHolder(BoundDictionary):
+    """
+    Allow columns to be grouped
+    """
+
+    def __init__(self, worksheet, reference="index", default_factory=None):
+        self.worksheet = worksheet
+        self.max_outline = None
+        self.default_factory = default_factory
+        super().__init__(reference, default_factory)
+
+
+    def group(self, start, end=None, outline_level=1, hidden=False):
+        """allow grouping a range of consecutive rows or columns together
+
+        :param start: first row or column to be grouped (mandatory)
+        :param end: last row or column to be grouped (optional, default to start)
+        :param outline_level: outline level
+        :param hidden: should the group be hidden on workbook open or not
+        """
+        if end is None:
+            end = start
+
+        if isinstance(self.default_factory(), ColumnDimension):
+            new_dim = self[start]
+            new_dim.outline_level = outline_level
+            new_dim.hidden = hidden
+            work_sequence = get_column_interval(start, end)[1:]
+            for column_letter in work_sequence:
+                if column_letter in self:
+                    del self[column_letter]
+            new_dim.min, new_dim.max = map(column_index_from_string, (start, end))
+        elif isinstance(self.default_factory(), RowDimension):
+            for el in range(start, end + 1):
+                new_dim = self.worksheet.row_dimensions[el]
+                new_dim.outline_level = outline_level
+                new_dim.hidden = hidden
+
+
+    def to_tree(self):
+
+        def sorter(value):
+            value.reindex()
+            return value.min
+
+        el = Element('cols')
+        outlines = set()
+
+        for col in sorted(self.values(), key=sorter):
+            obj = col.to_tree()
+            if obj is not None:
+                outlines.add(col.outlineLevel)
+                el.append(obj)
+
+        if outlines:
+            self.max_outline = max(outlines)
+
+        if len(el):
+            return el # must have at least one child
+
+
+class SheetFormatProperties(Serialisable):
+
+    tagname = "sheetFormatPr"
+
+    baseColWidth = Integer(allow_none=True)
+    defaultColWidth = Float(allow_none=True)
+    defaultRowHeight = Float()
+    customHeight = Bool(allow_none=True)
+    zeroHeight = Bool(allow_none=True)
+    thickTop = Bool(allow_none=True)
+    thickBottom = Bool(allow_none=True)
+    outlineLevelRow = Integer(allow_none=True)
+    outlineLevelCol = Integer(allow_none=True)
+
+    def __init__(self,
+                 baseColWidth=8, #according to spec
+                 defaultColWidth=None,
+                 defaultRowHeight=15,
+                 customHeight=None,
+                 zeroHeight=None,
+                 thickTop=None,
+                 thickBottom=None,
+                 outlineLevelRow=None,
+                 outlineLevelCol=None,
+                ):
+        self.baseColWidth = baseColWidth
+        self.defaultColWidth = defaultColWidth
+        self.defaultRowHeight = defaultRowHeight
+        self.customHeight = customHeight
+        self.zeroHeight = zeroHeight
+        self.thickTop = thickTop
+        self.thickBottom = thickBottom
+        self.outlineLevelRow = outlineLevelRow
+        self.outlineLevelCol = outlineLevelCol
+
+
+class SheetDimension(Serialisable):
+
+    tagname = "dimension"
+
+    ref = String()
+
+    def __init__(self,
+                 ref=None,
+                ):
+        self.ref = ref
+
+
+    @property
+    def boundaries(self):
+        return range_boundaries(self.ref)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/drawing.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/drawing.py
new file mode 100644
index 00000000..45bf4d35
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/drawing.py
@@ -0,0 +1,14 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors.excel import Relation
+
+
+class Drawing(Serialisable):
+
+    tagname = "drawing"
+
+    id = Relation()
+
+    def __init__(self, id=None):
+        self.id = id
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/errors.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/errors.py
new file mode 100644
index 00000000..1bed3f78
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/errors.py
@@ -0,0 +1,93 @@
+#Autogenerated schema
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Typed,
+    String,
+    Bool,
+    Sequence,
+)
+from openpyxl.descriptors.excel import CellRange
+
+
+class Extension(Serialisable):
+
+    tagname = "extension"
+
+    uri = String(allow_none=True)
+
+    def __init__(self,
+                 uri=None,
+                ):
+        self.uri = uri
+
+
+class ExtensionList(Serialisable):
+
+    tagname = "extensionList"
+
+    # uses element group EG_ExtensionList
+    ext = Sequence(expected_type=Extension)
+
+    __elements__ = ('ext',)
+
+    def __init__(self,
+                 ext=(),
+                ):
+        self.ext = ext
+
+
+class IgnoredError(Serialisable):
+
+    tagname = "ignoredError"
+
+    sqref = CellRange
+    evalError = Bool(allow_none=True)
+    twoDigitTextYear = Bool(allow_none=True)
+    numberStoredAsText = Bool(allow_none=True)
+    formula = Bool(allow_none=True)
+    formulaRange = Bool(allow_none=True)
+    unlockedFormula = Bool(allow_none=True)
+    emptyCellReference = Bool(allow_none=True)
+    listDataValidation = Bool(allow_none=True)
+    calculatedColumn = Bool(allow_none=True)
+
+    def __init__(self,
+                 sqref=None,
+                 evalError=False,
+                 twoDigitTextYear=False,
+                 numberStoredAsText=False,
+                 formula=False,
+                 formulaRange=False,
+                 unlockedFormula=False,
+                 emptyCellReference=False,
+                 listDataValidation=False,
+                 calculatedColumn=False,
+                ):
+        self.sqref = sqref
+        self.evalError = evalError
+        self.twoDigitTextYear = twoDigitTextYear
+        self.numberStoredAsText = numberStoredAsText
+        self.formula = formula
+        self.formulaRange = formulaRange
+        self.unlockedFormula = unlockedFormula
+        self.emptyCellReference = emptyCellReference
+        self.listDataValidation = listDataValidation
+        self.calculatedColumn = calculatedColumn
+
+
+class IgnoredErrors(Serialisable):
+
+    tagname = "ignoredErrors"
+
+    ignoredError = Sequence(expected_type=IgnoredError)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('ignoredError', 'extLst')
+
+    def __init__(self,
+                 ignoredError=(),
+                 extLst=None,
+                ):
+        self.ignoredError = ignoredError
+        self.extLst = extLst
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/filters.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/filters.py
new file mode 100644
index 00000000..a2cfd8eb
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/filters.py
@@ -0,0 +1,486 @@
+# Copyright (c) 2010-2024 openpyxl
+
+import re
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Alias,
+    Typed,
+    Set,
+    Float,
+    DateTime,
+    NoneSet,
+    Bool,
+    Integer,
+    String,
+    Sequence,
+    MinMax,
+)
+from openpyxl.descriptors.excel import ExtensionList, CellRange
+from openpyxl.descriptors.sequence import ValueSequence
+from openpyxl.utils import absolute_coordinate
+
+
+class SortCondition(Serialisable):
+
+    tagname = "sortCondition"
+
+    descending = Bool(allow_none=True)
+    sortBy = NoneSet(values=(['value', 'cellColor', 'fontColor', 'icon']))
+    ref = CellRange()
+    customList = String(allow_none=True)
+    dxfId = Integer(allow_none=True)
+    iconSet = NoneSet(values=(['3Arrows', '3ArrowsGray', '3Flags',
+                           '3TrafficLights1', '3TrafficLights2', '3Signs', '3Symbols', '3Symbols2',
+                           '4Arrows', '4ArrowsGray', '4RedToBlack', '4Rating', '4TrafficLights',
+                           '5Arrows', '5ArrowsGray', '5Rating', '5Quarters']))
+    iconId = Integer(allow_none=True)
+
+    def __init__(self,
+                 ref=None,
+                 descending=None,
+                 sortBy=None,
+                 customList=None,
+                 dxfId=None,
+                 iconSet=None,
+                 iconId=None,
+                ):
+        self.descending = descending
+        self.sortBy = sortBy
+        self.ref = ref
+        self.customList = customList
+        self.dxfId = dxfId
+        self.iconSet = iconSet
+        self.iconId = iconId
+
+
+class SortState(Serialisable):
+
+    tagname = "sortState"
+
+    columnSort = Bool(allow_none=True)
+    caseSensitive = Bool(allow_none=True)
+    sortMethod = NoneSet(values=(['stroke', 'pinYin']))
+    ref = CellRange()
+    sortCondition = Sequence(expected_type=SortCondition, allow_none=True)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('sortCondition',)
+
+    def __init__(self,
+                 columnSort=None,
+                 caseSensitive=None,
+                 sortMethod=None,
+                 ref=None,
+                 sortCondition=(),
+                 extLst=None,
+                ):
+        self.columnSort = columnSort
+        self.caseSensitive = caseSensitive
+        self.sortMethod = sortMethod
+        self.ref = ref
+        self.sortCondition = sortCondition
+
+
+    def __bool__(self):
+        return self.ref is not None
+
+
+
+class IconFilter(Serialisable):
+
+    tagname = "iconFilter"
+
+    iconSet = Set(values=(['3Arrows', '3ArrowsGray', '3Flags',
+                           '3TrafficLights1', '3TrafficLights2', '3Signs', '3Symbols', '3Symbols2',
+                           '4Arrows', '4ArrowsGray', '4RedToBlack', '4Rating', '4TrafficLights',
+                           '5Arrows', '5ArrowsGray', '5Rating', '5Quarters']))
+    iconId = Integer(allow_none=True)
+
+    def __init__(self,
+                 iconSet=None,
+                 iconId=None,
+                ):
+        self.iconSet = iconSet
+        self.iconId = iconId
+
+
+class ColorFilter(Serialisable):
+
+    tagname = "colorFilter"
+
+    dxfId = Integer(allow_none=True)
+    cellColor = Bool(allow_none=True)
+
+    def __init__(self,
+                 dxfId=None,
+                 cellColor=None,
+                ):
+        self.dxfId = dxfId
+        self.cellColor = cellColor
+
+
+class DynamicFilter(Serialisable):
+
+    tagname = "dynamicFilter"
+
+    type = Set(values=(['null', 'aboveAverage', 'belowAverage', 'tomorrow',
+                        'today', 'yesterday', 'nextWeek', 'thisWeek', 'lastWeek', 'nextMonth',
+                        'thisMonth', 'lastMonth', 'nextQuarter', 'thisQuarter', 'lastQuarter',
+                        'nextYear', 'thisYear', 'lastYear', 'yearToDate', 'Q1', 'Q2', 'Q3', 'Q4',
+                        'M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8', 'M9', 'M10', 'M11',
+                        'M12']))
+    val = Float(allow_none=True)
+    valIso = DateTime(allow_none=True)
+    maxVal = Float(allow_none=True)
+    maxValIso = DateTime(allow_none=True)
+
+    def __init__(self,
+                 type=None,
+                 val=None,
+                 valIso=None,
+                 maxVal=None,
+                 maxValIso=None,
+                ):
+        self.type = type
+        self.val = val
+        self.valIso = valIso
+        self.maxVal = maxVal
+        self.maxValIso = maxValIso
+
+
+class CustomFilter(Serialisable):
+
+    tagname = "customFilter"
+
+    val = String()
+    operator = Set(values=['equal', 'lessThan', 'lessThanOrEqual',
+                           'notEqual', 'greaterThanOrEqual', 'greaterThan'])
+
+    def __init__(self, operator="equal", val=None):
+        self.operator = operator
+        self.val = val
+
+
+    def _get_subtype(self):
+        if self.val == " ":
+            subtype = BlankFilter
+        else:
+            try:
+                float(self.val)
+                subtype = NumberFilter
+            except ValueError:
+                subtype = StringFilter
+        return subtype
+
+
+    def convert(self):
+        """Convert to more specific filter"""
+        typ = self._get_subtype()
+        if typ in (BlankFilter, NumberFilter):
+            return typ(**dict(self))
+
+        operator, term = StringFilter._guess_operator(self.val)
+        flt = StringFilter(operator, term)
+        if self.operator == "notEqual":
+            flt.exclude = True
+        return flt
+
+
+class BlankFilter(CustomFilter):
+    """
+    Exclude blanks
+    """
+
+    __attrs__ = ("operator", "val")
+
+    def __init__(self, **kw):
+        pass
+
+
+    @property
+    def operator(self):
+        return "notEqual"
+
+
+    @property
+    def val(self):
+        return " "
+
+
+class NumberFilter(CustomFilter):
+
+
+    operator = Set(values=
+                   ['equal', 'lessThan', 'lessThanOrEqual',
+                    'notEqual', 'greaterThanOrEqual', 'greaterThan'])
+    val = Float()
+
+    def __init__(self, operator="equal", val=None):
+        self.operator = operator
+        self.val = val
+
+
+string_format_mapping = {
+    "contains": "*{}*",
+    "startswith": "{}*",
+    "endswith": "*{}",
+    "wildcard":  "{}",
+}
+
+
+class StringFilter(CustomFilter):
+
+    operator = Set(values=['contains', 'startswith', 'endswith', 'wildcard']
+                   )
+    val = String()
+    exclude = Bool()
+
+
+    def __init__(self, operator="contains", val=None, exclude=False):
+        self.operator = operator
+        self.val = val
+        self.exclude = exclude
+
+
+    def _escape(self):
+        """Escape wildcards ~, * ? when serialising"""
+        if self.operator == "wildcard":
+            return self.val
+        return re.sub(r"~|\*|\?", r"~\g<0>", self.val)
+
+
+    @staticmethod
+    def _unescape(value):
+        """
+        Unescape value
+        """
+        return re.sub(r"~(?P<op>[~*?])", r"\g<op>", value)
+
+
+    @staticmethod
+    def _guess_operator(value):
+        value = StringFilter._unescape(value)
+        endswith = r"^(?P<endswith>\*)(?P<term>[^\*\?]*$)"
+        startswith = r"^(?P<term>[^\*\?]*)(?P<startswith>\*)$"
+        contains = r"^(?P<contains>\*)(?P<term>[^\*\?]*)\*$"
+        d = {"wildcard": True, "term": value}
+        for pat in [contains, startswith, endswith]:
+            m = re.match(pat, value)
+            if m:
+                d = m.groupdict()
+
+        term = d.pop("term")
+        op = list(d)[0]
+        return op, term
+
+
+    def to_tree(self, tagname=None, idx=None, namespace=None):
+        fmt = string_format_mapping[self.operator]
+        op = self.exclude and "notEqual" or "equal"
+        value = fmt.format(self._escape())
+        flt = CustomFilter(op, value)
+        return flt.to_tree(tagname, idx, namespace)
+
+
+class CustomFilters(Serialisable):
+
+    tagname = "customFilters"
+
+    _and = Bool(allow_none=True)
+    customFilter = Sequence(expected_type=CustomFilter) # min 1, max 2
+
+    __elements__ = ('customFilter',)
+
+    def __init__(self,
+                 _and=None,
+                 customFilter=(),
+                ):
+        self._and = _and
+        self.customFilter = customFilter
+
+
+class Top10(Serialisable):
+
+    tagname = "top10"
+
+    top = Bool(allow_none=True)
+    percent = Bool(allow_none=True)
+    val = Float()
+    filterVal = Float(allow_none=True)
+
+    def __init__(self,
+                 top=None,
+                 percent=None,
+                 val=None,
+                 filterVal=None,
+                ):
+        self.top = top
+        self.percent = percent
+        self.val = val
+        self.filterVal = filterVal
+
+
+class DateGroupItem(Serialisable):
+
+    tagname = "dateGroupItem"
+
+    year = Integer()
+    month = MinMax(min=1, max=12, allow_none=True)
+    day = MinMax(min=1, max=31, allow_none=True)
+    hour = MinMax(min=0, max=23, allow_none=True)
+    minute = MinMax(min=0, max=59, allow_none=True)
+    second = Integer(min=0, max=59, allow_none=True)
+    dateTimeGrouping = Set(values=(['year', 'month', 'day', 'hour', 'minute',
+                                    'second']))
+
+    def __init__(self,
+                 year=None,
+                 month=None,
+                 day=None,
+                 hour=None,
+                 minute=None,
+                 second=None,
+                 dateTimeGrouping=None,
+                ):
+        self.year = year
+        self.month = month
+        self.day = day
+        self.hour = hour
+        self.minute = minute
+        self.second = second
+        self.dateTimeGrouping = dateTimeGrouping
+
+
+class Filters(Serialisable):
+
+    tagname = "filters"
+
+    blank = Bool(allow_none=True)
+    calendarType = NoneSet(values=["gregorian","gregorianUs",
+                                   "gregorianMeFrench","gregorianArabic", "hijri","hebrew",
+                                   "taiwan","japan", "thai","korea",
+                                   "saka","gregorianXlitEnglish","gregorianXlitFrench"])
+    filter = ValueSequence(expected_type=str)
+    dateGroupItem = Sequence(expected_type=DateGroupItem, allow_none=True)
+
+    __elements__ = ('filter', 'dateGroupItem')
+
+    def __init__(self,
+                 blank=None,
+                 calendarType=None,
+                 filter=(),
+                 dateGroupItem=(),
+                ):
+        self.blank = blank
+        self.calendarType = calendarType
+        self.filter = filter
+        self.dateGroupItem = dateGroupItem
+
+
+class FilterColumn(Serialisable):
+
+    tagname = "filterColumn"
+
+    colId = Integer()
+    col_id = Alias('colId')
+    hiddenButton = Bool(allow_none=True)
+    showButton = Bool(allow_none=True)
+    # some elements are choice
+    filters = Typed(expected_type=Filters, allow_none=True)
+    top10 = Typed(expected_type=Top10, allow_none=True)
+    customFilters = Typed(expected_type=CustomFilters, allow_none=True)
+    dynamicFilter = Typed(expected_type=DynamicFilter, allow_none=True)
+    colorFilter = Typed(expected_type=ColorFilter, allow_none=True)
+    iconFilter = Typed(expected_type=IconFilter, allow_none=True)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('filters', 'top10', 'customFilters', 'dynamicFilter',
+                    'colorFilter', 'iconFilter')
+
+    def __init__(self,
+                 colId=None,
+                 hiddenButton=False,
+                 showButton=True,
+                 filters=None,
+                 top10=None,
+                 customFilters=None,
+                 dynamicFilter=None,
+                 colorFilter=None,
+                 iconFilter=None,
+                 extLst=None,
+                 blank=None,
+                 vals=None,
+                ):
+        self.colId = colId
+        self.hiddenButton = hiddenButton
+        self.showButton = showButton
+        self.filters = filters
+        self.top10 = top10
+        self.customFilters = customFilters
+        self.dynamicFilter = dynamicFilter
+        self.colorFilter = colorFilter
+        self.iconFilter = iconFilter
+        if blank is not None and self.filters:
+            self.filters.blank = blank
+        if vals is not None and self.filters:
+            self.filters.filter = vals
+
+
+class AutoFilter(Serialisable):
+
+    tagname = "autoFilter"
+
+    ref = CellRange()
+    filterColumn = Sequence(expected_type=FilterColumn, allow_none=True)
+    sortState = Typed(expected_type=SortState, allow_none=True)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('filterColumn', 'sortState')
+
+    def __init__(self,
+                 ref=None,
+                 filterColumn=(),
+                 sortState=None,
+                 extLst=None,
+                ):
+        self.ref = ref
+        self.filterColumn = filterColumn
+        self.sortState = sortState
+
+
+    def __bool__(self):
+        return self.ref is not None
+
+
+    def __str__(self):
+        return absolute_coordinate(self.ref)
+
+
+    def add_filter_column(self, col_id, vals, blank=False):
+        """
+        Add row filter for specified column.
+
+        :param col_id: Zero-origin column id. 0 means first column.
+        :type  col_id: int
+        :param vals: Value list to show.
+        :type  vals: str[]
+        :param blank: Show rows that have blank cell if True (default=``False``)
+        :type  blank: bool
+        """
+        self.filterColumn.append(FilterColumn(colId=col_id, filters=Filters(blank=blank, filter=vals)))
+
+
+    def add_sort_condition(self, ref, descending=False):
+        """
+        Add sort condition for cpecified range of cells.
+
+        :param ref: range of the cells (e.g. 'A2:A150')
+        :type  ref: string, is the same as that of the filter
+        :param descending: Descending sort order (default=``False``)
+        :type  descending: bool
+        """
+        cond = SortCondition(ref, descending)
+        if self.sortState is None:
+            self.sortState = SortState(ref=self.ref)
+        self.sortState.sortCondition.append(cond)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/formula.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/formula.py
new file mode 100644
index 00000000..7eb920e9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/formula.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.compat import safe_string
+
+class DataTableFormula:
+
+
+    t = "dataTable"
+
+    def __init__(self,
+                 ref,
+                 ca=False,
+                 dt2D=False,
+                 dtr=False,
+                 r1=None,
+                 r2=None,
+                 del1=False,
+                 del2=False,
+                 **kw):
+        self.ref = ref
+        self.ca = ca
+        self.dt2D = dt2D
+        self.dtr = dtr
+        self.r1 = r1
+        self.r2 = r2
+        self.del1 = del1
+        self.del2 = del2
+
+
+    def __iter__(self):
+        for k in ["t", "ref", "dt2D", "dtr", "r1", "r2", "del1", "del2", "ca"]:
+            v = getattr(self, k)
+            if v:
+                yield k, safe_string(v)
+
+
+class ArrayFormula:
+
+    t = "array"
+
+
+    def __init__(self, ref, text=None):
+        self.ref = ref
+        self.text = text
+
+
+    def __iter__(self):
+        for k in ["t", "ref"]:
+            v = getattr(self, k)
+            if v:
+                yield k, safe_string(v)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/header_footer.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/header_footer.py
new file mode 100644
index 00000000..598aa23d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/header_footer.py
@@ -0,0 +1,270 @@
+# Copyright (c) 2010-2024 openpyxl
+
+# Simplified implementation of headers and footers: let worksheets have separate items
+
+import re
+from warnings import warn
+
+from openpyxl.descriptors import (
+    Alias,
+    Bool,
+    Strict,
+    String,
+    Integer,
+    MatchPattern,
+    Typed,
+)
+from openpyxl.descriptors.serialisable import Serialisable
+
+
+from openpyxl.xml.functions import Element
+from openpyxl.utils.escape import escape, unescape
+
+
+FONT_PATTERN = '&"(?P<font>.+)"'
+COLOR_PATTERN  = "&K(?P<color>[A-F0-9]{6})"
+SIZE_REGEX = r"&(?P<size>\d+\s?)"
+FORMAT_REGEX = re.compile("{0}|{1}|{2}".format(FONT_PATTERN, COLOR_PATTERN,
+                                               SIZE_REGEX)
+                          )
+
+def _split_string(text):
+    """
+    Split the combined (decoded) string into left, center and right parts
+
+    # See http://stackoverflow.com/questions/27711175/regex-with-multiple-optional-groups for discussion
+    """
+
+    ITEM_REGEX = re.compile("""
+    (&L(?P<left>.+?))?
+    (&C(?P<center>.+?))?
+    (&R(?P<right>.+?))?
+    $""", re.VERBOSE | re.DOTALL)
+
+    m = ITEM_REGEX.match(text)
+    try:
+        parts = m.groupdict()
+    except AttributeError:
+        warn("""Cannot parse header or footer so it will be ignored""")
+        parts = {'left':'', 'right':'', 'center':''}
+    return parts
+
+
+class _HeaderFooterPart(Strict):
+
+    """
+    Individual left/center/right header/footer part
+
+    Do not use directly.
+
+    Header & Footer ampersand codes:
+
+    * &A   Inserts the worksheet name
+    * &B   Toggles bold
+    * &D or &[Date]   Inserts the current date
+    * &E   Toggles double-underline
+    * &F or &[File]   Inserts the workbook name
+    * &I   Toggles italic
+    * &N or &[Pages]   Inserts the total page count
+    * &S   Toggles strikethrough
+    * &T   Inserts the current time
+    * &[Tab]   Inserts the worksheet name
+    * &U   Toggles underline
+    * &X   Toggles superscript
+    * &Y   Toggles subscript
+    * &P or &[Page]   Inserts the current page number
+    * &P+n   Inserts the page number incremented by n
+    * &P-n   Inserts the page number decremented by n
+    * &[Path]   Inserts the workbook path
+    * &&   Escapes the ampersand character
+    * &"fontname"   Selects the named font
+    * &nn   Selects the specified 2-digit font point size
+
+    Colours are in RGB Hex
+    """
+
+    text = String(allow_none=True)
+    font = String(allow_none=True)
+    size = Integer(allow_none=True)
+    RGB = ("^[A-Fa-f0-9]{6}$")
+    color = MatchPattern(allow_none=True, pattern=RGB)
+
+
+    def __init__(self, text=None, font=None, size=None, color=None):
+        self.text = text
+        self.font = font
+        self.size = size
+        self.color = color
+
+
+    def __str__(self):
+        """
+        Convert to Excel HeaderFooter miniformat minus position
+        """
+        fmt = []
+        if self.font:
+            fmt.append(u'&"{0}"'.format(self.font))
+        if self.size:
+            fmt.append("&{0} ".format(self.size))
+        if self.color:
+            fmt.append("&K{0}".format(self.color))
+        return u"".join(fmt + [self.text])
+
+    def __bool__(self):
+        return bool(self.text)
+
+
+
+    @classmethod
+    def from_str(cls, text):
+        """
+        Convert from miniformat to object
+        """
+        keys = ('font', 'color', 'size')
+        kw = dict((k, v) for match in FORMAT_REGEX.findall(text)
+                  for k, v in zip(keys, match) if v)
+
+        kw['text'] = FORMAT_REGEX.sub('', text)
+
+        return cls(**kw)
+
+
+class HeaderFooterItem(Strict):
+    """
+    Header or footer item
+
+    """
+
+    left = Typed(expected_type=_HeaderFooterPart)
+    center = Typed(expected_type=_HeaderFooterPart)
+    centre = Alias("center")
+    right = Typed(expected_type=_HeaderFooterPart)
+
+    __keys = ('L', 'C', 'R')
+
+
+    def __init__(self, left=None, right=None, center=None):
+        if left is None:
+            left = _HeaderFooterPart()
+        self.left = left
+        if center is None:
+            center = _HeaderFooterPart()
+        self.center = center
+        if right is None:
+            right = _HeaderFooterPart()
+        self.right = right
+
+
+    def __str__(self):
+        """
+        Pack parts into a single string
+        """
+        TRANSFORM = {'&[Tab]': '&A', '&[Pages]': '&N', '&[Date]': '&D',
+                     '&[Path]': '&Z', '&[Page]': '&P', '&[Time]': '&T', '&[File]': '&F',
+                     '&[Picture]': '&G'}
+
+        # escape keys and create regex
+        SUBS_REGEX = re.compile("|".join(["({0})".format(re.escape(k))
+                                          for k in TRANSFORM]))
+
+        def replace(match):
+            """
+            Callback for re.sub
+            Replace expanded control with mini-format equivalent
+            """
+            sub = match.group(0)
+            return TRANSFORM[sub]
+
+        txt = []
+        for key, part in zip(
+            self.__keys, [self.left, self.center, self.right]):
+            if part.text is not None:
+                txt.append(u"&{0}{1}".format(key, str(part)))
+        txt = "".join(txt)
+        txt = SUBS_REGEX.sub(replace, txt)
+        return escape(txt)
+
+
+    def __bool__(self):
+        return any([self.left, self.center, self.right])
+
+
+
+    def to_tree(self, tagname):
+        """
+        Return as XML node
+        """
+        el = Element(tagname)
+        el.text = str(self)
+        return el
+
+
+    @classmethod
+    def from_tree(cls, node):
+        if node.text:
+            text = unescape(node.text)
+            parts = _split_string(text)
+            for k, v in parts.items():
+                if v is not None:
+                    parts[k] = _HeaderFooterPart.from_str(v)
+            self = cls(**parts)
+            return self
+
+
+class HeaderFooter(Serialisable):
+
+    tagname = "headerFooter"
+
+    differentOddEven = Bool(allow_none=True)
+    differentFirst = Bool(allow_none=True)
+    scaleWithDoc = Bool(allow_none=True)
+    alignWithMargins = Bool(allow_none=True)
+    oddHeader = Typed(expected_type=HeaderFooterItem, allow_none=True)
+    oddFooter = Typed(expected_type=HeaderFooterItem, allow_none=True)
+    evenHeader = Typed(expected_type=HeaderFooterItem, allow_none=True)
+    evenFooter = Typed(expected_type=HeaderFooterItem, allow_none=True)
+    firstHeader = Typed(expected_type=HeaderFooterItem, allow_none=True)
+    firstFooter = Typed(expected_type=HeaderFooterItem, allow_none=True)
+
+    __elements__ = ("oddHeader", "oddFooter", "evenHeader", "evenFooter", "firstHeader", "firstFooter")
+
+    def __init__(self,
+                 differentOddEven=None,
+                 differentFirst=None,
+                 scaleWithDoc=None,
+                 alignWithMargins=None,
+                 oddHeader=None,
+                 oddFooter=None,
+                 evenHeader=None,
+                 evenFooter=None,
+                 firstHeader=None,
+                 firstFooter=None,
+                ):
+        self.differentOddEven = differentOddEven
+        self.differentFirst = differentFirst
+        self.scaleWithDoc = scaleWithDoc
+        self.alignWithMargins = alignWithMargins
+        if oddHeader is None:
+            oddHeader = HeaderFooterItem()
+        self.oddHeader = oddHeader
+        if oddFooter is None:
+            oddFooter = HeaderFooterItem()
+        self.oddFooter = oddFooter
+        if evenHeader is None:
+            evenHeader = HeaderFooterItem()
+        self.evenHeader = evenHeader
+        if evenFooter is None:
+            evenFooter = HeaderFooterItem()
+        self.evenFooter = evenFooter
+        if firstHeader is None:
+            firstHeader = HeaderFooterItem()
+        self.firstHeader = firstHeader
+        if firstFooter is None:
+            firstFooter = HeaderFooterItem()
+        self.firstFooter = firstFooter
+
+
+    def __bool__(self):
+        parts = [getattr(self, attr) for attr in self.__attrs__ + self.__elements__]
+        return any(parts)
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/hyperlink.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/hyperlink.py
new file mode 100644
index 00000000..332b4154
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/hyperlink.py
@@ -0,0 +1,46 @@
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    String,
+    Sequence,
+)
+from openpyxl.descriptors.excel import Relation
+
+
+class Hyperlink(Serialisable):
+
+    tagname = "hyperlink"
+
+    ref = String()
+    location = String(allow_none=True)
+    tooltip = String(allow_none=True)
+    display = String(allow_none=True)
+    id = Relation()
+    target = String(allow_none=True)
+
+    __attrs__ = ("ref", "location", "tooltip", "display", "id")
+
+    def __init__(self,
+                 ref=None,
+                 location=None,
+                 tooltip=None,
+                 display=None,
+                 id=None,
+                 target=None,
+                ):
+        self.ref = ref
+        self.location = location
+        self.tooltip = tooltip
+        self.display = display
+        self.id = id
+        self.target = target
+
+
+class HyperlinkList(Serialisable):
+
+    tagname = "hyperlinks"
+
+    __expected_type = Hyperlink
+    hyperlink = Sequence(expected_type=__expected_type)
+
+    def __init__(self, hyperlink=()):
+        self.hyperlink = hyperlink
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/merge.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/merge.py
new file mode 100644
index 00000000..a3a6bebd
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/merge.py
@@ -0,0 +1,141 @@
+# Copyright (c) 2010-2024 openpyxl
+
+import copy
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Integer,
+    Sequence,
+)
+
+from openpyxl.cell.cell import MergedCell
+from openpyxl.styles.borders import Border
+
+from .cell_range import CellRange
+
+
+class MergeCell(CellRange):
+
+    tagname = "mergeCell"
+    ref = CellRange.coord
+
+    __attrs__ = ("ref",)
+
+
+    def __init__(self,
+                 ref=None,
+                ):
+        super().__init__(ref)
+
+
+    def __copy__(self):
+        return self.__class__(self.ref)
+
+
+class MergeCells(Serialisable):
+
+    tagname = "mergeCells"
+
+    count = Integer(allow_none=True)
+    mergeCell = Sequence(expected_type=MergeCell, )
+
+    __elements__ = ('mergeCell',)
+    __attrs__ = ('count',)
+
+    def __init__(self,
+                 count=None,
+                 mergeCell=(),
+                ):
+        self.mergeCell = mergeCell
+
+
+    @property
+    def count(self):
+        return len(self.mergeCell)
+
+
+class MergedCellRange(CellRange):
+
+    """
+    MergedCellRange stores the border information of a merged cell in the top
+    left cell of the merged cell.
+    The remaining cells in the merged cell are stored as MergedCell objects and
+    get their border information from the upper left cell.
+    """
+
+    def __init__(self, worksheet, coord):
+        self.ws = worksheet
+        super().__init__(range_string=coord)
+        self.start_cell = None
+        self._get_borders()
+
+
+    def _get_borders(self):
+        """
+        If the upper left cell of the merged cell does not yet exist, it is
+        created.
+        The upper left cell gets the border information of the bottom and right
+        border from the bottom right cell of the merged cell, if available.
+        """
+
+        # Top-left cell.
+        self.start_cell = self.ws._cells.get((self.min_row, self.min_col))
+        if self.start_cell is None:
+            self.start_cell = self.ws.cell(row=self.min_row, column=self.min_col)
+
+        # Bottom-right cell
+        end_cell = self.ws._cells.get((self.max_row, self.max_col))
+        if end_cell is not None:
+            self.start_cell.border += Border(right=end_cell.border.right,
+                                             bottom=end_cell.border.bottom)
+
+
+    def format(self):
+        """
+        Each cell of the merged cell is created as MergedCell if it does not
+        already exist.
+
+        The MergedCells at the edge of the merged cell gets its borders from
+        the upper left cell.
+
+         - The top MergedCells get the top border from the top left cell.
+         - The bottom MergedCells get the bottom border from the top left cell.
+         - The left MergedCells get the left border from the top left cell.
+         - The right MergedCells get the right border from the top left cell.
+        """
+
+        names = ['top', 'left', 'right', 'bottom']
+
+        for name in names:
+            side = getattr(self.start_cell.border, name)
+            if side and side.style is None:
+                continue # don't need to do anything if there is no border style
+            border = Border(**{name:side})
+            for coord in getattr(self, name):
+                cell = self.ws._cells.get(coord)
+                if cell is None:
+                    row, col = coord
+                    cell = MergedCell(self.ws, row=row, column=col)
+                    self.ws._cells[(cell.row, cell.column)] = cell
+                cell.border += border
+
+        protected = self.start_cell.protection is not None
+        if protected:
+            protection = copy.copy(self.start_cell.protection)
+        for coord in self.cells:
+            cell = self.ws._cells.get(coord)
+            if cell is None:
+                row, col = coord
+                cell = MergedCell(self.ws, row=row, column=col)
+                self.ws._cells[(cell.row, cell.column)] = cell
+
+            if protected:
+                cell.protection = protection
+
+
+    def __contains__(self, coord):
+        return coord in CellRange(self.coord)
+
+
+    def __copy__(self):
+        return self.__class__(self.ws, self.coord)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/ole.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/ole.py
new file mode 100644
index 00000000..61dc0048
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/ole.py
@@ -0,0 +1,133 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Typed,
+    Integer,
+    String,
+    Set,
+    Bool,
+    Sequence,
+)
+
+from openpyxl.drawing.spreadsheet_drawing import AnchorMarker
+from openpyxl.xml.constants import SHEET_DRAWING_NS
+
+
+class ObjectAnchor(Serialisable):
+
+    tagname = "anchor"
+
+    _from = Typed(expected_type=AnchorMarker, namespace=SHEET_DRAWING_NS)
+    to = Typed(expected_type=AnchorMarker, namespace=SHEET_DRAWING_NS)
+    moveWithCells = Bool(allow_none=True)
+    sizeWithCells = Bool(allow_none=True)
+    z_order = Integer(allow_none=True, hyphenated=True)
+
+
+    def __init__(self,
+                 _from=None,
+                 to=None,
+                 moveWithCells=False,
+                 sizeWithCells=False,
+                 z_order=None,
+                ):
+        self._from = _from
+        self.to = to
+        self.moveWithCells = moveWithCells
+        self.sizeWithCells = sizeWithCells
+        self.z_order = z_order
+
+
+class ObjectPr(Serialisable):
+
+    tagname = "objectPr"
+
+    anchor = Typed(expected_type=ObjectAnchor, )
+    locked = Bool(allow_none=True)
+    defaultSize = Bool(allow_none=True)
+    _print = Bool(allow_none=True)
+    disabled = Bool(allow_none=True)
+    uiObject = Bool(allow_none=True)
+    autoFill = Bool(allow_none=True)
+    autoLine = Bool(allow_none=True)
+    autoPict = Bool(allow_none=True)
+    macro = String()
+    altText = String(allow_none=True)
+    dde = Bool(allow_none=True)
+
+    __elements__ = ('anchor',)
+
+    def __init__(self,
+                 anchor=None,
+                 locked=True,
+                 defaultSize=True,
+                 _print=True,
+                 disabled=False,
+                 uiObject=False,
+                 autoFill=True,
+                 autoLine=True,
+                 autoPict=True,
+                 macro=None,
+                 altText=None,
+                 dde=False,
+                ):
+        self.anchor = anchor
+        self.locked = locked
+        self.defaultSize = defaultSize
+        self._print = _print
+        self.disabled = disabled
+        self.uiObject = uiObject
+        self.autoFill = autoFill
+        self.autoLine = autoLine
+        self.autoPict = autoPict
+        self.macro = macro
+        self.altText = altText
+        self.dde = dde
+
+
+class OleObject(Serialisable):
+
+    tagname = "oleObject"
+
+    objectPr = Typed(expected_type=ObjectPr, allow_none=True)
+    progId = String(allow_none=True)
+    dvAspect = Set(values=(['DVASPECT_CONTENT', 'DVASPECT_ICON']))
+    link = String(allow_none=True)
+    oleUpdate = Set(values=(['OLEUPDATE_ALWAYS', 'OLEUPDATE_ONCALL']))
+    autoLoad = Bool(allow_none=True)
+    shapeId = Integer()
+
+    __elements__ = ('objectPr',)
+
+    def __init__(self,
+                 objectPr=None,
+                 progId=None,
+                 dvAspect='DVASPECT_CONTENT',
+                 link=None,
+                 oleUpdate=None,
+                 autoLoad=False,
+                 shapeId=None,
+                ):
+        self.objectPr = objectPr
+        self.progId = progId
+        self.dvAspect = dvAspect
+        self.link = link
+        self.oleUpdate = oleUpdate
+        self.autoLoad = autoLoad
+        self.shapeId = shapeId
+
+
+class OleObjects(Serialisable):
+
+    tagname = "oleObjects"
+
+    oleObject = Sequence(expected_type=OleObject)
+
+    __elements__ = ('oleObject',)
+
+    def __init__(self,
+                 oleObject=(),
+                ):
+        self.oleObject = oleObject
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/page.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/page.py
new file mode 100644
index 00000000..7d630c2c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/page.py
@@ -0,0 +1,174 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Float,
+    Bool,
+    Integer,
+    NoneSet,
+    )
+from openpyxl.descriptors.excel import UniversalMeasure, Relation
+
+
+class PrintPageSetup(Serialisable):
+    """ Worksheet print page setup """
+
+    tagname = "pageSetup"
+
+    orientation = NoneSet(values=("default", "portrait", "landscape"))
+    paperSize = Integer(allow_none=True)
+    scale = Integer(allow_none=True)
+    fitToHeight = Integer(allow_none=True)
+    fitToWidth = Integer(allow_none=True)
+    firstPageNumber = Integer(allow_none=True)
+    useFirstPageNumber = Bool(allow_none=True)
+    paperHeight = UniversalMeasure(allow_none=True)
+    paperWidth = UniversalMeasure(allow_none=True)
+    pageOrder = NoneSet(values=("downThenOver", "overThenDown"))
+    usePrinterDefaults = Bool(allow_none=True)
+    blackAndWhite = Bool(allow_none=True)
+    draft = Bool(allow_none=True)
+    cellComments = NoneSet(values=("asDisplayed", "atEnd"))
+    errors = NoneSet(values=("displayed", "blank", "dash", "NA"))
+    horizontalDpi = Integer(allow_none=True)
+    verticalDpi = Integer(allow_none=True)
+    copies = Integer(allow_none=True)
+    id = Relation()
+
+
+    def __init__(self,
+                 worksheet=None,
+                 orientation=None,
+                 paperSize=None,
+                 scale=None,
+                 fitToHeight=None,
+                 fitToWidth=None,
+                 firstPageNumber=None,
+                 useFirstPageNumber=None,
+                 paperHeight=None,
+                 paperWidth=None,
+                 pageOrder=None,
+                 usePrinterDefaults=None,
+                 blackAndWhite=None,
+                 draft=None,
+                 cellComments=None,
+                 errors=None,
+                 horizontalDpi=None,
+                 verticalDpi=None,
+                 copies=None,
+                 id=None):
+        self._parent = worksheet
+        self.orientation = orientation
+        self.paperSize = paperSize
+        self.scale = scale
+        self.fitToHeight = fitToHeight
+        self.fitToWidth = fitToWidth
+        self.firstPageNumber = firstPageNumber
+        self.useFirstPageNumber = useFirstPageNumber
+        self.paperHeight = paperHeight
+        self.paperWidth = paperWidth
+        self.pageOrder = pageOrder
+        self.usePrinterDefaults = usePrinterDefaults
+        self.blackAndWhite = blackAndWhite
+        self.draft = draft
+        self.cellComments = cellComments
+        self.errors = errors
+        self.horizontalDpi = horizontalDpi
+        self.verticalDpi = verticalDpi
+        self.copies = copies
+        self.id = id
+
+
+    def __bool__(self):
+        return bool(dict(self))
+
+
+
+
+    @property
+    def sheet_properties(self):
+        """
+        Proxy property
+        """
+        return self._parent.sheet_properties.pageSetUpPr
+
+
+    @property
+    def fitToPage(self):
+        return self.sheet_properties.fitToPage
+
+
+    @fitToPage.setter
+    def fitToPage(self, value):
+        self.sheet_properties.fitToPage = value
+
+
+    @property
+    def autoPageBreaks(self):
+        return self.sheet_properties.autoPageBreaks
+
+
+    @autoPageBreaks.setter
+    def autoPageBreaks(self, value):
+        self.sheet_properties.autoPageBreaks = value
+
+
+    @classmethod
+    def from_tree(cls, node):
+        self = super().from_tree(node)
+        self.id = None # strip link to binary settings
+        return self
+
+
+class PrintOptions(Serialisable):
+    """ Worksheet print options """
+
+    tagname = "printOptions"
+    horizontalCentered = Bool(allow_none=True)
+    verticalCentered = Bool(allow_none=True)
+    headings = Bool(allow_none=True)
+    gridLines = Bool(allow_none=True)
+    gridLinesSet = Bool(allow_none=True)
+
+    def __init__(self, horizontalCentered=None,
+                 verticalCentered=None,
+                 headings=None,
+                 gridLines=None,
+                 gridLinesSet=None,
+                 ):
+        self.horizontalCentered = horizontalCentered
+        self.verticalCentered = verticalCentered
+        self.headings = headings
+        self.gridLines = gridLines
+        self.gridLinesSet = gridLinesSet
+
+
+    def __bool__(self):
+        return bool(dict(self))
+
+
+class PageMargins(Serialisable):
+    """
+    Information about page margins for view/print layouts.
+    Standard values (in inches)
+    left, right = 0.75
+    top, bottom = 1
+    header, footer = 0.5
+    """
+    tagname = "pageMargins"
+
+    left = Float()
+    right = Float()
+    top = Float()
+    bottom = Float()
+    header = Float()
+    footer = Float()
+
+    def __init__(self, left=0.75, right=0.75, top=1, bottom=1, header=0.5,
+                 footer=0.5):
+        self.left = left
+        self.right = right
+        self.top = top
+        self.bottom = bottom
+        self.header = header
+        self.footer = footer
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/pagebreak.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/pagebreak.py
new file mode 100644
index 00000000..ad50a321
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/pagebreak.py
@@ -0,0 +1,94 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Integer,
+    Bool,
+    Sequence,
+)
+
+
+class Break(Serialisable):
+
+    tagname = "brk"
+
+    id = Integer(allow_none=True)
+    min = Integer(allow_none=True)
+    max = Integer(allow_none=True)
+    man = Bool(allow_none=True)
+    pt = Bool(allow_none=True)
+
+    def __init__(self,
+                 id=0,
+                 min=0,
+                 max=16383,
+                 man=True,
+                 pt=None,
+                ):
+        self.id = id
+        self.min = min
+        self.max = max
+        self.man = man
+        self.pt = pt
+
+
+class RowBreak(Serialisable):
+
+    tagname = "rowBreaks"
+
+    count = Integer(allow_none=True)
+    manualBreakCount = Integer(allow_none=True)
+    brk = Sequence(expected_type=Break, allow_none=True)
+
+    __elements__ = ('brk',)
+    __attrs__ = ("count", "manualBreakCount",)
+
+    def __init__(self,
+                 count=None,
+                 manualBreakCount=None,
+                 brk=(),
+                ):
+        self.brk = brk
+
+
+    def __bool__(self):
+        return len(self.brk) > 0
+
+
+    def __len__(self):
+        return len(self.brk)
+
+
+    @property
+    def count(self):
+        return len(self)
+
+
+    @property
+    def manualBreakCount(self):
+        return len(self)
+
+
+    def append(self, brk=None):
+        """
+        Add a page break
+        """
+        vals = list(self.brk)
+        if not isinstance(brk, Break):
+            brk = Break(id=self.count+1)
+        vals.append(brk)
+        self.brk = vals
+
+
+PageBreak = RowBreak
+
+
+class ColBreak(RowBreak):
+
+    tagname = "colBreaks"
+
+    count = RowBreak.count
+    manualBreakCount = RowBreak.manualBreakCount
+    brk = RowBreak.brk
+
+    __attrs__ = RowBreak.__attrs__
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/picture.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/picture.py
new file mode 100644
index 00000000..8fff338a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/picture.py
@@ -0,0 +1,8 @@
+#Autogenerated schema
+from openpyxl.descriptors.serialisable import Serialisable
+
+# same as related
+
+class SheetBackgroundPicture(Serialisable):
+
+    tagname = "sheetBackgroundPicture"
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/print_settings.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/print_settings.py
new file mode 100644
index 00000000..b4629df1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/print_settings.py
@@ -0,0 +1,184 @@
+# Copyright (c) 2010-2024 openpyxl
+
+import re
+from openpyxl.descriptors import (
+    Strict,
+    Integer,
+    String,
+    Typed,
+)
+from openpyxl.utils import quote_sheetname, absolute_coordinate
+from openpyxl.utils.cell import SHEET_TITLE, SHEETRANGE_RE, RANGE_EXPR
+
+from .cell_range import MultiCellRange
+
+COL_RANGE = r"""(?P<cols>[$]?(?P<min_col>[a-zA-Z]{1,3}):[$]?(?P<max_col>[a-zA-Z]{1,3}))"""
+COL_RANGE_RE = re.compile(COL_RANGE)
+ROW_RANGE = r"""(?P<rows>[$]?(?P<min_row>\d+):[$]?(?P<max_row>\d+))"""
+ROW_RANGE_RE = re.compile(ROW_RANGE)
+TITLES_REGEX = re.compile("""{0}{1}?,?{2}?,?""".format(SHEET_TITLE, ROW_RANGE, COL_RANGE),
+                          re.VERBOSE)
+PRINT_AREA_RE = re.compile(f"({SHEET_TITLE})?(?P<cells>{RANGE_EXPR})", re.VERBOSE)
+
+class ColRange(Strict):
+    """
+    Represent a range of at least one column
+    """
+
+    min_col = String()
+    max_col = String()
+
+
+    def __init__(self, range_string=None, min_col=None, max_col=None):
+        if range_string is not None:
+            match = COL_RANGE_RE.match(range_string)
+            if not match:
+                raise ValueError(f"{range_string} is not a valid column range")
+            min_col, max_col = match.groups()[1:]
+        self.min_col = min_col
+        self.max_col = max_col
+
+
+    def __eq__(self, other):
+        if isinstance(other, self.__class__):
+            return (self.min_col == other.min_col
+                    and
+                    self.max_col == other.max_col)
+        elif isinstance(other, str):
+            return (str(self) == other
+                    or
+                    f"{self.min_col}:{self.max_col}")
+        return False
+
+
+    def __repr__(self):
+        return f"Range of columns from '{self.min_col}' to '{self.max_col}'"
+
+
+    def __str__(self):
+        return f"${self.min_col}:${self.max_col}"
+
+
+class RowRange(Strict):
+    """
+    Represent a range of at least one row
+    """
+
+    min_row = Integer()
+    max_row = Integer()
+
+    def __init__(self, range_string=None, min_row=None, max_row=None):
+        if range_string is not None:
+            match = ROW_RANGE_RE.match(range_string)
+            if not match:
+                raise ValueError(f"{range_string} is not a valid row range")
+            min_row, max_row = match.groups()[1:]
+        self.min_row = min_row
+        self.max_row = max_row
+
+
+    def __eq__(self, other):
+        if isinstance(other, self.__class__):
+            return (self.min_row == other.min_row
+                    and
+                    self.max_row == other.max_row)
+        elif isinstance(other, str):
+            return (str(self) == other
+                    or
+                    f"{self.min_row}:{self.max_row}")
+        return False
+
+    def __repr__(self):
+        return f"Range of rows from '{self.min_row}' to '{self.max_row}'"
+
+
+    def __str__(self):
+        return f"${self.min_row}:${self.max_row}"
+
+
+class PrintTitles(Strict):
+    """
+    Contains at least either a range of rows or columns
+    """
+
+    cols = Typed(expected_type=ColRange, allow_none=True)
+    rows = Typed(expected_type=RowRange, allow_none=True)
+    title = String()
+
+
+    def __init__(self, cols=None, rows=None, title=""):
+        self.cols = cols
+        self.rows = rows
+        self.title = title
+
+
+    @classmethod
+    def from_string(cls, value):
+        kw = dict((k, v) for match in TITLES_REGEX.finditer(value)
+                  for k, v in match.groupdict().items() if v)
+
+        if not kw:
+            raise ValueError(f"{value} is not a valid print titles definition")
+
+        cols = rows = None
+
+        if "cols" in kw:
+            cols = ColRange(kw["cols"])
+        if "rows" in kw:
+            rows = RowRange(kw["rows"])
+
+        title = kw.get("quoted") or kw.get("notquoted")
+
+        return cls(cols=cols, rows=rows, title=title)
+
+
+    def __eq__(self, other):
+        if isinstance(other, self.__class__):
+            return (self.cols == other.cols
+                    and
+                    self.rows == other.rows
+                    and
+                    self.title == other.title)
+        elif isinstance(other, str):
+            return str(self) == other
+        return False
+
+    def __repr__(self):
+        return f"Print titles for sheet {self.title} cols {self.rows}, rows {self.cols}"
+
+
+    def __str__(self):
+        title = quote_sheetname(self.title)
+        titles = ",".join([f"{title}!{value}" for value in (self.rows, self.cols) if value])
+        return titles or ""
+
+
+class PrintArea(MultiCellRange):
+
+
+    @classmethod
+    def from_string(cls, value):
+        new = []
+        for m in PRINT_AREA_RE.finditer(value): # can be multiple
+            coord = m.group("cells")
+            if coord:
+                new.append(coord)
+        return cls(new)
+
+
+    def __init__(self, ranges=(), title=""):
+        self.title = ""
+        super().__init__(ranges)
+
+
+    def __str__(self):
+        if self.ranges:
+            return ",".join([f"{quote_sheetname(self.title)}!{absolute_coordinate(str(range))}"
+                             for range in self.sorted()])
+        return ""
+
+
+    def __eq__(self, other):
+        super().__eq__(other)
+        if isinstance(other, str):
+            return str(self) == other
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/properties.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/properties.py
new file mode 100644
index 00000000..e16d15be
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/properties.py
@@ -0,0 +1,97 @@
+# Copyright (c) 2010-2024 openpyxl
+
+"""Worksheet Properties"""
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import String, Bool, Typed
+from openpyxl.styles.colors import ColorDescriptor
+
+
+class Outline(Serialisable):
+
+    tagname = "outlinePr"
+
+    applyStyles = Bool(allow_none=True)
+    summaryBelow = Bool(allow_none=True)
+    summaryRight = Bool(allow_none=True)
+    showOutlineSymbols = Bool(allow_none=True)
+
+
+    def __init__(self,
+                 applyStyles=None,
+                 summaryBelow=None,
+                 summaryRight=None,
+                 showOutlineSymbols=None
+                 ):
+        self.applyStyles = applyStyles
+        self.summaryBelow = summaryBelow
+        self.summaryRight = summaryRight
+        self.showOutlineSymbols = showOutlineSymbols
+
+
+class PageSetupProperties(Serialisable):
+
+    tagname = "pageSetUpPr"
+
+    autoPageBreaks = Bool(allow_none=True)
+    fitToPage = Bool(allow_none=True)
+
+    def __init__(self, autoPageBreaks=None, fitToPage=None):
+        self.autoPageBreaks = autoPageBreaks
+        self.fitToPage = fitToPage
+
+
+class WorksheetProperties(Serialisable):
+
+    tagname = "sheetPr"
+
+    codeName = String(allow_none=True)
+    enableFormatConditionsCalculation = Bool(allow_none=True)
+    filterMode = Bool(allow_none=True)
+    published = Bool(allow_none=True)
+    syncHorizontal = Bool(allow_none=True)
+    syncRef = String(allow_none=True)
+    syncVertical = Bool(allow_none=True)
+    transitionEvaluation = Bool(allow_none=True)
+    transitionEntry = Bool(allow_none=True)
+    tabColor = ColorDescriptor(allow_none=True)
+    outlinePr = Typed(expected_type=Outline, allow_none=True)
+    pageSetUpPr = Typed(expected_type=PageSetupProperties, allow_none=True)
+
+    __elements__ = ('tabColor', 'outlinePr', 'pageSetUpPr')
+
+
+    def __init__(self,
+                 codeName=None,
+                 enableFormatConditionsCalculation=None,
+                 filterMode=None,
+                 published=None,
+                 syncHorizontal=None,
+                 syncRef=None,
+                 syncVertical=None,
+                 transitionEvaluation=None,
+                 transitionEntry=None,
+                 tabColor=None,
+                 outlinePr=None,
+                 pageSetUpPr=None
+                 ):
+        """ Attributes """
+        self.codeName = codeName
+        self.enableFormatConditionsCalculation = enableFormatConditionsCalculation
+        self.filterMode = filterMode
+        self.published = published
+        self.syncHorizontal = syncHorizontal
+        self.syncRef = syncRef
+        self.syncVertical = syncVertical
+        self.transitionEvaluation = transitionEvaluation
+        self.transitionEntry = transitionEntry
+        """ Elements """
+        self.tabColor = tabColor
+        if outlinePr is None:
+            self.outlinePr = Outline(summaryBelow=True, summaryRight=True)
+        else:
+            self.outlinePr = outlinePr
+
+        if pageSetUpPr is None:
+            pageSetUpPr = PageSetupProperties()
+        self.pageSetUpPr = pageSetUpPr
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/protection.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/protection.py
new file mode 100644
index 00000000..7f931840
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/protection.py
@@ -0,0 +1,120 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors import (
+    Bool,
+    String,
+    Alias,
+    Integer,
+)
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors.excel import (
+    Base64Binary,
+)
+from openpyxl.utils.protection import hash_password
+
+
+class _Protected:
+    _password = None
+
+    def set_password(self, value='', already_hashed=False):
+        """Set a password on this sheet."""
+        if not already_hashed:
+            value = hash_password(value)
+        self._password = value
+
+    @property
+    def password(self):
+        """Return the password value, regardless of hash."""
+        return self._password
+
+    @password.setter
+    def password(self, value):
+        """Set a password directly, forcing a hash step."""
+        self.set_password(value)
+
+
+class SheetProtection(Serialisable, _Protected):
+    """
+    Information about protection of various aspects of a sheet. True values
+    mean that protection for the object or action is active This is the
+    **default** when protection is active, ie. users cannot do something
+    """
+
+    tagname = "sheetProtection"
+
+    sheet = Bool()
+    enabled = Alias('sheet')
+    objects = Bool()
+    scenarios = Bool()
+    formatCells = Bool()
+    formatColumns = Bool()
+    formatRows = Bool()
+    insertColumns = Bool()
+    insertRows = Bool()
+    insertHyperlinks = Bool()
+    deleteColumns = Bool()
+    deleteRows = Bool()
+    selectLockedCells = Bool()
+    selectUnlockedCells = Bool()
+    sort = Bool()
+    autoFilter = Bool()
+    pivotTables = Bool()
+    saltValue = Base64Binary(allow_none=True)
+    spinCount = Integer(allow_none=True)
+    algorithmName = String(allow_none=True)
+    hashValue = Base64Binary(allow_none=True)
+
+
+    __attrs__ = ('selectLockedCells', 'selectUnlockedCells', 'algorithmName',
+              'sheet', 'objects', 'insertRows', 'insertHyperlinks', 'autoFilter',
+              'scenarios', 'formatColumns', 'deleteColumns', 'insertColumns',
+              'pivotTables', 'deleteRows', 'formatCells', 'saltValue', 'formatRows',
+              'sort', 'spinCount', 'password', 'hashValue')
+
+
+    def __init__(self, sheet=False, objects=False, scenarios=False,
+                 formatCells=True, formatRows=True, formatColumns=True,
+                 insertColumns=True, insertRows=True, insertHyperlinks=True,
+                 deleteColumns=True, deleteRows=True, selectLockedCells=False,
+                 selectUnlockedCells=False, sort=True, autoFilter=True, pivotTables=True,
+                 password=None, algorithmName=None, saltValue=None, spinCount=None, hashValue=None):
+        self.sheet = sheet
+        self.objects = objects
+        self.scenarios = scenarios
+        self.formatCells = formatCells
+        self.formatColumns = formatColumns
+        self.formatRows = formatRows
+        self.insertColumns = insertColumns
+        self.insertRows = insertRows
+        self.insertHyperlinks = insertHyperlinks
+        self.deleteColumns = deleteColumns
+        self.deleteRows = deleteRows
+        self.selectLockedCells = selectLockedCells
+        self.selectUnlockedCells = selectUnlockedCells
+        self.sort = sort
+        self.autoFilter = autoFilter
+        self.pivotTables = pivotTables
+        if password is not None:
+            self.password = password
+        self.algorithmName = algorithmName
+        self.saltValue = saltValue
+        self.spinCount = spinCount
+        self.hashValue = hashValue
+
+
+    def set_password(self, value='', already_hashed=False):
+        super().set_password(value, already_hashed)
+        self.enable()
+
+
+    def enable(self):
+        self.sheet = True
+
+
+    def disable(self):
+        self.sheet = False
+
+
+    def  __bool__(self):
+        return self.sheet
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/related.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/related.py
new file mode 100644
index 00000000..2bf05019
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/related.py
@@ -0,0 +1,17 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors.excel import Relation
+
+
+class Related(Serialisable):
+
+    id = Relation()
+
+
+    def __init__(self, id=None):
+        self.id = id
+
+
+    def to_tree(self, tagname, idx=None):
+        return super().to_tree(tagname)
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/scenario.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/scenario.py
new file mode 100644
index 00000000..3c86f607
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/scenario.py
@@ -0,0 +1,105 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    String,
+    Integer,
+    Bool,
+    Sequence,
+    Convertible,
+)
+from .cell_range import MultiCellRange
+
+
+class InputCells(Serialisable):
+
+    tagname = "inputCells"
+
+    r = String()
+    deleted = Bool(allow_none=True)
+    undone = Bool(allow_none=True)
+    val = String()
+    numFmtId = Integer(allow_none=True)
+
+    def __init__(self,
+                 r=None,
+                 deleted=False,
+                 undone=False,
+                 val=None,
+                 numFmtId=None,
+                ):
+        self.r = r
+        self.deleted = deleted
+        self.undone = undone
+        self.val = val
+        self.numFmtId = numFmtId
+
+
+class Scenario(Serialisable):
+
+    tagname = "scenario"
+
+    inputCells = Sequence(expected_type=InputCells)
+    name = String()
+    locked = Bool(allow_none=True)
+    hidden = Bool(allow_none=True)
+    user = String(allow_none=True)
+    comment = String(allow_none=True)
+
+    __elements__ = ('inputCells',)
+    __attrs__ = ('name', 'locked', 'hidden', 'user', 'comment', 'count')
+
+    def __init__(self,
+                 inputCells=(),
+                 name=None,
+                 locked=False,
+                 hidden=False,
+                 count=None,
+                 user=None,
+                 comment=None,
+                ):
+        self.inputCells = inputCells
+        self.name = name
+        self.locked = locked
+        self.hidden = hidden
+        self.user = user
+        self.comment = comment
+
+
+    @property
+    def count(self):
+        return len(self.inputCells)
+
+
+class ScenarioList(Serialisable):
+
+    tagname = "scenarios"
+
+    scenario = Sequence(expected_type=Scenario)
+    current = Integer(allow_none=True)
+    show = Integer(allow_none=True)
+    sqref = Convertible(expected_type=MultiCellRange, allow_none=True)
+
+    __elements__ = ('scenario',)
+
+    def __init__(self,
+                 scenario=(),
+                 current=None,
+                 show=None,
+                 sqref=None,
+                ):
+        self.scenario = scenario
+        self.current = current
+        self.show = show
+        self.sqref = sqref
+
+
+    def append(self, scenario):
+        s = self.scenario
+        s.append(scenario)
+        self.scenario = s
+
+
+    def __bool__(self):
+        return bool(self.scenario)
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/smart_tag.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/smart_tag.py
new file mode 100644
index 00000000..29fe1926
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/smart_tag.py
@@ -0,0 +1,78 @@
+#Autogenerated schema
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Bool,
+    Integer,
+    String,
+    Sequence,
+)
+
+
+class CellSmartTagPr(Serialisable):
+
+    tagname = "cellSmartTagPr"
+
+    key = String()
+    val = String()
+
+    def __init__(self,
+                 key=None,
+                 val=None,
+                ):
+        self.key = key
+        self.val = val
+
+
+class CellSmartTag(Serialisable):
+
+    tagname = "cellSmartTag"
+
+    cellSmartTagPr = Sequence(expected_type=CellSmartTagPr)
+    type = Integer()
+    deleted = Bool(allow_none=True)
+    xmlBased = Bool(allow_none=True)
+
+    __elements__ = ('cellSmartTagPr',)
+
+    def __init__(self,
+                 cellSmartTagPr=(),
+                 type=None,
+                 deleted=False,
+                 xmlBased=False,
+                ):
+        self.cellSmartTagPr = cellSmartTagPr
+        self.type = type
+        self.deleted = deleted
+        self.xmlBased = xmlBased
+
+
+class CellSmartTags(Serialisable):
+
+    tagname = "cellSmartTags"
+
+    cellSmartTag = Sequence(expected_type=CellSmartTag)
+    r = String()
+
+    __elements__ = ('cellSmartTag',)
+
+    def __init__(self,
+                 cellSmartTag=(),
+                 r=None,
+                ):
+        self.cellSmartTag = cellSmartTag
+        self.r = r
+
+
+class SmartTags(Serialisable):
+
+    tagname = "smartTags"
+
+    cellSmartTags = Sequence(expected_type=CellSmartTags)
+
+    __elements__ = ('cellSmartTags',)
+
+    def __init__(self,
+                 cellSmartTags=(),
+                ):
+        self.cellSmartTags = cellSmartTags
+
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/table.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/table.py
new file mode 100644
index 00000000..756345f9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/table.py
@@ -0,0 +1,385 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors.serialisable import Serialisable
+from openpyxl.descriptors import (
+    Descriptor,
+    Alias,
+    Typed,
+    Bool,
+    Integer,
+    NoneSet,
+    String,
+    Sequence,
+)
+from openpyxl.descriptors.excel import ExtensionList, CellRange
+from openpyxl.descriptors.sequence import NestedSequence
+from openpyxl.xml.constants import SHEET_MAIN_NS, REL_NS
+from openpyxl.xml.functions import tostring
+from openpyxl.utils import range_boundaries
+from openpyxl.utils.escape import escape, unescape
+
+from .related import Related
+
+from .filters import (
+    AutoFilter,
+    SortState,
+)
+
+TABLESTYLES = tuple(
+    ["TableStyleMedium{0}".format(i) for i in range(1, 29)]
+    + ["TableStyleLight{0}".format(i) for i in range(1, 22)]
+    + ["TableStyleDark{0}".format(i) for i in range(1, 12)]
+)
+
+PIVOTSTYLES = tuple(
+    ["PivotStyleMedium{0}".format(i) for i in range(1, 29)]
+    + ["PivotStyleLight{0}".format(i) for i in range(1, 29)]
+    + ["PivotStyleDark{0}".format(i) for i in range(1, 29)]
+)
+
+
+class TableStyleInfo(Serialisable):
+
+    tagname = "tableStyleInfo"
+
+    name = String(allow_none=True)
+    showFirstColumn = Bool(allow_none=True)
+    showLastColumn = Bool(allow_none=True)
+    showRowStripes = Bool(allow_none=True)
+    showColumnStripes = Bool(allow_none=True)
+
+    def __init__(self,
+                 name=None,
+                 showFirstColumn=None,
+                 showLastColumn=None,
+                 showRowStripes=None,
+                 showColumnStripes=None,
+                ):
+        self.name = name
+        self.showFirstColumn = showFirstColumn
+        self.showLastColumn = showLastColumn
+        self.showRowStripes = showRowStripes
+        self.showColumnStripes = showColumnStripes
+
+
+class XMLColumnProps(Serialisable):
+
+    tagname = "xmlColumnPr"
+
+    mapId = Integer()
+    xpath = String()
+    denormalized = Bool(allow_none=True)
+    xmlDataType = String()
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ()
+
+    def __init__(self,
+                 mapId=None,
+                 xpath=None,
+                 denormalized=None,
+                 xmlDataType=None,
+                 extLst=None,
+                ):
+        self.mapId = mapId
+        self.xpath = xpath
+        self.denormalized = denormalized
+        self.xmlDataType = xmlDataType
+
+
+class TableFormula(Serialisable):
+
+    tagname = "tableFormula"
+
+    ## Note formula is stored as the text value
+
+    array = Bool(allow_none=True)
+    attr_text = Descriptor()
+    text = Alias('attr_text')
+
+
+    def __init__(self,
+                 array=None,
+                 attr_text=None,
+                ):
+        self.array = array
+        self.attr_text = attr_text
+
+
+class TableColumn(Serialisable):
+
+    tagname = "tableColumn"
+
+    id = Integer()
+    uniqueName = String(allow_none=True)
+    name = String()
+    totalsRowFunction = NoneSet(values=(['sum', 'min', 'max', 'average',
+                                         'count', 'countNums', 'stdDev', 'var', 'custom']))
+    totalsRowLabel = String(allow_none=True)
+    queryTableFieldId = Integer(allow_none=True)
+    headerRowDxfId = Integer(allow_none=True)
+    dataDxfId = Integer(allow_none=True)
+    totalsRowDxfId = Integer(allow_none=True)
+    headerRowCellStyle = String(allow_none=True)
+    dataCellStyle = String(allow_none=True)
+    totalsRowCellStyle = String(allow_none=True)
+    calculatedColumnFormula = Typed(expected_type=TableFormula, allow_none=True)
+    totalsRowFormula = Typed(expected_type=TableFormula, allow_none=True)
+    xmlColumnPr = Typed(expected_type=XMLColumnProps, allow_none=True)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('calculatedColumnFormula', 'totalsRowFormula',
+                    'xmlColumnPr', 'extLst')
+
+    def __init__(self,
+                 id=None,
+                 uniqueName=None,
+                 name=None,
+                 totalsRowFunction=None,
+                 totalsRowLabel=None,
+                 queryTableFieldId=None,
+                 headerRowDxfId=None,
+                 dataDxfId=None,
+                 totalsRowDxfId=None,
+                 headerRowCellStyle=None,
+                 dataCellStyle=None,
+                 totalsRowCellStyle=None,
+                 calculatedColumnFormula=None,
+                 totalsRowFormula=None,
+                 xmlColumnPr=None,
+                 extLst=None,
+                ):
+        self.id = id
+        self.uniqueName = uniqueName
+        self.name = name
+        self.totalsRowFunction = totalsRowFunction
+        self.totalsRowLabel = totalsRowLabel
+        self.queryTableFieldId = queryTableFieldId
+        self.headerRowDxfId = headerRowDxfId
+        self.dataDxfId = dataDxfId
+        self.totalsRowDxfId = totalsRowDxfId
+        self.headerRowCellStyle = headerRowCellStyle
+        self.dataCellStyle = dataCellStyle
+        self.totalsRowCellStyle = totalsRowCellStyle
+        self.calculatedColumnFormula = calculatedColumnFormula
+        self.totalsRowFormula = totalsRowFormula
+        self.xmlColumnPr = xmlColumnPr
+        self.extLst = extLst
+
+
+    def __iter__(self):
+        for k, v in super().__iter__():
+            if k == 'name':
+                v = escape(v)
+            yield k, v
+
+
+    @classmethod
+    def from_tree(cls, node):
+        self = super().from_tree(node)
+        self.name = unescape(self.name)
+        return self
+
+
+class TableNameDescriptor(String):
+
+    """
+    Table names cannot have spaces in them
+    """
+
+    def __set__(self, instance, value):
+        if value is not None and " " in value:
+            raise ValueError("Table names cannot have spaces")
+        super().__set__(instance, value)
+
+
+class Table(Serialisable):
+
+    _path = "/tables/table{0}.xml"
+    mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"
+    _rel_type = REL_NS + "/table"
+    _rel_id = None
+
+    tagname = "table"
+
+    id = Integer()
+    name = String(allow_none=True)
+    displayName = TableNameDescriptor()
+    comment = String(allow_none=True)
+    ref = CellRange()
+    tableType = NoneSet(values=(['worksheet', 'xml', 'queryTable']))
+    headerRowCount = Integer(allow_none=True)
+    insertRow = Bool(allow_none=True)
+    insertRowShift = Bool(allow_none=True)
+    totalsRowCount = Integer(allow_none=True)
+    totalsRowShown = Bool(allow_none=True)
+    published = Bool(allow_none=True)
+    headerRowDxfId = Integer(allow_none=True)
+    dataDxfId = Integer(allow_none=True)
+    totalsRowDxfId = Integer(allow_none=True)
+    headerRowBorderDxfId = Integer(allow_none=True)
+    tableBorderDxfId = Integer(allow_none=True)
+    totalsRowBorderDxfId = Integer(allow_none=True)
+    headerRowCellStyle = String(allow_none=True)
+    dataCellStyle = String(allow_none=True)
+    totalsRowCellStyle = String(allow_none=True)
+    connectionId = Integer(allow_none=True)
+    autoFilter = Typed(expected_type=AutoFilter, allow_none=True)
+    sortState = Typed(expected_type=SortState, allow_none=True)
+    tableColumns = NestedSequence(expected_type=TableColumn, count=True)
+    tableStyleInfo = Typed(expected_type=TableStyleInfo, allow_none=True)
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('autoFilter', 'sortState', 'tableColumns',
+                    'tableStyleInfo')
+
+    def __init__(self,
+                 id=1,
+                 displayName=None,
+                 ref=None,
+                 name=None,
+                 comment=None,
+                 tableType=None,
+                 headerRowCount=1,
+                 insertRow=None,
+                 insertRowShift=None,
+                 totalsRowCount=None,
+                 totalsRowShown=None,
+                 published=None,
+                 headerRowDxfId=None,
+                 dataDxfId=None,
+                 totalsRowDxfId=None,
+                 headerRowBorderDxfId=None,
+                 tableBorderDxfId=None,
+                 totalsRowBorderDxfId=None,
+                 headerRowCellStyle=None,
+                 dataCellStyle=None,
+                 totalsRowCellStyle=None,
+                 connectionId=None,
+                 autoFilter=None,
+                 sortState=None,
+                 tableColumns=(),
+                 tableStyleInfo=None,
+                 extLst=None,
+                ):
+        self.id = id
+        self.displayName = displayName
+        if name is None:
+            name = displayName
+        self.name = name
+        self.comment = comment
+        self.ref = ref
+        self.tableType = tableType
+        self.headerRowCount = headerRowCount
+        self.insertRow = insertRow
+        self.insertRowShift = insertRowShift
+        self.totalsRowCount = totalsRowCount
+        self.totalsRowShown = totalsRowShown
+        self.published = published
+        self.headerRowDxfId = headerRowDxfId
+        self.dataDxfId = dataDxfId
+        self.totalsRowDxfId = totalsRowDxfId
+        self.headerRowBorderDxfId = headerRowBorderDxfId
+        self.tableBorderDxfId = tableBorderDxfId
+        self.totalsRowBorderDxfId = totalsRowBorderDxfId
+        self.headerRowCellStyle = headerRowCellStyle
+        self.dataCellStyle = dataCellStyle
+        self.totalsRowCellStyle = totalsRowCellStyle
+        self.connectionId = connectionId
+        self.autoFilter = autoFilter
+        self.sortState = sortState
+        self.tableColumns = tableColumns
+        self.tableStyleInfo = tableStyleInfo
+
+
+    def to_tree(self):
+        tree = super().to_tree()
+        tree.set("xmlns", SHEET_MAIN_NS)
+        return tree
+
+
+    @property
+    def path(self):
+        """
+        Return path within the archive
+        """
+        return "/xl" + self._path.format(self.id)
+
+
+    def _write(self, archive):
+        """
+        Serialise to XML and write to archive
+        """
+        xml = self.to_tree()
+        archive.writestr(self.path[1:], tostring(xml))
+
+
+    def _initialise_columns(self):
+        """
+        Create a list of table columns from a cell range
+        Always set a ref if we have headers (the default)
+        Column headings must be strings and must match cells in the worksheet.
+        """
+
+        min_col, min_row, max_col, max_row = range_boundaries(self.ref)
+        for idx in range(min_col, max_col+1):
+            col = TableColumn(id=idx, name="Column{0}".format(idx))
+            self.tableColumns.append(col)
+        if self.headerRowCount and not self.autoFilter:
+            self.autoFilter = AutoFilter(ref=self.ref)
+
+
+    @property
+    def column_names(self):
+        return [column.name for column in self.tableColumns]
+
+
+class TablePartList(Serialisable):
+
+    tagname = "tableParts"
+
+    count = Integer(allow_none=True)
+    tablePart = Sequence(expected_type=Related)
+
+    __elements__ = ('tablePart',)
+    __attrs__ = ('count',)
+
+    def __init__(self,
+                 count=None,
+                 tablePart=(),
+                ):
+        self.tablePart = tablePart
+
+
+    def append(self, part):
+        self.tablePart.append(part)
+
+
+    @property
+    def count(self):
+        return len(self.tablePart)
+
+
+    def __bool__(self):
+        return bool(self.tablePart)
+
+
+class TableList(dict):
+
+
+    def add(self, table):
+        if not isinstance(table, Table):
+            raise TypeError("You can only add tables")
+        self[table.name] = table
+
+
+    def get(self, name=None, table_range=None):
+        if name is not None:
+            return super().get(name)
+        for table in self.values():
+            if table_range == table.ref:
+                return table
+
+
+    def items(self):
+        return [(name, table.ref) for name, table in super().items()]
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/views.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/views.py
new file mode 100644
index 00000000..27046b0d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/views.py
@@ -0,0 +1,155 @@
+# Copyright (c) 2010-2024 openpyxl
+
+from openpyxl.descriptors import (
+    Bool,
+    Integer,
+    String,
+    Set,
+    Float,
+    Typed,
+    NoneSet,
+    Sequence,
+)
+from openpyxl.descriptors.excel import ExtensionList
+from openpyxl.descriptors.serialisable import Serialisable
+
+
+class Pane(Serialisable):
+    xSplit = Float(allow_none=True)
+    ySplit = Float(allow_none=True)
+    topLeftCell = String(allow_none=True)
+    activePane = Set(values=("bottomRight", "topRight", "bottomLeft", "topLeft"))
+    state = Set(values=("split", "frozen", "frozenSplit"))
+
+    def __init__(self,
+                 xSplit=None,
+                 ySplit=None,
+                 topLeftCell=None,
+                 activePane="topLeft",
+                 state="split"):
+        self.xSplit = xSplit
+        self.ySplit = ySplit
+        self.topLeftCell = topLeftCell
+        self.activePane = activePane
+        self.state = state
+
+
+class Selection(Serialisable):
+    pane = NoneSet(values=("bottomRight", "topRight", "bottomLeft", "topLeft"))
+    activeCell = String(allow_none=True)
+    activeCellId = Integer(allow_none=True)
+    sqref = String(allow_none=True)
+
+    def __init__(self,
+                 pane=None,
+                 activeCell="A1",
+                 activeCellId=None,
+                 sqref="A1"):
+        self.pane = pane
+        self.activeCell = activeCell
+        self.activeCellId = activeCellId
+        self.sqref = sqref
+
+
+class SheetView(Serialisable):
+
+    """Information about the visible portions of this sheet."""
+
+    tagname = "sheetView"
+
+    windowProtection = Bool(allow_none=True)
+    showFormulas = Bool(allow_none=True)
+    showGridLines = Bool(allow_none=True)
+    showRowColHeaders = Bool(allow_none=True)
+    showZeros = Bool(allow_none=True)
+    rightToLeft = Bool(allow_none=True)
+    tabSelected = Bool(allow_none=True)
+    showRuler = Bool(allow_none=True)
+    showOutlineSymbols = Bool(allow_none=True)
+    defaultGridColor = Bool(allow_none=True)
+    showWhiteSpace = Bool(allow_none=True)
+    view = NoneSet(values=("normal", "pageBreakPreview", "pageLayout"))
+    topLeftCell = String(allow_none=True)
+    colorId = Integer(allow_none=True)
+    zoomScale = Integer(allow_none=True)
+    zoomScaleNormal = Integer(allow_none=True)
+    zoomScaleSheetLayoutView = Integer(allow_none=True)
+    zoomScalePageLayoutView = Integer(allow_none=True)
+    zoomToFit = Bool(allow_none=True) # Chart sheets only
+    workbookViewId = Integer()
+    selection = Sequence(expected_type=Selection)
+    pane = Typed(expected_type=Pane, allow_none=True)
+
+    def __init__(self,
+                 windowProtection=None,
+                 showFormulas=None,
+                 showGridLines=None,
+                 showRowColHeaders=None,
+                 showZeros=None,
+                 rightToLeft=None,
+                 tabSelected=None,
+                 showRuler=None,
+                 showOutlineSymbols=None,
+                 defaultGridColor=None,
+                 showWhiteSpace=None,
+                 view=None,
+                 topLeftCell=None,
+                 colorId=None,
+                 zoomScale=None,
+                 zoomScaleNormal=None,
+                 zoomScaleSheetLayoutView=None,
+                 zoomScalePageLayoutView=None,
+                 zoomToFit=None,
+                 workbookViewId=0,
+                 selection=None,
+                 pane=None,):
+        self.windowProtection = windowProtection
+        self.showFormulas = showFormulas
+        self.showGridLines = showGridLines
+        self.showRowColHeaders = showRowColHeaders
+        self.showZeros = showZeros
+        self.rightToLeft = rightToLeft
+        self.tabSelected = tabSelected
+        self.showRuler = showRuler
+        self.showOutlineSymbols = showOutlineSymbols
+        self.defaultGridColor = defaultGridColor
+        self.showWhiteSpace = showWhiteSpace
+        self.view = view
+        self.topLeftCell = topLeftCell
+        self.colorId = colorId
+        self.zoomScale = zoomScale
+        self.zoomScaleNormal = zoomScaleNormal
+        self.zoomScaleSheetLayoutView = zoomScaleSheetLayoutView
+        self.zoomScalePageLayoutView = zoomScalePageLayoutView
+        self.zoomToFit = zoomToFit
+        self.workbookViewId = workbookViewId
+        self.pane = pane
+        if selection is None:
+            selection = (Selection(), )
+        self.selection = selection
+
+
+class SheetViewList(Serialisable):
+
+    tagname = "sheetViews"
+
+    sheetView = Sequence(expected_type=SheetView, )
+    extLst = Typed(expected_type=ExtensionList, allow_none=True)
+
+    __elements__ = ('sheetView',)
+
+    def __init__(self,
+                 sheetView=None,
+                 extLst=None,
+                ):
+        if sheetView is None:
+            sheetView = [SheetView()]
+        self.sheetView = sheetView
+
+
+    @property
+    def active(self):
+        """
+        Returns the first sheet view which is assumed to be active
+        """
+        return self.sheetView[0]
diff --git a/.venv/lib/python3.12/site-packages/openpyxl/worksheet/worksheet.py b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/worksheet.py
new file mode 100644
index 00000000..b7ffbebc
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/openpyxl/worksheet/worksheet.py
@@ -0,0 +1,907 @@
+# Copyright (c) 2010-2024 openpyxl
+
+"""Worksheet is the 2nd-level container in Excel."""
+
+
+# Python stdlib imports
+from itertools import chain
+from operator import itemgetter
+from inspect import isgenerator
+from warnings import warn
+
+# compatibility imports
+from openpyxl.compat import (
+    deprecated,
+)
+
+# package imports
+from openpyxl.utils import (
+    column_index_from_string,
+    get_column_letter,
+    range_boundaries,
+    coordinate_to_tuple,
+)
+from openpyxl.cell import Cell, MergedCell
+from openpyxl.formatting.formatting import ConditionalFormattingList
+from openpyxl.packaging.relationship import RelationshipList
+from openpyxl.workbook.child import _WorkbookChild
+from openpyxl.workbook.defined_name import (
+    DefinedNameDict,
+)
+
+from openpyxl.formula.translate import Translator
+
+from .datavalidation import DataValidationList
+from .page import (
+    PrintPageSetup,
+    PageMargins,
+    PrintOptions,
+)
+from .dimensions import (
+    ColumnDimension,
+    RowDimension,
+    DimensionHolder,
+    SheetFormatProperties,
+)
+from .protection import SheetProtection
+from .filters import AutoFilter
+from .views import (
+    Pane,
+    Selection,
+    SheetViewList,
+)
+from .cell_range import MultiCellRange, CellRange
+from .merge import MergedCellRange
+from .properties import WorksheetProperties
+from .pagebreak import RowBreak, ColBreak
+from .scenario import ScenarioList
+from .table import TableList
+from .formula import ArrayFormula
+from .print_settings import (
+    PrintTitles,
+    ColRange,
+    RowRange,
+    PrintArea,
+)
+
+
+class Worksheet(_WorkbookChild):
+    """Represents a worksheet.
+
+    Do not create worksheets yourself,
+    use :func:`openpyxl.workbook.Workbook.create_sheet` instead
+
+    """
+
+    _rel_type = "worksheet"
+    _path = "/xl/worksheets/sheet{0}.xml"
+    mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
+
+    BREAK_NONE = 0
+    BREAK_ROW = 1
+    BREAK_COLUMN = 2
+
+    SHEETSTATE_VISIBLE = 'visible'
+    SHEETSTATE_HIDDEN = 'hidden'
+    SHEETSTATE_VERYHIDDEN = 'veryHidden'
+
+    # Paper size
+    PAPERSIZE_LETTER = '1'
+    PAPERSIZE_LETTER_SMALL = '2'
+    PAPERSIZE_TABLOID = '3'
+    PAPERSIZE_LEDGER = '4'
+    PAPERSIZE_LEGAL = '5'
+    PAPERSIZE_STATEMENT = '6'
+    PAPERSIZE_EXECUTIVE = '7'
+    PAPERSIZE_A3 = '8'
+    PAPERSIZE_A4 = '9'
+    PAPERSIZE_A4_SMALL = '10'
+    PAPERSIZE_A5 = '11'
+
+    # Page orientation
+    ORIENTATION_PORTRAIT = 'portrait'
+    ORIENTATION_LANDSCAPE = 'landscape'
+
+    def __init__(self, parent, title=None):
+        _WorkbookChild.__init__(self, parent, title)
+        self._setup()
+
+    def _setup(self):
+        self.row_dimensions = DimensionHolder(worksheet=self,
+                                              default_factory=self._add_row)
+        self.column_dimensions = DimensionHolder(worksheet=self,
+                                                 default_factory=self._add_column)
+        self.row_breaks = RowBreak()
+        self.col_breaks = ColBreak()
+        self._cells = {}
+        self._charts = []
+        self._images = []
+        self._rels = RelationshipList()
+        self._drawing = None
+        self._comments = []
+        self.merged_cells = MultiCellRange()
+        self._tables = TableList()
+        self._pivots = []
+        self.data_validations = DataValidationList()
+        self._hyperlinks = []
+        self.sheet_state = 'visible'
+        self.page_setup = PrintPageSetup(worksheet=self)
+        self.print_options = PrintOptions()
+        self._print_rows = None
+        self._print_cols = None
+        self._print_area = PrintArea()
+        self.page_margins = PageMargins()
+        self.views = SheetViewList()
+        self.protection = SheetProtection()
+        self.defined_names = DefinedNameDict()
+
+        self._current_row = 0
+        self.auto_filter = AutoFilter()
+        self.conditional_formatting = ConditionalFormattingList()
+        self.legacy_drawing = None
+        self.sheet_properties = WorksheetProperties()
+        self.sheet_format = SheetFormatProperties()
+        self.scenarios = ScenarioList()
+
+
+    @property
+    def sheet_view(self):
+        return self.views.active
+
+
+    @property
+    def selected_cell(self):
+        return self.sheet_view.selection[0].sqref
+
+
+    @property
+    def active_cell(self):
+        return self.sheet_view.selection[0].activeCell
+
+
+    @property
+    def array_formulae(self):
+        """Returns a dictionary of cells with array formulae and the cells in array"""
+        result = {}
+        for c in self._cells.values():
+            if c.data_type == "f":
+                if isinstance(c.value, ArrayFormula):
+                    result[c.coordinate] = c.value.ref
+        return result
+
+
+    @property
+    def show_gridlines(self):
+        return self.sheet_view.showGridLines
+
+
+    @property
+    def freeze_panes(self):
+        if self.sheet_view.pane is not None:
+            return self.sheet_view.pane.topLeftCell
+
+
+    @freeze_panes.setter
+    def freeze_panes(self, topLeftCell=None):
+        if isinstance(topLeftCell, Cell):
+            topLeftCell = topLeftCell.coordinate
+        if topLeftCell == 'A1':
+            topLeftCell = None
+
+        if not topLeftCell:
+            self.sheet_view.pane = None
+            return
+
+        row, column = coordinate_to_tuple(topLeftCell)
+
+        view = self.sheet_view
+        view.pane = Pane(topLeftCell=topLeftCell,
+                        activePane="topRight",
+                        state="frozen")
+        view.selection[0].pane = "topRight"
+
+        if column > 1:
+            view.pane.xSplit = column - 1
+        if row > 1:
+            view.pane.ySplit = row - 1
+            view.pane.activePane = 'bottomLeft'
+            view.selection[0].pane = "bottomLeft"
+            if column > 1:
+                view.selection[0].pane = "bottomRight"
+                view.pane.activePane = 'bottomRight'
+
+        if row > 1 and column > 1:
+            sel = list(view.selection)
+            sel.insert(0, Selection(pane="topRight", activeCell=None, sqref=None))
+            sel.insert(1, Selection(pane="bottomLeft", activeCell=None, sqref=None))
+            view.selection = sel
+
+
+    def cell(self, row, column, value=None):
+        """
+        Returns a cell object based on the given coordinates.
+
+        Usage: cell(row=15, column=1, value=5)
+
+        Calling `cell` creates cells in memory when they
+        are first accessed.
+
+        :param row: row index of the cell (e.g. 4)
+        :type row: int
+
+        :param column: column index of the cell (e.g. 3)
+        :type column: int
+
+        :param value: value of the cell (e.g. 5)
+        :type value: numeric or time or string or bool or none
+
+        :rtype: openpyxl.cell.cell.Cell
+        """
+
+        if row < 1 or column < 1:
+            raise ValueError("Row or column values must be at least 1")
+
+        cell = self._get_cell(row, column)
+        if value is not None:
+            cell.value = value
+
+        return cell
+
+
+    def _get_cell(self, row, column):
+        """
+        Internal method for getting a cell from a worksheet.
+        Will create a new cell if one doesn't already exist.
+        """
+        if not 0 < row < 1048577:
+            raise ValueError(f"Row numbers must be between 1 and 1048576. Row number supplied was {row}")
+        coordinate = (row, column)
+        if not coordinate in self._cells:
+            cell = Cell(self, row=row, column=column)
+            self._add_cell(cell)
+        return self._cells[coordinate]
+
+
+    def _add_cell(self, cell):
+        """
+        Internal method for adding cell objects.
+        """
+        column = cell.col_idx
+        row = cell.row
+        self._current_row = max(row, self._current_row)
+        self._cells[(row, column)] = cell
+
+
+    def __getitem__(self, key):
+        """Convenience access by Excel style coordinates
+
+        The key can be a single cell coordinate 'A1', a range of cells 'A1:D25',
+        individual rows or columns 'A', 4 or ranges of rows or columns 'A:D',
+        4:10.
+
+        Single cells will always be created if they do not exist.
+
+        Returns either a single cell or a tuple of rows or columns.
+        """
+        if isinstance(key, slice):
+            if not all([key.start, key.stop]):
+                raise IndexError("{0} is not a valid coordinate or range".format(key))
+            key = "{0}:{1}".format(key.start, key.stop)
+
+        if isinstance(key, int):
+            key = str(key
+                      )
+        min_col, min_row, max_col, max_row = range_boundaries(key)
+
+        if not any([min_col, min_row, max_col, max_row]):
+            raise IndexError("{0} is not a valid coordinate or range".format(key))
+
+        if min_row is None:
+            cols = tuple(self.iter_cols(min_col, max_col))
+            if min_col == max_col:
+                cols = cols[0]
+            return cols
+        if min_col is None:
+            rows = tuple(self.iter_rows(min_col=min_col, min_row=min_row,
+                                        max_col=self.max_column, max_row=max_row))
+            if min_row == max_row:
+                rows = rows[0]
+            return rows
+        if ":" not in key:
+            return self._get_cell(min_row, min_col)
+        return tuple(self.iter_rows(min_row=min_row, min_col=min_col,
+                                    max_row=max_row, max_col=max_col))
+
+
+    def __setitem__(self, key, value):
+        self[key].value = value
+
+
+    def __iter__(self):
+        return self.iter_rows()
+
+
+    def __delitem__(self, key):
+        row, column = coordinate_to_tuple(key)
+        if (row, column) in self._cells:
+            del self._cells[(row, column)]
+
+
+    @property
+    def min_row(self):
+        """The minimum row index containing data (1-based)
+
+        :type: int
+        """
+        min_row = 1
+        if self._cells:
+            min_row = min(self._cells)[0]
+        return min_row
+
+
+    @property
+    def max_row(self):
+        """The maximum row index containing data (1-based)
+
+        :type: int
+        """
+        max_row = 1
+        if self._cells:
+            max_row = max(self._cells)[0]
+        return max_row
+
+
+    @property
+    def min_column(self):
+        """The minimum column index containing data (1-based)
+
+        :type: int
+        """
+        min_col = 1
+        if self._cells:
+            min_col = min(c[1] for c in self._cells)
+        return min_col
+
+
+    @property
+    def max_column(self):
+        """The maximum column index containing data (1-based)
+
+        :type: int
+        """
+        max_col = 1
+        if self._cells:
+            max_col = max(c[1] for c in self._cells)
+        return max_col
+
+
+    def calculate_dimension(self):
+        """Return the minimum bounding range for all cells containing data (ex. 'A1:M24')
+
+        :rtype: string
+        """
+        if self._cells:
+            rows = set()
+            cols = set()
+            for row, col in self._cells:
+                rows.add(row)
+                cols.add(col)
+            max_row = max(rows)
+            max_col = max(cols)
+            min_col = min(cols)
+            min_row = min(rows)
+        else:
+            return "A1:A1"
+
+        return f"{get_column_letter(min_col)}{min_row}:{get_column_letter(max_col)}{max_row}"
+
+
+    @property
+    def dimensions(self):
+        """Returns the result of :func:`calculate_dimension`"""
+        return self.calculate_dimension()
+
+
+    def iter_rows(self, min_row=None, max_row=None, min_col=None, max_col=None, values_only=False):
+        """
+        Produces cells from the worksheet, by row. Specify the iteration range
+        using indices of rows and columns.
+
+        If no indices are specified the range starts at A1.
+
+        If no cells are in the worksheet an empty tuple will be returned.
+
+        :param min_col: smallest column index (1-based index)
+        :type min_col: int
+
+        :param min_row: smallest row index (1-based index)
+        :type min_row: int
+
+        :param max_col: largest column index (1-based index)
+        :type max_col: int
+
+        :param max_row: largest row index (1-based index)
+        :type max_row: int
+
+        :param values_only: whether only cell values should be returned
+        :type values_only: bool
+
+        :rtype: generator
+        """
+
+        if self._current_row == 0 and not any([min_col, min_row, max_col, max_row ]):
+            return iter(())
+
+
+        min_col = min_col or 1
+        min_row = min_row or 1
+        max_col = max_col or self.max_column
+        max_row = max_row or self.max_row
+
+        return self._cells_by_row(min_col, min_row, max_col, max_row, values_only)
+
+
+    def _cells_by_row(self, min_col, min_row, max_col, max_row, values_only=False):
+        for row in range(min_row, max_row + 1):
+            cells = (self.cell(row=row, column=column) for column in range(min_col, max_col + 1))
+            if values_only:
+                yield tuple(cell.value for cell in cells)
+            else:
+                yield tuple(cells)
+
+
+    @property
+    def rows(self):
+        """Produces all cells in the worksheet, by row (see :func:`iter_rows`)
+
+        :type: generator
+        """
+        return self.iter_rows()
+
+
+    @property
+    def values(self):
+        """Produces all cell values in the worksheet, by row
+
+        :type: generator
+        """
+        for row in self.iter_rows(values_only=True):
+            yield row
+
+
+    def iter_cols(self, min_col=None, max_col=None, min_row=None, max_row=None, values_only=False):
+        """
+        Produces cells from the worksheet, by column. Specify the iteration range
+        using indices of rows and columns.
+
+        If no indices are specified the range starts at A1.
+
+        If no cells are in the worksheet an empty tuple will be returned.
+
+        :param min_col: smallest column index (1-based index)
+        :type min_col: int
+
+        :param min_row: smallest row index (1-based index)
+        :type min_row: int
+
+        :param max_col: largest column index (1-based index)
+        :type max_col: int
+
+        :param max_row: largest row index (1-based index)
+        :type max_row: int
+
+        :param values_only: whether only cell values should be returned
+        :type values_only: bool
+
+        :rtype: generator
+        """
+
+        if self._current_row == 0 and not any([min_col, min_row, max_col, max_row]):
+            return iter(())
+
+        min_col = min_col or 1
+        min_row = min_row or 1
+        max_col = max_col or self.max_column
+        max_row = max_row or self.max_row
+
+        return self._cells_by_col(min_col, min_row, max_col, max_row, values_only)
+
+
+    def _cells_by_col(self, min_col, min_row, max_col, max_row, values_only=False):
+        """
+        Get cells by column
+        """
+        for column in range(min_col, max_col+1):
+            cells = (self.cell(row=row, column=column)
+                        for row in range(min_row, max_row+1))
+            if values_only:
+                yield tuple(cell.value for cell in cells)
+            else:
+                yield tuple(cells)
+
+
+    @property
+    def columns(self):
+        """Produces all cells in the worksheet, by column  (see :func:`iter_cols`)"""
+        return self.iter_cols()
+
+
+    @property
+    def column_groups(self):
+        """
+        Return a list of column ranges where more than one column
+        """
+        return [cd.range for cd in self.column_dimensions.values() if cd.min and cd.max > cd.min]
+
+
+    def set_printer_settings(self, paper_size, orientation):
+        """Set printer settings """
+
+        self.page_setup.paperSize = paper_size
+        self.page_setup.orientation = orientation
+
+
+    def add_data_validation(self, data_validation):
+        """ Add a data-validation object to the sheet.  The data-validation
+            object defines the type of data-validation to be applied and the
+            cell or range of cells it should apply to.
+        """
+        self.data_validations.append(data_validation)
+
+
+    def add_chart(self, chart, anchor=None):
+        """
+        Add a chart to the sheet
+        Optionally provide a cell for the top-left anchor
+        """
+        if anchor is not None:
+            chart.anchor = anchor
+        self._charts.append(chart)
+
+
+    def add_image(self, img, anchor=None):
+        """
+        Add an image to the sheet.
+        Optionally provide a cell for the top-left anchor
+        """
+        if anchor is not None:
+            img.anchor = anchor
+        self._images.append(img)
+
+
+    def add_table(self, table):
+        """
+        Check for duplicate name in definedNames and other worksheet tables
+        before adding table.
+        """
+
+        if self.parent._duplicate_name(table.name):
+            raise ValueError("Table with name {0} already exists".format(table.name))
+        if not hasattr(self, "_get_cell"):
+            warn("In write-only mode you must add table columns manually")
+        self._tables.add(table)
+
+
+    @property
+    def tables(self):
+        return self._tables
+
+
+    def add_pivot(self, pivot):
+        self._pivots.append(pivot)
+
+
+    def merge_cells(self, range_string=None, start_row=None, start_column=None, end_row=None, end_column=None):
+        """ Set merge on a cell range.  Range is a cell range (e.g. A1:E1) """
+        if range_string is None:
+            cr = CellRange(range_string=range_string, min_col=start_column, min_row=start_row,
+                      max_col=end_column, max_row=end_row)
+            range_string = cr.coord
+        mcr = MergedCellRange(self, range_string)
+        self.merged_cells.add(mcr)
+        self._clean_merge_range(mcr)
+
+
+    def _clean_merge_range(self, mcr):
+        """
+        Remove all but the top left-cell from a range of merged cells
+        and recreate the lost border information.
+        Borders are then applied
+        """
+        cells = mcr.cells
+        next(cells) # skip first cell
+        for row, col in cells:
+            self._cells[row, col] = MergedCell(self, row, col)
+        mcr.format()
+
+
+    @property
+    @deprecated("Use ws.merged_cells.ranges")
+    def merged_cell_ranges(self):
+        """Return a copy of cell ranges"""
+        return self.merged_cells.ranges[:]
+
+
+    def unmerge_cells(self, range_string=None, start_row=None, start_column=None, end_row=None, end_column=None):
+        """ Remove merge on a cell range.  Range is a cell range (e.g. A1:E1) """
+        cr = CellRange(range_string=range_string, min_col=start_column, min_row=start_row,
+                      max_col=end_column, max_row=end_row)
+
+        if cr.coord not in self.merged_cells:
+            raise ValueError("Cell range {0} is not merged".format(cr.coord))
+
+        self.merged_cells.remove(cr)
+
+        cells = cr.cells
+        next(cells) # skip first cell
+        for row, col in cells:
+            del self._cells[(row, col)]
+
+
+    def append(self, iterable):
+        """Appends a group of values at the bottom of the current sheet.
+
+        * If it's a list: all values are added in order, starting from the first column
+        * If it's a dict: values are assigned to the columns indicated by the keys (numbers or letters)
+
+        :param iterable: list, range or generator, or dict containing values to append
+        :type iterable: list|tuple|range|generator or dict
+
+        Usage:
+
+        * append(['This is A1', 'This is B1', 'This is C1'])
+        * **or** append({'A' : 'This is A1', 'C' : 'This is C1'})
+        * **or** append({1 : 'This is A1', 3 : 'This is C1'})
+
+        :raise: TypeError when iterable is neither a list/tuple nor a dict
+
+        """
+        row_idx = self._current_row + 1
+
+        if (isinstance(iterable, (list, tuple, range))
+            or isgenerator(iterable)):
+            for col_idx, content in enumerate(iterable, 1):
+                if isinstance(content, Cell):
+                    # compatible with write-only mode
+                    cell = content
+                    if cell.parent and cell.parent != self:
+                        raise ValueError("Cells cannot be copied from other worksheets")
+                    cell.parent = self
+                    cell.column = col_idx
+                    cell.row = row_idx
+                else:
+                    cell = Cell(self, row=row_idx, column=col_idx, value=content)
+                self._cells[(row_idx, col_idx)] = cell
+
+        elif isinstance(iterable, dict):
+            for col_idx, content in iterable.items():
+                if isinstance(col_idx, str):
+                    col_idx = column_index_from_string(col_idx)
+                cell = Cell(self, row=row_idx, column=col_idx, value=content)
+                self._cells[(row_idx, col_idx)] = cell
+
+        else:
+            self._invalid_row(iterable)
+
+        self._current_row = row_idx
+
+
+    def _move_cells(self, min_row=None, min_col=None, offset=0, row_or_col="row"):
+        """
+        Move either rows or columns around by the offset
+        """
+        reverse = offset > 0 # start at the end if inserting
+        row_offset = 0
+        col_offset = 0
+
+        # need to make affected ranges contiguous
+        if row_or_col == 'row':
+            cells = self.iter_rows(min_row=min_row)
+            row_offset = offset
+            key = 0
+        else:
+            cells = self.iter_cols(min_col=min_col)
+            col_offset = offset
+            key = 1
+        cells = list(cells)
+
+        for row, column in sorted(self._cells, key=itemgetter(key), reverse=reverse):
+            if min_row and row < min_row:
+                continue
+            elif min_col and column < min_col:
+                continue
+
+            self._move_cell(row, column, row_offset, col_offset)
+
+
+    def insert_rows(self, idx, amount=1):
+        """
+        Insert row or rows before row==idx
+        """
+        self._move_cells(min_row=idx, offset=amount, row_or_col="row")
+        self._current_row = self.max_row
+
+
+    def insert_cols(self, idx, amount=1):
+        """
+        Insert column or columns before col==idx
+        """
+        self._move_cells(min_col=idx, offset=amount, row_or_col="column")
+
+
+    def delete_rows(self, idx, amount=1):
+        """
+        Delete row or rows from row==idx
+        """
+
+        remainder = _gutter(idx, amount, self.max_row)
+
+        self._move_cells(min_row=idx+amount, offset=-amount, row_or_col="row")
+
+        # calculating min and max col is an expensive operation, do it only once
+        min_col = self.min_column
+        max_col = self.max_column + 1
+        for row in remainder:
+            for col in range(min_col, max_col):
+                if (row, col) in self._cells:
+                    del self._cells[row, col]
+        self._current_row = self.max_row
+        if not self._cells:
+            self._current_row = 0
+
+
+    def delete_cols(self, idx, amount=1):
+        """
+        Delete column or columns from col==idx
+        """
+
+        remainder = _gutter(idx, amount, self.max_column)
+
+        self._move_cells(min_col=idx+amount, offset=-amount, row_or_col="column")
+
+        # calculating min and max row is an expensive operation, do it only once
+        min_row = self.min_row
+        max_row = self.max_row + 1
+        for col in remainder:
+            for row in range(min_row, max_row):
+                if (row, col) in self._cells:
+                    del self._cells[row, col]
+
+
+    def move_range(self, cell_range, rows=0, cols=0, translate=False):
+        """
+        Move a cell range by the number of rows and/or columns:
+        down if rows > 0 and up if rows < 0
+        right if cols > 0 and left if cols < 0
+        Existing cells will be overwritten.
+        Formulae and references will not be updated.
+        """
+        if isinstance(cell_range, str):
+            cell_range = CellRange(cell_range)
+        if not isinstance(cell_range, CellRange):
+            raise ValueError("Only CellRange objects can be moved")
+        if not rows and not cols:
+            return
+
+        down = rows > 0
+        right = cols > 0
+
+        if rows:
+            cells = sorted(cell_range.rows, reverse=down)
+        else:
+            cells = sorted(cell_range.cols, reverse=right)
+
+        for row, col in chain.from_iterable(cells):
+            self._move_cell(row, col, rows, cols, translate)
+
+        # rebase moved range
+        cell_range.shift(row_shift=rows, col_shift=cols)
+
+
+    def _move_cell(self, row, column, row_offset, col_offset, translate=False):
+        """
+        Move a cell from one place to another.
+        Delete at old index
+        Rebase coordinate
+        """
+        cell = self._get_cell(row, column)
+        new_row = cell.row + row_offset
+        new_col = cell.column + col_offset
+        self._cells[new_row, new_col] = cell
+        del self._cells[(cell.row, cell.column)]
+        cell.row = new_row
+        cell.column = new_col
+        if translate and cell.data_type == "f":
+            t = Translator(cell.value, cell.coordinate)
+            cell.value = t.translate_formula(row_delta=row_offset, col_delta=col_offset)
+
+
+    def _invalid_row(self, iterable):
+        raise TypeError('Value must be a list, tuple, range or generator, or a dict. Supplied value is {0}'.format(
+            type(iterable))
+                        )
+
+
+    def _add_column(self):
+        """Dimension factory for column information"""
+
+        return ColumnDimension(self)
+
+    def _add_row(self):
+        """Dimension factory for row information"""
+
+        return RowDimension(self)
+
+
+    @property
+    def print_title_rows(self):
+        """Rows to be printed at the top of every page (ex: '1:3')"""
+        if self._print_rows:
+            return str(self._print_rows)
+
+
+    @print_title_rows.setter
+    def print_title_rows(self, rows):
+        """
+        Set rows to be printed on the top of every page
+        format `1:3`
+        """
+        if rows is not None:
+            self._print_rows = RowRange(rows)
+
+
+    @property
+    def print_title_cols(self):
+        """Columns to be printed at the left side of every page (ex: 'A:C')"""
+        if self._print_cols:
+            return str(self._print_cols)
+
+
+    @print_title_cols.setter
+    def print_title_cols(self, cols):
+        """
+        Set cols to be printed on the left of every page
+        format ``A:C`
+        """
+        if cols is not None:
+            self._print_cols = ColRange(cols)
+
+
+    @property
+    def print_titles(self):
+        titles = PrintTitles(cols=self._print_cols, rows=self._print_rows, title=self.title)
+        return str(titles)
+
+
+    @property
+    def print_area(self):
+        """
+        The print area for the worksheet, or None if not set. To set, supply a range
+        like 'A1:D4' or a list of ranges.
+        """
+        self._print_area.title = self.title
+        return str(self._print_area)
+
+
+    @print_area.setter
+    def print_area(self, value):
+        """
+        Range of cells in the form A1:D4 or list of ranges. Print area can be cleared
+        by passing `None` or an empty list
+        """
+        if not value:
+            self._print_area = PrintArea()
+        elif isinstance(value, str):
+            self._print_area = PrintArea.from_string(value)
+        elif hasattr(value, "__iter__"):
+            self._print_area = PrintArea.from_string(",".join(value))
+
+
+def _gutter(idx, offset, max_val):
+    """
+    When deleting rows and columns are deleted we rely on overwriting.
+    This may not be the case for a large offset on small set of cells:
+    range(cells_to_delete) > range(cell_to_be_moved)
+    """
+    gutter = range(max(max_val+1-offset, idx), min(idx+offset, max_val)+1)
+    return gutter