############################################################################### # # Worksheet - A class for writing Excel Worksheets. # # SPDX-License-Identifier: BSD-2-Clause # # Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org # import datetime import re from typing import Dict, Optional, Tuple, Union from warnings import warn from xlsxwriter.color import Color COL_NAMES: Dict[int, str] = {} CHAR_WIDTHS = { " ": 3, "!": 5, '"': 6, "#": 7, "$": 7, "%": 11, "&": 10, "'": 3, "(": 5, ")": 5, "*": 7, "+": 7, ",": 4, "-": 5, ".": 4, "/": 6, "0": 7, "1": 7, "2": 7, "3": 7, "4": 7, "5": 7, "6": 7, "7": 7, "8": 7, "9": 7, ":": 4, ";": 4, "<": 7, "=": 7, ">": 7, "?": 7, "@": 13, "A": 9, "B": 8, "C": 8, "D": 9, "E": 7, "F": 7, "G": 9, "H": 9, "I": 4, "J": 5, "K": 8, "L": 6, "M": 12, "N": 10, "O": 10, "P": 8, "Q": 10, "R": 8, "S": 7, "T": 7, "U": 9, "V": 9, "W": 13, "X": 8, "Y": 7, "Z": 7, "[": 5, "\\": 6, "]": 5, "^": 7, "_": 7, "`": 4, "a": 7, "b": 8, "c": 6, "d": 8, "e": 8, "f": 5, "g": 7, "h": 8, "i": 4, "j": 4, "k": 7, "l": 4, "m": 12, "n": 8, "o": 8, "p": 8, "q": 8, "r": 5, "s": 6, "t": 5, "u": 8, "v": 7, "w": 11, "x": 7, "y": 7, "z": 6, "{": 5, "|": 7, "}": 5, "~": 7, } # The following is a list of Emojis used to decide if worksheet names require # quoting since there is (currently) no native support for matching them in # Python regular expressions. It is probably unnecessary to exclude them since # the default quoting is safe in Excel even when unnecessary (the reverse isn't # true). The Emoji list was generated from: # # https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AEmoji%3DYes%3A%5D&abb=on&esc=on&g=&i= # # pylint: disable-next=line-too-long EMOJIS = "\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u261d\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u265f\u2660\u2663\u2665\u2666\u2668\u267b\u267e\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26ce\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\U0001f004\U0001f0cf\U0001f170\U0001f171\U0001f17e\U0001f17f\U0001f18e\U0001f191-\U0001f19a\U0001f1e6-\U0001f1ff\U0001f201\U0001f202\U0001f21a\U0001f22f\U0001f232-\U0001f23a\U0001f250\U0001f251\U0001f300-\U0001f321\U0001f324-\U0001f393\U0001f396\U0001f397\U0001f399-\U0001f39b\U0001f39e-\U0001f3f0\U0001f3f3-\U0001f3f5\U0001f3f7-\U0001f4fd\U0001f4ff-\U0001f53d\U0001f549-\U0001f54e\U0001f550-\U0001f567\U0001f56f\U0001f570\U0001f573-\U0001f57a\U0001f587\U0001f58a-\U0001f58d\U0001f590\U0001f595\U0001f596\U0001f5a4\U0001f5a5\U0001f5a8\U0001f5b1\U0001f5b2\U0001f5bc\U0001f5c2-\U0001f5c4\U0001f5d1-\U0001f5d3\U0001f5dc-\U0001f5de\U0001f5e1\U0001f5e3\U0001f5e8\U0001f5ef\U0001f5f3\U0001f5fa-\U0001f64f\U0001f680-\U0001f6c5\U0001f6cb-\U0001f6d2\U0001f6d5-\U0001f6d7\U0001f6dc-\U0001f6e5\U0001f6e9\U0001f6eb\U0001f6ec\U0001f6f0\U0001f6f3-\U0001f6fc\U0001f7e0-\U0001f7eb\U0001f7f0\U0001f90c-\U0001f93a\U0001f93c-\U0001f945\U0001f947-\U0001f9ff\U0001fa70-\U0001fa7c\U0001fa80-\U0001fa88\U0001fa90-\U0001fabd\U0001fabf-\U0001fac5\U0001face-\U0001fadb\U0001fae0-\U0001fae8\U0001faf0-\U0001faf8" # noqa # Compile performance critical regular expressions. RE_LEADING_WHITESPACE = re.compile(r"^\s") RE_TRAILING_WHITESPACE = re.compile(r"\s$") RE_RANGE_PARTS = re.compile(r"(\$?)([A-Z]{1,3})(\$?)(\d+)") RE_QUOTE_RULE1 = re.compile(rf"[^\w\.{EMOJIS}]") RE_QUOTE_RULE2 = re.compile(rf"^[\d\.{EMOJIS}]") RE_QUOTE_RULE3 = re.compile(r"^([A-Z]{1,3}\d+)$") RE_QUOTE_RULE4_ROW = re.compile(r"^R(\d+)") RE_QUOTE_RULE4_COLUMN = re.compile(r"^R?C(\d+)") def xl_rowcol_to_cell( row: int, col: int, row_abs: bool = False, col_abs: bool = False, ) -> str: """ Convert a zero indexed row and column cell reference to a A1 style string. Args: row: The cell row. Int. col: The cell column. Int. row_abs: Optional flag to make the row absolute. Bool. col_abs: Optional flag to make the column absolute. Bool. Returns: A1 style string. """ if row < 0: warn(f"Row number '{row}' must be >= 0") return "" if col < 0: warn(f"Col number '{col}' must be >= 0") return "" row += 1 # Change to 1-index. row_abs_str = "$" if row_abs else "" col_str = xl_col_to_name(col, col_abs) return col_str + row_abs_str + str(row) def xl_rowcol_to_cell_fast(row: int, col: int) -> str: """ Optimized version of the xl_rowcol_to_cell function. Only used internally. Args: row: The cell row. Int. col: The cell column. Int. Returns: A1 style string. """ if col in COL_NAMES: col_str = COL_NAMES[col] else: col_str = xl_col_to_name(col) COL_NAMES[col] = col_str return col_str + str(row + 1) def xl_col_to_name(col: int, col_abs: bool = False) -> str: """ Convert a zero indexed column cell reference to a string. Args: col: The cell column. Int. col_abs: Optional flag to make the column absolute. Bool. Returns: Column style string. """ col_num = col if col_num < 0: warn(f"Col number '{col_num}' must be >= 0") return "" col_num += 1 # Change to 1-index. col_str = "" col_abs_str = "$" if col_abs else "" while col_num: # Set remainder from 1 .. 26 remainder = col_num % 26 if remainder == 0: remainder = 26 # Convert the remainder to a character. col_letter = chr(ord("A") + remainder - 1) # Accumulate the column letters, right to left. col_str = col_letter + col_str # Get the next order of magnitude. col_num = int((col_num - 1) / 26) return col_abs_str + col_str def xl_cell_to_rowcol(cell_str: str) -> Tuple[int, int]: """ Convert a cell reference in A1 notation to a zero indexed row and column. Args: cell_str: A1 style string. Returns: row, col: Zero indexed cell row and column indices. """ if not cell_str: return 0, 0 match = RE_RANGE_PARTS.match(cell_str) if match is None: warn(f"Invalid cell reference '{cell_str}'") return 0, 0 col_str = match.group(2) row_str = match.group(4) # Convert base26 column string to number. expn = 0 col = 0 for char in reversed(col_str): col += (ord(char) - ord("A") + 1) * (26**expn) expn += 1 # Convert 1-index to zero-index row = int(row_str) - 1 col -= 1 return row, col def xl_cell_to_rowcol_abs(cell_str: str) -> Tuple[int, int, bool, bool]: """ Convert an absolute cell reference in A1 notation to a zero indexed row and column, with True/False values for absolute rows or columns. Args: cell_str: A1 style string. Returns: row, col, row_abs, col_abs: Zero indexed cell row and column indices. """ if not cell_str: return 0, 0, False, False match = RE_RANGE_PARTS.match(cell_str) if match is None: warn(f"Invalid cell reference '{cell_str}'") return 0, 0, False, False col_abs = bool(match.group(1)) col_str = match.group(2) row_abs = bool(match.group(3)) row_str = match.group(4) # Convert base26 column string to number. expn = 0 col = 0 for char in reversed(col_str): col += (ord(char) - ord("A") + 1) * (26**expn) expn += 1 # Convert 1-index to zero-index row = int(row_str) - 1 col -= 1 return row, col, row_abs, col_abs def xl_range(first_row: int, first_col: int, last_row: int, last_col: int) -> str: """ Convert zero indexed row and col cell references to a A1:B1 range string. Args: first_row: The first cell row. Int. first_col: The first cell column. Int. last_row: The last cell row. Int. last_col: The last cell column. Int. Returns: A1:B1 style range string. """ range1 = xl_rowcol_to_cell(first_row, first_col) range2 = xl_rowcol_to_cell(last_row, last_col) if range1 == "" or range2 == "": warn("Row and column numbers must be >= 0") return "" if range1 == range2: return range1 return range1 + ":" + range2 def xl_range_abs(first_row: int, first_col: int, last_row: int, last_col: int) -> str: """ Convert zero indexed row and col cell references to a $A$1:$B$1 absolute range string. Args: first_row: The first cell row. Int. first_col: The first cell column. Int. last_row: The last cell row. Int. last_col: The last cell column. Int. Returns: $A$1:$B$1 style range string. """ range1 = xl_rowcol_to_cell(first_row, first_col, True, True) range2 = xl_rowcol_to_cell(last_row, last_col, True, True) if range1 == "" or range2 == "": warn("Row and column numbers must be >= 0") return "" if range1 == range2: return range1 return range1 + ":" + range2 def xl_range_formula( sheetname: str, first_row: int, first_col: int, last_row: int, last_col: int ) -> str: """ Convert worksheet name and zero indexed row and col cell references to a Sheet1!A1:B1 range formula string. Args: sheetname: The worksheet name. String. first_row: The first cell row. Int. first_col: The first cell column. Int. last_row: The last cell row. Int. last_col: The last cell column. Int. Returns: A1:B1 style range string. """ cell_range = xl_range_abs(first_row, first_col, last_row, last_col) sheetname = quote_sheetname(sheetname) return sheetname + "!" + cell_range def quote_sheetname(sheetname: str) -> str: """ Sheetnames used in references should be quoted if they contain any spaces, special characters or if they look like a A1 or RC cell reference. The rules are shown inline below. Args: sheetname: The worksheet name. String. Returns: A quoted worksheet string. """ uppercase_sheetname = sheetname.upper() requires_quoting = False col_max = 163_84 row_max = 1048576 # Don't quote sheetname if it is already quoted by the user. if not sheetname.startswith("'"): match_rule3 = RE_QUOTE_RULE3.match(uppercase_sheetname) match_rule4_row = RE_QUOTE_RULE4_ROW.match(uppercase_sheetname) match_rule4_column = RE_QUOTE_RULE4_COLUMN.match(uppercase_sheetname) # -------------------------------------------------------------------- # Rule 1. Sheet names that contain anything other than \w and "." # characters must be quoted. # -------------------------------------------------------------------- if RE_QUOTE_RULE1.search(sheetname): requires_quoting = True # -------------------------------------------------------------------- # Rule 2. Sheet names that start with a digit or "." must be quoted. # -------------------------------------------------------------------- elif RE_QUOTE_RULE2.search(sheetname): requires_quoting = True # -------------------------------------------------------------------- # Rule 3. Sheet names must not be a valid A1 style cell reference. # Valid means that the row and column range values must also be within # Excel row and column limits. # -------------------------------------------------------------------- elif match_rule3: cell = match_rule3.group(1) (row, col) = xl_cell_to_rowcol(cell) if 0 <= row < row_max and 0 <= col < col_max: requires_quoting = True # -------------------------------------------------------------------- # Rule 4. Sheet names must not *start* with a valid RC style cell # reference. Other characters after the valid RC reference are ignored # by Excel. Valid means that the row and column range values must also # be within Excel row and column limits. # # Note: references without trailing characters like R12345 or C12345 # are caught by Rule 3. Negative references like R-12345 are caught by # Rule 1 due to the dash. # -------------------------------------------------------------------- # Rule 4a. Check for sheet names that start with R1 style references. elif match_rule4_row: row = int(match_rule4_row.group(1)) if 0 < row <= row_max: requires_quoting = True # Rule 4b. Check for sheet names that start with C1 or RC1 style elif match_rule4_column: col = int(match_rule4_column.group(1)) if 0 < col <= col_max: requires_quoting = True # Rule 4c. Check for some single R/C references. elif uppercase_sheetname in ("R", "C", "RC"): requires_quoting = True if requires_quoting: # Double quote any single quotes. sheetname = sheetname.replace("'", "''") # Single quote the sheet name. sheetname = f"'{sheetname}'" return sheetname def cell_autofit_width(string: str) -> int: """ Calculate the width required to auto-fit a string in a cell. Args: string: The string to calculate the cell width for. String. Returns: The string autofit width in pixels. Returns 0 if the string is empty. """ if not string or len(string) == 0: return 0 # Excel adds an additional 7 pixels of padding to the cell boundary. return xl_pixel_width(string) + 7 def xl_pixel_width(string: str) -> int: """ Get the pixel width of a string based on individual character widths taken from Excel. UTF8 characters, and other unhandled characters, are given a default width of 8. Args: string: The string to calculate the width for. String. Returns: The string width in pixels. Note, Excel adds an additional 7 pixels of padding in the cell. """ length = 0 for char in string: length += CHAR_WIDTHS.get(char, 8) return length def _get_sparkline_style(style_id: int) -> Dict[str, Dict[str, str]]: """ Get the numbered sparkline styles. """ styles = [ { # 0 "low": Color.theme(4, 0), "high": Color.theme(4, 0), "last": Color.theme(4, 3), "first": Color.theme(4, 3), "series": Color.theme(4, 5), "markers": Color.theme(4, 5), "negative": Color.theme(5, 0), }, { # 1 "low": Color.theme(4, 0), "high": Color.theme(4, 0), "last": Color.theme(4, 3), "first": Color.theme(4, 3), "series": Color.theme(4, 5), "markers": Color.theme(4, 5), "negative": Color.theme(5, 0), }, { # 2 "low": Color.theme(5, 0), "high": Color.theme(5, 0), "last": Color.theme(5, 3), "first": Color.theme(5, 3), "series": Color.theme(5, 5), "markers": Color.theme(5, 5), "negative": Color.theme(6, 0), }, { # 3 "low": Color.theme(6, 0), "high": Color.theme(6, 0), "last": Color.theme(6, 3), "first": Color.theme(6, 3), "series": Color.theme(6, 5), "markers": Color.theme(6, 5), "negative": Color.theme(7, 0), }, { # 4 "low": Color.theme(7, 0), "high": Color.theme(7, 0), "last": Color.theme(7, 3), "first": Color.theme(7, 3), "series": Color.theme(7, 5), "markers": Color.theme(7, 5), "negative": Color.theme(8, 0), }, { # 5 "low": Color.theme(8, 0), "high": Color.theme(8, 0), "last": Color.theme(8, 3), "first": Color.theme(8, 3), "series": Color.theme(8, 5), "markers": Color.theme(8, 5), "negative": Color.theme(9, 0), }, { # 6 "low": Color.theme(9, 0), "high": Color.theme(9, 0), "last": Color.theme(9, 3), "first": Color.theme(9, 3), "series": Color.theme(9, 5), "markers": Color.theme(9, 5), "negative": Color.theme(4, 0), }, { # 7 "low": Color.theme(5, 4), "high": Color.theme(5, 4), "last": Color.theme(5, 4), "first": Color.theme(5, 4), "series": Color.theme(4, 4), "markers": Color.theme(5, 4), "negative": Color.theme(5, 0), }, { # 8 "low": Color.theme(6, 4), "high": Color.theme(6, 4), "last": Color.theme(6, 4), "first": Color.theme(6, 4), "series": Color.theme(5, 4), "markers": Color.theme(6, 4), "negative": Color.theme(6, 0), }, { # 9 "low": Color.theme(7, 4), "high": Color.theme(7, 4), "last": Color.theme(7, 4), "first": Color.theme(7, 4), "series": Color.theme(6, 4), "markers": Color.theme(7, 4), "negative": Color.theme(7, 0), }, { # 10 "low": Color.theme(8, 4), "high": Color.theme(8, 4), "last": Color.theme(8, 4), "first": Color.theme(8, 4), "series": Color.theme(7, 4), "markers": Color.theme(8, 4), "negative": Color.theme(8, 0), }, { # 11 "low": Color.theme(9, 4), "high": Color.theme(9, 4), "last": Color.theme(9, 4), "first": Color.theme(9, 4), "series": Color.theme(8, 4), "markers": Color.theme(9, 4), "negative": Color.theme(9, 0), }, { # 12 "low": Color.theme(4, 4), "high": Color.theme(4, 4), "last": Color.theme(4, 4), "first": Color.theme(4, 4), "series": Color.theme(9, 4), "markers": Color.theme(4, 4), "negative": Color.theme(4, 0), }, { # 13 "low": Color.theme(4, 4), "high": Color.theme(4, 4), "last": Color.theme(4, 4), "first": Color.theme(4, 4), "series": Color.theme(4, 0), "markers": Color.theme(4, 4), "negative": Color.theme(5, 0), }, { # 14 "low": Color.theme(5, 4), "high": Color.theme(5, 4), "last": Color.theme(5, 4), "first": Color.theme(5, 4), "series": Color.theme(5, 0), "markers": Color.theme(5, 4), "negative": Color.theme(6, 0), }, { # 15 "low": Color.theme(6, 4), "high": Color.theme(6, 4), "last": Color.theme(6, 4), "first": Color.theme(6, 4), "series": Color.theme(6, 0), "markers": Color.theme(6, 4), "negative": Color.theme(7, 0), }, { # 16 "low": Color.theme(7, 4), "high": Color.theme(7, 4), "last": Color.theme(7, 4), "first": Color.theme(7, 4), "series": Color.theme(7, 0), "markers": Color.theme(7, 4), "negative": Color.theme(8, 0), }, { # 17 "low": Color.theme(8, 4), "high": Color.theme(8, 4), "last": Color.theme(8, 4), "first": Color.theme(8, 4), "series": Color.theme(8, 0), "markers": Color.theme(8, 4), "negative": Color.theme(9, 0), }, { # 18 "low": Color.theme(9, 4), "high": Color.theme(9, 4), "last": Color.theme(9, 4), "first": Color.theme(9, 4), "series": Color.theme(9, 0), "markers": Color.theme(9, 4), "negative": Color.theme(4, 0), }, { # 19 "low": Color.theme(4, 5), "high": Color.theme(4, 5), "last": Color.theme(4, 4), "first": Color.theme(4, 4), "series": Color.theme(4, 3), "markers": Color.theme(4, 1), "negative": Color.theme(0, 5), }, { # 20 "low": Color.theme(5, 5), "high": Color.theme(5, 5), "last": Color.theme(5, 4), "first": Color.theme(5, 4), "series": Color.theme(5, 3), "markers": Color.theme(5, 1), "negative": Color.theme(0, 5), }, { # 21 "low": Color.theme(6, 5), "high": Color.theme(6, 5), "last": Color.theme(6, 4), "first": Color.theme(6, 4), "series": Color.theme(6, 3), "markers": Color.theme(6, 1), "negative": Color.theme(0, 5), }, { # 22 "low": Color.theme(7, 5), "high": Color.theme(7, 5), "last": Color.theme(7, 4), "first": Color.theme(7, 4), "series": Color.theme(7, 3), "markers": Color.theme(7, 1), "negative": Color.theme(0, 5), }, { # 23 "low": Color.theme(8, 5), "high": Color.theme(8, 5), "last": Color.theme(8, 4), "first": Color.theme(8, 4), "series": Color.theme(8, 3), "markers": Color.theme(8, 1), "negative": Color.theme(0, 5), }, { # 24 "low": Color.theme(9, 5), "high": Color.theme(9, 5), "last": Color.theme(9, 4), "first": Color.theme(9, 4), "series": Color.theme(9, 3), "markers": Color.theme(9, 1), "negative": Color.theme(0, 5), }, { # 25 "low": Color.theme(1, 3), "high": Color.theme(1, 3), "last": Color.theme(1, 3), "first": Color.theme(1, 3), "series": Color.theme(1, 1), "markers": Color.theme(1, 3), "negative": Color.theme(1, 3), }, { # 26 "low": Color.theme(0, 3), "high": Color.theme(0, 3), "last": Color.theme(0, 3), "first": Color.theme(0, 3), "series": Color.theme(1, 2), "markers": Color.theme(0, 3), "negative": Color.theme(0, 3), }, { # 27 "low": Color("#D00000"), "high": Color("#D00000"), "last": Color("#D00000"), "first": Color("#D00000"), "series": Color("#323232"), "markers": Color("#D00000"), "negative": Color("#D00000"), }, { # 28 "low": Color("#0070C0"), "high": Color("#0070C0"), "last": Color("#0070C0"), "first": Color("#0070C0"), "series": Color("#000000"), "markers": Color("#0070C0"), "negative": Color("#0070C0"), }, { # 29 "low": Color("#D00000"), "high": Color("#D00000"), "last": Color("#D00000"), "first": Color("#D00000"), "series": Color("#376092"), "markers": Color("#D00000"), "negative": Color("#D00000"), }, { # 30 "low": Color("#000000"), "high": Color("#000000"), "last": Color("#000000"), "first": Color("#000000"), "series": Color("#0070C0"), "markers": Color("#000000"), "negative": Color("#000000"), }, { # 31 "low": Color("#FF5055"), "high": Color("#56BE79"), "last": Color("#359CEB"), "first": Color("#5687C2"), "series": Color("#5F5F5F"), "markers": Color("#D70077"), "negative": Color("#FFB620"), }, { # 32 "low": Color("#FF5055"), "high": Color("#56BE79"), "last": Color("#359CEB"), "first": Color("#777777"), "series": Color("#5687C2"), "markers": Color("#D70077"), "negative": Color("#FFB620"), }, { # 33 "low": Color("#FF5367"), "high": Color("#60D276"), "last": Color("#FFEB9C"), "first": Color("#FFDC47"), "series": Color("#C6EFCE"), "markers": Color("#8CADD6"), "negative": Color("#FFC7CE"), }, { # 34 "low": Color("#FF0000"), "high": Color("#00B050"), "last": Color("#FFC000"), "first": Color("#FFC000"), "series": Color("#00B050"), "markers": Color("#0070C0"), "negative": Color("#FF0000"), }, { # 35 "low": Color.theme(7, 0), "high": Color.theme(6, 0), "last": Color.theme(5, 0), "first": Color.theme(4, 0), "series": Color.theme(3, 0), "markers": Color.theme(8, 0), "negative": Color.theme(9, 0), }, { # 36 "low": Color.theme(7, 0), "high": Color.theme(6, 0), "last": Color.theme(5, 0), "first": Color.theme(4, 0), "series": Color.theme(1, 0), "markers": Color.theme(8, 0), "negative": Color.theme(9, 0), }, ] return styles[style_id] def _supported_datetime( dt: Union[datetime.datetime, datetime.time, datetime.date], ) -> bool: # Determine is an argument is a supported datetime object. return isinstance( dt, (datetime.datetime, datetime.date, datetime.time, datetime.timedelta) ) def _remove_datetime_timezone( dt_obj: datetime.datetime, remove_timezone: bool ) -> datetime.datetime: # Excel doesn't support timezones in datetimes/times so we remove the # tzinfo from the object if the user has specified that option in the # constructor. if remove_timezone: dt_obj = dt_obj.replace(tzinfo=None) else: if dt_obj.tzinfo: raise TypeError( "Excel doesn't support timezones in datetimes. " "Set the tzinfo in the datetime/time object to None or " "use the 'remove_timezone' Workbook() option" ) return dt_obj def _datetime_to_excel_datetime( dt_obj: Union[datetime.time, datetime.datetime, datetime.timedelta, datetime.date], date_1904: bool, remove_timezone: bool, ) -> float: # Convert a datetime object to an Excel serial date and time. The integer # part of the number stores the number of days since the epoch and the # fractional part stores the percentage of the day. date_type = dt_obj is_timedelta = False if date_1904: # Excel for Mac date epoch. epoch = datetime.datetime(1904, 1, 1) else: # Default Excel epoch. epoch = datetime.datetime(1899, 12, 31) # We handle datetime .datetime, .date and .time objects but convert # them to datetime.datetime objects and process them in the same way. if isinstance(dt_obj, datetime.datetime): dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone) delta = dt_obj - epoch elif isinstance(dt_obj, datetime.date): dt_obj = datetime.datetime.fromordinal(dt_obj.toordinal()) delta = dt_obj - epoch elif isinstance(dt_obj, datetime.time): dt_obj = datetime.datetime.combine(epoch, dt_obj) dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone) delta = dt_obj - epoch elif isinstance(dt_obj, datetime.timedelta): is_timedelta = True delta = dt_obj else: raise TypeError("Unknown or unsupported datetime type") # Convert a Python datetime.datetime value to an Excel date number. excel_time = delta.days + ( float(delta.seconds) + float(delta.microseconds) / 1e6 ) / (60 * 60 * 24) # The following is a workaround for the fact that in Excel a time only # value is represented as 1899-12-31+time whereas in datetime.datetime() # it is 1900-1-1+time so we need to subtract the 1 day difference. if ( isinstance(date_type, datetime.datetime) and not isinstance(dt_obj, datetime.timedelta) and dt_obj.isocalendar() == ( 1900, 1, 1, ) ): excel_time -= 1 # Account for Excel erroneously treating 1900 as a leap year. if not date_1904 and not is_timedelta and excel_time > 59: excel_time += 1 return excel_time def _preserve_whitespace(string: str) -> Optional[re.Match]: # Check if a string has leading or trailing whitespace that requires a # "preserve" attribute. return RE_LEADING_WHITESPACE.search(string) or RE_TRAILING_WHITESPACE.search(string)