1###############################################################################
2#
3# Worksheet - A class for writing Excel Worksheets.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9import datetime
10import re
11from typing import Dict, Optional, Tuple, Union
12from warnings import warn
13
14from xlsxwriter.color import Color
15
16COL_NAMES: Dict[int, str] = {}
17
18CHAR_WIDTHS = {
19 " ": 3,
20 "!": 5,
21 '"': 6,
22 "#": 7,
23 "$": 7,
24 "%": 11,
25 "&": 10,
26 "'": 3,
27 "(": 5,
28 ")": 5,
29 "*": 7,
30 "+": 7,
31 ",": 4,
32 "-": 5,
33 ".": 4,
34 "/": 6,
35 "0": 7,
36 "1": 7,
37 "2": 7,
38 "3": 7,
39 "4": 7,
40 "5": 7,
41 "6": 7,
42 "7": 7,
43 "8": 7,
44 "9": 7,
45 ":": 4,
46 ";": 4,
47 "<": 7,
48 "=": 7,
49 ">": 7,
50 "?": 7,
51 "@": 13,
52 "A": 9,
53 "B": 8,
54 "C": 8,
55 "D": 9,
56 "E": 7,
57 "F": 7,
58 "G": 9,
59 "H": 9,
60 "I": 4,
61 "J": 5,
62 "K": 8,
63 "L": 6,
64 "M": 12,
65 "N": 10,
66 "O": 10,
67 "P": 8,
68 "Q": 10,
69 "R": 8,
70 "S": 7,
71 "T": 7,
72 "U": 9,
73 "V": 9,
74 "W": 13,
75 "X": 8,
76 "Y": 7,
77 "Z": 7,
78 "[": 5,
79 "\\": 6,
80 "]": 5,
81 "^": 7,
82 "_": 7,
83 "`": 4,
84 "a": 7,
85 "b": 8,
86 "c": 6,
87 "d": 8,
88 "e": 8,
89 "f": 5,
90 "g": 7,
91 "h": 8,
92 "i": 4,
93 "j": 4,
94 "k": 7,
95 "l": 4,
96 "m": 12,
97 "n": 8,
98 "o": 8,
99 "p": 8,
100 "q": 8,
101 "r": 5,
102 "s": 6,
103 "t": 5,
104 "u": 8,
105 "v": 7,
106 "w": 11,
107 "x": 7,
108 "y": 7,
109 "z": 6,
110 "{": 5,
111 "|": 7,
112 "}": 5,
113 "~": 7,
114}
115
116# The following is a list of Emojis used to decide if worksheet names require
117# quoting since there is (currently) no native support for matching them in
118# Python regular expressions. It is probably unnecessary to exclude them since
119# the default quoting is safe in Excel even when unnecessary (the reverse isn't
120# true). The Emoji list was generated from:
121#
122# https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AEmoji%3DYes%3A%5D&abb=on&esc=on&g=&i=
123#
124# pylint: disable-next=line-too-long
125EMOJIS = "\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
126
127# Compile performance critical regular expressions.
128RE_LEADING_WHITESPACE = re.compile(r"^\s")
129RE_TRAILING_WHITESPACE = re.compile(r"\s$")
130RE_RANGE_PARTS = re.compile(r"(\$?)([A-Z]{1,3})(\$?)(\d+)")
131RE_QUOTE_RULE1 = re.compile(rf"[^\w\.{EMOJIS}]")
132RE_QUOTE_RULE2 = re.compile(rf"^[\d\.{EMOJIS}]")
133RE_QUOTE_RULE3 = re.compile(r"^([A-Z]{1,3}\d+)$")
134RE_QUOTE_RULE4_ROW = re.compile(r"^R(\d+)")
135RE_QUOTE_RULE4_COLUMN = re.compile(r"^R?C(\d+)")
136
137
138def xl_rowcol_to_cell(
139 row: int,
140 col: int,
141 row_abs: bool = False,
142 col_abs: bool = False,
143) -> str:
144 """
145 Convert a zero indexed row and column cell reference to a A1 style string.
146
147 Args:
148 row: The cell row. Int.
149 col: The cell column. Int.
150 row_abs: Optional flag to make the row absolute. Bool.
151 col_abs: Optional flag to make the column absolute. Bool.
152
153 Returns:
154 A1 style string.
155
156 """
157 if row < 0:
158 warn(f"Row number '{row}' must be >= 0")
159 return ""
160
161 if col < 0:
162 warn(f"Col number '{col}' must be >= 0")
163 return ""
164
165 row += 1 # Change to 1-index.
166 row_abs_str = "$" if row_abs else ""
167
168 col_str = xl_col_to_name(col, col_abs)
169
170 return col_str + row_abs_str + str(row)
171
172
173def xl_rowcol_to_cell_fast(row: int, col: int) -> str:
174 """
175 Optimized version of the xl_rowcol_to_cell function. Only used internally.
176
177 Args:
178 row: The cell row. Int.
179 col: The cell column. Int.
180
181 Returns:
182 A1 style string.
183
184 """
185 if col in COL_NAMES:
186 col_str = COL_NAMES[col]
187 else:
188 col_str = xl_col_to_name(col)
189 COL_NAMES[col] = col_str
190
191 return col_str + str(row + 1)
192
193
194def xl_col_to_name(col: int, col_abs: bool = False) -> str:
195 """
196 Convert a zero indexed column cell reference to a string.
197
198 Args:
199 col: The cell column. Int.
200 col_abs: Optional flag to make the column absolute. Bool.
201
202 Returns:
203 Column style string.
204
205 """
206 col_num = col
207 if col_num < 0:
208 warn(f"Col number '{col_num}' must be >= 0")
209 return ""
210
211 col_num += 1 # Change to 1-index.
212 col_str = ""
213 col_abs_str = "$" if col_abs else ""
214
215 while col_num:
216 # Set remainder from 1 .. 26
217 remainder = col_num % 26
218
219 if remainder == 0:
220 remainder = 26
221
222 # Convert the remainder to a character.
223 col_letter = chr(ord("A") + remainder - 1)
224
225 # Accumulate the column letters, right to left.
226 col_str = col_letter + col_str
227
228 # Get the next order of magnitude.
229 col_num = int((col_num - 1) / 26)
230
231 return col_abs_str + col_str
232
233
234def xl_cell_to_rowcol(cell_str: str) -> Tuple[int, int]:
235 """
236 Convert a cell reference in A1 notation to a zero indexed row and column.
237
238 Args:
239 cell_str: A1 style string.
240
241 Returns:
242 row, col: Zero indexed cell row and column indices.
243
244 """
245 if not cell_str:
246 return 0, 0
247
248 match = RE_RANGE_PARTS.match(cell_str)
249 if match is None:
250 warn(f"Invalid cell reference '{cell_str}'")
251 return 0, 0
252
253 col_str = match.group(2)
254 row_str = match.group(4)
255
256 # Convert base26 column string to number.
257 expn = 0
258 col = 0
259 for char in reversed(col_str):
260 col += (ord(char) - ord("A") + 1) * (26**expn)
261 expn += 1
262
263 # Convert 1-index to zero-index
264 row = int(row_str) - 1
265 col -= 1
266
267 return row, col
268
269
270def xl_cell_to_rowcol_abs(cell_str: str) -> Tuple[int, int, bool, bool]:
271 """
272 Convert an absolute cell reference in A1 notation to a zero indexed
273 row and column, with True/False values for absolute rows or columns.
274
275 Args:
276 cell_str: A1 style string.
277
278 Returns:
279 row, col, row_abs, col_abs: Zero indexed cell row and column indices.
280
281 """
282 if not cell_str:
283 return 0, 0, False, False
284
285 match = RE_RANGE_PARTS.match(cell_str)
286 if match is None:
287 warn(f"Invalid cell reference '{cell_str}'")
288 return 0, 0, False, False
289
290 col_abs = bool(match.group(1))
291 col_str = match.group(2)
292 row_abs = bool(match.group(3))
293 row_str = match.group(4)
294
295 # Convert base26 column string to number.
296 expn = 0
297 col = 0
298 for char in reversed(col_str):
299 col += (ord(char) - ord("A") + 1) * (26**expn)
300 expn += 1
301
302 # Convert 1-index to zero-index
303 row = int(row_str) - 1
304 col -= 1
305
306 return row, col, row_abs, col_abs
307
308
309def xl_range(first_row: int, first_col: int, last_row: int, last_col: int) -> str:
310 """
311 Convert zero indexed row and col cell references to a A1:B1 range string.
312
313 Args:
314 first_row: The first cell row. Int.
315 first_col: The first cell column. Int.
316 last_row: The last cell row. Int.
317 last_col: The last cell column. Int.
318
319 Returns:
320 A1:B1 style range string.
321
322 """
323 range1 = xl_rowcol_to_cell(first_row, first_col)
324 range2 = xl_rowcol_to_cell(last_row, last_col)
325
326 if range1 == "" or range2 == "":
327 warn("Row and column numbers must be >= 0")
328 return ""
329
330 if range1 == range2:
331 return range1
332
333 return range1 + ":" + range2
334
335
336def xl_range_abs(first_row: int, first_col: int, last_row: int, last_col: int) -> str:
337 """
338 Convert zero indexed row and col cell references to a $A$1:$B$1 absolute
339 range string.
340
341 Args:
342 first_row: The first cell row. Int.
343 first_col: The first cell column. Int.
344 last_row: The last cell row. Int.
345 last_col: The last cell column. Int.
346
347 Returns:
348 $A$1:$B$1 style range string.
349
350 """
351 range1 = xl_rowcol_to_cell(first_row, first_col, True, True)
352 range2 = xl_rowcol_to_cell(last_row, last_col, True, True)
353
354 if range1 == "" or range2 == "":
355 warn("Row and column numbers must be >= 0")
356 return ""
357
358 if range1 == range2:
359 return range1
360
361 return range1 + ":" + range2
362
363
364def xl_range_formula(
365 sheetname: str, first_row: int, first_col: int, last_row: int, last_col: int
366) -> str:
367 """
368 Convert worksheet name and zero indexed row and col cell references to
369 a Sheet1!A1:B1 range formula string.
370
371 Args:
372 sheetname: The worksheet name. String.
373 first_row: The first cell row. Int.
374 first_col: The first cell column. Int.
375 last_row: The last cell row. Int.
376 last_col: The last cell column. Int.
377
378 Returns:
379 A1:B1 style range string.
380
381 """
382 cell_range = xl_range_abs(first_row, first_col, last_row, last_col)
383 sheetname = quote_sheetname(sheetname)
384
385 return sheetname + "!" + cell_range
386
387
388def quote_sheetname(sheetname: str) -> str:
389 """
390 Sheetnames used in references should be quoted if they contain any spaces,
391 special characters or if they look like a A1 or RC cell reference. The rules
392 are shown inline below.
393
394 Args:
395 sheetname: The worksheet name. String.
396
397 Returns:
398 A quoted worksheet string.
399
400 """
401 uppercase_sheetname = sheetname.upper()
402 requires_quoting = False
403 col_max = 163_84
404 row_max = 1048576
405
406 # Don't quote sheetname if it is already quoted by the user.
407 if not sheetname.startswith("'"):
408
409 match_rule3 = RE_QUOTE_RULE3.match(uppercase_sheetname)
410 match_rule4_row = RE_QUOTE_RULE4_ROW.match(uppercase_sheetname)
411 match_rule4_column = RE_QUOTE_RULE4_COLUMN.match(uppercase_sheetname)
412
413 # --------------------------------------------------------------------
414 # Rule 1. Sheet names that contain anything other than \w and "."
415 # characters must be quoted.
416 # --------------------------------------------------------------------
417 if RE_QUOTE_RULE1.search(sheetname):
418 requires_quoting = True
419
420 # --------------------------------------------------------------------
421 # Rule 2. Sheet names that start with a digit or "." must be quoted.
422 # --------------------------------------------------------------------
423 elif RE_QUOTE_RULE2.search(sheetname):
424 requires_quoting = True
425
426 # --------------------------------------------------------------------
427 # Rule 3. Sheet names must not be a valid A1 style cell reference.
428 # Valid means that the row and column range values must also be within
429 # Excel row and column limits.
430 # --------------------------------------------------------------------
431 elif match_rule3:
432 cell = match_rule3.group(1)
433 (row, col) = xl_cell_to_rowcol(cell)
434
435 if 0 <= row < row_max and 0 <= col < col_max:
436 requires_quoting = True
437
438 # --------------------------------------------------------------------
439 # Rule 4. Sheet names must not *start* with a valid RC style cell
440 # reference. Other characters after the valid RC reference are ignored
441 # by Excel. Valid means that the row and column range values must also
442 # be within Excel row and column limits.
443 #
444 # Note: references without trailing characters like R12345 or C12345
445 # are caught by Rule 3. Negative references like R-12345 are caught by
446 # Rule 1 due to the dash.
447 # --------------------------------------------------------------------
448
449 # Rule 4a. Check for sheet names that start with R1 style references.
450 elif match_rule4_row:
451 row = int(match_rule4_row.group(1))
452
453 if 0 < row <= row_max:
454 requires_quoting = True
455
456 # Rule 4b. Check for sheet names that start with C1 or RC1 style
457 elif match_rule4_column:
458 col = int(match_rule4_column.group(1))
459
460 if 0 < col <= col_max:
461 requires_quoting = True
462
463 # Rule 4c. Check for some single R/C references.
464 elif uppercase_sheetname in ("R", "C", "RC"):
465 requires_quoting = True
466
467 if requires_quoting:
468 # Double quote any single quotes.
469 sheetname = sheetname.replace("'", "''")
470
471 # Single quote the sheet name.
472 sheetname = f"'{sheetname}'"
473
474 return sheetname
475
476
477def cell_autofit_width(string: str) -> int:
478 """
479 Calculate the width required to auto-fit a string in a cell.
480
481 Args:
482 string: The string to calculate the cell width for. String.
483
484 Returns:
485 The string autofit width in pixels. Returns 0 if the string is empty.
486
487 """
488 if not string or len(string) == 0:
489 return 0
490
491 # Excel adds an additional 7 pixels of padding to the cell boundary.
492 return xl_pixel_width(string) + 7
493
494
495def xl_pixel_width(string: str) -> int:
496 """
497 Get the pixel width of a string based on individual character widths taken
498 from Excel. UTF8 characters, and other unhandled characters, are given a
499 default width of 8.
500
501 Args:
502 string: The string to calculate the width for. String.
503
504 Returns:
505 The string width in pixels. Note, Excel adds an additional 7 pixels of
506 padding in the cell.
507
508 """
509 length = 0
510 for char in string:
511 length += CHAR_WIDTHS.get(char, 8)
512
513 return length
514
515
516def _get_sparkline_style(style_id: int) -> Dict[str, Dict[str, str]]:
517 """
518 Get the numbered sparkline styles.
519
520 """
521 styles = [
522 { # 0
523 "low": Color.theme(4, 0),
524 "high": Color.theme(4, 0),
525 "last": Color.theme(4, 3),
526 "first": Color.theme(4, 3),
527 "series": Color.theme(4, 5),
528 "markers": Color.theme(4, 5),
529 "negative": Color.theme(5, 0),
530 },
531 { # 1
532 "low": Color.theme(4, 0),
533 "high": Color.theme(4, 0),
534 "last": Color.theme(4, 3),
535 "first": Color.theme(4, 3),
536 "series": Color.theme(4, 5),
537 "markers": Color.theme(4, 5),
538 "negative": Color.theme(5, 0),
539 },
540 { # 2
541 "low": Color.theme(5, 0),
542 "high": Color.theme(5, 0),
543 "last": Color.theme(5, 3),
544 "first": Color.theme(5, 3),
545 "series": Color.theme(5, 5),
546 "markers": Color.theme(5, 5),
547 "negative": Color.theme(6, 0),
548 },
549 { # 3
550 "low": Color.theme(6, 0),
551 "high": Color.theme(6, 0),
552 "last": Color.theme(6, 3),
553 "first": Color.theme(6, 3),
554 "series": Color.theme(6, 5),
555 "markers": Color.theme(6, 5),
556 "negative": Color.theme(7, 0),
557 },
558 { # 4
559 "low": Color.theme(7, 0),
560 "high": Color.theme(7, 0),
561 "last": Color.theme(7, 3),
562 "first": Color.theme(7, 3),
563 "series": Color.theme(7, 5),
564 "markers": Color.theme(7, 5),
565 "negative": Color.theme(8, 0),
566 },
567 { # 5
568 "low": Color.theme(8, 0),
569 "high": Color.theme(8, 0),
570 "last": Color.theme(8, 3),
571 "first": Color.theme(8, 3),
572 "series": Color.theme(8, 5),
573 "markers": Color.theme(8, 5),
574 "negative": Color.theme(9, 0),
575 },
576 { # 6
577 "low": Color.theme(9, 0),
578 "high": Color.theme(9, 0),
579 "last": Color.theme(9, 3),
580 "first": Color.theme(9, 3),
581 "series": Color.theme(9, 5),
582 "markers": Color.theme(9, 5),
583 "negative": Color.theme(4, 0),
584 },
585 { # 7
586 "low": Color.theme(5, 4),
587 "high": Color.theme(5, 4),
588 "last": Color.theme(5, 4),
589 "first": Color.theme(5, 4),
590 "series": Color.theme(4, 4),
591 "markers": Color.theme(5, 4),
592 "negative": Color.theme(5, 0),
593 },
594 { # 8
595 "low": Color.theme(6, 4),
596 "high": Color.theme(6, 4),
597 "last": Color.theme(6, 4),
598 "first": Color.theme(6, 4),
599 "series": Color.theme(5, 4),
600 "markers": Color.theme(6, 4),
601 "negative": Color.theme(6, 0),
602 },
603 { # 9
604 "low": Color.theme(7, 4),
605 "high": Color.theme(7, 4),
606 "last": Color.theme(7, 4),
607 "first": Color.theme(7, 4),
608 "series": Color.theme(6, 4),
609 "markers": Color.theme(7, 4),
610 "negative": Color.theme(7, 0),
611 },
612 { # 10
613 "low": Color.theme(8, 4),
614 "high": Color.theme(8, 4),
615 "last": Color.theme(8, 4),
616 "first": Color.theme(8, 4),
617 "series": Color.theme(7, 4),
618 "markers": Color.theme(8, 4),
619 "negative": Color.theme(8, 0),
620 },
621 { # 11
622 "low": Color.theme(9, 4),
623 "high": Color.theme(9, 4),
624 "last": Color.theme(9, 4),
625 "first": Color.theme(9, 4),
626 "series": Color.theme(8, 4),
627 "markers": Color.theme(9, 4),
628 "negative": Color.theme(9, 0),
629 },
630 { # 12
631 "low": Color.theme(4, 4),
632 "high": Color.theme(4, 4),
633 "last": Color.theme(4, 4),
634 "first": Color.theme(4, 4),
635 "series": Color.theme(9, 4),
636 "markers": Color.theme(4, 4),
637 "negative": Color.theme(4, 0),
638 },
639 { # 13
640 "low": Color.theme(4, 4),
641 "high": Color.theme(4, 4),
642 "last": Color.theme(4, 4),
643 "first": Color.theme(4, 4),
644 "series": Color.theme(4, 0),
645 "markers": Color.theme(4, 4),
646 "negative": Color.theme(5, 0),
647 },
648 { # 14
649 "low": Color.theme(5, 4),
650 "high": Color.theme(5, 4),
651 "last": Color.theme(5, 4),
652 "first": Color.theme(5, 4),
653 "series": Color.theme(5, 0),
654 "markers": Color.theme(5, 4),
655 "negative": Color.theme(6, 0),
656 },
657 { # 15
658 "low": Color.theme(6, 4),
659 "high": Color.theme(6, 4),
660 "last": Color.theme(6, 4),
661 "first": Color.theme(6, 4),
662 "series": Color.theme(6, 0),
663 "markers": Color.theme(6, 4),
664 "negative": Color.theme(7, 0),
665 },
666 { # 16
667 "low": Color.theme(7, 4),
668 "high": Color.theme(7, 4),
669 "last": Color.theme(7, 4),
670 "first": Color.theme(7, 4),
671 "series": Color.theme(7, 0),
672 "markers": Color.theme(7, 4),
673 "negative": Color.theme(8, 0),
674 },
675 { # 17
676 "low": Color.theme(8, 4),
677 "high": Color.theme(8, 4),
678 "last": Color.theme(8, 4),
679 "first": Color.theme(8, 4),
680 "series": Color.theme(8, 0),
681 "markers": Color.theme(8, 4),
682 "negative": Color.theme(9, 0),
683 },
684 { # 18
685 "low": Color.theme(9, 4),
686 "high": Color.theme(9, 4),
687 "last": Color.theme(9, 4),
688 "first": Color.theme(9, 4),
689 "series": Color.theme(9, 0),
690 "markers": Color.theme(9, 4),
691 "negative": Color.theme(4, 0),
692 },
693 { # 19
694 "low": Color.theme(4, 5),
695 "high": Color.theme(4, 5),
696 "last": Color.theme(4, 4),
697 "first": Color.theme(4, 4),
698 "series": Color.theme(4, 3),
699 "markers": Color.theme(4, 1),
700 "negative": Color.theme(0, 5),
701 },
702 { # 20
703 "low": Color.theme(5, 5),
704 "high": Color.theme(5, 5),
705 "last": Color.theme(5, 4),
706 "first": Color.theme(5, 4),
707 "series": Color.theme(5, 3),
708 "markers": Color.theme(5, 1),
709 "negative": Color.theme(0, 5),
710 },
711 { # 21
712 "low": Color.theme(6, 5),
713 "high": Color.theme(6, 5),
714 "last": Color.theme(6, 4),
715 "first": Color.theme(6, 4),
716 "series": Color.theme(6, 3),
717 "markers": Color.theme(6, 1),
718 "negative": Color.theme(0, 5),
719 },
720 { # 22
721 "low": Color.theme(7, 5),
722 "high": Color.theme(7, 5),
723 "last": Color.theme(7, 4),
724 "first": Color.theme(7, 4),
725 "series": Color.theme(7, 3),
726 "markers": Color.theme(7, 1),
727 "negative": Color.theme(0, 5),
728 },
729 { # 23
730 "low": Color.theme(8, 5),
731 "high": Color.theme(8, 5),
732 "last": Color.theme(8, 4),
733 "first": Color.theme(8, 4),
734 "series": Color.theme(8, 3),
735 "markers": Color.theme(8, 1),
736 "negative": Color.theme(0, 5),
737 },
738 { # 24
739 "low": Color.theme(9, 5),
740 "high": Color.theme(9, 5),
741 "last": Color.theme(9, 4),
742 "first": Color.theme(9, 4),
743 "series": Color.theme(9, 3),
744 "markers": Color.theme(9, 1),
745 "negative": Color.theme(0, 5),
746 },
747 { # 25
748 "low": Color.theme(1, 3),
749 "high": Color.theme(1, 3),
750 "last": Color.theme(1, 3),
751 "first": Color.theme(1, 3),
752 "series": Color.theme(1, 1),
753 "markers": Color.theme(1, 3),
754 "negative": Color.theme(1, 3),
755 },
756 { # 26
757 "low": Color.theme(0, 3),
758 "high": Color.theme(0, 3),
759 "last": Color.theme(0, 3),
760 "first": Color.theme(0, 3),
761 "series": Color.theme(1, 2),
762 "markers": Color.theme(0, 3),
763 "negative": Color.theme(0, 3),
764 },
765 { # 27
766 "low": Color("#D00000"),
767 "high": Color("#D00000"),
768 "last": Color("#D00000"),
769 "first": Color("#D00000"),
770 "series": Color("#323232"),
771 "markers": Color("#D00000"),
772 "negative": Color("#D00000"),
773 },
774 { # 28
775 "low": Color("#0070C0"),
776 "high": Color("#0070C0"),
777 "last": Color("#0070C0"),
778 "first": Color("#0070C0"),
779 "series": Color("#000000"),
780 "markers": Color("#0070C0"),
781 "negative": Color("#0070C0"),
782 },
783 { # 29
784 "low": Color("#D00000"),
785 "high": Color("#D00000"),
786 "last": Color("#D00000"),
787 "first": Color("#D00000"),
788 "series": Color("#376092"),
789 "markers": Color("#D00000"),
790 "negative": Color("#D00000"),
791 },
792 { # 30
793 "low": Color("#000000"),
794 "high": Color("#000000"),
795 "last": Color("#000000"),
796 "first": Color("#000000"),
797 "series": Color("#0070C0"),
798 "markers": Color("#000000"),
799 "negative": Color("#000000"),
800 },
801 { # 31
802 "low": Color("#FF5055"),
803 "high": Color("#56BE79"),
804 "last": Color("#359CEB"),
805 "first": Color("#5687C2"),
806 "series": Color("#5F5F5F"),
807 "markers": Color("#D70077"),
808 "negative": Color("#FFB620"),
809 },
810 { # 32
811 "low": Color("#FF5055"),
812 "high": Color("#56BE79"),
813 "last": Color("#359CEB"),
814 "first": Color("#777777"),
815 "series": Color("#5687C2"),
816 "markers": Color("#D70077"),
817 "negative": Color("#FFB620"),
818 },
819 { # 33
820 "low": Color("#FF5367"),
821 "high": Color("#60D276"),
822 "last": Color("#FFEB9C"),
823 "first": Color("#FFDC47"),
824 "series": Color("#C6EFCE"),
825 "markers": Color("#8CADD6"),
826 "negative": Color("#FFC7CE"),
827 },
828 { # 34
829 "low": Color("#FF0000"),
830 "high": Color("#00B050"),
831 "last": Color("#FFC000"),
832 "first": Color("#FFC000"),
833 "series": Color("#00B050"),
834 "markers": Color("#0070C0"),
835 "negative": Color("#FF0000"),
836 },
837 { # 35
838 "low": Color.theme(7, 0),
839 "high": Color.theme(6, 0),
840 "last": Color.theme(5, 0),
841 "first": Color.theme(4, 0),
842 "series": Color.theme(3, 0),
843 "markers": Color.theme(8, 0),
844 "negative": Color.theme(9, 0),
845 },
846 { # 36
847 "low": Color.theme(7, 0),
848 "high": Color.theme(6, 0),
849 "last": Color.theme(5, 0),
850 "first": Color.theme(4, 0),
851 "series": Color.theme(1, 0),
852 "markers": Color.theme(8, 0),
853 "negative": Color.theme(9, 0),
854 },
855 ]
856
857 return styles[style_id]
858
859
860def _supported_datetime(
861 dt: Union[datetime.datetime, datetime.time, datetime.date],
862) -> bool:
863 # Determine is an argument is a supported datetime object.
864 return isinstance(
865 dt, (datetime.datetime, datetime.date, datetime.time, datetime.timedelta)
866 )
867
868
869def _remove_datetime_timezone(
870 dt_obj: datetime.datetime, remove_timezone: bool
871) -> datetime.datetime:
872 # Excel doesn't support timezones in datetimes/times so we remove the
873 # tzinfo from the object if the user has specified that option in the
874 # constructor.
875 if remove_timezone:
876 dt_obj = dt_obj.replace(tzinfo=None)
877 else:
878 if dt_obj.tzinfo:
879 raise TypeError(
880 "Excel doesn't support timezones in datetimes. "
881 "Set the tzinfo in the datetime/time object to None or "
882 "use the 'remove_timezone' Workbook() option"
883 )
884
885 return dt_obj
886
887
888def _datetime_to_excel_datetime(
889 dt_obj: Union[datetime.time, datetime.datetime, datetime.timedelta, datetime.date],
890 date_1904: bool,
891 remove_timezone: bool,
892) -> float:
893 # Convert a datetime object to an Excel serial date and time. The integer
894 # part of the number stores the number of days since the epoch and the
895 # fractional part stores the percentage of the day.
896 date_type = dt_obj
897 is_timedelta = False
898
899 if date_1904:
900 # Excel for Mac date epoch.
901 epoch = datetime.datetime(1904, 1, 1)
902 else:
903 # Default Excel epoch.
904 epoch = datetime.datetime(1899, 12, 31)
905
906 # We handle datetime .datetime, .date and .time objects but convert
907 # them to datetime.datetime objects and process them in the same way.
908 if isinstance(dt_obj, datetime.datetime):
909 dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone)
910 delta = dt_obj - epoch
911 elif isinstance(dt_obj, datetime.date):
912 dt_obj = datetime.datetime.fromordinal(dt_obj.toordinal())
913 delta = dt_obj - epoch
914 elif isinstance(dt_obj, datetime.time):
915 dt_obj = datetime.datetime.combine(epoch, dt_obj)
916 dt_obj = _remove_datetime_timezone(dt_obj, remove_timezone)
917 delta = dt_obj - epoch
918 elif isinstance(dt_obj, datetime.timedelta):
919 is_timedelta = True
920 delta = dt_obj
921 else:
922 raise TypeError("Unknown or unsupported datetime type")
923
924 # Convert a Python datetime.datetime value to an Excel date number.
925 excel_time = delta.days + (
926 float(delta.seconds) + float(delta.microseconds) / 1e6
927 ) / (60 * 60 * 24)
928
929 # The following is a workaround for the fact that in Excel a time only
930 # value is represented as 1899-12-31+time whereas in datetime.datetime()
931 # it is 1900-1-1+time so we need to subtract the 1 day difference.
932 if (
933 isinstance(date_type, datetime.datetime)
934 and not isinstance(dt_obj, datetime.timedelta)
935 and dt_obj.isocalendar()
936 == (
937 1900,
938 1,
939 1,
940 )
941 ):
942 excel_time -= 1
943
944 # Account for Excel erroneously treating 1900 as a leap year.
945 if not date_1904 and not is_timedelta and excel_time > 59:
946 excel_time += 1
947
948 return excel_time
949
950
951def _preserve_whitespace(string: str) -> Optional[re.Match]:
952 # Check if a string has leading or trailing whitespace that requires a
953 # "preserve" attribute.
954 return RE_LEADING_WHITESPACE.search(string) or RE_TRAILING_WHITESPACE.search(string)