1###############################################################################
2#
3# Worksheet - A class for writing the Excel XLSX Worksheet file.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9
10# pylint: disable=too-many-return-statements
11
12# Standard packages.
13import datetime
14import math
15import os
16import re
17import tempfile
18from collections import defaultdict, namedtuple
19from decimal import Decimal
20from fractions import Fraction
21from functools import wraps
22from io import StringIO
23from math import isinf, isnan
24from warnings import warn
25
26from xlsxwriter.color import Color
27from xlsxwriter.comments import CommentType
28from xlsxwriter.image import Image
29from xlsxwriter.url import Url, UrlTypes
30from xlsxwriter.vml import ButtonType
31
32# Package imports.
33from . import xmlwriter
34from .drawing import Drawing, DrawingInfo, DrawingTypes
35from .exceptions import DuplicateTableName, OverlappingRange
36from .format import Format
37from .shape import Shape
38from .utility import (
39 _datetime_to_excel_datetime,
40 _get_sparkline_style,
41 _preserve_whitespace,
42 _supported_datetime,
43 quote_sheetname,
44 xl_cell_to_rowcol,
45 xl_col_to_name,
46 xl_pixel_width,
47 xl_range,
48 xl_rowcol_to_cell,
49 xl_rowcol_to_cell_fast,
50)
51from .xmlwriter import XMLwriter
52
53re_dynamic_function = re.compile(
54 r"""
55 \bANCHORARRAY\( |
56 \bBYCOL\( |
57 \bBYROW\( |
58 \bCHOOSECOLS\( |
59 \bCHOOSEROWS\( |
60 \bDROP\( |
61 \bEXPAND\( |
62 \bFILTER\( |
63 \bHSTACK\( |
64 \bLAMBDA\( |
65 \bMAKEARRAY\( |
66 \bMAP\( |
67 \bRANDARRAY\( |
68 \bREDUCE\( |
69 \bSCAN\( |
70 \bSEQUENCE\( |
71 \bSINGLE\( |
72 \bSORT\( |
73 \bSORTBY\( |
74 \bSWITCH\( |
75 \bTAKE\( |
76 \bTEXTSPLIT\( |
77 \bTOCOL\( |
78 \bTOROW\( |
79 \bUNIQUE\( |
80 \bVSTACK\( |
81 \bWRAPCOLS\( |
82 \bWRAPROWS\( |
83 \bXLOOKUP\(""",
84 re.VERBOSE,
85)
86
87
88###############################################################################
89#
90# Decorator functions.
91#
92###############################################################################
93def convert_cell_args(method):
94 """
95 Decorator function to convert A1 notation in cell method calls
96 to the default row/col notation.
97
98 """
99
100 @wraps(method)
101 def cell_wrapper(self, *args, **kwargs):
102 try:
103 # First arg is an int, default to row/col notation.
104 if args:
105 first_arg = args[0]
106 int(first_arg)
107 except ValueError:
108 # First arg isn't an int, convert to A1 notation.
109 new_args = xl_cell_to_rowcol(first_arg)
110 args = new_args + args[1:]
111
112 return method(self, *args, **kwargs)
113
114 return cell_wrapper
115
116
117def convert_range_args(method):
118 """
119 Decorator function to convert A1 notation in range method calls
120 to the default row/col notation.
121
122 """
123
124 @wraps(method)
125 def cell_wrapper(self, *args, **kwargs):
126 try:
127 # First arg is an int, default to row/col notation.
128 if args:
129 int(args[0])
130 except ValueError:
131 # First arg isn't an int, convert to A1 notation.
132 if ":" in args[0]:
133 cell_1, cell_2 = args[0].split(":")
134 row_1, col_1 = xl_cell_to_rowcol(cell_1)
135 row_2, col_2 = xl_cell_to_rowcol(cell_2)
136 else:
137 row_1, col_1 = xl_cell_to_rowcol(args[0])
138 row_2, col_2 = row_1, col_1
139
140 new_args = [row_1, col_1, row_2, col_2]
141 new_args.extend(args[1:])
142 args = new_args
143
144 return method(self, *args, **kwargs)
145
146 return cell_wrapper
147
148
149def convert_column_args(method):
150 """
151 Decorator function to convert A1 notation in columns method calls
152 to the default row/col notation.
153
154 """
155
156 @wraps(method)
157 def column_wrapper(self, *args, **kwargs):
158 try:
159 # First arg is an int, default to row/col notation.
160 if args:
161 int(args[0])
162 except ValueError:
163 # First arg isn't an int, convert to A1 notation.
164 cell_1, cell_2 = [col + "1" for col in args[0].split(":")]
165 _, col_1 = xl_cell_to_rowcol(cell_1)
166 _, col_2 = xl_cell_to_rowcol(cell_2)
167 new_args = [col_1, col_2]
168 new_args.extend(args[1:])
169 args = new_args
170
171 return method(self, *args, **kwargs)
172
173 return column_wrapper
174
175
176###############################################################################
177#
178# Named tuples used for cell types.
179#
180###############################################################################
181CellBlankTuple = namedtuple("Blank", "format")
182CellErrorTuple = namedtuple("Error", "error, format, value")
183CellNumberTuple = namedtuple("Number", "number, format")
184CellStringTuple = namedtuple("String", "string, format")
185CellBooleanTuple = namedtuple("Boolean", "boolean, format")
186CellFormulaTuple = namedtuple("Formula", "formula, format, value")
187CellDatetimeTuple = namedtuple("Datetime", "number, format")
188CellRichStringTuple = namedtuple("RichString", "string, format, raw_string")
189CellArrayFormulaTuple = namedtuple(
190 "ArrayFormula", "formula, format, value, range, atype"
191)
192
193
194###############################################################################
195#
196# Worksheet Class definition.
197#
198###############################################################################
199class Worksheet(xmlwriter.XMLwriter):
200 """
201 A class for writing the Excel XLSX Worksheet file.
202
203 """
204
205 ###########################################################################
206 #
207 # Public API.
208 #
209 ###########################################################################
210
211 def __init__(self):
212 """
213 Constructor.
214
215 """
216
217 super().__init__()
218
219 self.name = None
220 self.index = None
221 self.str_table = None
222 self.palette = None
223 self.constant_memory = 0
224 self.tmpdir = None
225 self.is_chartsheet = False
226
227 self.ext_sheets = []
228 self.fileclosed = 0
229 self.excel_version = 2007
230 self.excel2003_style = False
231
232 self.xls_rowmax = 1048576
233 self.xls_colmax = 16384
234 self.xls_strmax = 32767
235 self.dim_rowmin = None
236 self.dim_rowmax = None
237 self.dim_colmin = None
238 self.dim_colmax = None
239
240 self.col_info = {}
241 self.selections = []
242 self.hidden = 0
243 self.active = 0
244 self.tab_color = 0
245 self.top_left_cell = ""
246
247 self.panes = []
248 self.active_pane = 3
249 self.selected = 0
250
251 self.page_setup_changed = False
252 self.paper_size = 0
253 self.orientation = 1
254
255 self.print_options_changed = False
256 self.hcenter = False
257 self.vcenter = False
258 self.print_gridlines = False
259 self.screen_gridlines = True
260 self.print_headers = False
261 self.row_col_headers = False
262
263 self.header_footer_changed = False
264 self.header = ""
265 self.footer = ""
266 self.header_footer_aligns = True
267 self.header_footer_scales = True
268 self.header_images = []
269 self.footer_images = []
270 self.header_images_list = []
271
272 self.margin_left = 0.7
273 self.margin_right = 0.7
274 self.margin_top = 0.75
275 self.margin_bottom = 0.75
276 self.margin_header = 0.3
277 self.margin_footer = 0.3
278
279 self.repeat_row_range = ""
280 self.repeat_col_range = ""
281 self.print_area_range = ""
282
283 self.page_order = 0
284 self.black_white = 0
285 self.draft_quality = 0
286 self.print_comments = 0
287 self.page_start = 0
288
289 self.fit_page = 0
290 self.fit_width = 0
291 self.fit_height = 0
292
293 self.hbreaks = []
294 self.vbreaks = []
295
296 self.protect_options = {}
297 self.protected_ranges = []
298 self.num_protected_ranges = 0
299 self.set_cols = {}
300 self.set_rows = defaultdict(dict)
301
302 self.zoom = 100
303 self.zoom_scale_normal = 1
304 self.print_scale = 100
305 self.is_right_to_left = False
306 self.show_zeros = 1
307 self.leading_zeros = 0
308
309 self.outline_row_level = 0
310 self.outline_col_level = 0
311 self.outline_style = 0
312 self.outline_below = 1
313 self.outline_right = 1
314 self.outline_on = 1
315 self.outline_changed = False
316
317 self.original_row_height = 15
318 self.default_row_height = 15
319 self.default_row_pixels = 20
320 self.default_col_width = 8.43
321 self.default_col_pixels = 64
322 self.default_date_pixels = 68
323 self.default_row_zeroed = 0
324
325 self.names = {}
326 self.write_match = []
327 self.table = defaultdict(dict)
328 self.merge = []
329 self.merged_cells = {}
330 self.table_cells = {}
331 self.row_spans = {}
332
333 self.has_vml = False
334 self.has_header_vml = False
335 self.has_comments = False
336 self.comments = defaultdict(dict)
337 self.comments_list = []
338 self.comments_author = ""
339 self.comments_visible = False
340 self.vml_shape_id = 1024
341 self.buttons_list = []
342 self.vml_header_id = 0
343
344 self.autofilter_area = ""
345 self.autofilter_ref = None
346 self.filter_range = [0, 9]
347 self.filter_on = 0
348 self.filter_cols = {}
349 self.filter_type = {}
350 self.filter_cells = {}
351
352 self.row_sizes = {}
353 self.col_size_changed = False
354 self.row_size_changed = False
355
356 self.last_shape_id = 1
357 self.rel_count = 0
358 self.hlink_count = 0
359 self.hlink_refs = []
360 self.external_hyper_links = []
361 self.external_drawing_links = []
362 self.external_comment_links = []
363 self.external_vml_links = []
364 self.external_table_links = []
365 self.external_background_links = []
366 self.drawing_links = []
367 self.vml_drawing_links = []
368 self.charts = []
369 self.images = []
370 self.tables = []
371 self.sparklines = []
372 self.shapes = []
373 self.shape_hash = {}
374 self.drawing = 0
375 self.drawing_rels = {}
376 self.drawing_rels_id = 0
377 self.vml_drawing_rels = {}
378 self.vml_drawing_rels_id = 0
379 self.background_image = None
380
381 self.rstring = ""
382 self.previous_row = 0
383
384 self.validations = []
385 self.cond_formats = {}
386 self.data_bars_2010 = []
387 self.use_data_bars_2010 = False
388 self.dxf_priority = 1
389 self.page_view = 0
390
391 self.vba_codename = None
392
393 self.date_1904 = False
394 self.hyperlinks = defaultdict(dict)
395
396 self.strings_to_numbers = False
397 self.strings_to_urls = True
398 self.nan_inf_to_errors = False
399 self.strings_to_formulas = True
400
401 self.default_date_format = None
402 self.default_url_format = None
403 self.default_checkbox_format = None
404 self.workbook_add_format = None
405 self.remove_timezone = False
406 self.max_url_length = 2079
407
408 self.row_data_filename = None
409 self.row_data_fh = None
410 self.worksheet_meta = None
411 self.vml_data_id = None
412 self.vml_shape_id = None
413
414 self.row_data_filename = None
415 self.row_data_fh = None
416 self.row_data_fh_closed = False
417
418 self.vertical_dpi = 0
419 self.horizontal_dpi = 0
420
421 self.write_handlers = {}
422
423 self.ignored_errors = None
424
425 self.has_dynamic_arrays = False
426 self.use_future_functions = False
427 self.ignore_write_string = False
428 self.embedded_images = None
429
430 # Utility function for writing different types of strings.
431 def _write_token_as_string(self, token, row, col, *args):
432 # Map the data to the appropriate write_*() method.
433 if token == "":
434 return self._write_blank(row, col, *args)
435
436 if self.strings_to_formulas and token.startswith("="):
437 return self._write_formula(row, col, *args)
438
439 if token.startswith("{=") and token.endswith("}"):
440 return self._write_formula(row, col, *args)
441
442 # pylint: disable=too-many-boolean-expressions
443 if (
444 ":" in token
445 and self.strings_to_urls
446 and (
447 re.match("(ftp|http)s?://", token)
448 or re.match("mailto:", token)
449 or re.match("(in|ex)ternal:", token)
450 or re.match("file://", token)
451 )
452 ):
453 return self._write_url(row, col, *args)
454
455 if self.strings_to_numbers:
456 try:
457 f = float(token)
458 if self.nan_inf_to_errors or (not isnan(f) and not isinf(f)):
459 return self._write_number(row, col, f, *args[1:])
460 except ValueError:
461 # Not a number, write as a string.
462 pass
463
464 return self._write_string(row, col, *args)
465
466 # We have a plain string.
467 return self._write_string(row, col, *args)
468
469 @convert_cell_args
470 def write(self, row, col, *args):
471 """
472 Write data to a worksheet cell by calling the appropriate write_*()
473 method based on the type of data being passed.
474
475 Args:
476 row: The cell row (zero indexed).
477 col: The cell column (zero indexed).
478 *args: Args to pass to sub functions.
479
480 Returns:
481 0: Success.
482 -1: Row or column is out of worksheet bounds.
483 other: Return value of called method.
484
485 """
486 return self._write(row, col, *args)
487
488 # Undecorated version of write().
489 def _write(self, row, col, *args):
490 # pylint: disable=raise-missing-from
491 # Check the number of args passed.
492 if not args:
493 raise TypeError("write() takes at least 4 arguments (3 given)")
494
495 # The first arg should be the token for all write calls.
496 token = args[0]
497
498 # Avoid isinstance() for better performance.
499 token_type = token.__class__
500
501 # Check for any user defined type handlers with callback functions.
502 if token_type in self.write_handlers:
503 write_handler = self.write_handlers[token_type]
504 function_return = write_handler(self, row, col, *args)
505
506 # If the return value is None then the callback has returned
507 # control to this function and we should continue as
508 # normal. Otherwise we return the value to the caller and exit.
509 if function_return is None:
510 pass
511 else:
512 return function_return
513
514 # Write None as a blank cell.
515 if token is None:
516 return self._write_blank(row, col, *args)
517
518 # Check for standard Python types.
519 if token_type is bool:
520 return self._write_boolean(row, col, *args)
521
522 if token_type in (float, int, Decimal, Fraction):
523 return self._write_number(row, col, *args)
524
525 if token_type is str:
526 return self._write_token_as_string(token, row, col, *args)
527
528 if token_type in (
529 datetime.datetime,
530 datetime.date,
531 datetime.time,
532 datetime.timedelta,
533 ):
534 return self._write_datetime(row, col, *args)
535
536 # Resort to isinstance() for subclassed primitives.
537
538 # Write number types.
539 if isinstance(token, (float, int, Decimal, Fraction)):
540 return self._write_number(row, col, *args)
541
542 # Write string types.
543 if isinstance(token, str):
544 return self._write_token_as_string(token, row, col, *args)
545
546 # Write boolean types.
547 if isinstance(token, bool):
548 return self._write_boolean(row, col, *args)
549
550 # Write datetime objects.
551 if _supported_datetime(token):
552 return self._write_datetime(row, col, *args)
553
554 # Write Url type.
555 if isinstance(token, Url):
556 return self._write_url(row, col, *args)
557
558 # We haven't matched a supported type. Try float.
559 try:
560 f = float(token)
561 return self._write_number(row, col, f, *args[1:])
562 except ValueError:
563 pass
564 except TypeError:
565 raise TypeError(f"Unsupported type {type(token)} in write()")
566
567 # Finally try string.
568 try:
569 str(token)
570 return self._write_string(row, col, *args)
571 except ValueError:
572 raise TypeError(f"Unsupported type {type(token)} in write()")
573
574 @convert_cell_args
575 def write_string(self, row, col, string, cell_format=None):
576 """
577 Write a string to a worksheet cell.
578
579 Args:
580 row: The cell row (zero indexed).
581 col: The cell column (zero indexed).
582 string: Cell data. Str.
583 format: An optional cell Format object.
584
585 Returns:
586 0: Success.
587 -1: Row or column is out of worksheet bounds.
588 -2: String truncated to 32k characters.
589
590 """
591 return self._write_string(row, col, string, cell_format)
592
593 # Undecorated version of write_string().
594 def _write_string(self, row, col, string, cell_format=None):
595 str_error = 0
596
597 # Check that row and col are valid and store max and min values.
598 if self._check_dimensions(row, col):
599 return -1
600
601 # Check that the string is < 32767 chars.
602 if len(string) > self.xls_strmax:
603 string = string[: self.xls_strmax]
604 str_error = -2
605
606 # Write a shared string or an in-line string in constant_memory mode.
607 if not self.constant_memory:
608 string_index = self.str_table._get_shared_string_index(string)
609 else:
610 string_index = string
611
612 # Write previous row if in in-line string constant_memory mode.
613 if self.constant_memory and row > self.previous_row:
614 self._write_single_row(row)
615
616 # Store the cell data in the worksheet data table.
617 self.table[row][col] = CellStringTuple(string_index, cell_format)
618
619 return str_error
620
621 @convert_cell_args
622 def write_number(self, row, col, number, cell_format=None):
623 """
624 Write a number to a worksheet cell.
625
626 Args:
627 row: The cell row (zero indexed).
628 col: The cell column (zero indexed).
629 number: Cell data. Int or float.
630 cell_format: An optional cell Format object.
631
632 Returns:
633 0: Success.
634 -1: Row or column is out of worksheet bounds.
635
636 """
637 return self._write_number(row, col, number, cell_format)
638
639 # Undecorated version of write_number().
640 def _write_number(self, row, col, number, cell_format=None):
641 if isnan(number) or isinf(number):
642 if self.nan_inf_to_errors:
643 if isnan(number):
644 return self._write_formula(row, col, "#NUM!", cell_format, "#NUM!")
645
646 if number == math.inf:
647 return self._write_formula(row, col, "1/0", cell_format, "#DIV/0!")
648
649 if number == -math.inf:
650 return self._write_formula(row, col, "-1/0", cell_format, "#DIV/0!")
651 else:
652 raise TypeError(
653 "NAN/INF not supported in write_number() "
654 "without 'nan_inf_to_errors' Workbook() option"
655 )
656
657 if number.__class__ is Fraction:
658 number = float(number)
659
660 # Check that row and col are valid and store max and min values.
661 if self._check_dimensions(row, col):
662 return -1
663
664 # Write previous row if in in-line string constant_memory mode.
665 if self.constant_memory and row > self.previous_row:
666 self._write_single_row(row)
667
668 # Store the cell data in the worksheet data table.
669 self.table[row][col] = CellNumberTuple(number, cell_format)
670
671 return 0
672
673 @convert_cell_args
674 def write_blank(self, row, col, blank, cell_format=None):
675 """
676 Write a blank cell with formatting to a worksheet cell. The blank
677 token is ignored and the format only is written to the cell.
678
679 Args:
680 row: The cell row (zero indexed).
681 col: The cell column (zero indexed).
682 blank: Any value. It is ignored.
683 cell_format: An optional cell Format object.
684
685 Returns:
686 0: Success.
687 -1: Row or column is out of worksheet bounds.
688
689 """
690 return self._write_blank(row, col, blank, cell_format)
691
692 # Undecorated version of write_blank().
693 def _write_blank(self, row, col, _, cell_format=None):
694 # Don't write a blank cell unless it has a format.
695 if cell_format is None:
696 return 0
697
698 # Check that row and col are valid and store max and min values.
699 if self._check_dimensions(row, col):
700 return -1
701
702 # Write previous row if in in-line string constant_memory mode.
703 if self.constant_memory and row > self.previous_row:
704 self._write_single_row(row)
705
706 # Store the cell data in the worksheet data table.
707 self.table[row][col] = CellBlankTuple(cell_format)
708
709 return 0
710
711 @convert_cell_args
712 def write_formula(self, row, col, formula, cell_format=None, value=0):
713 """
714 Write a formula to a worksheet cell.
715
716 Args:
717 row: The cell row (zero indexed).
718 col: The cell column (zero indexed).
719 formula: Cell formula.
720 cell_format: An optional cell Format object.
721 value: An optional value for the formula. Default is 0.
722
723 Returns:
724 0: Success.
725 -1: Row or column is out of worksheet bounds.
726 -2: Formula can't be None or empty.
727
728 """
729 # Check that row and col are valid and store max and min values.
730 return self._write_formula(row, col, formula, cell_format, value)
731
732 # Undecorated version of write_formula().
733 def _write_formula(self, row, col, formula, cell_format=None, value=0):
734 if self._check_dimensions(row, col):
735 return -1
736
737 if formula is None or formula == "":
738 warn("Formula can't be None or empty")
739 return -1
740
741 # Check for dynamic array functions.
742 if re_dynamic_function.search(formula):
743 return self.write_dynamic_array_formula(
744 row, col, row, col, formula, cell_format, value
745 )
746
747 # Hand off array formulas.
748 if formula.startswith("{") and formula.endswith("}"):
749 return self._write_array_formula(
750 row, col, row, col, formula, cell_format, value
751 )
752
753 # Modify the formula string, as needed.
754 formula = self._prepare_formula(formula)
755
756 # Write previous row if in in-line string constant_memory mode.
757 if self.constant_memory and row > self.previous_row:
758 self._write_single_row(row)
759
760 # Store the cell data in the worksheet data table.
761 self.table[row][col] = CellFormulaTuple(formula, cell_format, value)
762
763 return 0
764
765 @convert_range_args
766 def write_array_formula(
767 self,
768 first_row,
769 first_col,
770 last_row,
771 last_col,
772 formula,
773 cell_format=None,
774 value=0,
775 ):
776 """
777 Write a formula to a worksheet cell/range.
778
779 Args:
780 first_row: The first row of the cell range. (zero indexed).
781 first_col: The first column of the cell range.
782 last_row: The last row of the cell range. (zero indexed).
783 last_col: The last column of the cell range.
784 formula: Cell formula.
785 cell_format: An optional cell Format object.
786 value: An optional value for the formula. Default is 0.
787
788 Returns:
789 0: Success.
790 -1: Row or column is out of worksheet bounds.
791
792 """
793 # Check for dynamic array functions.
794 if re_dynamic_function.search(formula):
795 return self.write_dynamic_array_formula(
796 first_row, first_col, last_row, last_col, formula, cell_format, value
797 )
798
799 return self._write_array_formula(
800 first_row,
801 first_col,
802 last_row,
803 last_col,
804 formula,
805 cell_format,
806 value,
807 "static",
808 )
809
810 @convert_range_args
811 def write_dynamic_array_formula(
812 self,
813 first_row,
814 first_col,
815 last_row,
816 last_col,
817 formula,
818 cell_format=None,
819 value=0,
820 ):
821 """
822 Write a dynamic array formula to a worksheet cell/range.
823
824 Args:
825 first_row: The first row of the cell range. (zero indexed).
826 first_col: The first column of the cell range.
827 last_row: The last row of the cell range. (zero indexed).
828 last_col: The last column of the cell range.
829 formula: Cell formula.
830 cell_format: An optional cell Format object.
831 value: An optional value for the formula. Default is 0.
832
833 Returns:
834 0: Success.
835 -1: Row or column is out of worksheet bounds.
836
837 """
838 error = self._write_array_formula(
839 first_row,
840 first_col,
841 last_row,
842 last_col,
843 formula,
844 cell_format,
845 value,
846 "dynamic",
847 )
848
849 if error == 0:
850 self.has_dynamic_arrays = True
851
852 return error
853
854 # Utility method to strip equal sign and array braces from a formula and
855 # also expand out future and dynamic array formulas.
856 def _prepare_formula(self, formula, expand_future_functions=False):
857 # Remove array formula braces and the leading =.
858 if formula.startswith("{"):
859 formula = formula[1:]
860 if formula.startswith("="):
861 formula = formula[1:]
862 if formula.endswith("}"):
863 formula = formula[:-1]
864
865 # Check if formula is already expanded by the user.
866 if "_xlfn." in formula:
867 return formula
868
869 # Expand dynamic formulas.
870 formula = re.sub(r"\bANCHORARRAY\(", "_xlfn.ANCHORARRAY(", formula)
871 formula = re.sub(r"\bBYCOL\(", "_xlfn.BYCOL(", formula)
872 formula = re.sub(r"\bBYROW\(", "_xlfn.BYROW(", formula)
873 formula = re.sub(r"\bCHOOSECOLS\(", "_xlfn.CHOOSECOLS(", formula)
874 formula = re.sub(r"\bCHOOSEROWS\(", "_xlfn.CHOOSEROWS(", formula)
875 formula = re.sub(r"\bDROP\(", "_xlfn.DROP(", formula)
876 formula = re.sub(r"\bEXPAND\(", "_xlfn.EXPAND(", formula)
877 formula = re.sub(r"\bFILTER\(", "_xlfn._xlws.FILTER(", formula)
878 formula = re.sub(r"\bHSTACK\(", "_xlfn.HSTACK(", formula)
879 formula = re.sub(r"\bLAMBDA\(", "_xlfn.LAMBDA(", formula)
880 formula = re.sub(r"\bMAKEARRAY\(", "_xlfn.MAKEARRAY(", formula)
881 formula = re.sub(r"\bMAP\(", "_xlfn.MAP(", formula)
882 formula = re.sub(r"\bRANDARRAY\(", "_xlfn.RANDARRAY(", formula)
883 formula = re.sub(r"\bREDUCE\(", "_xlfn.REDUCE(", formula)
884 formula = re.sub(r"\bSCAN\(", "_xlfn.SCAN(", formula)
885 formula = re.sub(r"\SINGLE\(", "_xlfn.SINGLE(", formula)
886 formula = re.sub(r"\bSEQUENCE\(", "_xlfn.SEQUENCE(", formula)
887 formula = re.sub(r"\bSORT\(", "_xlfn._xlws.SORT(", formula)
888 formula = re.sub(r"\bSORTBY\(", "_xlfn.SORTBY(", formula)
889 formula = re.sub(r"\bSWITCH\(", "_xlfn.SWITCH(", formula)
890 formula = re.sub(r"\bTAKE\(", "_xlfn.TAKE(", formula)
891 formula = re.sub(r"\bTEXTSPLIT\(", "_xlfn.TEXTSPLIT(", formula)
892 formula = re.sub(r"\bTOCOL\(", "_xlfn.TOCOL(", formula)
893 formula = re.sub(r"\bTOROW\(", "_xlfn.TOROW(", formula)
894 formula = re.sub(r"\bUNIQUE\(", "_xlfn.UNIQUE(", formula)
895 formula = re.sub(r"\bVSTACK\(", "_xlfn.VSTACK(", formula)
896 formula = re.sub(r"\bWRAPCOLS\(", "_xlfn.WRAPCOLS(", formula)
897 formula = re.sub(r"\bWRAPROWS\(", "_xlfn.WRAPROWS(", formula)
898 formula = re.sub(r"\bXLOOKUP\(", "_xlfn.XLOOKUP(", formula)
899
900 if not self.use_future_functions and not expand_future_functions:
901 return formula
902
903 formula = re.sub(r"\bACOTH\(", "_xlfn.ACOTH(", formula)
904 formula = re.sub(r"\bACOT\(", "_xlfn.ACOT(", formula)
905 formula = re.sub(r"\bAGGREGATE\(", "_xlfn.AGGREGATE(", formula)
906 formula = re.sub(r"\bARABIC\(", "_xlfn.ARABIC(", formula)
907 formula = re.sub(r"\bARRAYTOTEXT\(", "_xlfn.ARRAYTOTEXT(", formula)
908 formula = re.sub(r"\bBASE\(", "_xlfn.BASE(", formula)
909 formula = re.sub(r"\bBETA.DIST\(", "_xlfn.BETA.DIST(", formula)
910 formula = re.sub(r"\bBETA.INV\(", "_xlfn.BETA.INV(", formula)
911 formula = re.sub(r"\bBINOM.DIST.RANGE\(", "_xlfn.BINOM.DIST.RANGE(", formula)
912 formula = re.sub(r"\bBINOM.DIST\(", "_xlfn.BINOM.DIST(", formula)
913 formula = re.sub(r"\bBINOM.INV\(", "_xlfn.BINOM.INV(", formula)
914 formula = re.sub(r"\bBITAND\(", "_xlfn.BITAND(", formula)
915 formula = re.sub(r"\bBITLSHIFT\(", "_xlfn.BITLSHIFT(", formula)
916 formula = re.sub(r"\bBITOR\(", "_xlfn.BITOR(", formula)
917 formula = re.sub(r"\bBITRSHIFT\(", "_xlfn.BITRSHIFT(", formula)
918 formula = re.sub(r"\bBITXOR\(", "_xlfn.BITXOR(", formula)
919 formula = re.sub(r"\bCEILING.MATH\(", "_xlfn.CEILING.MATH(", formula)
920 formula = re.sub(r"\bCEILING.PRECISE\(", "_xlfn.CEILING.PRECISE(", formula)
921 formula = re.sub(r"\bCHISQ.DIST.RT\(", "_xlfn.CHISQ.DIST.RT(", formula)
922 formula = re.sub(r"\bCHISQ.DIST\(", "_xlfn.CHISQ.DIST(", formula)
923 formula = re.sub(r"\bCHISQ.INV.RT\(", "_xlfn.CHISQ.INV.RT(", formula)
924 formula = re.sub(r"\bCHISQ.INV\(", "_xlfn.CHISQ.INV(", formula)
925 formula = re.sub(r"\bCHISQ.TEST\(", "_xlfn.CHISQ.TEST(", formula)
926 formula = re.sub(r"\bCOMBINA\(", "_xlfn.COMBINA(", formula)
927 formula = re.sub(r"\bCONCAT\(", "_xlfn.CONCAT(", formula)
928 formula = re.sub(r"\bCONFIDENCE.NORM\(", "_xlfn.CONFIDENCE.NORM(", formula)
929 formula = re.sub(r"\bCONFIDENCE.T\(", "_xlfn.CONFIDENCE.T(", formula)
930 formula = re.sub(r"\bCOTH\(", "_xlfn.COTH(", formula)
931 formula = re.sub(r"\bCOT\(", "_xlfn.COT(", formula)
932 formula = re.sub(r"\bCOVARIANCE.P\(", "_xlfn.COVARIANCE.P(", formula)
933 formula = re.sub(r"\bCOVARIANCE.S\(", "_xlfn.COVARIANCE.S(", formula)
934 formula = re.sub(r"\bCSCH\(", "_xlfn.CSCH(", formula)
935 formula = re.sub(r"\bCSC\(", "_xlfn.CSC(", formula)
936 formula = re.sub(r"\bDAYS\(", "_xlfn.DAYS(", formula)
937 formula = re.sub(r"\bDECIMAL\(", "_xlfn.DECIMAL(", formula)
938 formula = re.sub(r"\bERF.PRECISE\(", "_xlfn.ERF.PRECISE(", formula)
939 formula = re.sub(r"\bERFC.PRECISE\(", "_xlfn.ERFC.PRECISE(", formula)
940 formula = re.sub(r"\bEXPON.DIST\(", "_xlfn.EXPON.DIST(", formula)
941 formula = re.sub(r"\bF.DIST.RT\(", "_xlfn.F.DIST.RT(", formula)
942 formula = re.sub(r"\bF.DIST\(", "_xlfn.F.DIST(", formula)
943 formula = re.sub(r"\bF.INV.RT\(", "_xlfn.F.INV.RT(", formula)
944 formula = re.sub(r"\bF.INV\(", "_xlfn.F.INV(", formula)
945 formula = re.sub(r"\bF.TEST\(", "_xlfn.F.TEST(", formula)
946 formula = re.sub(r"\bFILTERXML\(", "_xlfn.FILTERXML(", formula)
947 formula = re.sub(r"\bFLOOR.MATH\(", "_xlfn.FLOOR.MATH(", formula)
948 formula = re.sub(r"\bFLOOR.PRECISE\(", "_xlfn.FLOOR.PRECISE(", formula)
949 formula = re.sub(
950 r"\bFORECAST.ETS.CONFINT\(", "_xlfn.FORECAST.ETS.CONFINT(", formula
951 )
952 formula = re.sub(
953 r"\bFORECAST.ETS.SEASONALITY\(", "_xlfn.FORECAST.ETS.SEASONALITY(", formula
954 )
955 formula = re.sub(r"\bFORECAST.ETS.STAT\(", "_xlfn.FORECAST.ETS.STAT(", formula)
956 formula = re.sub(r"\bFORECAST.ETS\(", "_xlfn.FORECAST.ETS(", formula)
957 formula = re.sub(r"\bFORECAST.LINEAR\(", "_xlfn.FORECAST.LINEAR(", formula)
958 formula = re.sub(r"\bFORMULATEXT\(", "_xlfn.FORMULATEXT(", formula)
959 formula = re.sub(r"\bGAMMA.DIST\(", "_xlfn.GAMMA.DIST(", formula)
960 formula = re.sub(r"\bGAMMA.INV\(", "_xlfn.GAMMA.INV(", formula)
961 formula = re.sub(r"\bGAMMALN.PRECISE\(", "_xlfn.GAMMALN.PRECISE(", formula)
962 formula = re.sub(r"\bGAMMA\(", "_xlfn.GAMMA(", formula)
963 formula = re.sub(r"\bGAUSS\(", "_xlfn.GAUSS(", formula)
964 formula = re.sub(r"\bHYPGEOM.DIST\(", "_xlfn.HYPGEOM.DIST(", formula)
965 formula = re.sub(r"\bIFNA\(", "_xlfn.IFNA(", formula)
966 formula = re.sub(r"\bIFS\(", "_xlfn.IFS(", formula)
967 formula = re.sub(r"\bIMAGE\(", "_xlfn.IMAGE(", formula)
968 formula = re.sub(r"\bIMCOSH\(", "_xlfn.IMCOSH(", formula)
969 formula = re.sub(r"\bIMCOT\(", "_xlfn.IMCOT(", formula)
970 formula = re.sub(r"\bIMCSCH\(", "_xlfn.IMCSCH(", formula)
971 formula = re.sub(r"\bIMCSC\(", "_xlfn.IMCSC(", formula)
972 formula = re.sub(r"\bIMSECH\(", "_xlfn.IMSECH(", formula)
973 formula = re.sub(r"\bIMSEC\(", "_xlfn.IMSEC(", formula)
974 formula = re.sub(r"\bIMSINH\(", "_xlfn.IMSINH(", formula)
975 formula = re.sub(r"\bIMTAN\(", "_xlfn.IMTAN(", formula)
976 formula = re.sub(r"\bISFORMULA\(", "_xlfn.ISFORMULA(", formula)
977 formula = re.sub(r"\bISOMITTED\(", "_xlfn.ISOMITTED(", formula)
978 formula = re.sub(r"\bISOWEEKNUM\(", "_xlfn.ISOWEEKNUM(", formula)
979 formula = re.sub(r"\bLET\(", "_xlfn.LET(", formula)
980 formula = re.sub(r"\bLOGNORM.DIST\(", "_xlfn.LOGNORM.DIST(", formula)
981 formula = re.sub(r"\bLOGNORM.INV\(", "_xlfn.LOGNORM.INV(", formula)
982 formula = re.sub(r"\bMAXIFS\(", "_xlfn.MAXIFS(", formula)
983 formula = re.sub(r"\bMINIFS\(", "_xlfn.MINIFS(", formula)
984 formula = re.sub(r"\bMODE.MULT\(", "_xlfn.MODE.MULT(", formula)
985 formula = re.sub(r"\bMODE.SNGL\(", "_xlfn.MODE.SNGL(", formula)
986 formula = re.sub(r"\bMUNIT\(", "_xlfn.MUNIT(", formula)
987 formula = re.sub(r"\bNEGBINOM.DIST\(", "_xlfn.NEGBINOM.DIST(", formula)
988 formula = re.sub(r"\bNORM.DIST\(", "_xlfn.NORM.DIST(", formula)
989 formula = re.sub(r"\bNORM.INV\(", "_xlfn.NORM.INV(", formula)
990 formula = re.sub(r"\bNORM.S.DIST\(", "_xlfn.NORM.S.DIST(", formula)
991 formula = re.sub(r"\bNORM.S.INV\(", "_xlfn.NORM.S.INV(", formula)
992 formula = re.sub(r"\bNUMBERVALUE\(", "_xlfn.NUMBERVALUE(", formula)
993 formula = re.sub(r"\bPDURATION\(", "_xlfn.PDURATION(", formula)
994 formula = re.sub(r"\bPERCENTILE.EXC\(", "_xlfn.PERCENTILE.EXC(", formula)
995 formula = re.sub(r"\bPERCENTILE.INC\(", "_xlfn.PERCENTILE.INC(", formula)
996 formula = re.sub(r"\bPERCENTRANK.EXC\(", "_xlfn.PERCENTRANK.EXC(", formula)
997 formula = re.sub(r"\bPERCENTRANK.INC\(", "_xlfn.PERCENTRANK.INC(", formula)
998 formula = re.sub(r"\bPERMUTATIONA\(", "_xlfn.PERMUTATIONA(", formula)
999 formula = re.sub(r"\bPHI\(", "_xlfn.PHI(", formula)
1000 formula = re.sub(r"\bPOISSON.DIST\(", "_xlfn.POISSON.DIST(", formula)
1001 formula = re.sub(r"\bQUARTILE.EXC\(", "_xlfn.QUARTILE.EXC(", formula)
1002 formula = re.sub(r"\bQUARTILE.INC\(", "_xlfn.QUARTILE.INC(", formula)
1003 formula = re.sub(r"\bQUERYSTRING\(", "_xlfn.QUERYSTRING(", formula)
1004 formula = re.sub(r"\bRANK.AVG\(", "_xlfn.RANK.AVG(", formula)
1005 formula = re.sub(r"\bRANK.EQ\(", "_xlfn.RANK.EQ(", formula)
1006 formula = re.sub(r"\bRRI\(", "_xlfn.RRI(", formula)
1007 formula = re.sub(r"\bSECH\(", "_xlfn.SECH(", formula)
1008 formula = re.sub(r"\bSEC\(", "_xlfn.SEC(", formula)
1009 formula = re.sub(r"\bSHEETS\(", "_xlfn.SHEETS(", formula)
1010 formula = re.sub(r"\bSHEET\(", "_xlfn.SHEET(", formula)
1011 formula = re.sub(r"\bSKEW.P\(", "_xlfn.SKEW.P(", formula)
1012 formula = re.sub(r"\bSTDEV.P\(", "_xlfn.STDEV.P(", formula)
1013 formula = re.sub(r"\bSTDEV.S\(", "_xlfn.STDEV.S(", formula)
1014 formula = re.sub(r"\bT.DIST.2T\(", "_xlfn.T.DIST.2T(", formula)
1015 formula = re.sub(r"\bT.DIST.RT\(", "_xlfn.T.DIST.RT(", formula)
1016 formula = re.sub(r"\bT.DIST\(", "_xlfn.T.DIST(", formula)
1017 formula = re.sub(r"\bT.INV.2T\(", "_xlfn.T.INV.2T(", formula)
1018 formula = re.sub(r"\bT.INV\(", "_xlfn.T.INV(", formula)
1019 formula = re.sub(r"\bT.TEST\(", "_xlfn.T.TEST(", formula)
1020 formula = re.sub(r"\bTEXTAFTER\(", "_xlfn.TEXTAFTER(", formula)
1021 formula = re.sub(r"\bTEXTBEFORE\(", "_xlfn.TEXTBEFORE(", formula)
1022 formula = re.sub(r"\bTEXTJOIN\(", "_xlfn.TEXTJOIN(", formula)
1023 formula = re.sub(r"\bUNICHAR\(", "_xlfn.UNICHAR(", formula)
1024 formula = re.sub(r"\bUNICODE\(", "_xlfn.UNICODE(", formula)
1025 formula = re.sub(r"\bVALUETOTEXT\(", "_xlfn.VALUETOTEXT(", formula)
1026 formula = re.sub(r"\bVAR.P\(", "_xlfn.VAR.P(", formula)
1027 formula = re.sub(r"\bVAR.S\(", "_xlfn.VAR.S(", formula)
1028 formula = re.sub(r"\bWEBSERVICE\(", "_xlfn.WEBSERVICE(", formula)
1029 formula = re.sub(r"\bWEIBULL.DIST\(", "_xlfn.WEIBULL.DIST(", formula)
1030 formula = re.sub(r"\bXMATCH\(", "_xlfn.XMATCH(", formula)
1031 formula = re.sub(r"\bXOR\(", "_xlfn.XOR(", formula)
1032 formula = re.sub(r"\bZ.TEST\(", "_xlfn.Z.TEST(", formula)
1033
1034 return formula
1035
1036 # Escape/expand table functions. This mainly involves converting Excel 2010
1037 # "@" table ref to 2007 "[#This Row],". We parse the string to avoid
1038 # replacements in string literals within the formula.
1039 @staticmethod
1040 def _prepare_table_formula(formula):
1041 if "@" not in formula:
1042 # No escaping required.
1043 return formula
1044
1045 escaped_formula = []
1046 in_string_literal = False
1047
1048 for char in formula:
1049 # Match the start/end of string literals to avoid escaping
1050 # references in strings.
1051 if char == '"':
1052 in_string_literal = not in_string_literal
1053
1054 # Copy the string literal.
1055 if in_string_literal:
1056 escaped_formula.append(char)
1057 continue
1058
1059 # Replace table reference.
1060 if char == "@":
1061 escaped_formula.append("[#This Row],")
1062 else:
1063 escaped_formula.append(char)
1064
1065 return ("").join(escaped_formula)
1066
1067 # Undecorated version of write_array_formula() and
1068 # write_dynamic_array_formula().
1069 def _write_array_formula(
1070 self,
1071 first_row,
1072 first_col,
1073 last_row,
1074 last_col,
1075 formula,
1076 cell_format=None,
1077 value=0,
1078 atype="static",
1079 ):
1080 # Swap last row/col with first row/col as necessary.
1081 if first_row > last_row:
1082 first_row, last_row = last_row, first_row
1083 if first_col > last_col:
1084 first_col, last_col = last_col, first_col
1085
1086 # Check that row and col are valid and store max and min values.
1087 if self._check_dimensions(first_row, first_col):
1088 return -1
1089 if self._check_dimensions(last_row, last_col):
1090 return -1
1091
1092 # Define array range
1093 if first_row == last_row and first_col == last_col:
1094 cell_range = xl_rowcol_to_cell(first_row, first_col)
1095 else:
1096 cell_range = (
1097 xl_rowcol_to_cell(first_row, first_col)
1098 + ":"
1099 + xl_rowcol_to_cell(last_row, last_col)
1100 )
1101
1102 # Modify the formula string, as needed.
1103 formula = self._prepare_formula(formula)
1104
1105 # Write previous row if in in-line string constant_memory mode.
1106 if self.constant_memory and first_row > self.previous_row:
1107 self._write_single_row(first_row)
1108
1109 # Store the cell data in the worksheet data table.
1110 self.table[first_row][first_col] = CellArrayFormulaTuple(
1111 formula, cell_format, value, cell_range, atype
1112 )
1113
1114 # Pad out the rest of the area with formatted zeroes.
1115 if not self.constant_memory:
1116 for row in range(first_row, last_row + 1):
1117 for col in range(first_col, last_col + 1):
1118 if row != first_row or col != first_col:
1119 self._write_number(row, col, 0, cell_format)
1120
1121 return 0
1122
1123 @convert_cell_args
1124 def write_datetime(self, row, col, date, cell_format=None):
1125 """
1126 Write a date or time to a worksheet cell.
1127
1128 Args:
1129 row: The cell row (zero indexed).
1130 col: The cell column (zero indexed).
1131 date: Date and/or time as a datetime object.
1132 cell_format: A cell Format object.
1133
1134 Returns:
1135 0: Success.
1136 -1: Row or column is out of worksheet bounds.
1137
1138 """
1139 return self._write_datetime(row, col, date, cell_format)
1140
1141 # Undecorated version of write_datetime().
1142 def _write_datetime(self, row, col, date, cell_format=None):
1143 # Check that row and col are valid and store max and min values.
1144 if self._check_dimensions(row, col):
1145 return -1
1146
1147 # Write previous row if in in-line string constant_memory mode.
1148 if self.constant_memory and row > self.previous_row:
1149 self._write_single_row(row)
1150
1151 # Convert datetime to an Excel date.
1152 number = self._convert_date_time(date)
1153
1154 # Add the default date format.
1155 if cell_format is None:
1156 cell_format = self.default_date_format
1157
1158 # Store the cell data in the worksheet data table.
1159 self.table[row][col] = CellDatetimeTuple(number, cell_format)
1160
1161 return 0
1162
1163 @convert_cell_args
1164 def write_boolean(self, row, col, boolean, cell_format=None):
1165 """
1166 Write a boolean value to a worksheet cell.
1167
1168 Args:
1169 row: The cell row (zero indexed).
1170 col: The cell column (zero indexed).
1171 boolean: Cell data. bool type.
1172 cell_format: An optional cell Format object.
1173
1174 Returns:
1175 0: Success.
1176 -1: Row or column is out of worksheet bounds.
1177
1178 """
1179 return self._write_boolean(row, col, boolean, cell_format)
1180
1181 # Undecorated version of write_boolean().
1182 def _write_boolean(self, row, col, boolean, cell_format=None):
1183 # Check that row and col are valid and store max and min values.
1184 if self._check_dimensions(row, col):
1185 return -1
1186
1187 # Write previous row if in in-line string constant_memory mode.
1188 if self.constant_memory and row > self.previous_row:
1189 self._write_single_row(row)
1190
1191 if boolean:
1192 value = 1
1193 else:
1194 value = 0
1195
1196 # Store the cell data in the worksheet data table.
1197 self.table[row][col] = CellBooleanTuple(value, cell_format)
1198
1199 return 0
1200
1201 # Write a hyperlink. This is comprised of two elements: the displayed
1202 # string and the non-displayed link. The displayed string is the same as
1203 # the link unless an alternative string is specified. The display string
1204 # is written using the write_string() method. Therefore the max characters
1205 # string limit applies.
1206 #
1207 # The hyperlink can be to a http, ftp, mail, internal sheet, or external
1208 # directory urls.
1209 @convert_cell_args
1210 def write_url(self, row, col, url, cell_format=None, string=None, tip=None):
1211 """
1212 Write a hyperlink to a worksheet cell.
1213
1214 Args:
1215 row: The cell row (zero indexed).
1216 col: The cell column (zero indexed).
1217 url: Hyperlink url.
1218 format: An optional cell Format object.
1219 string: An optional display string for the hyperlink.
1220 tip: An optional tooltip.
1221 Returns:
1222 0: Success.
1223 -1: Row or column is out of worksheet bounds.
1224 -2: String longer than 32767 characters.
1225 -3: URL longer than Excel limit of 255 characters.
1226 -4: Exceeds Excel limit of 65,530 urls per worksheet.
1227 """
1228 return self._write_url(row, col, url, cell_format, string, tip)
1229
1230 # Undecorated version of write_url().
1231 def _write_url(self, row, col, url, cell_format=None, string=None, tip=None):
1232 # Check that row and col are valid and store max and min values
1233 if self._check_dimensions(row, col):
1234 return -1
1235
1236 # If the URL is a string convert it to a Url object.
1237 if not isinstance(url, Url):
1238
1239 # For backwards compatibility check if the string URL exceeds the
1240 # Excel character limit for URLs and ignore it with a warning.
1241 max_url = self.max_url_length
1242 if "#" in url:
1243 url_str, anchor_str = url.split("#", 1)
1244 else:
1245 url_str = url
1246 anchor_str = ""
1247
1248 if len(url_str) > max_url or len(anchor_str) > max_url:
1249 warn(
1250 f"Ignoring URL '{url}' with link or location/anchor > {max_url} "
1251 f"characters since it exceeds Excel's limit for URLs."
1252 )
1253 return -3
1254
1255 url = Url(url)
1256
1257 if string is not None:
1258 url._text = string
1259
1260 if tip is not None:
1261 url._tip = tip
1262
1263 # Check the limit of URLs per worksheet.
1264 self.hlink_count += 1
1265
1266 if self.hlink_count > 65530:
1267 warn(
1268 f"Ignoring URL '{url._original_url}' since it exceeds Excel's limit of "
1269 f"65,530 URLs per worksheet."
1270 )
1271 return -4
1272
1273 # Add the default URL format.
1274 if cell_format is None:
1275 cell_format = self.default_url_format
1276
1277 if not self.ignore_write_string:
1278 # Write previous row if in in-line string constant_memory mode.
1279 if self.constant_memory and row > self.previous_row:
1280 self._write_single_row(row)
1281
1282 # Write the hyperlink string.
1283 self._write_string(row, col, url.text, cell_format)
1284
1285 # Store the hyperlink data in a separate structure.
1286 self.hyperlinks[row][col] = url
1287
1288 return 0
1289
1290 @convert_cell_args
1291 def write_rich_string(self, row, col, *args):
1292 """
1293 Write a "rich" string with multiple formats to a worksheet cell.
1294
1295 Args:
1296 row: The cell row (zero indexed).
1297 col: The cell column (zero indexed).
1298 string_parts: String and format pairs.
1299 cell_format: Optional Format object.
1300
1301 Returns:
1302 0: Success.
1303 -1: Row or column is out of worksheet bounds.
1304 -2: String truncated to 32k characters.
1305 -3: 2 consecutive formats used.
1306 -4: Empty string used.
1307 -5: Insufficient parameters.
1308
1309 """
1310
1311 return self._write_rich_string(row, col, *args)
1312
1313 # Undecorated version of write_rich_string().
1314 def _write_rich_string(self, row, col, *args):
1315 tokens = list(args)
1316 cell_format = None
1317 string_index = 0
1318 raw_string = ""
1319
1320 # Check that row and col are valid and store max and min values
1321 if self._check_dimensions(row, col):
1322 return -1
1323
1324 # If the last arg is a format we use it as the cell format.
1325 if isinstance(tokens[-1], Format):
1326 cell_format = tokens.pop()
1327
1328 # Create a temp XMLWriter object and use it to write the rich string
1329 # XML to a string.
1330 fh = StringIO()
1331 self.rstring = XMLwriter()
1332 self.rstring._set_filehandle(fh)
1333
1334 # Create a temp format with the default font for unformatted fragments.
1335 default = Format()
1336
1337 # Convert list of format, string tokens to pairs of (format, string)
1338 # except for the first string fragment which doesn't require a default
1339 # formatting run. Use the default for strings without a leading format.
1340 fragments = []
1341 previous = "format"
1342 pos = 0
1343
1344 if len(tokens) <= 2:
1345 warn(
1346 "You must specify more than 2 format/fragments for rich "
1347 "strings. Ignoring input in write_rich_string()."
1348 )
1349 return -5
1350
1351 for token in tokens:
1352 if not isinstance(token, Format):
1353 # Token is a string.
1354 if previous != "format":
1355 # If previous token wasn't a format add one before string.
1356 fragments.append(default)
1357 fragments.append(token)
1358 else:
1359 # If previous token was a format just add the string.
1360 fragments.append(token)
1361
1362 if token == "":
1363 warn(
1364 "Excel doesn't allow empty strings in rich strings. "
1365 "Ignoring input in write_rich_string()."
1366 )
1367 return -4
1368
1369 # Keep track of unformatted string.
1370 raw_string += token
1371 previous = "string"
1372 else:
1373 # Can't allow 2 formats in a row.
1374 if previous == "format" and pos > 0:
1375 warn(
1376 "Excel doesn't allow 2 consecutive formats in rich "
1377 "strings. Ignoring input in write_rich_string()."
1378 )
1379 return -3
1380
1381 # Token is a format object. Add it to the fragment list.
1382 fragments.append(token)
1383 previous = "format"
1384
1385 pos += 1
1386
1387 # If the first token is a string start the <r> element.
1388 if not isinstance(fragments[0], Format):
1389 self.rstring._xml_start_tag("r")
1390
1391 # Write the XML elements for the $format $string fragments.
1392 for token in fragments:
1393 if isinstance(token, Format):
1394 # Write the font run.
1395 self.rstring._xml_start_tag("r")
1396 self._write_font(token)
1397 else:
1398 # Write the string fragment part, with whitespace handling.
1399 attributes = []
1400
1401 if _preserve_whitespace(token):
1402 attributes.append(("xml:space", "preserve"))
1403
1404 self.rstring._xml_data_element("t", token, attributes)
1405 self.rstring._xml_end_tag("r")
1406
1407 # Read the in-memory string.
1408 string = self.rstring.fh.getvalue()
1409
1410 # Check that the string is < 32767 chars.
1411 if len(raw_string) > self.xls_strmax:
1412 warn(
1413 "String length must be less than or equal to Excel's limit "
1414 "of 32,767 characters in write_rich_string()."
1415 )
1416 return -2
1417
1418 # Write a shared string or an in-line string in constant_memory mode.
1419 if not self.constant_memory:
1420 string_index = self.str_table._get_shared_string_index(string)
1421 else:
1422 string_index = string
1423
1424 # Write previous row if in in-line string constant_memory mode.
1425 if self.constant_memory and row > self.previous_row:
1426 self._write_single_row(row)
1427
1428 # Store the cell data in the worksheet data table.
1429 self.table[row][col] = CellRichStringTuple(
1430 string_index, cell_format, raw_string
1431 )
1432
1433 return 0
1434
1435 def add_write_handler(self, user_type, user_function):
1436 """
1437 Add a callback function to the write() method to handle user defined
1438 types.
1439
1440 Args:
1441 user_type: The user type() to match on.
1442 user_function: The user defined function to write the type data.
1443 Returns:
1444 Nothing.
1445
1446 """
1447
1448 self.write_handlers[user_type] = user_function
1449
1450 @convert_cell_args
1451 def write_row(self, row, col, data, cell_format=None):
1452 """
1453 Write a row of data starting from (row, col).
1454
1455 Args:
1456 row: The cell row (zero indexed).
1457 col: The cell column (zero indexed).
1458 data: A list of tokens to be written with write().
1459 format: An optional cell Format object.
1460 Returns:
1461 0: Success.
1462 other: Return value of write() method.
1463
1464 """
1465 for token in data:
1466 error = self._write(row, col, token, cell_format)
1467 if error:
1468 return error
1469 col += 1
1470
1471 return 0
1472
1473 @convert_cell_args
1474 def write_column(self, row, col, data, cell_format=None):
1475 """
1476 Write a column of data starting from (row, col).
1477
1478 Args:
1479 row: The cell row (zero indexed).
1480 col: The cell column (zero indexed).
1481 data: A list of tokens to be written with write().
1482 format: An optional cell Format object.
1483 Returns:
1484 0: Success.
1485 other: Return value of write() method.
1486
1487 """
1488 for token in data:
1489 error = self._write(row, col, token, cell_format)
1490 if error:
1491 return error
1492 row += 1
1493
1494 return 0
1495
1496 @convert_cell_args
1497 def insert_image(self, row, col, source, options=None):
1498 """
1499 Insert an image with its top-left corner in a worksheet cell.
1500
1501 Args:
1502 row: The cell row (zero indexed).
1503 col: The cell column (zero indexed).
1504 source: Filename, BytesIO, or Image object.
1505 options: Position, scale, url and data stream of the image.
1506
1507 Returns:
1508 0: Success.
1509 -1: Row or column is out of worksheet bounds.
1510
1511 """
1512 # Check insert (row, col) without storing.
1513 if self._check_dimensions(row, col, True, True):
1514 warn(f"Cannot insert image at ({row}, {col}).")
1515 return -1
1516
1517 # Convert the source to an Image object.
1518 image = self._image_from_source(source, options)
1519
1520 image._row = row
1521 image._col = col
1522 image._set_user_options(options)
1523
1524 self.images.append(image)
1525
1526 return 0
1527
1528 @convert_cell_args
1529 def embed_image(self, row, col, source, options=None):
1530 """
1531 Embed an image in a worksheet cell.
1532
1533 Args:
1534 row: The cell row (zero indexed).
1535 col: The cell column (zero indexed).
1536 source: Filename, BytesIO, or Image object.
1537 options: Url and data stream of the image.
1538
1539 Returns:
1540 0: Success.
1541 -1: Row or column is out of worksheet bounds.
1542
1543 """
1544 # Check insert (row, col) without storing.
1545 if self._check_dimensions(row, col):
1546 warn(f"Cannot embed image at ({row}, {col}).")
1547 return -1
1548
1549 if options is None:
1550 options = {}
1551
1552 # Convert the source to an Image object.
1553 image = self._image_from_source(source, options)
1554 image._set_user_options(options)
1555
1556 cell_format = options.get("cell_format", None)
1557
1558 if image.url:
1559 if cell_format is None:
1560 cell_format = self.default_url_format
1561
1562 self.ignore_write_string = True
1563 self.write_url(row, col, image.url, cell_format)
1564 self.ignore_write_string = False
1565
1566 image_index = self.embedded_images.get_image_index(image)
1567
1568 # Store the cell error and image index in the worksheet data table.
1569 self.table[row][col] = CellErrorTuple("#VALUE!", cell_format, image_index)
1570
1571 return 0
1572
1573 @convert_cell_args
1574 def insert_textbox(self, row, col, text, options=None):
1575 """
1576 Insert an textbox with its top-left corner in a worksheet cell.
1577
1578 Args:
1579 row: The cell row (zero indexed).
1580 col: The cell column (zero indexed).
1581 text: The text for the textbox.
1582 options: Textbox options.
1583
1584 Returns:
1585 0: Success.
1586 -1: Row or column is out of worksheet bounds.
1587
1588 """
1589 # Check insert (row, col) without storing.
1590 if self._check_dimensions(row, col, True, True):
1591 warn(f"Cannot insert textbox at ({row}, {col}).")
1592 return -1
1593
1594 if text is None:
1595 text = ""
1596
1597 if options is None:
1598 options = {}
1599
1600 x_offset = options.get("x_offset", 0)
1601 y_offset = options.get("y_offset", 0)
1602 x_scale = options.get("x_scale", 1)
1603 y_scale = options.get("y_scale", 1)
1604 anchor = options.get("object_position", 1)
1605 description = options.get("description", None)
1606 decorative = options.get("decorative", False)
1607
1608 self.shapes.append(
1609 [
1610 row,
1611 col,
1612 x_offset,
1613 y_offset,
1614 x_scale,
1615 y_scale,
1616 text,
1617 anchor,
1618 options,
1619 description,
1620 decorative,
1621 ]
1622 )
1623 return 0
1624
1625 @convert_cell_args
1626 def insert_chart(self, row, col, chart, options=None):
1627 """
1628 Insert an chart with its top-left corner in a worksheet cell.
1629
1630 Args:
1631 row: The cell row (zero indexed).
1632 col: The cell column (zero indexed).
1633 chart: Chart object.
1634 options: Position and scale of the chart.
1635
1636 Returns:
1637 0: Success.
1638 -1: Row or column is out of worksheet bounds.
1639
1640 """
1641 # Check insert (row, col) without storing.
1642 if self._check_dimensions(row, col, True, True):
1643 warn(f"Cannot insert chart at ({row}, {col}).")
1644 return -1
1645
1646 if options is None:
1647 options = {}
1648
1649 # Ensure a chart isn't inserted more than once.
1650 if chart.already_inserted or chart.combined and chart.combined.already_inserted:
1651 warn("Chart cannot be inserted in a worksheet more than once.")
1652 return -2
1653
1654 chart.already_inserted = True
1655
1656 if chart.combined:
1657 chart.combined.already_inserted = True
1658
1659 x_offset = options.get("x_offset", 0)
1660 y_offset = options.get("y_offset", 0)
1661 x_scale = options.get("x_scale", 1)
1662 y_scale = options.get("y_scale", 1)
1663 anchor = options.get("object_position", 1)
1664 description = options.get("description", None)
1665 decorative = options.get("decorative", False)
1666
1667 # Allow Chart to override the scale and offset.
1668 if chart.x_scale != 1:
1669 x_scale = chart.x_scale
1670
1671 if chart.y_scale != 1:
1672 y_scale = chart.y_scale
1673
1674 if chart.x_offset:
1675 x_offset = chart.x_offset
1676
1677 if chart.y_offset:
1678 y_offset = chart.y_offset
1679
1680 self.charts.append(
1681 [
1682 row,
1683 col,
1684 chart,
1685 x_offset,
1686 y_offset,
1687 x_scale,
1688 y_scale,
1689 anchor,
1690 description,
1691 decorative,
1692 ]
1693 )
1694 return 0
1695
1696 @convert_cell_args
1697 def write_comment(self, row, col, comment, options=None):
1698 """
1699 Write a comment to a worksheet cell.
1700
1701 Args:
1702 row: The cell row (zero indexed).
1703 col: The cell column (zero indexed).
1704 comment: Cell comment. Str.
1705 options: Comment formatting options.
1706
1707 Returns:
1708 0: Success.
1709 -1: Row or column is out of worksheet bounds.
1710 -2: String longer than 32k characters.
1711
1712 """
1713 # Check that row and col are valid and store max and min values
1714 if self._check_dimensions(row, col):
1715 return -1
1716
1717 # Check that the comment string is < 32767 chars.
1718 if len(comment) > self.xls_strmax:
1719 return -2
1720
1721 self.has_vml = True
1722 self.has_comments = True
1723
1724 # Store the options of the cell comment, to process on file close.
1725 comment = CommentType(row, col, comment, options)
1726 self.comments[row][col] = comment
1727
1728 return 0
1729
1730 def show_comments(self):
1731 """
1732 Make any comments in the worksheet visible.
1733
1734 Args:
1735 None.
1736
1737 Returns:
1738 Nothing.
1739
1740 """
1741 self.comments_visible = True
1742
1743 def set_background(self, source, is_byte_stream=False):
1744 """
1745 Set a background image for a worksheet.
1746
1747 Args:
1748 source: Filename, BytesIO, or Image object.
1749 is_byte_stream: Deprecated. Use a BytesIO object instead.
1750
1751 Returns:
1752 0: Success.
1753
1754 """
1755 # Convert the source to an Image object.
1756 image = self._image_from_source(source)
1757
1758 self.background_image = image
1759
1760 if is_byte_stream:
1761 warn(
1762 "The `is_byte_stream` parameter in `set_background()` is deprecated. "
1763 "This argument can be omitted if you are using a BytesIO object."
1764 )
1765
1766 return 0
1767
1768 def set_comments_author(self, author):
1769 """
1770 Set the default author of the cell comments.
1771
1772 Args:
1773 author: Comment author name. String.
1774
1775 Returns:
1776 Nothing.
1777
1778 """
1779 self.comments_author = author
1780
1781 def get_name(self):
1782 """
1783 Retrieve the worksheet name.
1784
1785 Args:
1786 None.
1787
1788 Returns:
1789 Nothing.
1790
1791 """
1792 # There is no set_name() method. Name must be set in add_worksheet().
1793 return self.name
1794
1795 def activate(self):
1796 """
1797 Set this worksheet as the active worksheet, i.e. the worksheet that is
1798 displayed when the workbook is opened. Also set it as selected.
1799
1800 Note: An active worksheet cannot be hidden.
1801
1802 Args:
1803 None.
1804
1805 Returns:
1806 Nothing.
1807
1808 """
1809 self.hidden = 0
1810 self.selected = 1
1811 self.worksheet_meta.activesheet = self.index
1812
1813 def select(self):
1814 """
1815 Set current worksheet as a selected worksheet, i.e. the worksheet
1816 has its tab highlighted.
1817
1818 Note: A selected worksheet cannot be hidden.
1819
1820 Args:
1821 None.
1822
1823 Returns:
1824 Nothing.
1825
1826 """
1827 self.selected = 1
1828 self.hidden = 0
1829
1830 def hide(self):
1831 """
1832 Hide the current worksheet.
1833
1834 Args:
1835 None.
1836
1837 Returns:
1838 Nothing.
1839
1840 """
1841 self.hidden = 1
1842
1843 # A hidden worksheet shouldn't be active or selected.
1844 self.selected = 0
1845
1846 def very_hidden(self):
1847 """
1848 Hide the current worksheet. This can only be unhidden by VBA.
1849
1850 Args:
1851 None.
1852
1853 Returns:
1854 Nothing.
1855
1856 """
1857 self.hidden = 2
1858
1859 # A hidden worksheet shouldn't be active or selected.
1860 self.selected = 0
1861
1862 def set_first_sheet(self):
1863 """
1864 Set current worksheet as the first visible sheet. This is necessary
1865 when there are a large number of worksheets and the activated
1866 worksheet is not visible on the screen.
1867
1868 Note: A selected worksheet cannot be hidden.
1869
1870 Args:
1871 None.
1872
1873 Returns:
1874 Nothing.
1875
1876 """
1877 self.hidden = 0 # Active worksheet can't be hidden.
1878 self.worksheet_meta.firstsheet = self.index
1879
1880 @convert_column_args
1881 def set_column(
1882 self, first_col, last_col, width=None, cell_format=None, options=None
1883 ):
1884 """
1885 Set the width, and other properties of a single column or a
1886 range of columns.
1887
1888 Args:
1889 first_col: First column (zero-indexed).
1890 last_col: Last column (zero-indexed). Can be same as first_col.
1891 width: Column width. (optional).
1892 cell_format: Column cell_format. (optional).
1893 options: Dict of options such as hidden and level.
1894
1895 Returns:
1896 0: Success.
1897 -1: Column number is out of worksheet bounds.
1898
1899 """
1900 if options is None:
1901 options = {}
1902
1903 # Ensure 2nd col is larger than first.
1904 if first_col > last_col:
1905 (first_col, last_col) = (last_col, first_col)
1906
1907 # Don't modify the row dimensions when checking the columns.
1908 ignore_row = True
1909
1910 # Set optional column values.
1911 hidden = options.get("hidden", False)
1912 collapsed = options.get("collapsed", False)
1913 level = options.get("level", 0)
1914
1915 # Store the column dimension only in some conditions.
1916 if cell_format or (width and hidden):
1917 ignore_col = False
1918 else:
1919 ignore_col = True
1920
1921 # Check that each column is valid and store the max and min values.
1922 if self._check_dimensions(0, last_col, ignore_row, ignore_col):
1923 return -1
1924 if self._check_dimensions(0, first_col, ignore_row, ignore_col):
1925 return -1
1926
1927 # Set the limits for the outline levels (0 <= x <= 7).
1928 level = max(level, 0)
1929 level = min(level, 7)
1930
1931 self.outline_col_level = max(self.outline_col_level, level)
1932
1933 # Store the column data.
1934 for col in range(first_col, last_col + 1):
1935 self.col_info[col] = [width, cell_format, hidden, level, collapsed, False]
1936
1937 # Store the column change to allow optimizations.
1938 self.col_size_changed = True
1939
1940 return 0
1941
1942 @convert_column_args
1943 def set_column_pixels(
1944 self, first_col, last_col, width=None, cell_format=None, options=None
1945 ):
1946 """
1947 Set the width, and other properties of a single column or a
1948 range of columns, where column width is in pixels.
1949
1950 Args:
1951 first_col: First column (zero-indexed).
1952 last_col: Last column (zero-indexed). Can be same as first_col.
1953 width: Column width in pixels. (optional).
1954 cell_format: Column cell_format. (optional).
1955 options: Dict of options such as hidden and level.
1956
1957 Returns:
1958 0: Success.
1959 -1: Column number is out of worksheet bounds.
1960
1961 """
1962 if width is not None:
1963 width = self._pixels_to_width(width)
1964
1965 return self.set_column(first_col, last_col, width, cell_format, options)
1966
1967 def autofit(self, max_width=1790):
1968 """
1969 Simulate autofit based on the data, and datatypes in each column.
1970
1971 Args:
1972 max_width (optional): max column width to autofit, in pixels.
1973
1974 Returns:
1975 Nothing.
1976
1977 """
1978 # pylint: disable=too-many-nested-blocks
1979 if self.constant_memory:
1980 warn("Autofit is not supported in constant_memory mode.")
1981 return
1982
1983 # No data written to the target sheet; nothing to autofit
1984 if self.dim_rowmax is None:
1985 return
1986
1987 # Store the max pixel width for each column.
1988 col_width_max = {}
1989
1990 # Convert the autofit maximum pixel width to a column/character width,
1991 # but limit it to the Excel max limit.
1992 max_width = min(self._pixels_to_width(max_width), 255.0)
1993
1994 # Create a reverse lookup for the share strings table so we can convert
1995 # the string id back to the original string.
1996 strings = sorted(
1997 self.str_table.string_table, key=self.str_table.string_table.__getitem__
1998 )
1999
2000 for row_num in range(self.dim_rowmin, self.dim_rowmax + 1):
2001 if not self.table.get(row_num):
2002 continue
2003
2004 for col_num in range(self.dim_colmin, self.dim_colmax + 1):
2005 if col_num in self.table[row_num]:
2006 cell = self.table[row_num][col_num]
2007 cell_type = cell.__class__.__name__
2008 length = 0
2009
2010 if cell_type in ("String", "RichString"):
2011 # Handle strings and rich strings.
2012 #
2013 # For standard shared strings we do a reverse lookup
2014 # from the shared string id to the actual string. For
2015 # rich strings we use the unformatted string. We also
2016 # split multi-line strings and handle each part
2017 # separately.
2018 if cell_type == "String":
2019 string_id = cell.string
2020 string = strings[string_id]
2021 else:
2022 string = cell.raw_string
2023
2024 if "\n" not in string:
2025 # Single line string.
2026 length = xl_pixel_width(string)
2027 else:
2028 # Handle multi-line strings.
2029 for string in string.split("\n"):
2030 seg_length = xl_pixel_width(string)
2031 length = max(length, seg_length)
2032
2033 elif cell_type == "Number":
2034 # Handle numbers.
2035 #
2036 # We use a workaround/optimization for numbers since
2037 # digits all have a pixel width of 7. This gives a
2038 # slightly greater width for the decimal place and
2039 # minus sign but only by a few pixels and
2040 # over-estimation is okay.
2041 length = 7 * len(str(cell.number))
2042
2043 elif cell_type == "Datetime":
2044 # Handle dates.
2045 #
2046 # The following uses the default width for mm/dd/yyyy
2047 # dates. It isn't feasible to parse the number format
2048 # to get the actual string width for all format types.
2049 length = self.default_date_pixels
2050
2051 elif cell_type == "Boolean":
2052 # Handle boolean values.
2053 #
2054 # Use the Excel standard widths for TRUE and FALSE.
2055 if cell.boolean:
2056 length = 31
2057 else:
2058 length = 36
2059
2060 elif cell_type in ("Formula", "ArrayFormula"):
2061 # Handle formulas.
2062 #
2063 # We only try to autofit a formula if it has a
2064 # non-zero value.
2065 if isinstance(cell.value, (float, int)):
2066 if cell.value > 0:
2067 length = 7 * len(str(cell.value))
2068
2069 elif isinstance(cell.value, str):
2070 length = xl_pixel_width(cell.value)
2071
2072 elif isinstance(cell.value, bool):
2073 if cell.value:
2074 length = 31
2075 else:
2076 length = 36
2077
2078 # If the cell is in an autofilter header we add an
2079 # additional 16 pixels for the dropdown arrow.
2080 if self.filter_cells.get((row_num, col_num)) and length > 0:
2081 length += 16
2082
2083 # Add the string length to the lookup table.
2084 width_max = col_width_max.get(col_num, 0)
2085 if length > width_max:
2086 col_width_max[col_num] = length
2087
2088 # Apply the width to the column.
2089 for col_num, pixel_width in col_width_max.items():
2090 # Convert the string pixel width to a character width using an
2091 # additional padding of 7 pixels, like Excel.
2092 width = self._pixels_to_width(pixel_width + 7)
2093
2094 # Limit the width to the maximum user or Excel value.
2095 width = min(width, max_width)
2096
2097 # Add the width to an existing col info structure or add a new one.
2098 if self.col_info.get(col_num):
2099 # We only update the width for an existing column if it is
2100 # greater than the user defined value. This allows the user
2101 # to pre-load a minimum col width.
2102 col_info = self.col_info.get(col_num)
2103 user_width = col_info[0]
2104 hidden = col_info[5]
2105 if user_width is not None and not hidden:
2106 # Col info is user defined.
2107 if width > user_width:
2108 self.col_info[col_num][0] = width
2109 self.col_info[col_num][5] = True
2110 else:
2111 self.col_info[col_num][0] = width
2112 self.col_info[col_num][5] = True
2113 else:
2114 self.col_info[col_num] = [width, None, False, 0, False, True]
2115
2116 def set_row(self, row, height=None, cell_format=None, options=None):
2117 """
2118 Set the width, and other properties of a row.
2119
2120 Args:
2121 row: Row number (zero-indexed).
2122 height: Row height. (optional).
2123 cell_format: Row cell_format. (optional).
2124 options: Dict of options such as hidden, level and collapsed.
2125
2126 Returns:
2127 0: Success.
2128 -1: Row number is out of worksheet bounds.
2129
2130 """
2131 if options is None:
2132 options = {}
2133
2134 # Use minimum col in _check_dimensions().
2135 if self.dim_colmin is not None:
2136 min_col = self.dim_colmin
2137 else:
2138 min_col = 0
2139
2140 # Check that row is valid.
2141 if self._check_dimensions(row, min_col):
2142 return -1
2143
2144 if height is None:
2145 height = self.default_row_height
2146
2147 # Set optional row values.
2148 hidden = options.get("hidden", False)
2149 collapsed = options.get("collapsed", False)
2150 level = options.get("level", 0)
2151
2152 # If the height is 0 the row is hidden and the height is the default.
2153 if height == 0:
2154 hidden = 1
2155 height = self.default_row_height
2156
2157 # Set the limits for the outline levels (0 <= x <= 7).
2158 level = max(level, 0)
2159 level = min(level, 7)
2160
2161 self.outline_row_level = max(self.outline_row_level, level)
2162
2163 # Store the row properties.
2164 self.set_rows[row] = [height, cell_format, hidden, level, collapsed]
2165
2166 # Store the row change to allow optimizations.
2167 self.row_size_changed = True
2168
2169 # Store the row sizes for use when calculating image vertices.
2170 self.row_sizes[row] = [height, hidden]
2171
2172 return 0
2173
2174 def set_row_pixels(self, row, height=None, cell_format=None, options=None):
2175 """
2176 Set the width (in pixels), and other properties of a row.
2177
2178 Args:
2179 row: Row number (zero-indexed).
2180 height: Row height in pixels. (optional).
2181 cell_format: Row cell_format. (optional).
2182 options: Dict of options such as hidden, level and collapsed.
2183
2184 Returns:
2185 0: Success.
2186 -1: Row number is out of worksheet bounds.
2187
2188 """
2189 if height is not None:
2190 height = self._pixels_to_height(height)
2191
2192 return self.set_row(row, height, cell_format, options)
2193
2194 def set_default_row(self, height=None, hide_unused_rows=False):
2195 """
2196 Set the default row properties.
2197
2198 Args:
2199 height: Default height. Optional, defaults to 15.
2200 hide_unused_rows: Hide unused rows. Optional, defaults to False.
2201
2202 Returns:
2203 Nothing.
2204
2205 """
2206 if height is None:
2207 height = self.default_row_height
2208
2209 if height != self.original_row_height:
2210 # Store the row change to allow optimizations.
2211 self.row_size_changed = True
2212 self.default_row_height = height
2213
2214 if hide_unused_rows:
2215 self.default_row_zeroed = 1
2216
2217 @convert_range_args
2218 def merge_range(
2219 self, first_row, first_col, last_row, last_col, data, cell_format=None
2220 ):
2221 """
2222 Merge a range of cells.
2223
2224 Args:
2225 first_row: The first row of the cell range. (zero indexed).
2226 first_col: The first column of the cell range.
2227 last_row: The last row of the cell range. (zero indexed).
2228 last_col: The last column of the cell range.
2229 data: Cell data.
2230 cell_format: Cell Format object.
2231
2232 Returns:
2233 0: Success.
2234 -1: Row or column is out of worksheet bounds.
2235 other: Return value of write().
2236
2237 """
2238 # Merge a range of cells. The first cell should contain the data and
2239 # the others should be blank. All cells should have the same format.
2240
2241 # Excel doesn't allow a single cell to be merged
2242 if first_row == last_row and first_col == last_col:
2243 warn("Can't merge single cell")
2244 return -1
2245
2246 # Swap last row/col with first row/col as necessary
2247 if first_row > last_row:
2248 (first_row, last_row) = (last_row, first_row)
2249 if first_col > last_col:
2250 (first_col, last_col) = (last_col, first_col)
2251
2252 # Check that row and col are valid and store max and min values.
2253 if self._check_dimensions(first_row, first_col):
2254 return -1
2255 if self._check_dimensions(last_row, last_col):
2256 return -1
2257
2258 # Check if the merge range overlaps a previous merged or table range.
2259 # This is a critical file corruption error in Excel.
2260 cell_range = xl_range(first_row, first_col, last_row, last_col)
2261 for row in range(first_row, last_row + 1):
2262 for col in range(first_col, last_col + 1):
2263 if self.merged_cells.get((row, col)):
2264 previous_range = self.merged_cells.get((row, col))
2265 raise OverlappingRange(
2266 f"Merge range '{cell_range}' overlaps previous merge "
2267 f"range '{previous_range}'."
2268 )
2269
2270 if self.table_cells.get((row, col)):
2271 previous_range = self.table_cells.get((row, col))
2272 raise OverlappingRange(
2273 f"Merge range '{cell_range}' overlaps previous table "
2274 f"range '{previous_range}'."
2275 )
2276
2277 self.merged_cells[(row, col)] = cell_range
2278
2279 # Store the merge range.
2280 self.merge.append([first_row, first_col, last_row, last_col])
2281
2282 # Write the first cell
2283 self._write(first_row, first_col, data, cell_format)
2284
2285 # Pad out the rest of the area with formatted blank cells.
2286 for row in range(first_row, last_row + 1):
2287 for col in range(first_col, last_col + 1):
2288 if row == first_row and col == first_col:
2289 continue
2290 self._write_blank(row, col, "", cell_format)
2291
2292 return 0
2293
2294 @convert_range_args
2295 def autofilter(self, first_row, first_col, last_row, last_col):
2296 """
2297 Set the autofilter area in the worksheet.
2298
2299 Args:
2300 first_row: The first row of the cell range. (zero indexed).
2301 first_col: The first column of the cell range.
2302 last_row: The last row of the cell range. (zero indexed).
2303 last_col: The last column of the cell range.
2304
2305 Returns:
2306 Nothing.
2307
2308 """
2309 # Reverse max and min values if necessary.
2310 if last_row < first_row:
2311 (first_row, last_row) = (last_row, first_row)
2312 if last_col < first_col:
2313 (first_col, last_col) = (last_col, first_col)
2314
2315 # Build up the autofilter area range "Sheet1!$A$1:$C$13".
2316 area = self._convert_name_area(first_row, first_col, last_row, last_col)
2317 ref = xl_range(first_row, first_col, last_row, last_col)
2318
2319 self.autofilter_area = area
2320 self.autofilter_ref = ref
2321 self.filter_range = [first_col, last_col]
2322
2323 # Store the filter cell positions for use in the autofit calculation.
2324 for col in range(first_col, last_col + 1):
2325 # Check that the autofilter doesn't overlap a table filter.
2326 if self.filter_cells.get((first_row, col)):
2327 filter_type, filter_range = self.filter_cells.get((first_row, col))
2328 if filter_type == "table":
2329 raise OverlappingRange(
2330 f"Worksheet autofilter range '{ref}' overlaps previous "
2331 f"Table autofilter range '{filter_range}'."
2332 )
2333
2334 self.filter_cells[(first_row, col)] = ("worksheet", ref)
2335
2336 def filter_column(self, col, criteria):
2337 """
2338 Set the column filter criteria.
2339
2340 Args:
2341 col: Filter column (zero-indexed).
2342 criteria: Filter criteria.
2343
2344 Returns:
2345 Nothing.
2346
2347 """
2348 if not self.autofilter_area:
2349 warn("Must call autofilter() before filter_column()")
2350 return
2351
2352 # Check for a column reference in A1 notation and substitute.
2353 try:
2354 int(col)
2355 except ValueError:
2356 # Convert col ref to a cell ref and then to a col number.
2357 col_letter = col
2358 (_, col) = xl_cell_to_rowcol(col + "1")
2359
2360 if col >= self.xls_colmax:
2361 warn(f"Invalid column '{col_letter}'")
2362 return
2363
2364 (col_first, col_last) = self.filter_range
2365
2366 # Reject column if it is outside filter range.
2367 if col < col_first or col > col_last:
2368 warn(
2369 f"Column '{col}' outside autofilter() column "
2370 f"range ({col_first}, {col_last})"
2371 )
2372 return
2373
2374 tokens = self._extract_filter_tokens(criteria)
2375
2376 if len(tokens) not in (3, 7):
2377 warn(f"Incorrect number of tokens in criteria '{criteria}'")
2378
2379 tokens = self._parse_filter_expression(criteria, tokens)
2380
2381 # Excel handles single or double custom filters as default filters.
2382 # We need to check for them and handle them accordingly.
2383 if len(tokens) == 2 and tokens[0] == 2:
2384 # Single equality.
2385 self.filter_column_list(col, [tokens[1]])
2386 elif len(tokens) == 5 and tokens[0] == 2 and tokens[2] == 1 and tokens[3] == 2:
2387 # Double equality with "or" operator.
2388 self.filter_column_list(col, [tokens[1], tokens[4]])
2389 else:
2390 # Non default custom filter.
2391 self.filter_cols[col] = tokens
2392 self.filter_type[col] = 0
2393
2394 self.filter_on = 1
2395
2396 def filter_column_list(self, col, filters):
2397 """
2398 Set the column filter criteria in Excel 2007 list style.
2399
2400 Args:
2401 col: Filter column (zero-indexed).
2402 filters: List of filter criteria to match.
2403
2404 Returns:
2405 Nothing.
2406
2407 """
2408 if not self.autofilter_area:
2409 warn("Must call autofilter() before filter_column()")
2410 return
2411
2412 # Check for a column reference in A1 notation and substitute.
2413 try:
2414 int(col)
2415 except ValueError:
2416 # Convert col ref to a cell ref and then to a col number.
2417 col_letter = col
2418 (_, col) = xl_cell_to_rowcol(col + "1")
2419
2420 if col >= self.xls_colmax:
2421 warn(f"Invalid column '{col_letter}'")
2422 return
2423
2424 (col_first, col_last) = self.filter_range
2425
2426 # Reject column if it is outside filter range.
2427 if col < col_first or col > col_last:
2428 warn(
2429 f"Column '{col}' outside autofilter() column range "
2430 f"({col_first},{col_last})"
2431 )
2432 return
2433
2434 self.filter_cols[col] = filters
2435 self.filter_type[col] = 1
2436 self.filter_on = 1
2437
2438 @convert_range_args
2439 def data_validation(self, first_row, first_col, last_row, last_col, options=None):
2440 """
2441 Add a data validation to a worksheet.
2442
2443 Args:
2444 first_row: The first row of the cell range. (zero indexed).
2445 first_col: The first column of the cell range.
2446 last_row: The last row of the cell range. (zero indexed).
2447 last_col: The last column of the cell range.
2448 options: Data validation options.
2449
2450 Returns:
2451 0: Success.
2452 -1: Row or column is out of worksheet bounds.
2453 -2: Incorrect parameter or option.
2454 """
2455 # Check that row and col are valid without storing the values.
2456 if self._check_dimensions(first_row, first_col, True, True):
2457 return -1
2458 if self._check_dimensions(last_row, last_col, True, True):
2459 return -1
2460
2461 if options is None:
2462 options = {}
2463 else:
2464 # Copy the user defined options so they aren't modified.
2465 options = options.copy()
2466
2467 # Valid input parameters.
2468 valid_parameters = {
2469 "validate",
2470 "criteria",
2471 "value",
2472 "source",
2473 "minimum",
2474 "maximum",
2475 "ignore_blank",
2476 "dropdown",
2477 "show_input",
2478 "input_title",
2479 "input_message",
2480 "show_error",
2481 "error_title",
2482 "error_message",
2483 "error_type",
2484 "other_cells",
2485 "multi_range",
2486 }
2487
2488 # Check for valid input parameters.
2489 for param_key in options.keys():
2490 if param_key not in valid_parameters:
2491 warn(f"Unknown parameter '{param_key}' in data_validation()")
2492 return -2
2493
2494 # Map alternative parameter names 'source' or 'minimum' to 'value'.
2495 if "source" in options:
2496 options["value"] = options["source"]
2497 if "minimum" in options:
2498 options["value"] = options["minimum"]
2499
2500 # 'validate' is a required parameter.
2501 if "validate" not in options:
2502 warn("Parameter 'validate' is required in data_validation()")
2503 return -2
2504
2505 # List of valid validation types.
2506 valid_types = {
2507 "any": "none",
2508 "any value": "none",
2509 "whole number": "whole",
2510 "whole": "whole",
2511 "integer": "whole",
2512 "decimal": "decimal",
2513 "list": "list",
2514 "date": "date",
2515 "time": "time",
2516 "text length": "textLength",
2517 "length": "textLength",
2518 "custom": "custom",
2519 }
2520
2521 # Check for valid validation types.
2522 if options["validate"] not in valid_types:
2523 warn(
2524 f"Unknown validation type '{options['validate']}' for parameter "
2525 f"'validate' in data_validation()"
2526 )
2527 return -2
2528
2529 options["validate"] = valid_types[options["validate"]]
2530
2531 # No action is required for validation type 'any' if there are no
2532 # input messages to display.
2533 if (
2534 options["validate"] == "none"
2535 and options.get("input_title") is None
2536 and options.get("input_message") is None
2537 ):
2538 return -2
2539
2540 # The any, list and custom validations don't have a criteria so we use
2541 # a default of 'between'.
2542 if (
2543 options["validate"] == "none"
2544 or options["validate"] == "list"
2545 or options["validate"] == "custom"
2546 ):
2547 options["criteria"] = "between"
2548 options["maximum"] = None
2549
2550 # 'criteria' is a required parameter.
2551 if "criteria" not in options:
2552 warn("Parameter 'criteria' is required in data_validation()")
2553 return -2
2554
2555 # Valid criteria types.
2556 criteria_types = {
2557 "between": "between",
2558 "not between": "notBetween",
2559 "equal to": "equal",
2560 "=": "equal",
2561 "==": "equal",
2562 "not equal to": "notEqual",
2563 "!=": "notEqual",
2564 "<>": "notEqual",
2565 "greater than": "greaterThan",
2566 ">": "greaterThan",
2567 "less than": "lessThan",
2568 "<": "lessThan",
2569 "greater than or equal to": "greaterThanOrEqual",
2570 ">=": "greaterThanOrEqual",
2571 "less than or equal to": "lessThanOrEqual",
2572 "<=": "lessThanOrEqual",
2573 }
2574
2575 # Check for valid criteria types.
2576 if options["criteria"] not in criteria_types:
2577 warn(
2578 f"Unknown criteria type '{options['criteria']}' for parameter "
2579 f"'criteria' in data_validation()"
2580 )
2581 return -2
2582
2583 options["criteria"] = criteria_types[options["criteria"]]
2584
2585 # 'Between' and 'Not between' criteria require 2 values.
2586 if options["criteria"] == "between" or options["criteria"] == "notBetween":
2587 if "maximum" not in options:
2588 warn(
2589 "Parameter 'maximum' is required in data_validation() "
2590 "when using 'between' or 'not between' criteria"
2591 )
2592 return -2
2593 else:
2594 options["maximum"] = None
2595
2596 # Valid error dialog types.
2597 error_types = {
2598 "stop": 0,
2599 "warning": 1,
2600 "information": 2,
2601 }
2602
2603 # Check for valid error dialog types.
2604 if "error_type" not in options:
2605 options["error_type"] = 0
2606 elif options["error_type"] not in error_types:
2607 warn(
2608 f"Unknown criteria type '{options['error_type']}' "
2609 f"for parameter 'error_type'."
2610 )
2611 return -2
2612 else:
2613 options["error_type"] = error_types[options["error_type"]]
2614
2615 # Convert date/times value if required.
2616 if (
2617 options["validate"] in ("date", "time")
2618 and options["value"]
2619 and _supported_datetime(options["value"])
2620 ):
2621 date_time = self._convert_date_time(options["value"])
2622 # Format date number to the same precision as Excel.
2623 options["value"] = f"{date_time:.16g}"
2624
2625 if options["maximum"] and _supported_datetime(options["maximum"]):
2626 date_time = self._convert_date_time(options["maximum"])
2627 options["maximum"] = f"{date_time:.16g}"
2628
2629 # Check that the input title doesn't exceed the maximum length.
2630 if options.get("input_title") and len(options["input_title"]) > 32:
2631 warn(
2632 f"Length of input title '{options['input_title']}' "
2633 f"exceeds Excel's limit of 32"
2634 )
2635 return -2
2636
2637 # Check that the error title doesn't exceed the maximum length.
2638 if options.get("error_title") and len(options["error_title"]) > 32:
2639 warn(
2640 f"Length of error title '{options['error_title']}' "
2641 f"exceeds Excel's limit of 32"
2642 )
2643 return -2
2644
2645 # Check that the input message doesn't exceed the maximum length.
2646 if options.get("input_message") and len(options["input_message"]) > 255:
2647 warn(
2648 f"Length of input message '{options['input_message']}' "
2649 f"exceeds Excel's limit of 255"
2650 )
2651 return -2
2652
2653 # Check that the error message doesn't exceed the maximum length.
2654 if options.get("error_message") and len(options["error_message"]) > 255:
2655 warn(
2656 f"Length of error message '{options['error_message']}' "
2657 f"exceeds Excel's limit of 255"
2658 )
2659 return -2
2660
2661 # Check that the input list doesn't exceed the maximum length.
2662 if options["validate"] == "list" and isinstance(options["value"], list):
2663 formula = self._csv_join(*options["value"])
2664 if len(formula) > 255:
2665 warn(
2666 f"Length of list items '{formula}' exceeds Excel's limit of "
2667 f"255, use a formula range instead"
2668 )
2669 return -2
2670
2671 # Set some defaults if they haven't been defined by the user.
2672 if "ignore_blank" not in options:
2673 options["ignore_blank"] = 1
2674 if "dropdown" not in options:
2675 options["dropdown"] = 1
2676 if "show_input" not in options:
2677 options["show_input"] = 1
2678 if "show_error" not in options:
2679 options["show_error"] = 1
2680
2681 # These are the cells to which the validation is applied.
2682 options["cells"] = [[first_row, first_col, last_row, last_col]]
2683
2684 # A (for now) undocumented parameter to pass additional cell ranges.
2685 if "other_cells" in options:
2686 options["cells"].extend(options["other_cells"])
2687
2688 # Override with user defined multiple range if provided.
2689 if "multi_range" in options:
2690 options["multi_range"] = options["multi_range"].replace("$", "")
2691
2692 # Store the validation information until we close the worksheet.
2693 self.validations.append(options)
2694
2695 return 0
2696
2697 @convert_range_args
2698 def conditional_format(
2699 self, first_row, first_col, last_row, last_col, options=None
2700 ):
2701 """
2702 Add a conditional format to a worksheet.
2703
2704 Args:
2705 first_row: The first row of the cell range. (zero indexed).
2706 first_col: The first column of the cell range.
2707 last_row: The last row of the cell range. (zero indexed).
2708 last_col: The last column of the cell range.
2709 options: Conditional format options.
2710
2711 Returns:
2712 0: Success.
2713 -1: Row or column is out of worksheet bounds.
2714 -2: Incorrect parameter or option.
2715 """
2716 # Check that row and col are valid without storing the values.
2717 if self._check_dimensions(first_row, first_col, True, True):
2718 return -1
2719 if self._check_dimensions(last_row, last_col, True, True):
2720 return -1
2721
2722 if options is None:
2723 options = {}
2724 else:
2725 # Copy the user defined options so they aren't modified.
2726 options = options.copy()
2727
2728 # Valid input parameters.
2729 valid_parameter = {
2730 "type",
2731 "format",
2732 "criteria",
2733 "value",
2734 "minimum",
2735 "maximum",
2736 "stop_if_true",
2737 "min_type",
2738 "mid_type",
2739 "max_type",
2740 "min_value",
2741 "mid_value",
2742 "max_value",
2743 "min_color",
2744 "mid_color",
2745 "max_color",
2746 "min_length",
2747 "max_length",
2748 "multi_range",
2749 "bar_color",
2750 "bar_negative_color",
2751 "bar_negative_color_same",
2752 "bar_solid",
2753 "bar_border_color",
2754 "bar_negative_border_color",
2755 "bar_negative_border_color_same",
2756 "bar_no_border",
2757 "bar_direction",
2758 "bar_axis_position",
2759 "bar_axis_color",
2760 "bar_only",
2761 "data_bar_2010",
2762 "icon_style",
2763 "reverse_icons",
2764 "icons_only",
2765 "icons",
2766 }
2767
2768 # Check for valid input parameters.
2769 for param_key in options.keys():
2770 if param_key not in valid_parameter:
2771 warn(f"Unknown parameter '{param_key}' in conditional_format()")
2772 return -2
2773
2774 # 'type' is a required parameter.
2775 if "type" not in options:
2776 warn("Parameter 'type' is required in conditional_format()")
2777 return -2
2778
2779 # Valid types.
2780 valid_type = {
2781 "cell": "cellIs",
2782 "date": "date",
2783 "time": "time",
2784 "average": "aboveAverage",
2785 "duplicate": "duplicateValues",
2786 "unique": "uniqueValues",
2787 "top": "top10",
2788 "bottom": "top10",
2789 "text": "text",
2790 "time_period": "timePeriod",
2791 "blanks": "containsBlanks",
2792 "no_blanks": "notContainsBlanks",
2793 "errors": "containsErrors",
2794 "no_errors": "notContainsErrors",
2795 "2_color_scale": "2_color_scale",
2796 "3_color_scale": "3_color_scale",
2797 "data_bar": "dataBar",
2798 "formula": "expression",
2799 "icon_set": "iconSet",
2800 }
2801
2802 # Check for valid types.
2803 if options["type"] not in valid_type:
2804 warn(
2805 f"Unknown value '{options['type']}' for parameter 'type' "
2806 f"in conditional_format()"
2807 )
2808 return -2
2809
2810 if options["type"] == "bottom":
2811 options["direction"] = "bottom"
2812 options["type"] = valid_type[options["type"]]
2813
2814 # Valid criteria types.
2815 criteria_type = {
2816 "between": "between",
2817 "not between": "notBetween",
2818 "equal to": "equal",
2819 "=": "equal",
2820 "==": "equal",
2821 "not equal to": "notEqual",
2822 "!=": "notEqual",
2823 "<>": "notEqual",
2824 "greater than": "greaterThan",
2825 ">": "greaterThan",
2826 "less than": "lessThan",
2827 "<": "lessThan",
2828 "greater than or equal to": "greaterThanOrEqual",
2829 ">=": "greaterThanOrEqual",
2830 "less than or equal to": "lessThanOrEqual",
2831 "<=": "lessThanOrEqual",
2832 "containing": "containsText",
2833 "not containing": "notContains",
2834 "begins with": "beginsWith",
2835 "ends with": "endsWith",
2836 "yesterday": "yesterday",
2837 "today": "today",
2838 "last 7 days": "last7Days",
2839 "last week": "lastWeek",
2840 "this week": "thisWeek",
2841 "next week": "nextWeek",
2842 "last month": "lastMonth",
2843 "this month": "thisMonth",
2844 "next month": "nextMonth",
2845 # For legacy, but incorrect, support.
2846 "continue week": "nextWeek",
2847 "continue month": "nextMonth",
2848 }
2849
2850 # Check for valid criteria types.
2851 if "criteria" in options and options["criteria"] in criteria_type:
2852 options["criteria"] = criteria_type[options["criteria"]]
2853
2854 # Convert boolean values if required.
2855 if "value" in options and isinstance(options["value"], bool):
2856 options["value"] = str(options["value"]).upper()
2857
2858 # Convert date/times value if required.
2859 if options["type"] in ("date", "time"):
2860 options["type"] = "cellIs"
2861
2862 if "value" in options:
2863 if not _supported_datetime(options["value"]):
2864 warn("Conditional format 'value' must be a datetime object.")
2865 return -2
2866
2867 date_time = self._convert_date_time(options["value"])
2868 # Format date number to the same precision as Excel.
2869 options["value"] = f"{date_time:.16g}"
2870
2871 if "minimum" in options:
2872 if not _supported_datetime(options["minimum"]):
2873 warn("Conditional format 'minimum' must be a datetime object.")
2874 return -2
2875
2876 date_time = self._convert_date_time(options["minimum"])
2877 options["minimum"] = f"{date_time:.16g}"
2878
2879 if "maximum" in options:
2880 if not _supported_datetime(options["maximum"]):
2881 warn("Conditional format 'maximum' must be a datetime object.")
2882 return -2
2883
2884 date_time = self._convert_date_time(options["maximum"])
2885 options["maximum"] = f"{date_time:.16g}"
2886
2887 # Valid icon styles.
2888 valid_icons = {
2889 "3_arrows": "3Arrows", # 1
2890 "3_flags": "3Flags", # 2
2891 "3_traffic_lights_rimmed": "3TrafficLights2", # 3
2892 "3_symbols_circled": "3Symbols", # 4
2893 "4_arrows": "4Arrows", # 5
2894 "4_red_to_black": "4RedToBlack", # 6
2895 "4_traffic_lights": "4TrafficLights", # 7
2896 "5_arrows_gray": "5ArrowsGray", # 8
2897 "5_quarters": "5Quarters", # 9
2898 "3_arrows_gray": "3ArrowsGray", # 10
2899 "3_traffic_lights": "3TrafficLights", # 11
2900 "3_signs": "3Signs", # 12
2901 "3_symbols": "3Symbols2", # 13
2902 "4_arrows_gray": "4ArrowsGray", # 14
2903 "4_ratings": "4Rating", # 15
2904 "5_arrows": "5Arrows", # 16
2905 "5_ratings": "5Rating",
2906 } # 17
2907
2908 # Set the icon set properties.
2909 if options["type"] == "iconSet":
2910 # An icon_set must have an icon style.
2911 if not options.get("icon_style"):
2912 warn(
2913 "The 'icon_style' parameter must be specified when "
2914 "'type' == 'icon_set' in conditional_format()."
2915 )
2916 return -3
2917
2918 # Check for valid icon styles.
2919 if options["icon_style"] not in valid_icons:
2920 warn(
2921 f"Unknown icon_style '{options['icon_style']}' "
2922 f"in conditional_format()."
2923 )
2924 return -2
2925
2926 options["icon_style"] = valid_icons[options["icon_style"]]
2927
2928 # Set the number of icons for the icon style.
2929 options["total_icons"] = 3
2930 if options["icon_style"].startswith("4"):
2931 options["total_icons"] = 4
2932 elif options["icon_style"].startswith("5"):
2933 options["total_icons"] = 5
2934
2935 options["icons"] = self._set_icon_props(
2936 options.get("total_icons"), options.get("icons")
2937 )
2938
2939 # Swap last row/col for first row/col as necessary
2940 if first_row > last_row:
2941 first_row, last_row = last_row, first_row
2942
2943 if first_col > last_col:
2944 first_col, last_col = last_col, first_col
2945
2946 # Set the formatting range.
2947 cell_range = xl_range(first_row, first_col, last_row, last_col)
2948 start_cell = xl_rowcol_to_cell(first_row, first_col)
2949
2950 # Override with user defined multiple range if provided.
2951 if "multi_range" in options:
2952 cell_range = options["multi_range"]
2953 cell_range = cell_range.replace("$", "")
2954
2955 # Get the dxf format index.
2956 if "format" in options and options["format"]:
2957 options["format"] = options["format"]._get_dxf_index()
2958
2959 # Set the priority based on the order of adding.
2960 options["priority"] = self.dxf_priority
2961 self.dxf_priority += 1
2962
2963 # Check for 2010 style data_bar parameters.
2964 # pylint: disable=too-many-boolean-expressions
2965 if (
2966 self.use_data_bars_2010
2967 or options.get("data_bar_2010")
2968 or options.get("bar_solid")
2969 or options.get("bar_border_color")
2970 or options.get("bar_negative_color")
2971 or options.get("bar_negative_color_same")
2972 or options.get("bar_negative_border_color")
2973 or options.get("bar_negative_border_color_same")
2974 or options.get("bar_no_border")
2975 or options.get("bar_axis_position")
2976 or options.get("bar_axis_color")
2977 or options.get("bar_direction")
2978 ):
2979 options["is_data_bar_2010"] = True
2980
2981 # Special handling of text criteria.
2982 if options["type"] == "text":
2983 value = options["value"]
2984 length = len(value)
2985 criteria = options["criteria"]
2986
2987 if options["criteria"] == "containsText":
2988 options["type"] = "containsText"
2989 options["formula"] = f'NOT(ISERROR(SEARCH("{value}",{start_cell})))'
2990 elif options["criteria"] == "notContains":
2991 options["type"] = "notContainsText"
2992 options["formula"] = f'ISERROR(SEARCH("{value}",{start_cell}))'
2993 elif options["criteria"] == "beginsWith":
2994 options["type"] = "beginsWith"
2995 options["formula"] = f'LEFT({start_cell},{length})="{value}"'
2996 elif options["criteria"] == "endsWith":
2997 options["type"] = "endsWith"
2998 options["formula"] = f'RIGHT({start_cell},{length})="{value}"'
2999 else:
3000 warn(f"Invalid text criteria '{criteria}' in conditional_format()")
3001
3002 # Special handling of time time_period criteria.
3003 if options["type"] == "timePeriod":
3004 if options["criteria"] == "yesterday":
3005 options["formula"] = f"FLOOR({start_cell},1)=TODAY()-1"
3006
3007 elif options["criteria"] == "today":
3008 options["formula"] = f"FLOOR({start_cell},1)=TODAY()"
3009
3010 elif options["criteria"] == "tomorrow":
3011 options["formula"] = f"FLOOR({start_cell},1)=TODAY()+1"
3012
3013 # fmt: off
3014 elif options["criteria"] == "last7Days":
3015 options["formula"] = (
3016 f"AND(TODAY()-FLOOR({start_cell},1)<=6,"
3017 f"FLOOR({start_cell},1)<=TODAY())"
3018 )
3019 # fmt: on
3020
3021 elif options["criteria"] == "lastWeek":
3022 options["formula"] = (
3023 f"AND(TODAY()-ROUNDDOWN({start_cell},0)>=(WEEKDAY(TODAY())),"
3024 f"TODAY()-ROUNDDOWN({start_cell},0)<(WEEKDAY(TODAY())+7))"
3025 )
3026
3027 elif options["criteria"] == "thisWeek":
3028 options["formula"] = (
3029 f"AND(TODAY()-ROUNDDOWN({start_cell},0)<=WEEKDAY(TODAY())-1,"
3030 f"ROUNDDOWN({start_cell},0)-TODAY()<=7-WEEKDAY(TODAY()))"
3031 )
3032
3033 elif options["criteria"] == "nextWeek":
3034 options["formula"] = (
3035 f"AND(ROUNDDOWN({start_cell},0)-TODAY()>(7-WEEKDAY(TODAY())),"
3036 f"ROUNDDOWN({start_cell},0)-TODAY()<(15-WEEKDAY(TODAY())))"
3037 )
3038
3039 elif options["criteria"] == "lastMonth":
3040 options["formula"] = (
3041 f"AND(MONTH({start_cell})=MONTH(TODAY())-1,"
3042 f"OR(YEAR({start_cell})=YEAR("
3043 f"TODAY()),AND(MONTH({start_cell})=1,YEAR(A1)=YEAR(TODAY())-1)))"
3044 )
3045
3046 # fmt: off
3047 elif options["criteria"] == "thisMonth":
3048 options["formula"] = (
3049 f"AND(MONTH({start_cell})=MONTH(TODAY()),"
3050 f"YEAR({start_cell})=YEAR(TODAY()))"
3051 )
3052 # fmt: on
3053
3054 elif options["criteria"] == "nextMonth":
3055 options["formula"] = (
3056 f"AND(MONTH({start_cell})=MONTH(TODAY())+1,"
3057 f"OR(YEAR({start_cell})=YEAR("
3058 f"TODAY()),AND(MONTH({start_cell})=12,"
3059 f"YEAR({start_cell})=YEAR(TODAY())+1)))"
3060 )
3061
3062 else:
3063 warn(
3064 f"Invalid time_period criteria '{options['criteria']}' "
3065 f"in conditional_format()"
3066 )
3067
3068 # Special handling of blanks/error types.
3069 if options["type"] == "containsBlanks":
3070 options["formula"] = f"LEN(TRIM({start_cell}))=0"
3071
3072 if options["type"] == "notContainsBlanks":
3073 options["formula"] = f"LEN(TRIM({start_cell}))>0"
3074
3075 if options["type"] == "containsErrors":
3076 options["formula"] = f"ISERROR({start_cell})"
3077
3078 if options["type"] == "notContainsErrors":
3079 options["formula"] = f"NOT(ISERROR({start_cell}))"
3080
3081 # Special handling for 2 color scale.
3082 if options["type"] == "2_color_scale":
3083 options["type"] = "colorScale"
3084
3085 # Color scales don't use any additional formatting.
3086 options["format"] = None
3087
3088 # Turn off 3 color parameters.
3089 options["mid_type"] = None
3090 options["mid_color"] = None
3091
3092 options.setdefault("min_type", "min")
3093 options.setdefault("max_type", "max")
3094 options.setdefault("min_value", 0)
3095 options.setdefault("max_value", 0)
3096 options.setdefault("min_color", Color("#FF7128"))
3097 options.setdefault("max_color", Color("#FFEF9C"))
3098
3099 options["min_color"] = Color._from_value(options["min_color"])
3100 options["max_color"] = Color._from_value(options["max_color"])
3101
3102 # Special handling for 3 color scale.
3103 if options["type"] == "3_color_scale":
3104 options["type"] = "colorScale"
3105
3106 # Color scales don't use any additional formatting.
3107 options["format"] = None
3108
3109 options.setdefault("min_type", "min")
3110 options.setdefault("mid_type", "percentile")
3111 options.setdefault("max_type", "max")
3112 options.setdefault("min_value", 0)
3113 options.setdefault("max_value", 0)
3114 options.setdefault("min_color", Color("#F8696B"))
3115 options.setdefault("mid_color", Color("#FFEB84"))
3116 options.setdefault("max_color", Color("#63BE7B"))
3117
3118 options["min_color"] = Color._from_value(options["min_color"])
3119 options["mid_color"] = Color._from_value(options["mid_color"])
3120 options["max_color"] = Color._from_value(options["max_color"])
3121
3122 # Set a default mid value.
3123 if "mid_value" not in options:
3124 options["mid_value"] = 50
3125
3126 # Special handling for data bar.
3127 if options["type"] == "dataBar":
3128 # Color scales don't use any additional formatting.
3129 options["format"] = None
3130
3131 if not options.get("min_type"):
3132 options["min_type"] = "min"
3133 options["x14_min_type"] = "autoMin"
3134 else:
3135 options["x14_min_type"] = options["min_type"]
3136
3137 if not options.get("max_type"):
3138 options["max_type"] = "max"
3139 options["x14_max_type"] = "autoMax"
3140 else:
3141 options["x14_max_type"] = options["max_type"]
3142
3143 options.setdefault("min_value", 0)
3144 options.setdefault("max_value", 0)
3145 options.setdefault("bar_color", Color("#638EC6"))
3146 options.setdefault("bar_border_color", options["bar_color"])
3147 options.setdefault("bar_only", False)
3148 options.setdefault("bar_no_border", False)
3149 options.setdefault("bar_solid", False)
3150 options.setdefault("bar_direction", "")
3151 options.setdefault("bar_negative_color", Color("#FF0000"))
3152 options.setdefault("bar_negative_border_color", Color("#FF0000"))
3153 options.setdefault("bar_negative_color_same", False)
3154 options.setdefault("bar_negative_border_color_same", False)
3155 options.setdefault("bar_axis_position", "")
3156 options.setdefault("bar_axis_color", Color("#000000"))
3157
3158 options["bar_color"] = Color._from_value(options["bar_color"])
3159 options["bar_border_color"] = Color._from_value(options["bar_border_color"])
3160 options["bar_axis_color"] = Color._from_value(options["bar_axis_color"])
3161 options["bar_negative_color"] = Color._from_value(
3162 options["bar_negative_color"]
3163 )
3164 options["bar_negative_border_color"] = Color._from_value(
3165 options["bar_negative_border_color"]
3166 )
3167
3168 # Adjust for 2010 style data_bar parameters.
3169 if options.get("is_data_bar_2010"):
3170 self.excel_version = 2010
3171
3172 if options["min_type"] == "min" and options["min_value"] == 0:
3173 options["min_value"] = None
3174
3175 if options["max_type"] == "max" and options["max_value"] == 0:
3176 options["max_value"] = None
3177
3178 options["range"] = cell_range
3179
3180 # Strip the leading = from formulas.
3181 try:
3182 options["min_value"] = options["min_value"].lstrip("=")
3183 except (KeyError, AttributeError):
3184 pass
3185 try:
3186 options["mid_value"] = options["mid_value"].lstrip("=")
3187 except (KeyError, AttributeError):
3188 pass
3189 try:
3190 options["max_value"] = options["max_value"].lstrip("=")
3191 except (KeyError, AttributeError):
3192 pass
3193
3194 # Store the conditional format until we close the worksheet.
3195 if cell_range in self.cond_formats:
3196 self.cond_formats[cell_range].append(options)
3197 else:
3198 self.cond_formats[cell_range] = [options]
3199
3200 return 0
3201
3202 @convert_range_args
3203 def add_table(self, first_row, first_col, last_row, last_col, options=None):
3204 """
3205 Add an Excel table to a worksheet.
3206
3207 Args:
3208 first_row: The first row of the cell range. (zero indexed).
3209 first_col: The first column of the cell range.
3210 last_row: The last row of the cell range. (zero indexed).
3211 last_col: The last column of the cell range.
3212 options: Table format options. (Optional)
3213
3214 Returns:
3215 0: Success.
3216 -1: Row or column is out of worksheet bounds.
3217 -2: Incorrect parameter or option.
3218 -3: Not supported in constant_memory mode.
3219 """
3220 table = {}
3221 col_formats = {}
3222
3223 if options is None:
3224 options = {}
3225 else:
3226 # Copy the user defined options so they aren't modified.
3227 options = options.copy()
3228
3229 if self.constant_memory:
3230 warn("add_table() isn't supported in 'constant_memory' mode")
3231 return -3
3232
3233 # Check that row and col are valid without storing the values.
3234 if self._check_dimensions(first_row, first_col, True, True):
3235 return -1
3236 if self._check_dimensions(last_row, last_col, True, True):
3237 return -1
3238
3239 # Swap last row/col for first row/col as necessary.
3240 if first_row > last_row:
3241 (first_row, last_row) = (last_row, first_row)
3242 if first_col > last_col:
3243 (first_col, last_col) = (last_col, first_col)
3244
3245 # Check if the table range overlaps a previous merged or table range.
3246 # This is a critical file corruption error in Excel.
3247 cell_range = xl_range(first_row, first_col, last_row, last_col)
3248 for row in range(first_row, last_row + 1):
3249 for col in range(first_col, last_col + 1):
3250 if self.table_cells.get((row, col)):
3251 previous_range = self.table_cells.get((row, col))
3252 raise OverlappingRange(
3253 f"Table range '{cell_range}' overlaps previous "
3254 f"table range '{previous_range}'."
3255 )
3256
3257 if self.merged_cells.get((row, col)):
3258 previous_range = self.merged_cells.get((row, col))
3259 raise OverlappingRange(
3260 f"Table range '{cell_range}' overlaps previous "
3261 f"merge range '{previous_range}'."
3262 )
3263
3264 self.table_cells[(row, col)] = cell_range
3265
3266 # Valid input parameters.
3267 valid_parameter = {
3268 "autofilter",
3269 "banded_columns",
3270 "banded_rows",
3271 "columns",
3272 "data",
3273 "first_column",
3274 "header_row",
3275 "last_column",
3276 "name",
3277 "style",
3278 "total_row",
3279 }
3280
3281 # Check for valid input parameters.
3282 for param_key in options.keys():
3283 if param_key not in valid_parameter:
3284 warn(f"Unknown parameter '{param_key}' in add_table()")
3285 return -2
3286
3287 # Turn on Excel's defaults.
3288 options["banded_rows"] = options.get("banded_rows", True)
3289 options["header_row"] = options.get("header_row", True)
3290 options["autofilter"] = options.get("autofilter", True)
3291
3292 # Check that there are enough rows.
3293 num_rows = last_row - first_row
3294 if options["header_row"]:
3295 num_rows -= 1
3296
3297 if num_rows < 0:
3298 warn("Must have at least one data row in in add_table()")
3299 return -2
3300
3301 # Set the table options.
3302 table["show_first_col"] = options.get("first_column", False)
3303 table["show_last_col"] = options.get("last_column", False)
3304 table["show_row_stripes"] = options.get("banded_rows", False)
3305 table["show_col_stripes"] = options.get("banded_columns", False)
3306 table["header_row_count"] = options.get("header_row", 0)
3307 table["totals_row_shown"] = options.get("total_row", False)
3308
3309 # Set the table name.
3310 if "name" in options:
3311 name = options["name"]
3312 table["name"] = name
3313
3314 if " " in name:
3315 warn(f"Name '{name}' in add_table() cannot contain spaces")
3316 return -2
3317
3318 # Warn if the name contains invalid chars as defined by Excel.
3319 if not re.match(r"^[\w\\][\w\\.]*$", name, re.UNICODE) or re.match(
3320 r"^\d", name
3321 ):
3322 warn(f"Invalid Excel characters in add_table(): '{name}'")
3323 return -2
3324
3325 # Warn if the name looks like a cell name.
3326 if re.match(r"^[a-zA-Z][a-zA-Z]?[a-dA-D]?\d+$", name):
3327 warn(f"Name looks like a cell name in add_table(): '{name}'")
3328 return -2
3329
3330 # Warn if the name looks like a R1C1 cell reference.
3331 if re.match(r"^[rcRC]$", name) or re.match(r"^[rcRC]\d+[rcRC]\d+$", name):
3332 warn(f"Invalid name '{name}' like a RC cell ref in add_table()")
3333 return -2
3334
3335 # Set the table style.
3336 if "style" in options:
3337 table["style"] = options["style"]
3338
3339 if table["style"] is None:
3340 table["style"] = ""
3341
3342 # Remove whitespace from style name.
3343 table["style"] = table["style"].replace(" ", "")
3344 else:
3345 table["style"] = "TableStyleMedium9"
3346
3347 # Set the data range rows (without the header and footer).
3348 first_data_row = first_row
3349 last_data_row = last_row
3350
3351 if options.get("header_row"):
3352 first_data_row += 1
3353
3354 if options.get("total_row"):
3355 last_data_row -= 1
3356
3357 # Set the table and autofilter ranges.
3358 table["range"] = xl_range(first_row, first_col, last_row, last_col)
3359
3360 table["a_range"] = xl_range(first_row, first_col, last_data_row, last_col)
3361
3362 # If the header row if off the default is to turn autofilter off.
3363 if not options["header_row"]:
3364 options["autofilter"] = 0
3365
3366 # Set the autofilter range.
3367 if options["autofilter"]:
3368 table["autofilter"] = table["a_range"]
3369
3370 # Add the table columns.
3371 col_id = 1
3372 table["columns"] = []
3373 seen_names = {}
3374
3375 for col_num in range(first_col, last_col + 1):
3376 # Set up the default column data.
3377 col_data = {
3378 "id": col_id,
3379 "name": "Column" + str(col_id),
3380 "total_string": "",
3381 "total_function": "",
3382 "custom_total": "",
3383 "total_value": 0,
3384 "formula": "",
3385 "format": None,
3386 "name_format": None,
3387 }
3388
3389 # Overwrite the defaults with any user defined values.
3390 if "columns" in options:
3391 # Check if there are user defined values for this column.
3392 if col_id <= len(options["columns"]):
3393 user_data = options["columns"][col_id - 1]
3394 else:
3395 user_data = None
3396
3397 if user_data:
3398 # Get the column format.
3399 xformat = user_data.get("format", None)
3400
3401 # Map user defined values to internal values.
3402 if user_data.get("header"):
3403 col_data["name"] = user_data["header"]
3404
3405 # Excel requires unique case insensitive header names.
3406 header_name = col_data["name"]
3407 name = header_name.lower()
3408 if name in seen_names:
3409 warn(f"Duplicate header name in add_table(): '{name}'")
3410 return -2
3411
3412 seen_names[name] = True
3413
3414 col_data["name_format"] = user_data.get("header_format")
3415
3416 # Handle the column formula.
3417 if "formula" in user_data and user_data["formula"]:
3418 formula = user_data["formula"]
3419
3420 # Remove the formula '=' sign if it exists.
3421 if formula.startswith("="):
3422 formula = formula.lstrip("=")
3423
3424 # Convert Excel 2010 "@" ref to 2007 "#This Row".
3425 formula = self._prepare_table_formula(formula)
3426
3427 # Escape any future functions.
3428 formula = self._prepare_formula(formula, True)
3429
3430 col_data["formula"] = formula
3431 # We write the formulas below after the table data.
3432
3433 # Handle the function for the total row.
3434 if user_data.get("total_function"):
3435 function = user_data["total_function"]
3436 if function == "count_nums":
3437 function = "countNums"
3438 if function == "std_dev":
3439 function = "stdDev"
3440
3441 subtotals = set(
3442 [
3443 "average",
3444 "countNums",
3445 "count",
3446 "max",
3447 "min",
3448 "stdDev",
3449 "sum",
3450 "var",
3451 ]
3452 )
3453
3454 if function in subtotals:
3455 formula = self._table_function_to_formula(
3456 function, col_data["name"]
3457 )
3458 else:
3459 formula = self._prepare_formula(function, True)
3460 col_data["custom_total"] = formula
3461 function = "custom"
3462
3463 col_data["total_function"] = function
3464
3465 value = user_data.get("total_value", 0)
3466
3467 self._write_formula(last_row, col_num, formula, xformat, value)
3468
3469 elif user_data.get("total_string"):
3470 # Total label only (not a function).
3471 total_string = user_data["total_string"]
3472 col_data["total_string"] = total_string
3473
3474 self._write_string(
3475 last_row, col_num, total_string, user_data.get("format")
3476 )
3477
3478 # Get the dxf format index.
3479 if xformat is not None:
3480 col_data["format"] = xformat._get_dxf_index()
3481
3482 # Store the column format for writing the cell data.
3483 # It doesn't matter if it is undefined.
3484 col_formats[col_id - 1] = xformat
3485
3486 # Store the column data.
3487 table["columns"].append(col_data)
3488
3489 # Write the column headers to the worksheet.
3490 if options["header_row"]:
3491 self._write_string(
3492 first_row, col_num, col_data["name"], col_data["name_format"]
3493 )
3494
3495 col_id += 1
3496
3497 # Write the cell data if supplied.
3498 if "data" in options:
3499 data = options["data"]
3500
3501 i = 0 # For indexing the row data.
3502 for row in range(first_data_row, last_data_row + 1):
3503 j = 0 # For indexing the col data.
3504 for col in range(first_col, last_col + 1):
3505 if i < len(data) and j < len(data[i]):
3506 token = data[i][j]
3507 if j in col_formats:
3508 self._write(row, col, token, col_formats[j])
3509 else:
3510 self._write(row, col, token, None)
3511 j += 1
3512 i += 1
3513
3514 # Write any columns formulas after the user supplied table data to
3515 # overwrite it if required.
3516 for col_id, col_num in enumerate(range(first_col, last_col + 1)):
3517 column_data = table["columns"][col_id]
3518 if column_data and column_data["formula"]:
3519 formula_format = col_formats.get(col_id)
3520 formula = column_data["formula"]
3521
3522 for row in range(first_data_row, last_data_row + 1):
3523 self._write_formula(row, col_num, formula, formula_format)
3524
3525 # Store the table data.
3526 self.tables.append(table)
3527
3528 # Store the filter cell positions for use in the autofit calculation.
3529 if options["autofilter"]:
3530 for col in range(first_col, last_col + 1):
3531 # Check that the table autofilter doesn't overlap a worksheet filter.
3532 if self.filter_cells.get((first_row, col)):
3533 filter_type, filter_range = self.filter_cells.get((first_row, col))
3534 if filter_type == "worksheet":
3535 raise OverlappingRange(
3536 f"Table autofilter range '{cell_range}' overlaps previous "
3537 f"Worksheet autofilter range '{filter_range}'."
3538 )
3539
3540 self.filter_cells[(first_row, col)] = ("table", cell_range)
3541
3542 return 0
3543
3544 @convert_cell_args
3545 def add_sparkline(self, row, col, options=None):
3546 """
3547 Add sparklines to the worksheet.
3548
3549 Args:
3550 row: The cell row (zero indexed).
3551 col: The cell column (zero indexed).
3552 options: Sparkline formatting options.
3553
3554 Returns:
3555 0: Success.
3556 -1: Row or column is out of worksheet bounds.
3557 -2: Incorrect parameter or option.
3558
3559 """
3560
3561 # Check that row and col are valid without storing the values.
3562 if self._check_dimensions(row, col, True, True):
3563 return -1
3564
3565 sparkline = {"locations": [xl_rowcol_to_cell(row, col)]}
3566
3567 if options is None:
3568 options = {}
3569
3570 # Valid input parameters.
3571 valid_parameters = {
3572 "location",
3573 "range",
3574 "type",
3575 "high_point",
3576 "low_point",
3577 "negative_points",
3578 "first_point",
3579 "last_point",
3580 "markers",
3581 "style",
3582 "series_color",
3583 "negative_color",
3584 "markers_color",
3585 "first_color",
3586 "last_color",
3587 "high_color",
3588 "low_color",
3589 "max",
3590 "min",
3591 "axis",
3592 "reverse",
3593 "empty_cells",
3594 "show_hidden",
3595 "plot_hidden",
3596 "date_axis",
3597 "weight",
3598 }
3599
3600 # Check for valid input parameters.
3601 for param_key in options.keys():
3602 if param_key not in valid_parameters:
3603 warn(f"Unknown parameter '{param_key}' in add_sparkline()")
3604 return -1
3605
3606 # 'range' is a required parameter.
3607 if "range" not in options:
3608 warn("Parameter 'range' is required in add_sparkline()")
3609 return -2
3610
3611 # Handle the sparkline type.
3612 spark_type = options.get("type", "line")
3613
3614 if spark_type not in ("line", "column", "win_loss"):
3615 warn(
3616 "Parameter 'type' must be 'line', 'column' "
3617 "or 'win_loss' in add_sparkline()"
3618 )
3619 return -2
3620
3621 if spark_type == "win_loss":
3622 spark_type = "stacked"
3623 sparkline["type"] = spark_type
3624
3625 # We handle single location/range values or list of values.
3626 if "location" in options:
3627 if isinstance(options["location"], list):
3628 sparkline["locations"] = options["location"]
3629 else:
3630 sparkline["locations"] = [options["location"]]
3631
3632 if isinstance(options["range"], list):
3633 sparkline["ranges"] = options["range"]
3634 else:
3635 sparkline["ranges"] = [options["range"]]
3636
3637 range_count = len(sparkline["ranges"])
3638 location_count = len(sparkline["locations"])
3639
3640 # The ranges and locations must match.
3641 if range_count != location_count:
3642 warn(
3643 "Must have the same number of location and range "
3644 "parameters in add_sparkline()"
3645 )
3646 return -2
3647
3648 # Store the count.
3649 sparkline["count"] = len(sparkline["locations"])
3650
3651 # Get the worksheet name for the range conversion below.
3652 sheetname = quote_sheetname(self.name)
3653
3654 # Cleanup the input ranges.
3655 new_ranges = []
3656 for spark_range in sparkline["ranges"]:
3657 # Remove the absolute reference $ symbols.
3658 spark_range = spark_range.replace("$", "")
3659
3660 # Remove the = from formula.
3661 spark_range = spark_range.lstrip("=")
3662
3663 # Convert a simple range into a full Sheet1!A1:D1 range.
3664 if "!" not in spark_range:
3665 spark_range = sheetname + "!" + spark_range
3666
3667 new_ranges.append(spark_range)
3668
3669 sparkline["ranges"] = new_ranges
3670
3671 # Cleanup the input locations.
3672 new_locations = []
3673 for location in sparkline["locations"]:
3674 location = location.replace("$", "")
3675 new_locations.append(location)
3676
3677 sparkline["locations"] = new_locations
3678
3679 # Map options.
3680 sparkline["high"] = options.get("high_point")
3681 sparkline["low"] = options.get("low_point")
3682 sparkline["negative"] = options.get("negative_points")
3683 sparkline["first"] = options.get("first_point")
3684 sparkline["last"] = options.get("last_point")
3685 sparkline["markers"] = options.get("markers")
3686 sparkline["min"] = options.get("min")
3687 sparkline["max"] = options.get("max")
3688 sparkline["axis"] = options.get("axis")
3689 sparkline["reverse"] = options.get("reverse")
3690 sparkline["hidden"] = options.get("show_hidden")
3691 sparkline["weight"] = options.get("weight")
3692
3693 # Map empty cells options.
3694 empty = options.get("empty_cells", "")
3695
3696 if empty == "zero":
3697 sparkline["empty"] = 0
3698 elif empty == "connect":
3699 sparkline["empty"] = "span"
3700 else:
3701 sparkline["empty"] = "gap"
3702
3703 # Map the date axis range.
3704 date_range = options.get("date_axis")
3705
3706 if date_range and "!" not in date_range:
3707 date_range = sheetname + "!" + date_range
3708
3709 sparkline["date_axis"] = date_range
3710
3711 # Set the sparkline styles.
3712 style_id = options.get("style", 0)
3713 style = _get_sparkline_style(style_id)
3714
3715 sparkline["series_color"] = style["series"]
3716 sparkline["negative_color"] = style["negative"]
3717 sparkline["markers_color"] = style["markers"]
3718 sparkline["first_color"] = style["first"]
3719 sparkline["last_color"] = style["last"]
3720 sparkline["high_color"] = style["high"]
3721 sparkline["low_color"] = style["low"]
3722
3723 # Override the style colors with user defined colors.
3724 self._set_spark_color(sparkline, options, "series_color")
3725 self._set_spark_color(sparkline, options, "negative_color")
3726 self._set_spark_color(sparkline, options, "markers_color")
3727 self._set_spark_color(sparkline, options, "first_color")
3728 self._set_spark_color(sparkline, options, "last_color")
3729 self._set_spark_color(sparkline, options, "high_color")
3730 self._set_spark_color(sparkline, options, "low_color")
3731
3732 self.sparklines.append(sparkline)
3733
3734 return 0
3735
3736 @convert_range_args
3737 def set_selection(self, first_row, first_col, last_row, last_col):
3738 """
3739 Set the selected cell or cells in a worksheet
3740
3741 Args:
3742 first_row: The first row of the cell range. (zero indexed).
3743 first_col: The first column of the cell range.
3744 last_row: The last row of the cell range. (zero indexed).
3745 last_col: The last column of the cell range.
3746
3747 Returns:
3748 0: Nothing.
3749 """
3750 pane = None
3751
3752 # Range selection. Do this before swapping max/min to allow the
3753 # selection direction to be reversed.
3754 active_cell = xl_rowcol_to_cell(first_row, first_col)
3755
3756 # Swap last row/col for first row/col if necessary
3757 if first_row > last_row:
3758 (first_row, last_row) = (last_row, first_row)
3759
3760 if first_col > last_col:
3761 (first_col, last_col) = (last_col, first_col)
3762
3763 sqref = xl_range(first_row, first_col, last_row, last_col)
3764
3765 # Selection isn't set for cell A1.
3766 if sqref == "A1":
3767 return
3768
3769 self.selections = [[pane, active_cell, sqref]]
3770
3771 @convert_cell_args
3772 def set_top_left_cell(self, row=0, col=0):
3773 """
3774 Set the first visible cell at the top left of a worksheet.
3775
3776 Args:
3777 row: The cell row (zero indexed).
3778 col: The cell column (zero indexed).
3779
3780 Returns:
3781 0: Nothing.
3782 """
3783
3784 if row == 0 and col == 0:
3785 return
3786
3787 self.top_left_cell = xl_rowcol_to_cell(row, col)
3788
3789 def outline_settings(
3790 self, visible=1, symbols_below=1, symbols_right=1, auto_style=0
3791 ):
3792 """
3793 Control outline settings.
3794
3795 Args:
3796 visible: Outlines are visible. Optional, defaults to True.
3797 symbols_below: Show row outline symbols below the outline bar.
3798 Optional, defaults to True.
3799 symbols_right: Show column outline symbols to the right of the
3800 outline bar. Optional, defaults to True.
3801 auto_style: Use Automatic style. Optional, defaults to False.
3802
3803 Returns:
3804 0: Nothing.
3805 """
3806 self.outline_on = visible
3807 self.outline_below = symbols_below
3808 self.outline_right = symbols_right
3809 self.outline_style = auto_style
3810
3811 self.outline_changed = True
3812
3813 @convert_cell_args
3814 def freeze_panes(self, row, col, top_row=None, left_col=None, pane_type=0):
3815 """
3816 Create worksheet panes and mark them as frozen.
3817
3818 Args:
3819 row: The cell row (zero indexed).
3820 col: The cell column (zero indexed).
3821 top_row: Topmost visible row in scrolling region of pane.
3822 left_col: Leftmost visible row in scrolling region of pane.
3823
3824 Returns:
3825 0: Nothing.
3826
3827 """
3828 if top_row is None:
3829 top_row = row
3830
3831 if left_col is None:
3832 left_col = col
3833
3834 self.panes = [row, col, top_row, left_col, pane_type]
3835
3836 @convert_cell_args
3837 def split_panes(self, x, y, top_row=None, left_col=None):
3838 """
3839 Create worksheet panes and mark them as split.
3840
3841 Args:
3842 x: The position for the vertical split.
3843 y: The position for the horizontal split.
3844 top_row: Topmost visible row in scrolling region of pane.
3845 left_col: Leftmost visible row in scrolling region of pane.
3846
3847 Returns:
3848 0: Nothing.
3849
3850 """
3851 # Same as freeze panes with a different pane type.
3852 self.freeze_panes(x, y, top_row, left_col, 2)
3853
3854 def set_zoom(self, zoom=100):
3855 """
3856 Set the worksheet zoom factor.
3857
3858 Args:
3859 zoom: Scale factor: 10 <= zoom <= 400.
3860
3861 Returns:
3862 Nothing.
3863
3864 """
3865 # Ensure the zoom scale is in Excel's range.
3866 if zoom < 10 or zoom > 400:
3867 warn(f"Zoom factor '{zoom}' outside range: 10 <= zoom <= 400")
3868 zoom = 100
3869
3870 self.zoom = int(zoom)
3871
3872 def right_to_left(self):
3873 """
3874 Display the worksheet right to left for some versions of Excel.
3875
3876 Args:
3877 None.
3878
3879 Returns:
3880 Nothing.
3881
3882 """
3883 self.is_right_to_left = True
3884
3885 def hide_zero(self):
3886 """
3887 Hide zero values in worksheet cells.
3888
3889 Args:
3890 None.
3891
3892 Returns:
3893 Nothing.
3894
3895 """
3896 self.show_zeros = 0
3897
3898 def set_tab_color(self, color):
3899 """
3900 Set the color of the worksheet tab.
3901
3902 Args:
3903 color: A #RGB color index.
3904
3905 Returns:
3906 Nothing.
3907
3908 """
3909 self.tab_color = Color._from_value(color)
3910
3911 def protect(self, password="", options=None):
3912 """
3913 Set the password and protection options of the worksheet.
3914
3915 Args:
3916 password: An optional password string.
3917 options: A dictionary of worksheet objects to protect.
3918
3919 Returns:
3920 Nothing.
3921
3922 """
3923 if password != "":
3924 password = self._encode_password(password)
3925
3926 if not options:
3927 options = {}
3928
3929 # Default values for objects that can be protected.
3930 defaults = {
3931 "sheet": True,
3932 "content": False,
3933 "objects": False,
3934 "scenarios": False,
3935 "format_cells": False,
3936 "format_columns": False,
3937 "format_rows": False,
3938 "insert_columns": False,
3939 "insert_rows": False,
3940 "insert_hyperlinks": False,
3941 "delete_columns": False,
3942 "delete_rows": False,
3943 "select_locked_cells": True,
3944 "sort": False,
3945 "autofilter": False,
3946 "pivot_tables": False,
3947 "select_unlocked_cells": True,
3948 }
3949
3950 # Overwrite the defaults with user specified values.
3951 for key in options.keys():
3952 if key in defaults:
3953 defaults[key] = options[key]
3954 else:
3955 warn(f"Unknown protection object: '{key}'")
3956
3957 # Set the password after the user defined values.
3958 defaults["password"] = password
3959
3960 self.protect_options = defaults
3961
3962 def unprotect_range(self, cell_range, range_name=None, password=None):
3963 """
3964 Unprotect ranges within a protected worksheet.
3965
3966 Args:
3967 cell_range: The cell or cell range to unprotect.
3968 range_name: An optional name for the range.
3969 password: An optional password string. (undocumented)
3970
3971 Returns:
3972 0: Success.
3973 -1: Parameter error.
3974
3975 """
3976 if cell_range is None:
3977 warn("Cell range must be specified in unprotect_range()")
3978 return -1
3979
3980 # Sanitize the cell range.
3981 cell_range = cell_range.lstrip("=")
3982 cell_range = cell_range.replace("$", "")
3983
3984 self.num_protected_ranges += 1
3985
3986 if range_name is None:
3987 range_name = "Range" + str(self.num_protected_ranges)
3988
3989 if password:
3990 password = self._encode_password(password)
3991
3992 self.protected_ranges.append((cell_range, range_name, password))
3993
3994 return 0
3995
3996 @convert_cell_args
3997 def insert_button(self, row, col, options=None):
3998 """
3999 Insert a button form object into the worksheet.
4000
4001 Args:
4002 row: The cell row (zero indexed).
4003 col: The cell column (zero indexed).
4004 options: Button formatting options.
4005
4006 Returns:
4007 0: Success.
4008 -1: Row or column is out of worksheet bounds.
4009
4010 """
4011 # Check insert (row, col) without storing.
4012 if self._check_dimensions(row, col, True, True):
4013 warn(f"Cannot insert button at ({row}, {col}).")
4014 return -1
4015
4016 if options is None:
4017 options = {}
4018
4019 # Create a new button object.
4020 height = self.default_row_pixels
4021 width = self.default_col_pixels
4022 button_number = 1 + len(self.buttons_list)
4023
4024 button = ButtonType(row, col, height, width, button_number, options)
4025
4026 self.buttons_list.append(button)
4027
4028 self.has_vml = True
4029
4030 return 0
4031
4032 @convert_cell_args
4033 def insert_checkbox(self, row, col, boolean, cell_format=None):
4034 """
4035 Insert a boolean checkbox in a worksheet cell.
4036
4037 Args:
4038 row: The cell row (zero indexed).
4039 col: The cell column (zero indexed).
4040 boolean: The boolean value to display as a checkbox.
4041 cell_format: Cell Format object. (optional)
4042
4043 Returns:
4044 0: Success.
4045 -1: Row or column is out of worksheet bounds.
4046
4047 """
4048 # Ensure that the checkbox property is set in the user defined format.
4049 if cell_format and not cell_format.checkbox:
4050 # This needs to be fixed with a clone.
4051 cell_format.set_checkbox()
4052
4053 # If no format is supplied create and/or use the default checkbox format.
4054 if not cell_format:
4055 if not self.default_checkbox_format:
4056 self.default_checkbox_format = self.workbook_add_format()
4057 self.default_checkbox_format.set_checkbox()
4058
4059 cell_format = self.default_checkbox_format
4060
4061 return self._write_boolean(row, col, boolean, cell_format)
4062
4063 ###########################################################################
4064 #
4065 # Public API. Page Setup methods.
4066 #
4067 ###########################################################################
4068 def set_landscape(self):
4069 """
4070 Set the page orientation as landscape.
4071
4072 Args:
4073 None.
4074
4075 Returns:
4076 Nothing.
4077
4078 """
4079 self.orientation = 0
4080 self.page_setup_changed = True
4081
4082 def set_portrait(self):
4083 """
4084 Set the page orientation as portrait.
4085
4086 Args:
4087 None.
4088
4089 Returns:
4090 Nothing.
4091
4092 """
4093 self.orientation = 1
4094 self.page_setup_changed = True
4095
4096 def set_page_view(self, view=1):
4097 """
4098 Set the page view mode.
4099
4100 Args:
4101 0: Normal view mode
4102 1: Page view mode (the default)
4103 2: Page break view mode
4104
4105 Returns:
4106 Nothing.
4107
4108 """
4109 self.page_view = view
4110
4111 def set_pagebreak_view(self):
4112 """
4113 Set the page view mode.
4114
4115 Args:
4116 None.
4117
4118 Returns:
4119 Nothing.
4120
4121 """
4122 self.page_view = 2
4123
4124 def set_paper(self, paper_size):
4125 """
4126 Set the paper type. US Letter = 1, A4 = 9.
4127
4128 Args:
4129 paper_size: Paper index.
4130
4131 Returns:
4132 Nothing.
4133
4134 """
4135 if paper_size:
4136 self.paper_size = paper_size
4137 self.page_setup_changed = True
4138
4139 def center_horizontally(self):
4140 """
4141 Center the page horizontally.
4142
4143 Args:
4144 None.
4145
4146 Returns:
4147 Nothing.
4148
4149 """
4150 self.print_options_changed = True
4151 self.hcenter = 1
4152
4153 def center_vertically(self):
4154 """
4155 Center the page vertically.
4156
4157 Args:
4158 None.
4159
4160 Returns:
4161 Nothing.
4162
4163 """
4164 self.print_options_changed = True
4165 self.vcenter = 1
4166
4167 def set_margins(self, left=0.7, right=0.7, top=0.75, bottom=0.75):
4168 """
4169 Set all the page margins in inches.
4170
4171 Args:
4172 left: Left margin.
4173 right: Right margin.
4174 top: Top margin.
4175 bottom: Bottom margin.
4176
4177 Returns:
4178 Nothing.
4179
4180 """
4181 self.margin_left = left
4182 self.margin_right = right
4183 self.margin_top = top
4184 self.margin_bottom = bottom
4185
4186 def set_header(self, header="", options=None, margin=None):
4187 """
4188 Set the page header caption and optional margin.
4189
4190 Args:
4191 header: Header string.
4192 margin: Header margin.
4193 options: Header options, mainly for images.
4194
4195 Returns:
4196 Nothing.
4197
4198 """
4199 header_orig = header
4200 header = header.replace("&[Picture]", "&G")
4201
4202 if len(header) > 255:
4203 warn("Header string cannot be longer than Excel's limit of 255 characters")
4204 return
4205
4206 if options is not None:
4207 # For backward compatibility allow options to be the margin.
4208 if not isinstance(options, dict):
4209 options = {"margin": options}
4210 else:
4211 options = {}
4212
4213 # Copy the user defined options so they aren't modified.
4214 options = options.copy()
4215
4216 # For backward compatibility.
4217 if margin is not None:
4218 options["margin"] = margin
4219
4220 # Reset the list in case the function is called more than once.
4221 self.header_images = []
4222
4223 if options.get("image_left"):
4224 options["image_data"] = options.get("image_data_left")
4225 image = self._image_from_source(options.get("image_left"), options)
4226 image._header_position = "LH"
4227 self.header_images.append(image)
4228
4229 if options.get("image_center"):
4230 options["image_data"] = options.get("image_data_center")
4231 image = self._image_from_source(options.get("image_center"), options)
4232 image._header_position = "CH"
4233 self.header_images.append(image)
4234
4235 if options.get("image_right"):
4236 options["image_data"] = options.get("image_data_right")
4237 image = self._image_from_source(options.get("image_right"), options)
4238 image._header_position = "RH"
4239 self.header_images.append(image)
4240
4241 placeholder_count = header.count("&G")
4242 image_count = len(self.header_images)
4243
4244 if placeholder_count != image_count:
4245 warn(
4246 f"Number of footer images '{image_count}' doesn't match placeholder "
4247 f"count '{placeholder_count}' in string: {header_orig}"
4248 )
4249 self.header_images = []
4250 return
4251
4252 if "align_with_margins" in options:
4253 self.header_footer_aligns = options["align_with_margins"]
4254
4255 if "scale_with_doc" in options:
4256 self.header_footer_scales = options["scale_with_doc"]
4257
4258 self.header = header
4259 self.margin_header = options.get("margin", 0.3)
4260 self.header_footer_changed = True
4261
4262 if image_count:
4263 self.has_header_vml = True
4264
4265 def set_footer(self, footer="", options=None, margin=None):
4266 """
4267 Set the page footer caption and optional margin.
4268
4269 Args:
4270 footer: Footer string.
4271 margin: Footer margin.
4272 options: Footer options, mainly for images.
4273
4274 Returns:
4275 Nothing.
4276
4277 """
4278 footer_orig = footer
4279 footer = footer.replace("&[Picture]", "&G")
4280
4281 if len(footer) > 255:
4282 warn("Footer string cannot be longer than Excel's limit of 255 characters")
4283 return
4284
4285 if options is not None:
4286 # For backward compatibility allow options to be the margin.
4287 if not isinstance(options, dict):
4288 options = {"margin": options}
4289 else:
4290 options = {}
4291
4292 # Copy the user defined options so they aren't modified.
4293 options = options.copy()
4294
4295 # For backward compatibility.
4296 if margin is not None:
4297 options["margin"] = margin
4298
4299 # Reset the list in case the function is called more than once.
4300 self.footer_images = []
4301
4302 if options.get("image_left"):
4303 options["image_data"] = options.get("image_data_left")
4304 image = self._image_from_source(options.get("image_left"), options)
4305 image._header_position = "LF"
4306 self.footer_images.append(image)
4307
4308 if options.get("image_center"):
4309 options["image_data"] = options.get("image_data_center")
4310 image = self._image_from_source(options.get("image_center"), options)
4311 image._header_position = "CF"
4312 self.footer_images.append(image)
4313
4314 if options.get("image_right"):
4315 options["image_data"] = options.get("image_data_right")
4316 image = self._image_from_source(options.get("image_right"), options)
4317 image._header_position = "RF"
4318 self.footer_images.append(image)
4319
4320 placeholder_count = footer.count("&G")
4321 image_count = len(self.footer_images)
4322
4323 if placeholder_count != image_count:
4324 warn(
4325 f"Number of footer images '{image_count}' doesn't match placeholder "
4326 f"count '{placeholder_count}' in string: {footer_orig}"
4327 )
4328 self.footer_images = []
4329 return
4330
4331 if "align_with_margins" in options:
4332 self.header_footer_aligns = options["align_with_margins"]
4333
4334 if "scale_with_doc" in options:
4335 self.header_footer_scales = options["scale_with_doc"]
4336
4337 self.footer = footer
4338 self.margin_footer = options.get("margin", 0.3)
4339 self.header_footer_changed = True
4340
4341 if image_count:
4342 self.has_header_vml = True
4343
4344 def repeat_rows(self, first_row, last_row=None):
4345 """
4346 Set the rows to repeat at the top of each printed page.
4347
4348 Args:
4349 first_row: Start row for range.
4350 last_row: End row for range.
4351
4352 Returns:
4353 Nothing.
4354
4355 """
4356 if last_row is None:
4357 last_row = first_row
4358
4359 # Convert rows to 1 based.
4360 first_row += 1
4361 last_row += 1
4362
4363 # Create the row range area like: $1:$2.
4364 area = f"${first_row}:${last_row}"
4365
4366 # Build up the print titles area "Sheet1!$1:$2"
4367 sheetname = quote_sheetname(self.name)
4368 self.repeat_row_range = sheetname + "!" + area
4369
4370 @convert_column_args
4371 def repeat_columns(self, first_col, last_col=None):
4372 """
4373 Set the columns to repeat at the left hand side of each printed page.
4374
4375 Args:
4376 first_col: Start column for range.
4377 last_col: End column for range.
4378
4379 Returns:
4380 Nothing.
4381
4382 """
4383 if last_col is None:
4384 last_col = first_col
4385
4386 # Convert to A notation.
4387 first_col = xl_col_to_name(first_col, 1)
4388 last_col = xl_col_to_name(last_col, 1)
4389
4390 # Create a column range like $C:$D.
4391 area = first_col + ":" + last_col
4392
4393 # Build up the print area range "=Sheet2!$C:$D"
4394 sheetname = quote_sheetname(self.name)
4395 self.repeat_col_range = sheetname + "!" + area
4396
4397 def hide_gridlines(self, option=1):
4398 """
4399 Set the option to hide gridlines on the screen and the printed page.
4400
4401 Args:
4402 option: 0 : Don't hide gridlines
4403 1 : Hide printed gridlines only
4404 2 : Hide screen and printed gridlines
4405
4406 Returns:
4407 Nothing.
4408
4409 """
4410 if option == 0:
4411 self.print_gridlines = 1
4412 self.screen_gridlines = 1
4413 self.print_options_changed = True
4414 elif option == 1:
4415 self.print_gridlines = 0
4416 self.screen_gridlines = 1
4417 else:
4418 self.print_gridlines = 0
4419 self.screen_gridlines = 0
4420
4421 def print_row_col_headers(self):
4422 """
4423 Set the option to print the row and column headers on the printed page.
4424
4425 Args:
4426 None.
4427
4428 Returns:
4429 Nothing.
4430
4431 """
4432 self.print_headers = True
4433 self.print_options_changed = True
4434
4435 def hide_row_col_headers(self):
4436 """
4437 Set the option to hide the row and column headers on the worksheet.
4438
4439 Args:
4440 None.
4441
4442 Returns:
4443 Nothing.
4444
4445 """
4446 self.row_col_headers = True
4447
4448 @convert_range_args
4449 def print_area(self, first_row, first_col, last_row, last_col):
4450 """
4451 Set the print area in the current worksheet.
4452
4453 Args:
4454 first_row: The first row of the cell range. (zero indexed).
4455 first_col: The first column of the cell range.
4456 last_row: The last row of the cell range. (zero indexed).
4457 last_col: The last column of the cell range.
4458
4459 Returns:
4460 0: Success.
4461 -1: Row or column is out of worksheet bounds.
4462
4463 """
4464 # Set the print area in the current worksheet.
4465
4466 # Ignore max print area since it is the same as no area for Excel.
4467 if (
4468 first_row == 0
4469 and first_col == 0
4470 and last_row == self.xls_rowmax - 1
4471 and last_col == self.xls_colmax - 1
4472 ):
4473 return -1
4474
4475 # Build up the print area range "Sheet1!$A$1:$C$13".
4476 area = self._convert_name_area(first_row, first_col, last_row, last_col)
4477 self.print_area_range = area
4478
4479 return 0
4480
4481 def print_across(self):
4482 """
4483 Set the order in which pages are printed.
4484
4485 Args:
4486 None.
4487
4488 Returns:
4489 Nothing.
4490
4491 """
4492 self.page_order = 1
4493 self.page_setup_changed = True
4494
4495 def fit_to_pages(self, width, height):
4496 """
4497 Fit the printed area to a specific number of pages both vertically and
4498 horizontally.
4499
4500 Args:
4501 width: Number of pages horizontally.
4502 height: Number of pages vertically.
4503
4504 Returns:
4505 Nothing.
4506
4507 """
4508 self.fit_page = 1
4509 self.fit_width = width
4510 self.fit_height = height
4511 self.page_setup_changed = True
4512
4513 def set_start_page(self, start_page):
4514 """
4515 Set the start page number when printing.
4516
4517 Args:
4518 start_page: Start page number.
4519
4520 Returns:
4521 Nothing.
4522
4523 """
4524 self.page_start = start_page
4525
4526 def set_print_scale(self, scale):
4527 """
4528 Set the scale factor for the printed page.
4529
4530 Args:
4531 scale: Print scale. 10 <= scale <= 400.
4532
4533 Returns:
4534 Nothing.
4535
4536 """
4537 # Confine the scale to Excel's range.
4538 if scale < 10 or scale > 400:
4539 warn(f"Print scale '{scale}' outside range: 10 <= scale <= 400")
4540 return
4541
4542 # Turn off "fit to page" option when print scale is on.
4543 self.fit_page = 0
4544
4545 self.print_scale = int(scale)
4546 self.page_setup_changed = True
4547
4548 def print_black_and_white(self):
4549 """
4550 Set the option to print the worksheet in black and white.
4551
4552 Args:
4553 None.
4554
4555 Returns:
4556 Nothing.
4557
4558 """
4559 self.black_white = True
4560 self.page_setup_changed = True
4561
4562 def set_h_pagebreaks(self, breaks):
4563 """
4564 Set the horizontal page breaks on a worksheet.
4565
4566 Args:
4567 breaks: List of rows where the page breaks should be added.
4568
4569 Returns:
4570 Nothing.
4571
4572 """
4573 self.hbreaks = breaks
4574
4575 def set_v_pagebreaks(self, breaks):
4576 """
4577 Set the horizontal page breaks on a worksheet.
4578
4579 Args:
4580 breaks: List of columns where the page breaks should be added.
4581
4582 Returns:
4583 Nothing.
4584
4585 """
4586 self.vbreaks = breaks
4587
4588 def set_vba_name(self, name=None):
4589 """
4590 Set the VBA name for the worksheet. By default this is the
4591 same as the sheet name: i.e., Sheet1 etc.
4592
4593 Args:
4594 name: The VBA name for the worksheet.
4595
4596 Returns:
4597 Nothing.
4598
4599 """
4600 if name is not None:
4601 self.vba_codename = name
4602 else:
4603 self.vba_codename = "Sheet" + str(self.index + 1)
4604
4605 def ignore_errors(self, options=None):
4606 """
4607 Ignore various Excel errors/warnings in a worksheet for user defined
4608 ranges.
4609
4610 Args:
4611 options: A dict of ignore errors keys with cell range values.
4612
4613 Returns:
4614 0: Success.
4615 -1: Incorrect parameter or option.
4616
4617 """
4618 if options is None:
4619 return -1
4620
4621 # Copy the user defined options so they aren't modified.
4622 options = options.copy()
4623
4624 # Valid input parameters.
4625 valid_parameters = {
4626 "number_stored_as_text",
4627 "eval_error",
4628 "formula_differs",
4629 "formula_range",
4630 "formula_unlocked",
4631 "empty_cell_reference",
4632 "list_data_validation",
4633 "calculated_column",
4634 "two_digit_text_year",
4635 }
4636
4637 # Check for valid input parameters.
4638 for param_key in options.keys():
4639 if param_key not in valid_parameters:
4640 warn(f"Unknown parameter '{param_key}' in ignore_errors()")
4641 return -1
4642
4643 self.ignored_errors = options
4644
4645 return 0
4646
4647 ###########################################################################
4648 #
4649 # Private API.
4650 #
4651 ###########################################################################
4652 def _initialize(self, init_data):
4653 self.name = init_data["name"]
4654 self.index = init_data["index"]
4655 self.str_table = init_data["str_table"]
4656 self.worksheet_meta = init_data["worksheet_meta"]
4657 self.constant_memory = init_data["constant_memory"]
4658 self.tmpdir = init_data["tmpdir"]
4659 self.date_1904 = init_data["date_1904"]
4660 self.strings_to_numbers = init_data["strings_to_numbers"]
4661 self.strings_to_formulas = init_data["strings_to_formulas"]
4662 self.strings_to_urls = init_data["strings_to_urls"]
4663 self.nan_inf_to_errors = init_data["nan_inf_to_errors"]
4664 self.default_date_format = init_data["default_date_format"]
4665 self.default_url_format = init_data["default_url_format"]
4666 self.workbook_add_format = init_data["workbook_add_format"]
4667 self.excel2003_style = init_data["excel2003_style"]
4668 self.remove_timezone = init_data["remove_timezone"]
4669 self.max_url_length = init_data["max_url_length"]
4670 self.use_future_functions = init_data["use_future_functions"]
4671 self.embedded_images = init_data["embedded_images"]
4672
4673 if self.excel2003_style:
4674 self.original_row_height = 12.75
4675 self.default_row_height = 12.75
4676 self.default_row_pixels = 17
4677 self.margin_left = 0.75
4678 self.margin_right = 0.75
4679 self.margin_top = 1
4680 self.margin_bottom = 1
4681 self.margin_header = 0.5
4682 self.margin_footer = 0.5
4683 self.header_footer_aligns = False
4684
4685 # Open a temp filehandle to store row data in constant_memory mode.
4686 if self.constant_memory:
4687 # This is sub-optimal but we need to create a temp file
4688 # with utf8 encoding in Python < 3.
4689 (fd, filename) = tempfile.mkstemp(dir=self.tmpdir)
4690 os.close(fd)
4691 self.row_data_filename = filename
4692 # pylint: disable=consider-using-with
4693 self.row_data_fh = open(filename, mode="w+", encoding="utf-8")
4694
4695 # Set as the worksheet filehandle until the file is assembled.
4696 self.fh = self.row_data_fh
4697
4698 def _assemble_xml_file(self):
4699 # Assemble and write the XML file.
4700
4701 # Write the XML declaration.
4702 self._xml_declaration()
4703
4704 # Write the root worksheet element.
4705 self._write_worksheet()
4706
4707 # Write the worksheet properties.
4708 self._write_sheet_pr()
4709
4710 # Write the worksheet dimensions.
4711 self._write_dimension()
4712
4713 # Write the sheet view properties.
4714 self._write_sheet_views()
4715
4716 # Write the sheet format properties.
4717 self._write_sheet_format_pr()
4718
4719 # Write the sheet column info.
4720 self._write_cols()
4721
4722 # Write the worksheet data such as rows columns and cells.
4723 if not self.constant_memory:
4724 self._write_sheet_data()
4725 else:
4726 self._write_optimized_sheet_data()
4727
4728 # Write the sheetProtection element.
4729 self._write_sheet_protection()
4730
4731 # Write the protectedRanges element.
4732 self._write_protected_ranges()
4733
4734 # Write the phoneticPr element.
4735 if self.excel2003_style:
4736 self._write_phonetic_pr()
4737
4738 # Write the autoFilter element.
4739 self._write_auto_filter()
4740
4741 # Write the mergeCells element.
4742 self._write_merge_cells()
4743
4744 # Write the conditional formats.
4745 self._write_conditional_formats()
4746
4747 # Write the dataValidations element.
4748 self._write_data_validations()
4749
4750 # Write the hyperlink element.
4751 self._write_hyperlinks()
4752
4753 # Write the printOptions element.
4754 self._write_print_options()
4755
4756 # Write the worksheet page_margins.
4757 self._write_page_margins()
4758
4759 # Write the worksheet page setup.
4760 self._write_page_setup()
4761
4762 # Write the headerFooter element.
4763 self._write_header_footer()
4764
4765 # Write the rowBreaks element.
4766 self._write_row_breaks()
4767
4768 # Write the colBreaks element.
4769 self._write_col_breaks()
4770
4771 # Write the ignoredErrors element.
4772 self._write_ignored_errors()
4773
4774 # Write the drawing element.
4775 self._write_drawings()
4776
4777 # Write the legacyDrawing element.
4778 self._write_legacy_drawing()
4779
4780 # Write the legacyDrawingHF element.
4781 self._write_legacy_drawing_hf()
4782
4783 # Write the picture element, for the background.
4784 self._write_picture()
4785
4786 # Write the tableParts element.
4787 self._write_table_parts()
4788
4789 # Write the extLst elements.
4790 self._write_ext_list()
4791
4792 # Close the worksheet tag.
4793 self._xml_end_tag("worksheet")
4794
4795 # Close the file.
4796 self._xml_close()
4797
4798 def _check_dimensions(self, row, col, ignore_row=False, ignore_col=False):
4799 # Check that row and col are valid and store the max and min
4800 # values for use in other methods/elements. The ignore_row /
4801 # ignore_col flags is used to indicate that we wish to perform
4802 # the dimension check without storing the value. The ignore
4803 # flags are use by set_row() and data_validate.
4804
4805 # Check that the row/col are within the worksheet bounds.
4806 if row < 0 or col < 0:
4807 return -1
4808 if row >= self.xls_rowmax or col >= self.xls_colmax:
4809 return -1
4810
4811 # In constant_memory mode we don't change dimensions for rows
4812 # that are already written.
4813 if not ignore_row and not ignore_col and self.constant_memory:
4814 if row < self.previous_row:
4815 return -2
4816
4817 if not ignore_row:
4818 if self.dim_rowmin is None or row < self.dim_rowmin:
4819 self.dim_rowmin = row
4820 if self.dim_rowmax is None or row > self.dim_rowmax:
4821 self.dim_rowmax = row
4822
4823 if not ignore_col:
4824 if self.dim_colmin is None or col < self.dim_colmin:
4825 self.dim_colmin = col
4826 if self.dim_colmax is None or col > self.dim_colmax:
4827 self.dim_colmax = col
4828
4829 return 0
4830
4831 def _convert_date_time(self, dt_obj):
4832 # Convert a datetime object to an Excel serial date and time.
4833 return _datetime_to_excel_datetime(dt_obj, self.date_1904, self.remove_timezone)
4834
4835 def _convert_name_area(self, row_num_1, col_num_1, row_num_2, col_num_2):
4836 # Convert zero indexed rows and columns to the format required by
4837 # worksheet named ranges, eg, "Sheet1!$A$1:$C$13".
4838
4839 range1 = ""
4840 range2 = ""
4841 area = ""
4842 row_col_only = 0
4843
4844 # Convert to A1 notation.
4845 col_char_1 = xl_col_to_name(col_num_1, 1)
4846 col_char_2 = xl_col_to_name(col_num_2, 1)
4847 row_char_1 = "$" + str(row_num_1 + 1)
4848 row_char_2 = "$" + str(row_num_2 + 1)
4849
4850 # We need to handle special cases that refer to rows or columns only.
4851 if row_num_1 == 0 and row_num_2 == self.xls_rowmax - 1:
4852 range1 = col_char_1
4853 range2 = col_char_2
4854 row_col_only = 1
4855 elif col_num_1 == 0 and col_num_2 == self.xls_colmax - 1:
4856 range1 = row_char_1
4857 range2 = row_char_2
4858 row_col_only = 1
4859 else:
4860 range1 = col_char_1 + row_char_1
4861 range2 = col_char_2 + row_char_2
4862
4863 # A repeated range is only written once (if it isn't a special case).
4864 if range1 == range2 and not row_col_only:
4865 area = range1
4866 else:
4867 area = range1 + ":" + range2
4868
4869 # Build up the print area range "Sheet1!$A$1:$C$13".
4870 sheetname = quote_sheetname(self.name)
4871 area = sheetname + "!" + area
4872
4873 return area
4874
4875 def _sort_pagebreaks(self, breaks):
4876 # This is an internal method used to filter elements of a list of
4877 # pagebreaks used in the _store_hbreak() and _store_vbreak() methods.
4878 # It:
4879 # 1. Removes duplicate entries from the list.
4880 # 2. Sorts the list.
4881 # 3. Removes 0 from the list if present.
4882 if not breaks:
4883 return []
4884
4885 breaks_set = set(breaks)
4886
4887 if 0 in breaks_set:
4888 breaks_set.remove(0)
4889
4890 breaks_list = list(breaks_set)
4891 breaks_list.sort()
4892
4893 # The Excel 2007 specification says that the maximum number of page
4894 # breaks is 1026. However, in practice it is actually 1023.
4895 max_num_breaks = 1023
4896 if len(breaks_list) > max_num_breaks:
4897 breaks_list = breaks_list[:max_num_breaks]
4898
4899 return breaks_list
4900
4901 def _extract_filter_tokens(self, expression):
4902 # Extract the tokens from the filter expression. The tokens are mainly
4903 # non-whitespace groups. The only tricky part is to extract string
4904 # tokens that contain whitespace and/or quoted double quotes (Excel's
4905 # escaped quotes).
4906 #
4907 # Examples: 'x < 2000'
4908 # 'x > 2000 and x < 5000'
4909 # 'x = "foo"'
4910 # 'x = "foo bar"'
4911 # 'x = "foo "" bar"'
4912 #
4913 if not expression:
4914 return []
4915
4916 token_re = re.compile(r'"(?:[^"]|"")*"|\S+')
4917 tokens = token_re.findall(expression)
4918
4919 new_tokens = []
4920 # Remove single leading and trailing quotes and un-escape other quotes.
4921 for token in tokens:
4922 if token.startswith('"'):
4923 token = token[1:]
4924
4925 if token.endswith('"'):
4926 token = token[:-1]
4927
4928 token = token.replace('""', '"')
4929
4930 new_tokens.append(token)
4931
4932 return new_tokens
4933
4934 def _parse_filter_expression(self, expression, tokens):
4935 # Converts the tokens of a possibly conditional expression into 1 or 2
4936 # sub expressions for further parsing.
4937 #
4938 # Examples:
4939 # ('x', '==', 2000) -> exp1
4940 # ('x', '>', 2000, 'and', 'x', '<', 5000) -> exp1 and exp2
4941
4942 if len(tokens) == 7:
4943 # The number of tokens will be either 3 (for 1 expression)
4944 # or 7 (for 2 expressions).
4945 conditional = tokens[3]
4946
4947 if re.match("(and|&&)", conditional):
4948 conditional = 0
4949 elif re.match(r"(or|\|\|)", conditional):
4950 conditional = 1
4951 else:
4952 warn(
4953 f"Token '{conditional}' is not a valid conditional "
4954 f"in filter expression '{expression}'"
4955 )
4956
4957 expression_1 = self._parse_filter_tokens(expression, tokens[0:3])
4958 expression_2 = self._parse_filter_tokens(expression, tokens[4:7])
4959 return expression_1 + [conditional] + expression_2
4960
4961 return self._parse_filter_tokens(expression, tokens)
4962
4963 def _parse_filter_tokens(self, expression, tokens):
4964 # Parse the 3 tokens of a filter expression and return the operator
4965 # and token. The use of numbers instead of operators is a legacy of
4966 # Spreadsheet::WriteExcel.
4967 operators = {
4968 "==": 2,
4969 "=": 2,
4970 "=~": 2,
4971 "eq": 2,
4972 "!=": 5,
4973 "!~": 5,
4974 "ne": 5,
4975 "<>": 5,
4976 "<": 1,
4977 "<=": 3,
4978 ">": 4,
4979 ">=": 6,
4980 }
4981
4982 operator = operators.get(tokens[1], None)
4983 token = tokens[2]
4984
4985 # Special handling of "Top" filter expressions.
4986 if re.match("top|bottom", tokens[0].lower()):
4987 value = int(tokens[1])
4988
4989 if value < 1 or value > 500:
4990 warn(
4991 f"The value '{token}' in expression '{expression}' "
4992 f"must be in the range 1 to 500"
4993 )
4994
4995 token = token.lower()
4996
4997 if token not in ("items", "%"):
4998 warn(
4999 f"The type '{token}' in expression '{expression}' "
5000 f"must be either 'items' or '%%'"
5001 )
5002
5003 if tokens[0].lower() == "top":
5004 operator = 30
5005 else:
5006 operator = 32
5007
5008 if tokens[2] == "%":
5009 operator += 1
5010
5011 token = str(value)
5012
5013 if not operator and tokens[0]:
5014 warn(
5015 f"Token '{token[0]}' is not a valid operator "
5016 f"in filter expression '{expression}'."
5017 )
5018
5019 # Special handling for Blanks/NonBlanks.
5020 if re.match("blanks|nonblanks", token.lower()):
5021 # Only allow Equals or NotEqual in this context.
5022 if operator not in (2, 5):
5023 warn(
5024 f"The operator '{tokens[1]}' in expression '{expression}' "
5025 f"is not valid in relation to Blanks/NonBlanks'."
5026 )
5027
5028 token = token.lower()
5029
5030 # The operator should always be 2 (=) to flag a "simple" equality
5031 # in the binary record. Therefore we convert <> to =.
5032 if token == "blanks":
5033 if operator == 5:
5034 token = " "
5035 else:
5036 if operator == 5:
5037 operator = 2
5038 token = "blanks"
5039 else:
5040 operator = 5
5041 token = " "
5042
5043 # if the string token contains an Excel match character then change the
5044 # operator type to indicate a non "simple" equality.
5045 if operator == 2 and re.search("[*?]", token):
5046 operator = 22
5047
5048 return [operator, token]
5049
5050 def _encode_password(self, password):
5051 # Hash a worksheet password. Based on the algorithm in
5052 # ECMA-376-4:2016, Office Open XML File Formats — Transitional
5053 # Migration Features, Additional attributes for workbookProtection
5054 # element (Part 1, §18.2.29).
5055 digest = 0x0000
5056
5057 for char in password[::-1]:
5058 digest = ((digest >> 14) & 0x01) | ((digest << 1) & 0x7FFF)
5059 digest ^= ord(char)
5060
5061 digest = ((digest >> 14) & 0x01) | ((digest << 1) & 0x7FFF)
5062 digest ^= len(password)
5063 digest ^= 0xCE4B
5064
5065 return f"{digest:X}"
5066
5067 def _image_from_source(self, source, options=None):
5068 # Backward compatibility utility method to convert an input argument to
5069 # an Image object. The source can be a filename, BytesIO stream or
5070 # an existing Image object.
5071 if isinstance(source, Image):
5072 image = source
5073 elif options is not None and options.get("image_data"):
5074 image = Image(options["image_data"])
5075 image.image_name = source
5076 else:
5077 image = Image(source)
5078
5079 return image
5080
5081 def _prepare_image(
5082 self,
5083 image: Image,
5084 image_id: int,
5085 drawing_id: int,
5086 ):
5087 # Set up images/drawings.
5088
5089 # Get the effective image width and height in pixels.
5090 width = image._width * image._x_scale
5091 height = image._height * image._y_scale
5092
5093 # Scale by non 96dpi resolutions.
5094 width *= 96.0 / image._x_dpi
5095 height *= 96.0 / image._y_dpi
5096
5097 dimensions = self._position_object_emus(
5098 image._col,
5099 image._row,
5100 image._x_offset,
5101 image._y_offset,
5102 width,
5103 height,
5104 image._anchor,
5105 )
5106
5107 # Convert from pixels to emus.
5108 width = int(0.5 + (width * 9525))
5109 height = int(0.5 + (height * 9525))
5110
5111 # Create a Drawing obj to use with worksheet unless one already exists.
5112 if not self.drawing:
5113 drawing = Drawing()
5114 drawing.embedded = 1
5115 self.drawing = drawing
5116
5117 self.external_drawing_links.append(
5118 ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml", None]
5119 )
5120 else:
5121 drawing = self.drawing
5122
5123 drawing_object = DrawingInfo()
5124 drawing_object._drawing_type = DrawingTypes.IMAGE
5125 drawing_object._dimensions = dimensions
5126 drawing_object._description = image.image_name
5127 drawing_object._width = width
5128 drawing_object._height = height
5129 drawing_object._shape = None
5130 drawing_object._anchor = image._anchor
5131 drawing_object._rel_index = 0
5132 drawing_object._decorative = image._decorative
5133
5134 if image.description is not None:
5135 drawing_object._description = image.description
5136
5137 if image._url:
5138 url = image._url
5139 target = url._target()
5140 target_mode = url._target_mode()
5141
5142 if not self.drawing_rels.get(url._link):
5143 self.drawing_links.append(["/hyperlink", target, target_mode])
5144
5145 url._rel_index = self._get_drawing_rel_index(url._link)
5146 drawing_object._url = url
5147
5148 if not self.drawing_rels.get(image._digest):
5149 self.drawing_links.append(
5150 [
5151 "/image",
5152 "../media/image" + str(image_id) + "." + image._image_extension,
5153 ]
5154 )
5155
5156 drawing_object._rel_index = self._get_drawing_rel_index(image._digest)
5157 drawing._add_drawing_object(drawing_object)
5158
5159 def _prepare_shape(self, index, drawing_id):
5160 # Set up shapes/drawings.
5161 (
5162 row,
5163 col,
5164 x_offset,
5165 y_offset,
5166 x_scale,
5167 y_scale,
5168 text,
5169 anchor,
5170 options,
5171 description,
5172 decorative,
5173 ) = self.shapes[index]
5174
5175 width = options.get("width", self.default_col_pixels * 3)
5176 height = options.get("height", self.default_row_pixels * 6)
5177
5178 width *= x_scale
5179 height *= y_scale
5180
5181 dimensions = self._position_object_emus(
5182 col, row, x_offset, y_offset, width, height, anchor
5183 )
5184
5185 # Convert from pixels to emus.
5186 width = int(0.5 + (width * 9525))
5187 height = int(0.5 + (height * 9525))
5188
5189 # Create a Drawing obj to use with worksheet unless one already exists.
5190 if not self.drawing:
5191 drawing = Drawing()
5192 drawing.embedded = 1
5193 self.drawing = drawing
5194
5195 self.external_drawing_links.append(
5196 ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml", None]
5197 )
5198 else:
5199 drawing = self.drawing
5200
5201 shape = Shape("rect", "TextBox", options)
5202 shape.text = text
5203
5204 drawing_object = DrawingInfo()
5205 drawing_object._drawing_type = DrawingTypes.SHAPE
5206 drawing_object._dimensions = dimensions
5207 drawing_object._width = width
5208 drawing_object._height = height
5209 drawing_object._description = description
5210 drawing_object._shape = shape
5211 drawing_object._anchor = anchor
5212 drawing_object._rel_index = 0
5213 drawing_object._decorative = decorative
5214
5215 url = Url.from_options(options)
5216 if url:
5217 target = url._target()
5218 target_mode = url._target_mode()
5219
5220 if not self.drawing_rels.get(url._link):
5221 self.drawing_links.append(["/hyperlink", target, target_mode])
5222
5223 url._rel_index = self._get_drawing_rel_index(url._link)
5224 drawing_object._url = url
5225
5226 drawing._add_drawing_object(drawing_object)
5227
5228 def _prepare_header_image(self, image_id, image):
5229 # Set up an image without a drawing object for header/footer images.
5230
5231 # Strip the extension from the filename.
5232 image.image_name = re.sub(r"\..*$", "", image.image_name)
5233
5234 if not self.vml_drawing_rels.get(image._digest):
5235 self.vml_drawing_links.append(
5236 [
5237 "/image",
5238 "../media/image" + str(image_id) + "." + image._image_extension,
5239 ]
5240 )
5241
5242 image._ref_id = self._get_vml_drawing_rel_index(image._digest)
5243
5244 self.header_images_list.append(image)
5245
5246 def _prepare_background(self, image_id, image_extension):
5247 # Set up an image without a drawing object for backgrounds.
5248 self.external_background_links.append(
5249 ["/image", "../media/image" + str(image_id) + "." + image_extension]
5250 )
5251
5252 def _prepare_chart(self, index, chart_id, drawing_id):
5253 # Set up chart/drawings.
5254 (
5255 row,
5256 col,
5257 chart,
5258 x_offset,
5259 y_offset,
5260 x_scale,
5261 y_scale,
5262 anchor,
5263 description,
5264 decorative,
5265 ) = self.charts[index]
5266
5267 chart.id = chart_id - 1
5268
5269 # Use user specified dimensions, if any.
5270 width = int(0.5 + (chart.width * x_scale))
5271 height = int(0.5 + (chart.height * y_scale))
5272
5273 dimensions = self._position_object_emus(
5274 col, row, x_offset, y_offset, width, height, anchor
5275 )
5276
5277 # Set the chart name for the embedded object if it has been specified.
5278 name = chart.chart_name
5279
5280 # Create a Drawing obj to use with worksheet unless one already exists.
5281 if not self.drawing:
5282 drawing = Drawing()
5283 drawing.embedded = 1
5284 self.drawing = drawing
5285
5286 self.external_drawing_links.append(
5287 ["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml"]
5288 )
5289 else:
5290 drawing = self.drawing
5291
5292 drawing_object = DrawingInfo()
5293 drawing_object._drawing_type = DrawingTypes.CHART
5294 drawing_object._dimensions = dimensions
5295 drawing_object._width = width
5296 drawing_object._height = height
5297 drawing_object._name = name
5298 drawing_object._shape = None
5299 drawing_object._anchor = anchor
5300 drawing_object._rel_index = self._get_drawing_rel_index()
5301 drawing_object._description = description
5302 drawing_object._decorative = decorative
5303
5304 drawing._add_drawing_object(drawing_object)
5305
5306 self.drawing_links.append(
5307 ["/chart", "../charts/chart" + str(chart_id) + ".xml"]
5308 )
5309
5310 def _position_object_emus(
5311 self, col_start, row_start, x1, y1, width, height, anchor
5312 ):
5313 # Calculate the vertices that define the position of a graphical
5314 # object within the worksheet in EMUs.
5315 #
5316 # The vertices are expressed as English Metric Units (EMUs). There are
5317 # 12,700 EMUs per point. Therefore, 12,700 * 3 /4 = 9,525 EMUs per
5318 # pixel
5319 (
5320 col_start,
5321 row_start,
5322 x1,
5323 y1,
5324 col_end,
5325 row_end,
5326 x2,
5327 y2,
5328 x_abs,
5329 y_abs,
5330 ) = self._position_object_pixels(
5331 col_start, row_start, x1, y1, width, height, anchor
5332 )
5333
5334 # Convert the pixel values to EMUs. See above.
5335 x1 = int(0.5 + 9525 * x1)
5336 y1 = int(0.5 + 9525 * y1)
5337 x2 = int(0.5 + 9525 * x2)
5338 y2 = int(0.5 + 9525 * y2)
5339 x_abs = int(0.5 + 9525 * x_abs)
5340 y_abs = int(0.5 + 9525 * y_abs)
5341
5342 return (col_start, row_start, x1, y1, col_end, row_end, x2, y2, x_abs, y_abs)
5343
5344 # Calculate the vertices that define the position of a graphical object
5345 # within the worksheet in pixels.
5346 #
5347 # +------------+------------+
5348 # | A | B |
5349 # +-----+------------+------------+
5350 # | |(x1,y1) | |
5351 # | 1 |(A1)._______|______ |
5352 # | | | | |
5353 # | | | | |
5354 # +-----+----| OBJECT |-----+
5355 # | | | | |
5356 # | 2 | |______________. |
5357 # | | | (B2)|
5358 # | | | (x2,y2)|
5359 # +---- +------------+------------+
5360 #
5361 # Example of an object that covers some of the area from cell A1 to B2.
5362 #
5363 # Based on the width and height of the object we need to calculate 8 vars:
5364 #
5365 # col_start, row_start, col_end, row_end, x1, y1, x2, y2.
5366 #
5367 # We also calculate the absolute x and y position of the top left vertex of
5368 # the object. This is required for images.
5369 #
5370 # The width and height of the cells that the object occupies can be
5371 # variable and have to be taken into account.
5372 #
5373 # The values of col_start and row_start are passed in from the calling
5374 # function. The values of col_end and row_end are calculated by
5375 # subtracting the width and height of the object from the width and
5376 # height of the underlying cells.
5377 #
5378 def _position_object_pixels(
5379 self, col_start, row_start, x1, y1, width, height, anchor
5380 ):
5381 # col_start # Col containing upper left corner of object.
5382 # x1 # Distance to left side of object.
5383 #
5384 # row_start # Row containing top left corner of object.
5385 # y1 # Distance to top of object.
5386 #
5387 # col_end # Col containing lower right corner of object.
5388 # x2 # Distance to right side of object.
5389 #
5390 # row_end # Row containing bottom right corner of object.
5391 # y2 # Distance to bottom of object.
5392 #
5393 # width # Width of object frame.
5394 # height # Height of object frame.
5395 #
5396 # x_abs # Absolute distance to left side of object.
5397 # y_abs # Absolute distance to top side of object.
5398 x_abs = 0
5399 y_abs = 0
5400
5401 # Adjust start column for negative offsets.
5402 # pylint: disable=chained-comparison
5403 while x1 < 0 and col_start > 0:
5404 x1 += self._size_col(col_start - 1)
5405 col_start -= 1
5406
5407 # Adjust start row for negative offsets.
5408 while y1 < 0 and row_start > 0:
5409 y1 += self._size_row(row_start - 1)
5410 row_start -= 1
5411
5412 # Ensure that the image isn't shifted off the page at top left.
5413 x1 = max(0, x1)
5414 y1 = max(0, y1)
5415
5416 # Calculate the absolute x offset of the top-left vertex.
5417 if self.col_size_changed:
5418 for col_id in range(col_start):
5419 x_abs += self._size_col(col_id)
5420 else:
5421 # Optimization for when the column widths haven't changed.
5422 x_abs += self.default_col_pixels * col_start
5423
5424 x_abs += x1
5425
5426 # Calculate the absolute y offset of the top-left vertex.
5427 if self.row_size_changed:
5428 for row_id in range(row_start):
5429 y_abs += self._size_row(row_id)
5430 else:
5431 # Optimization for when the row heights haven't changed.
5432 y_abs += self.default_row_pixels * row_start
5433
5434 y_abs += y1
5435
5436 # Adjust start column for offsets that are greater than the col width.
5437 while x1 >= self._size_col(col_start, anchor):
5438 x1 -= self._size_col(col_start)
5439 col_start += 1
5440
5441 # Adjust start row for offsets that are greater than the row height.
5442 while y1 >= self._size_row(row_start, anchor):
5443 y1 -= self._size_row(row_start)
5444 row_start += 1
5445
5446 # Initialize end cell to the same as the start cell.
5447 col_end = col_start
5448 row_end = row_start
5449
5450 # Don't offset the image in the cell if the row/col is hidden.
5451 if self._size_col(col_start, anchor) > 0:
5452 width = width + x1
5453 if self._size_row(row_start, anchor) > 0:
5454 height = height + y1
5455
5456 # Subtract the underlying cell widths to find end cell of the object.
5457 while width >= self._size_col(col_end, anchor):
5458 width -= self._size_col(col_end, anchor)
5459 col_end += 1
5460
5461 # Subtract the underlying cell heights to find end cell of the object.
5462 while height >= self._size_row(row_end, anchor):
5463 height -= self._size_row(row_end, anchor)
5464 row_end += 1
5465
5466 # The end vertices are whatever is left from the width and height.
5467 x2 = width
5468 y2 = height
5469
5470 return [col_start, row_start, x1, y1, col_end, row_end, x2, y2, x_abs, y_abs]
5471
5472 def _size_col(self, col, anchor=0):
5473 # Convert the width of a cell from character units to pixels. Excel
5474 # rounds the column width to the nearest pixel. If the width hasn't
5475 # been set by the user we use the default value. A hidden column is
5476 # treated as having a width of zero unless it has the special
5477 # "object_position" of 4 (size with cells).
5478 max_digit_width = 7 # For Calibri 11.
5479 padding = 5
5480 pixels = 0
5481
5482 # Look up the cell value to see if it has been changed.
5483 if col in self.col_info:
5484 width = self.col_info[col][0]
5485 hidden = self.col_info[col][2]
5486
5487 if width is None:
5488 width = self.default_col_width
5489
5490 # Convert to pixels.
5491 if hidden and anchor != 4:
5492 pixels = 0
5493 elif width < 1:
5494 pixels = int(width * (max_digit_width + padding) + 0.5)
5495 else:
5496 pixels = int(width * max_digit_width + 0.5) + padding
5497 else:
5498 pixels = self.default_col_pixels
5499
5500 return pixels
5501
5502 def _size_row(self, row, anchor=0):
5503 # Convert the height of a cell from character units to pixels. If the
5504 # height hasn't been set by the user we use the default value. A
5505 # hidden row is treated as having a height of zero unless it has the
5506 # special "object_position" of 4 (size with cells).
5507 pixels = 0
5508
5509 # Look up the cell value to see if it has been changed
5510 if row in self.row_sizes:
5511 height = self.row_sizes[row][0]
5512 hidden = self.row_sizes[row][1]
5513
5514 if hidden and anchor != 4:
5515 pixels = 0
5516 else:
5517 pixels = int(4.0 / 3.0 * height)
5518 else:
5519 pixels = int(4.0 / 3.0 * self.default_row_height)
5520
5521 return pixels
5522
5523 def _pixels_to_width(self, pixels):
5524 # Convert the width of a cell from pixels to character units.
5525 max_digit_width = 7.0 # For Calabri 11.
5526 padding = 5.0
5527
5528 if pixels <= 12:
5529 width = pixels / (max_digit_width + padding)
5530 else:
5531 width = (pixels - padding) / max_digit_width
5532
5533 return width
5534
5535 def _pixels_to_height(self, pixels):
5536 # Convert the height of a cell from pixels to character units.
5537 return 0.75 * pixels
5538
5539 def _comment_vertices(self, comment: CommentType):
5540 # Calculate the positions of the comment object.
5541 anchor = 0
5542 vertices = self._position_object_pixels(
5543 comment.start_col,
5544 comment.start_row,
5545 comment.x_offset,
5546 comment.y_offset,
5547 comment.width,
5548 comment.height,
5549 anchor,
5550 )
5551
5552 # Add the width and height for VML.
5553 vertices.append(comment.width)
5554 vertices.append(comment.height)
5555
5556 return vertices
5557
5558 def _button_vertices(self, button: ButtonType):
5559 # Calculate the positions of the button object.
5560 anchor = 0
5561 vertices = self._position_object_pixels(
5562 button.col,
5563 button.row,
5564 button.x_offset,
5565 button.y_offset,
5566 button.width,
5567 button.height,
5568 anchor,
5569 )
5570
5571 # Add the width and height for VML.
5572 vertices.append(button.width)
5573 vertices.append(button.height)
5574
5575 return vertices
5576
5577 def _prepare_vml_objects(
5578 self, vml_data_id, vml_shape_id, vml_drawing_id, comment_id
5579 ):
5580 comments = []
5581 # Sort the comments into row/column order for easier comparison
5582 # testing and set the external links for comments and buttons.
5583 row_nums = sorted(self.comments.keys())
5584
5585 for row in row_nums:
5586 col_nums = sorted(self.comments[row].keys())
5587
5588 for col in col_nums:
5589 comment = self.comments[row][col]
5590 comment.vertices = self._comment_vertices(comment)
5591
5592 # Set comment visibility if required and not user defined.
5593 if comment.is_visible is None:
5594 comment.is_visible = self.comments_visible
5595
5596 # Set comment author if not already user defined.
5597 if comment.author is None:
5598 comment.author = self.comments_author
5599
5600 comments.append(comment)
5601
5602 for button in self.buttons_list:
5603 button.vertices = self._button_vertices(button)
5604
5605 self.external_vml_links.append(
5606 ["/vmlDrawing", "../drawings/vmlDrawing" + str(vml_drawing_id) + ".vml"]
5607 )
5608
5609 if self.has_comments:
5610 self.comments_list = comments
5611
5612 self.external_comment_links.append(
5613 ["/comments", "../comments" + str(comment_id) + ".xml"]
5614 )
5615
5616 count = len(comments)
5617 start_data_id = vml_data_id
5618
5619 # The VML o:idmap data id contains a comma separated range when there
5620 # is more than one 1024 block of comments, like this: data="1,2".
5621 for i in range(int(count / 1024)):
5622 data_id = start_data_id + i + 1
5623 vml_data_id = f"{vml_data_id},{data_id}"
5624
5625 self.vml_data_id = vml_data_id
5626 self.vml_shape_id = vml_shape_id
5627
5628 return count
5629
5630 def _prepare_header_vml_objects(self, vml_header_id, vml_drawing_id):
5631 # Set up external linkage for VML header/footer images.
5632
5633 self.vml_header_id = vml_header_id
5634
5635 self.external_vml_links.append(
5636 ["/vmlDrawing", "../drawings/vmlDrawing" + str(vml_drawing_id) + ".vml"]
5637 )
5638
5639 def _prepare_tables(self, table_id, seen):
5640 # Set the table ids for the worksheet tables.
5641 for table in self.tables:
5642 table["id"] = table_id
5643
5644 if table.get("name") is None:
5645 # Set a default name.
5646 table["name"] = "Table" + str(table_id)
5647
5648 # Check for duplicate table names.
5649 name = table["name"].lower()
5650
5651 if name in seen:
5652 raise DuplicateTableName(
5653 f"Duplicate name '{table['name']}' used in worksheet.add_table()."
5654 )
5655
5656 seen[name] = True
5657
5658 # Store the link used for the rels file.
5659 self.external_table_links.append(
5660 ["/table", "../tables/table" + str(table_id) + ".xml"]
5661 )
5662 table_id += 1
5663
5664 def _table_function_to_formula(self, function, col_name):
5665 # Convert a table total function to a worksheet formula.
5666 formula = ""
5667
5668 # Escape special characters, as required by Excel.
5669 col_name = col_name.replace("'", "''")
5670 col_name = col_name.replace("#", "'#")
5671 col_name = col_name.replace("]", "']")
5672 col_name = col_name.replace("[", "'[")
5673
5674 subtotals = {
5675 "average": 101,
5676 "countNums": 102,
5677 "count": 103,
5678 "max": 104,
5679 "min": 105,
5680 "stdDev": 107,
5681 "sum": 109,
5682 "var": 110,
5683 }
5684
5685 if function in subtotals:
5686 func_num = subtotals[function]
5687 formula = f"SUBTOTAL({func_num},[{col_name}])"
5688 else:
5689 warn(f"Unsupported function '{function}' in add_table()")
5690
5691 return formula
5692
5693 def _set_spark_color(self, sparkline, options, user_color):
5694 # Set the sparkline color.
5695 if user_color not in options:
5696 return
5697
5698 sparkline[user_color] = Color._from_value(options[user_color])
5699
5700 def _get_range_data(self, row_start, col_start, row_end, col_end):
5701 # Returns a range of data from the worksheet _table to be used in
5702 # chart cached data. Strings are returned as SST ids and decoded
5703 # in the workbook. Return None for data that doesn't exist since
5704 # Excel can chart series with data missing.
5705
5706 if self.constant_memory:
5707 return ()
5708
5709 data = []
5710
5711 # Iterate through the table data.
5712 for row_num in range(row_start, row_end + 1):
5713 # Store None if row doesn't exist.
5714 if row_num not in self.table:
5715 data.append(None)
5716 continue
5717
5718 for col_num in range(col_start, col_end + 1):
5719 if col_num in self.table[row_num]:
5720 cell = self.table[row_num][col_num]
5721
5722 cell_type = cell.__class__.__name__
5723
5724 if cell_type in ("Number", "Datetime"):
5725 # Return a number with Excel's precision.
5726 data.append(f"{cell.number:.16g}")
5727
5728 elif cell_type == "String":
5729 # Return a string from it's shared string index.
5730 index = cell.string
5731 string = self.str_table._get_shared_string(index)
5732
5733 data.append(string)
5734
5735 elif cell_type in ("Formula", "ArrayFormula"):
5736 # Return the formula value.
5737 value = cell.value
5738
5739 if value is None:
5740 value = 0
5741
5742 data.append(value)
5743
5744 elif cell_type == "Blank":
5745 # Return a empty cell.
5746 data.append("")
5747 else:
5748 # Store None if column doesn't exist.
5749 data.append(None)
5750
5751 return data
5752
5753 def _csv_join(self, *items):
5754 # Create a csv string for use with data validation formulas and lists.
5755
5756 # Convert non string types to string.
5757 items = [str(item) if not isinstance(item, str) else item for item in items]
5758
5759 return ",".join(items)
5760
5761 def _escape_url(self, url):
5762 # Don't escape URL if it looks already escaped.
5763 if re.search("%[0-9a-fA-F]{2}", url):
5764 return url
5765
5766 # Can't use url.quote() here because it doesn't match Excel.
5767 url = url.replace("%", "%25")
5768 url = url.replace('"', "%22")
5769 url = url.replace(" ", "%20")
5770 url = url.replace("<", "%3c")
5771 url = url.replace(">", "%3e")
5772 url = url.replace("[", "%5b")
5773 url = url.replace("]", "%5d")
5774 url = url.replace("^", "%5e")
5775 url = url.replace("`", "%60")
5776 url = url.replace("{", "%7b")
5777 url = url.replace("}", "%7d")
5778
5779 return url
5780
5781 def _get_drawing_rel_index(self, target=None):
5782 # Get the index used to address a drawing rel link.
5783 if target is None:
5784 self.drawing_rels_id += 1
5785 return self.drawing_rels_id
5786
5787 if self.drawing_rels.get(target):
5788 return self.drawing_rels[target]
5789
5790 self.drawing_rels_id += 1
5791 self.drawing_rels[target] = self.drawing_rels_id
5792 return self.drawing_rels_id
5793
5794 def _get_vml_drawing_rel_index(self, target=None):
5795 # Get the index used to address a vml drawing rel link.
5796 if self.vml_drawing_rels.get(target):
5797 return self.vml_drawing_rels[target]
5798
5799 self.vml_drawing_rels_id += 1
5800 self.vml_drawing_rels[target] = self.vml_drawing_rels_id
5801 return self.vml_drawing_rels_id
5802
5803 ###########################################################################
5804 #
5805 # The following font methods are mainly duplicated from the Styles class
5806 # with appropriate changes for rich string styles.
5807 #
5808 ###########################################################################
5809 def _write_font(self, xf_format):
5810 # Write the <font> element.
5811 xml_writer = self.rstring
5812
5813 xml_writer._xml_start_tag("rPr")
5814
5815 # Handle the main font properties.
5816 if xf_format.bold:
5817 xml_writer._xml_empty_tag("b")
5818 if xf_format.italic:
5819 xml_writer._xml_empty_tag("i")
5820 if xf_format.font_strikeout:
5821 xml_writer._xml_empty_tag("strike")
5822 if xf_format.font_outline:
5823 xml_writer._xml_empty_tag("outline")
5824 if xf_format.font_shadow:
5825 xml_writer._xml_empty_tag("shadow")
5826
5827 # Handle the underline variants.
5828 if xf_format.underline:
5829 self._write_underline(xf_format.underline)
5830
5831 # Handle super/subscript.
5832 if xf_format.font_script == 1:
5833 self._write_vert_align("superscript")
5834 if xf_format.font_script == 2:
5835 self._write_vert_align("subscript")
5836
5837 # Write the font size
5838 xml_writer._xml_empty_tag("sz", [("val", xf_format.font_size)])
5839
5840 # Handle colors.
5841 if xf_format.theme == -1:
5842 # Ignore for excel2003_style.
5843 pass
5844 elif xf_format.theme:
5845 self._write_rstring_color("color", [("theme", xf_format.theme)])
5846 elif xf_format.color_indexed:
5847 self._write_rstring_color("color", [("indexed", xf_format.color_indexed)])
5848 elif xf_format.font_color:
5849 color = xf_format.font_color
5850 if not color._is_automatic:
5851 self._write_rstring_color("color", color._attributes())
5852 else:
5853 self._write_rstring_color("color", [("theme", 1)])
5854
5855 # Write some other font properties related to font families.
5856 xml_writer._xml_empty_tag("rFont", [("val", xf_format.font_name)])
5857 xml_writer._xml_empty_tag("family", [("val", xf_format.font_family)])
5858
5859 if xf_format.font_name == "Calibri" and not xf_format.hyperlink:
5860 xml_writer._xml_empty_tag("scheme", [("val", xf_format.font_scheme)])
5861
5862 xml_writer._xml_end_tag("rPr")
5863
5864 def _write_underline(self, underline):
5865 # Write the underline font element.
5866 attributes = []
5867
5868 # Handle the underline variants.
5869 if underline == 2:
5870 attributes = [("val", "double")]
5871 elif underline == 33:
5872 attributes = [("val", "singleAccounting")]
5873 elif underline == 34:
5874 attributes = [("val", "doubleAccounting")]
5875
5876 self.rstring._xml_empty_tag("u", attributes)
5877
5878 def _write_vert_align(self, val):
5879 # Write the <vertAlign> font sub-element.
5880 attributes = [("val", val)]
5881
5882 self.rstring._xml_empty_tag("vertAlign", attributes)
5883
5884 def _write_rstring_color(self, name, attributes):
5885 # Write the <color> element.
5886 self.rstring._xml_empty_tag(name, attributes)
5887
5888 def _opt_close(self):
5889 # Close the row data filehandle in constant_memory mode.
5890 if not self.row_data_fh_closed:
5891 self.row_data_fh.close()
5892 self.row_data_fh_closed = True
5893
5894 def _opt_reopen(self):
5895 # Reopen the row data filehandle in constant_memory mode.
5896 if self.row_data_fh_closed:
5897 filename = self.row_data_filename
5898 # pylint: disable=consider-using-with
5899 self.row_data_fh = open(filename, mode="a+", encoding="utf-8")
5900 self.row_data_fh_closed = False
5901 self.fh = self.row_data_fh
5902
5903 def _set_icon_props(self, total_icons, user_props=None):
5904 # Set the sub-properties for icons.
5905 props = []
5906
5907 # Set the defaults.
5908 for _ in range(total_icons):
5909 props.append({"criteria": False, "value": 0, "type": "percent"})
5910
5911 # Set the default icon values based on the number of icons.
5912 if total_icons == 3:
5913 props[0]["value"] = 67
5914 props[1]["value"] = 33
5915
5916 if total_icons == 4:
5917 props[0]["value"] = 75
5918 props[1]["value"] = 50
5919 props[2]["value"] = 25
5920
5921 if total_icons == 5:
5922 props[0]["value"] = 80
5923 props[1]["value"] = 60
5924 props[2]["value"] = 40
5925 props[3]["value"] = 20
5926
5927 # Overwrite default properties with user defined properties.
5928 if user_props:
5929 # Ensure we don't set user properties for lowest icon.
5930 max_data = len(user_props)
5931 if max_data >= total_icons:
5932 max_data = total_icons - 1
5933
5934 for i in range(max_data):
5935 # Set the user defined 'value' property.
5936 if user_props[i].get("value") is not None:
5937 props[i]["value"] = user_props[i]["value"]
5938
5939 # Remove the formula '=' sign if it exists.
5940 tmp = props[i]["value"]
5941 if isinstance(tmp, str) and tmp.startswith("="):
5942 props[i]["value"] = tmp.lstrip("=")
5943
5944 # Set the user defined 'type' property.
5945 if user_props[i].get("type"):
5946 valid_types = ("percent", "percentile", "number", "formula")
5947
5948 if user_props[i]["type"] not in valid_types:
5949 warn(
5950 f"Unknown icon property type '{user_props[i]['type']}' "
5951 f"for sub-property 'type' in conditional_format()."
5952 )
5953 else:
5954 props[i]["type"] = user_props[i]["type"]
5955
5956 if props[i]["type"] == "number":
5957 props[i]["type"] = "num"
5958
5959 # Set the user defined 'criteria' property.
5960 criteria = user_props[i].get("criteria")
5961 if criteria and criteria == ">":
5962 props[i]["criteria"] = True
5963
5964 return props
5965
5966 ###########################################################################
5967 #
5968 # XML methods.
5969 #
5970 ###########################################################################
5971
5972 def _write_worksheet(self):
5973 # Write the <worksheet> element. This is the root element.
5974
5975 schema = "http://schemas.openxmlformats.org/"
5976 xmlns = schema + "spreadsheetml/2006/main"
5977 xmlns_r = schema + "officeDocument/2006/relationships"
5978 xmlns_mc = schema + "markup-compatibility/2006"
5979 ms_schema = "http://schemas.microsoft.com/"
5980 xmlns_x14ac = ms_schema + "office/spreadsheetml/2009/9/ac"
5981
5982 attributes = [("xmlns", xmlns), ("xmlns:r", xmlns_r)]
5983
5984 # Add some extra attributes for Excel 2010. Mainly for sparklines.
5985 if self.excel_version == 2010:
5986 attributes.append(("xmlns:mc", xmlns_mc))
5987 attributes.append(("xmlns:x14ac", xmlns_x14ac))
5988 attributes.append(("mc:Ignorable", "x14ac"))
5989
5990 self._xml_start_tag("worksheet", attributes)
5991
5992 def _write_dimension(self):
5993 # Write the <dimension> element. This specifies the range of
5994 # cells in the worksheet. As a special case, empty
5995 # spreadsheets use 'A1' as a range.
5996
5997 if self.dim_rowmin is None and self.dim_colmin is None:
5998 # If the min dimensions are not defined then no dimensions
5999 # have been set and we use the default 'A1'.
6000 ref = "A1"
6001
6002 elif self.dim_rowmin is None and self.dim_colmin is not None:
6003 # If the row dimensions aren't set but the column
6004 # dimensions are set then they have been changed via
6005 # set_column().
6006
6007 if self.dim_colmin == self.dim_colmax:
6008 # The dimensions are a single cell and not a range.
6009 ref = xl_rowcol_to_cell(0, self.dim_colmin)
6010 else:
6011 # The dimensions are a cell range.
6012 cell_1 = xl_rowcol_to_cell(0, self.dim_colmin)
6013 cell_2 = xl_rowcol_to_cell(0, self.dim_colmax)
6014 ref = cell_1 + ":" + cell_2
6015
6016 elif self.dim_rowmin == self.dim_rowmax and self.dim_colmin == self.dim_colmax:
6017 # The dimensions are a single cell and not a range.
6018 ref = xl_rowcol_to_cell(self.dim_rowmin, self.dim_colmin)
6019 else:
6020 # The dimensions are a cell range.
6021 cell_1 = xl_rowcol_to_cell(self.dim_rowmin, self.dim_colmin)
6022 cell_2 = xl_rowcol_to_cell(self.dim_rowmax, self.dim_colmax)
6023 ref = cell_1 + ":" + cell_2
6024
6025 self._xml_empty_tag("dimension", [("ref", ref)])
6026
6027 def _write_sheet_views(self):
6028 # Write the <sheetViews> element.
6029 self._xml_start_tag("sheetViews")
6030
6031 # Write the sheetView element.
6032 self._write_sheet_view()
6033
6034 self._xml_end_tag("sheetViews")
6035
6036 def _write_sheet_view(self):
6037 # Write the <sheetViews> element.
6038 attributes = []
6039
6040 # Hide screen gridlines if required.
6041 if not self.screen_gridlines:
6042 attributes.append(("showGridLines", 0))
6043
6044 # Hide screen row/column headers.
6045 if self.row_col_headers:
6046 attributes.append(("showRowColHeaders", 0))
6047
6048 # Hide zeroes in cells.
6049 if not self.show_zeros:
6050 attributes.append(("showZeros", 0))
6051
6052 # Display worksheet right to left for Hebrew, Arabic and others.
6053 if self.is_right_to_left:
6054 attributes.append(("rightToLeft", 1))
6055
6056 # Show that the sheet tab is selected.
6057 if self.selected:
6058 attributes.append(("tabSelected", 1))
6059
6060 # Turn outlines off. Also required in the outlinePr element.
6061 if not self.outline_on:
6062 attributes.append(("showOutlineSymbols", 0))
6063
6064 # Set the page view/layout mode if required.
6065 if self.page_view == 1:
6066 attributes.append(("view", "pageLayout"))
6067 elif self.page_view == 2:
6068 attributes.append(("view", "pageBreakPreview"))
6069
6070 # Set the first visible cell.
6071 if self.top_left_cell != "":
6072 attributes.append(("topLeftCell", self.top_left_cell))
6073
6074 # Set the zoom level.
6075 if self.zoom != 100:
6076 attributes.append(("zoomScale", self.zoom))
6077
6078 if self.page_view == 0 and self.zoom_scale_normal:
6079 attributes.append(("zoomScaleNormal", self.zoom))
6080 if self.page_view == 1:
6081 attributes.append(("zoomScalePageLayoutView", self.zoom))
6082 if self.page_view == 2:
6083 attributes.append(("zoomScaleSheetLayoutView", self.zoom))
6084
6085 attributes.append(("workbookViewId", 0))
6086
6087 if self.panes or self.selections:
6088 self._xml_start_tag("sheetView", attributes)
6089 self._write_panes()
6090 self._write_selections()
6091 self._xml_end_tag("sheetView")
6092 else:
6093 self._xml_empty_tag("sheetView", attributes)
6094
6095 def _write_sheet_format_pr(self):
6096 # Write the <sheetFormatPr> element.
6097 default_row_height = self.default_row_height
6098 row_level = self.outline_row_level
6099 col_level = self.outline_col_level
6100
6101 attributes = [("defaultRowHeight", default_row_height)]
6102
6103 if self.default_row_height != self.original_row_height:
6104 attributes.append(("customHeight", 1))
6105
6106 if self.default_row_zeroed:
6107 attributes.append(("zeroHeight", 1))
6108
6109 if row_level:
6110 attributes.append(("outlineLevelRow", row_level))
6111 if col_level:
6112 attributes.append(("outlineLevelCol", col_level))
6113
6114 if self.excel_version == 2010:
6115 attributes.append(("x14ac:dyDescent", "0.25"))
6116
6117 self._xml_empty_tag("sheetFormatPr", attributes)
6118
6119 def _write_cols(self):
6120 # Write the <cols> element and <col> sub elements.
6121
6122 # Exit unless some column have been formatted.
6123 if not self.col_info:
6124 return
6125
6126 self._xml_start_tag("cols")
6127
6128 # Use the first element of the column information structures to set
6129 # the initial/previous properties.
6130 first_col = (sorted(self.col_info.keys()))[0]
6131 last_col = first_col
6132 prev_col_options = self.col_info[first_col]
6133 del self.col_info[first_col]
6134 deleted_col = first_col
6135 deleted_col_options = prev_col_options
6136
6137 for col in sorted(self.col_info.keys()):
6138 col_options = self.col_info[col]
6139 # Check if the column number is contiguous with the previous
6140 # column and if the properties are the same.
6141 if col == last_col + 1 and col_options == prev_col_options:
6142 last_col = col
6143 else:
6144 # If not contiguous/equal then we write out the current range
6145 # of columns and start again.
6146 self._write_col_info(first_col, last_col, prev_col_options)
6147 first_col = col
6148 last_col = first_col
6149 prev_col_options = col_options
6150
6151 # We will exit the previous loop with one unhandled column range.
6152 self._write_col_info(first_col, last_col, prev_col_options)
6153
6154 # Put back the deleted first column information structure.
6155 self.col_info[deleted_col] = deleted_col_options
6156
6157 self._xml_end_tag("cols")
6158
6159 def _write_col_info(self, col_min, col_max, col_info):
6160 # Write the <col> element.
6161 (width, cell_format, hidden, level, collapsed, autofit) = col_info
6162
6163 custom_width = 1
6164 xf_index = 0
6165
6166 # Get the cell_format index.
6167 if cell_format:
6168 xf_index = cell_format._get_xf_index()
6169
6170 # Set the Excel default column width.
6171 if width is None:
6172 if not hidden:
6173 width = 8.43
6174 custom_width = 0
6175 else:
6176 width = 0
6177 elif width == 8.43:
6178 # Width is defined but same as default.
6179 custom_width = 0
6180
6181 # Convert column width from user units to character width.
6182 if width > 0:
6183 # For Calabri 11.
6184 max_digit_width = 7
6185 padding = 5
6186
6187 if width < 1:
6188 width = (
6189 int(
6190 (int(width * (max_digit_width + padding) + 0.5))
6191 / float(max_digit_width)
6192 * 256.0
6193 )
6194 / 256.0
6195 )
6196 else:
6197 width = (
6198 int(
6199 (int(width * max_digit_width + 0.5) + padding)
6200 / float(max_digit_width)
6201 * 256.0
6202 )
6203 / 256.0
6204 )
6205
6206 attributes = [
6207 ("min", col_min + 1),
6208 ("max", col_max + 1),
6209 ("width", f"{width:.16g}"),
6210 ]
6211
6212 if xf_index:
6213 attributes.append(("style", xf_index))
6214 if hidden:
6215 attributes.append(("hidden", "1"))
6216 if autofit:
6217 attributes.append(("bestFit", "1"))
6218 if custom_width:
6219 attributes.append(("customWidth", "1"))
6220 if level:
6221 attributes.append(("outlineLevel", level))
6222 if collapsed:
6223 attributes.append(("collapsed", "1"))
6224
6225 self._xml_empty_tag("col", attributes)
6226
6227 def _write_sheet_data(self):
6228 # Write the <sheetData> element.
6229 if self.dim_rowmin is None:
6230 # If the dimensions aren't defined there is no data to write.
6231 self._xml_empty_tag("sheetData")
6232 else:
6233 self._xml_start_tag("sheetData")
6234 self._write_rows()
6235 self._xml_end_tag("sheetData")
6236
6237 def _write_optimized_sheet_data(self):
6238 # Write the <sheetData> element when constant_memory is on. In this
6239 # case we read the data stored in the temp file and rewrite it to the
6240 # XML sheet file.
6241 if self.dim_rowmin is None:
6242 # If the dimensions aren't defined then there is no data to write.
6243 self._xml_empty_tag("sheetData")
6244 else:
6245 self._xml_start_tag("sheetData")
6246
6247 # Rewind the filehandle that was used for temp row data.
6248 buff_size = 65536
6249 self.row_data_fh.seek(0)
6250 data = self.row_data_fh.read(buff_size)
6251
6252 while data:
6253 self.fh.write(data)
6254 data = self.row_data_fh.read(buff_size)
6255
6256 self.row_data_fh.close()
6257 os.unlink(self.row_data_filename)
6258
6259 self._xml_end_tag("sheetData")
6260
6261 def _write_page_margins(self):
6262 # Write the <pageMargins> element.
6263 attributes = [
6264 ("left", self.margin_left),
6265 ("right", self.margin_right),
6266 ("top", self.margin_top),
6267 ("bottom", self.margin_bottom),
6268 ("header", self.margin_header),
6269 ("footer", self.margin_footer),
6270 ]
6271
6272 self._xml_empty_tag("pageMargins", attributes)
6273
6274 def _write_page_setup(self):
6275 # Write the <pageSetup> element.
6276 #
6277 # The following is an example taken from Excel.
6278 #
6279 # <pageSetup
6280 # paperSize="9"
6281 # scale="110"
6282 # fitToWidth="2"
6283 # fitToHeight="2"
6284 # pageOrder="overThenDown"
6285 # orientation="portrait"
6286 # blackAndWhite="1"
6287 # draft="1"
6288 # horizontalDpi="200"
6289 # verticalDpi="200"
6290 # r:id="rId1"
6291 # />
6292 #
6293 attributes = []
6294
6295 # Skip this element if no page setup has changed.
6296 if not self.page_setup_changed:
6297 return
6298
6299 # Set paper size.
6300 if self.paper_size:
6301 attributes.append(("paperSize", self.paper_size))
6302
6303 # Set the print_scale.
6304 if self.print_scale != 100:
6305 attributes.append(("scale", self.print_scale))
6306
6307 # Set the "Fit to page" properties.
6308 if self.fit_page and self.fit_width != 1:
6309 attributes.append(("fitToWidth", self.fit_width))
6310
6311 if self.fit_page and self.fit_height != 1:
6312 attributes.append(("fitToHeight", self.fit_height))
6313
6314 # Set the page print direction.
6315 if self.page_order:
6316 attributes.append(("pageOrder", "overThenDown"))
6317
6318 # Set start page for printing.
6319 if self.page_start > 1:
6320 attributes.append(("firstPageNumber", self.page_start))
6321
6322 # Set page orientation.
6323 if self.orientation:
6324 attributes.append(("orientation", "portrait"))
6325 else:
6326 attributes.append(("orientation", "landscape"))
6327
6328 # Set the print in black and white option.
6329 if self.black_white:
6330 attributes.append(("blackAndWhite", "1"))
6331
6332 # Set start page for printing.
6333 if self.page_start != 0:
6334 attributes.append(("useFirstPageNumber", "1"))
6335
6336 # Set the DPI. Mainly only for testing.
6337 if self.is_chartsheet:
6338 if self.horizontal_dpi:
6339 attributes.append(("horizontalDpi", self.horizontal_dpi))
6340
6341 if self.vertical_dpi:
6342 attributes.append(("verticalDpi", self.vertical_dpi))
6343 else:
6344 if self.vertical_dpi:
6345 attributes.append(("verticalDpi", self.vertical_dpi))
6346
6347 if self.horizontal_dpi:
6348 attributes.append(("horizontalDpi", self.horizontal_dpi))
6349
6350 self._xml_empty_tag("pageSetup", attributes)
6351
6352 def _write_print_options(self):
6353 # Write the <printOptions> element.
6354 attributes = []
6355
6356 if not self.print_options_changed:
6357 return
6358
6359 # Set horizontal centering.
6360 if self.hcenter:
6361 attributes.append(("horizontalCentered", 1))
6362
6363 # Set vertical centering.
6364 if self.vcenter:
6365 attributes.append(("verticalCentered", 1))
6366
6367 # Enable row and column headers.
6368 if self.print_headers:
6369 attributes.append(("headings", 1))
6370
6371 # Set printed gridlines.
6372 if self.print_gridlines:
6373 attributes.append(("gridLines", 1))
6374
6375 self._xml_empty_tag("printOptions", attributes)
6376
6377 def _write_header_footer(self):
6378 # Write the <headerFooter> element.
6379 attributes = []
6380
6381 if not self.header_footer_scales:
6382 attributes.append(("scaleWithDoc", 0))
6383
6384 if not self.header_footer_aligns:
6385 attributes.append(("alignWithMargins", 0))
6386
6387 if self.header_footer_changed:
6388 self._xml_start_tag("headerFooter", attributes)
6389 if self.header:
6390 self._write_odd_header()
6391 if self.footer:
6392 self._write_odd_footer()
6393 self._xml_end_tag("headerFooter")
6394 elif self.excel2003_style:
6395 self._xml_empty_tag("headerFooter", attributes)
6396
6397 def _write_odd_header(self):
6398 # Write the <headerFooter> element.
6399 self._xml_data_element("oddHeader", self.header)
6400
6401 def _write_odd_footer(self):
6402 # Write the <headerFooter> element.
6403 self._xml_data_element("oddFooter", self.footer)
6404
6405 def _write_rows(self):
6406 # Write out the worksheet data as a series of rows and cells.
6407 self._calculate_spans()
6408
6409 for row_num in range(self.dim_rowmin, self.dim_rowmax + 1):
6410 if (
6411 row_num in self.set_rows
6412 or row_num in self.comments
6413 or self.table[row_num]
6414 ):
6415 # Only process rows with formatting, cell data and/or comments.
6416
6417 span_index = int(row_num / 16)
6418
6419 if span_index in self.row_spans:
6420 span = self.row_spans[span_index]
6421 else:
6422 span = None
6423
6424 if self.table[row_num]:
6425 # Write the cells if the row contains data.
6426 if row_num not in self.set_rows:
6427 self._write_row(row_num, span)
6428 else:
6429 self._write_row(row_num, span, self.set_rows[row_num])
6430
6431 for col_num in range(self.dim_colmin, self.dim_colmax + 1):
6432 if col_num in self.table[row_num]:
6433 col_ref = self.table[row_num][col_num]
6434 self._write_cell(row_num, col_num, col_ref)
6435
6436 self._xml_end_tag("row")
6437
6438 elif row_num in self.comments:
6439 # Row with comments in cells.
6440 self._write_empty_row(row_num, span, self.set_rows[row_num])
6441 else:
6442 # Blank row with attributes only.
6443 self._write_empty_row(row_num, span, self.set_rows[row_num])
6444
6445 def _write_single_row(self, current_row_num=0):
6446 # Write out the worksheet data as a single row with cells.
6447 # This method is used when constant_memory is on. A single
6448 # row is written and the data table is reset. That way only
6449 # one row of data is kept in memory at any one time. We don't
6450 # write span data in the optimized case since it is optional.
6451
6452 # Set the new previous row as the current row.
6453 row_num = self.previous_row
6454 self.previous_row = current_row_num
6455
6456 if row_num in self.set_rows or row_num in self.comments or self.table[row_num]:
6457 # Only process rows with formatting, cell data and/or comments.
6458
6459 # No span data in optimized mode.
6460 span = None
6461
6462 if self.table[row_num]:
6463 # Write the cells if the row contains data.
6464 if row_num not in self.set_rows:
6465 self._write_row(row_num, span)
6466 else:
6467 self._write_row(row_num, span, self.set_rows[row_num])
6468
6469 for col_num in range(self.dim_colmin, self.dim_colmax + 1):
6470 if col_num in self.table[row_num]:
6471 col_ref = self.table[row_num][col_num]
6472 self._write_cell(row_num, col_num, col_ref)
6473
6474 self._xml_end_tag("row")
6475 else:
6476 # Row attributes or comments only.
6477 self._write_empty_row(row_num, span, self.set_rows[row_num])
6478
6479 # Reset table.
6480 self.table.clear()
6481
6482 def _calculate_spans(self):
6483 # Calculate the "spans" attribute of the <row> tag. This is an
6484 # XLSX optimization and isn't strictly required. However, it
6485 # makes comparing files easier. The span is the same for each
6486 # block of 16 rows.
6487 spans = {}
6488 span_min = None
6489 span_max = None
6490
6491 for row_num in range(self.dim_rowmin, self.dim_rowmax + 1):
6492 if row_num in self.table:
6493 # Calculate spans for cell data.
6494 for col_num in range(self.dim_colmin, self.dim_colmax + 1):
6495 if col_num in self.table[row_num]:
6496 if span_min is None:
6497 span_min = col_num
6498 span_max = col_num
6499 else:
6500 span_min = min(span_min, col_num)
6501 span_max = max(span_max, col_num)
6502
6503 if row_num in self.comments:
6504 # Calculate spans for comments.
6505 for col_num in range(self.dim_colmin, self.dim_colmax + 1):
6506 if row_num in self.comments and col_num in self.comments[row_num]:
6507 if span_min is None:
6508 span_min = col_num
6509 span_max = col_num
6510 else:
6511 span_min = min(span_min, col_num)
6512 span_max = max(span_max, col_num)
6513
6514 if ((row_num + 1) % 16 == 0) or row_num == self.dim_rowmax:
6515 span_index = int(row_num / 16)
6516
6517 if span_min is not None:
6518 span_min += 1
6519 span_max += 1
6520 spans[span_index] = f"{span_min}:{span_max}"
6521 span_min = None
6522
6523 self.row_spans = spans
6524
6525 def _write_row(self, row, spans, properties=None, empty_row=False):
6526 # Write the <row> element.
6527 xf_index = 0
6528
6529 if properties:
6530 height, cell_format, hidden, level, collapsed = properties
6531 else:
6532 height, cell_format, hidden, level, collapsed = None, None, 0, 0, 0
6533
6534 if height is None:
6535 height = self.default_row_height
6536
6537 attributes = [("r", row + 1)]
6538
6539 # Get the cell_format index.
6540 if cell_format:
6541 xf_index = cell_format._get_xf_index()
6542
6543 # Add row attributes where applicable.
6544 if spans:
6545 attributes.append(("spans", spans))
6546
6547 if xf_index:
6548 attributes.append(("s", xf_index))
6549
6550 if cell_format:
6551 attributes.append(("customFormat", 1))
6552
6553 if height != self.original_row_height or (
6554 height == self.original_row_height and height != self.default_row_height
6555 ):
6556 attributes.append(("ht", f"{height:g}"))
6557
6558 if hidden:
6559 attributes.append(("hidden", 1))
6560
6561 if height != self.original_row_height or (
6562 height == self.original_row_height and height != self.default_row_height
6563 ):
6564 attributes.append(("customHeight", 1))
6565
6566 if level:
6567 attributes.append(("outlineLevel", level))
6568
6569 if collapsed:
6570 attributes.append(("collapsed", 1))
6571
6572 if self.excel_version == 2010:
6573 attributes.append(("x14ac:dyDescent", "0.25"))
6574
6575 if empty_row:
6576 self._xml_empty_tag_unencoded("row", attributes)
6577 else:
6578 self._xml_start_tag_unencoded("row", attributes)
6579
6580 def _write_empty_row(self, row, spans, properties=None):
6581 # Write and empty <row> element.
6582 self._write_row(row, spans, properties, empty_row=True)
6583
6584 def _write_cell(self, row, col, cell):
6585 # Write the <cell> element.
6586 # Note. This is the innermost loop so efficiency is important.
6587
6588 cell_range = xl_rowcol_to_cell_fast(row, col)
6589 attributes = [("r", cell_range)]
6590
6591 if cell.format:
6592 # Add the cell format index.
6593 xf_index = cell.format._get_xf_index()
6594 attributes.append(("s", xf_index))
6595 elif row in self.set_rows and self.set_rows[row][1]:
6596 # Add the row format.
6597 row_xf = self.set_rows[row][1]
6598 attributes.append(("s", row_xf._get_xf_index()))
6599 elif col in self.col_info:
6600 # Add the column format.
6601 col_xf = self.col_info[col][1]
6602 if col_xf is not None:
6603 attributes.append(("s", col_xf._get_xf_index()))
6604
6605 type_cell_name = cell.__class__.__name__
6606
6607 # Write the various cell types.
6608 if type_cell_name in ("Number", "Datetime"):
6609 # Write a number.
6610 self._xml_number_element(cell.number, attributes)
6611
6612 elif type_cell_name in ("String", "RichString"):
6613 # Write a string.
6614 string = cell.string
6615
6616 if not self.constant_memory:
6617 # Write a shared string.
6618 self._xml_string_element(string, attributes)
6619 else:
6620 # Write an optimized in-line string.
6621
6622 # Convert control character to a _xHHHH_ escape.
6623 string = self._escape_control_characters(string)
6624
6625 # Write any rich strings without further tags.
6626 if string.startswith("<r>") and string.endswith("</r>"):
6627 self._xml_rich_inline_string(string, attributes)
6628 else:
6629 # Add attribute to preserve leading or trailing whitespace.
6630 preserve = _preserve_whitespace(string)
6631 self._xml_inline_string(string, preserve, attributes)
6632
6633 elif type_cell_name == "Formula":
6634 # Write a formula. First check the formula value type.
6635 value = cell.value
6636 if isinstance(cell.value, bool):
6637 attributes.append(("t", "b"))
6638 if cell.value:
6639 value = 1
6640 else:
6641 value = 0
6642
6643 elif isinstance(cell.value, str):
6644 error_codes = (
6645 "#DIV/0!",
6646 "#N/A",
6647 "#NAME?",
6648 "#NULL!",
6649 "#NUM!",
6650 "#REF!",
6651 "#VALUE!",
6652 )
6653
6654 if cell.value == "":
6655 # Allow blank to force recalc in some third party apps.
6656 pass
6657 elif cell.value in error_codes:
6658 attributes.append(("t", "e"))
6659 else:
6660 attributes.append(("t", "str"))
6661
6662 self._xml_formula_element(cell.formula, value, attributes)
6663
6664 elif type_cell_name == "ArrayFormula":
6665 # Write a array formula.
6666
6667 if cell.atype == "dynamic":
6668 attributes.append(("cm", 1))
6669
6670 # First check if the formula value is a string.
6671 try:
6672 float(cell.value)
6673 except ValueError:
6674 attributes.append(("t", "str"))
6675
6676 # Write an array formula.
6677 self._xml_start_tag("c", attributes)
6678
6679 self._write_cell_array_formula(cell.formula, cell.range)
6680 self._write_cell_value(cell.value)
6681 self._xml_end_tag("c")
6682
6683 elif type_cell_name == "Blank":
6684 # Write a empty cell.
6685 self._xml_empty_tag("c", attributes)
6686
6687 elif type_cell_name == "Boolean":
6688 # Write a boolean cell.
6689 attributes.append(("t", "b"))
6690 self._xml_start_tag("c", attributes)
6691 self._write_cell_value(cell.boolean)
6692 self._xml_end_tag("c")
6693
6694 elif type_cell_name == "Error":
6695 # Write a boolean cell.
6696 attributes.append(("t", "e"))
6697 attributes.append(("vm", cell.value))
6698 self._xml_start_tag("c", attributes)
6699 self._write_cell_value(cell.error)
6700 self._xml_end_tag("c")
6701
6702 def _write_cell_value(self, value):
6703 # Write the cell value <v> element.
6704 if value is None:
6705 value = ""
6706
6707 self._xml_data_element("v", value)
6708
6709 def _write_cell_array_formula(self, formula, cell_range):
6710 # Write the cell array formula <f> element.
6711 attributes = [("t", "array"), ("ref", cell_range)]
6712
6713 self._xml_data_element("f", formula, attributes)
6714
6715 def _write_sheet_pr(self):
6716 # Write the <sheetPr> element for Sheet level properties.
6717 attributes = []
6718
6719 if (
6720 not self.fit_page
6721 and not self.filter_on
6722 and not self.tab_color
6723 and not self.outline_changed
6724 and not self.vba_codename
6725 ):
6726 return
6727
6728 if self.vba_codename:
6729 attributes.append(("codeName", self.vba_codename))
6730
6731 if self.filter_on:
6732 attributes.append(("filterMode", 1))
6733
6734 if self.fit_page or self.tab_color or self.outline_changed:
6735 self._xml_start_tag("sheetPr", attributes)
6736 self._write_tab_color()
6737 self._write_outline_pr()
6738 self._write_page_set_up_pr()
6739 self._xml_end_tag("sheetPr")
6740 else:
6741 self._xml_empty_tag("sheetPr", attributes)
6742
6743 def _write_page_set_up_pr(self):
6744 # Write the <pageSetUpPr> element.
6745 if not self.fit_page:
6746 return
6747
6748 attributes = [("fitToPage", 1)]
6749 self._xml_empty_tag("pageSetUpPr", attributes)
6750
6751 def _write_tab_color(self):
6752 # Write the <tabColor> element.
6753 color = self.tab_color
6754
6755 if not color:
6756 return
6757
6758 self._write_color("tabColor", color._attributes())
6759
6760 def _write_outline_pr(self):
6761 # Write the <outlinePr> element.
6762 attributes = []
6763
6764 if not self.outline_changed:
6765 return
6766
6767 if self.outline_style:
6768 attributes.append(("applyStyles", 1))
6769 if not self.outline_below:
6770 attributes.append(("summaryBelow", 0))
6771 if not self.outline_right:
6772 attributes.append(("summaryRight", 0))
6773 if not self.outline_on:
6774 attributes.append(("showOutlineSymbols", 0))
6775
6776 self._xml_empty_tag("outlinePr", attributes)
6777
6778 def _write_row_breaks(self):
6779 # Write the <rowBreaks> element.
6780 page_breaks = self._sort_pagebreaks(self.hbreaks)
6781
6782 if not page_breaks:
6783 return
6784
6785 count = len(page_breaks)
6786
6787 attributes = [
6788 ("count", count),
6789 ("manualBreakCount", count),
6790 ]
6791
6792 self._xml_start_tag("rowBreaks", attributes)
6793
6794 for row_num in page_breaks:
6795 self._write_brk(row_num, 16383)
6796
6797 self._xml_end_tag("rowBreaks")
6798
6799 def _write_col_breaks(self):
6800 # Write the <colBreaks> element.
6801 page_breaks = self._sort_pagebreaks(self.vbreaks)
6802
6803 if not page_breaks:
6804 return
6805
6806 count = len(page_breaks)
6807
6808 attributes = [
6809 ("count", count),
6810 ("manualBreakCount", count),
6811 ]
6812
6813 self._xml_start_tag("colBreaks", attributes)
6814
6815 for col_num in page_breaks:
6816 self._write_brk(col_num, 1048575)
6817
6818 self._xml_end_tag("colBreaks")
6819
6820 def _write_brk(self, brk_id, brk_max):
6821 # Write the <brk> element.
6822 attributes = [("id", brk_id), ("max", brk_max), ("man", 1)]
6823
6824 self._xml_empty_tag("brk", attributes)
6825
6826 def _write_merge_cells(self):
6827 # Write the <mergeCells> element.
6828 merged_cells = self.merge
6829 count = len(merged_cells)
6830
6831 if not count:
6832 return
6833
6834 attributes = [("count", count)]
6835
6836 self._xml_start_tag("mergeCells", attributes)
6837
6838 for merged_range in merged_cells:
6839 # Write the mergeCell element.
6840 self._write_merge_cell(merged_range)
6841
6842 self._xml_end_tag("mergeCells")
6843
6844 def _write_merge_cell(self, merged_range):
6845 # Write the <mergeCell> element.
6846 (row_min, col_min, row_max, col_max) = merged_range
6847
6848 # Convert the merge dimensions to a cell range.
6849 cell_1 = xl_rowcol_to_cell(row_min, col_min)
6850 cell_2 = xl_rowcol_to_cell(row_max, col_max)
6851 ref = cell_1 + ":" + cell_2
6852
6853 attributes = [("ref", ref)]
6854
6855 self._xml_empty_tag("mergeCell", attributes)
6856
6857 def _write_hyperlinks(self):
6858 # Process any stored hyperlinks in row/col order and write the
6859 # <hyperlinks> element. The attributes are different for internal
6860 # and external links.
6861
6862 # Sort the hyperlinks into row order.
6863 row_nums = sorted(self.hyperlinks.keys())
6864
6865 # Exit if there are no hyperlinks to process.
6866 if not row_nums:
6867 return
6868
6869 # Write the hyperlink elements.
6870 self._xml_start_tag("hyperlinks")
6871
6872 # Iterate over the rows.
6873 for row_num in row_nums:
6874 # Sort the hyperlinks into column order.
6875 col_nums = sorted(self.hyperlinks[row_num].keys())
6876
6877 # Iterate over the columns.
6878 for col_num in col_nums:
6879 # Get the link data for this cell.
6880 url = self.hyperlinks[row_num][col_num]
6881
6882 # If the cell was overwritten by the user and isn't a string
6883 # then we have to add the url as the string to display.
6884 if self.table and self.table[row_num] and self.table[row_num][col_num]:
6885 cell = self.table[row_num][col_num]
6886 if cell.__class__.__name__ != "String":
6887 url._is_object_link = True
6888
6889 if url._link_type in (UrlTypes.URL, UrlTypes.EXTERNAL):
6890 # External link with rel file relationship.
6891 self.rel_count += 1
6892
6893 self._write_hyperlink_external(
6894 row_num, col_num, self.rel_count, url
6895 )
6896
6897 # Links for use by the packager.
6898 self.external_hyper_links.append(
6899 ["/hyperlink", url._target(), "External"]
6900 )
6901 else:
6902 # Internal link with rel file relationship.
6903 self._write_hyperlink_internal(row_num, col_num, url)
6904
6905 self._xml_end_tag("hyperlinks")
6906
6907 def _write_hyperlink_external(self, row: int, col: int, id_num: int, url: Url):
6908 # Write the <hyperlink> element for external links.
6909 ref = xl_rowcol_to_cell(row, col)
6910 r_id = "rId" + str(id_num)
6911
6912 attributes = [("ref", ref), ("r:id", r_id)]
6913
6914 if url._anchor:
6915 attributes.append(("location", url._anchor))
6916
6917 if url._is_object_link:
6918 attributes.append(("display", url._text))
6919
6920 if url._tip:
6921 attributes.append(("tooltip", url._tip))
6922
6923 self._xml_empty_tag("hyperlink", attributes)
6924
6925 def _write_hyperlink_internal(self, row: int, col: int, url: Url):
6926 # Write the <hyperlink> element for internal links.
6927 ref = xl_rowcol_to_cell(row, col)
6928
6929 attributes = [("ref", ref), ("location", url._link)]
6930
6931 if url._tip:
6932 attributes.append(("tooltip", url._tip))
6933
6934 attributes.append(("display", url._text))
6935
6936 self._xml_empty_tag("hyperlink", attributes)
6937
6938 def _write_auto_filter(self):
6939 # Write the <autoFilter> element.
6940 if not self.autofilter_ref:
6941 return
6942
6943 attributes = [("ref", self.autofilter_ref)]
6944
6945 if self.filter_on:
6946 # Autofilter defined active filters.
6947 self._xml_start_tag("autoFilter", attributes)
6948 self._write_autofilters()
6949 self._xml_end_tag("autoFilter")
6950
6951 else:
6952 # Autofilter defined without active filters.
6953 self._xml_empty_tag("autoFilter", attributes)
6954
6955 def _write_autofilters(self):
6956 # Function to iterate through the columns that form part of an
6957 # autofilter range and write the appropriate filters.
6958 (col1, col2) = self.filter_range
6959
6960 for col in range(col1, col2 + 1):
6961 # Skip if column doesn't have an active filter.
6962 if col not in self.filter_cols:
6963 continue
6964
6965 # Retrieve the filter tokens and write the autofilter records.
6966 tokens = self.filter_cols[col]
6967 filter_type = self.filter_type[col]
6968
6969 # Filters are relative to first column in the autofilter.
6970 self._write_filter_column(col - col1, filter_type, tokens)
6971
6972 def _write_filter_column(self, col_id, filter_type, filters):
6973 # Write the <filterColumn> element.
6974 attributes = [("colId", col_id)]
6975
6976 self._xml_start_tag("filterColumn", attributes)
6977
6978 if filter_type == 1:
6979 # Type == 1 is the new XLSX style filter.
6980 self._write_filters(filters)
6981 else:
6982 # Type == 0 is the classic "custom" filter.
6983 self._write_custom_filters(filters)
6984
6985 self._xml_end_tag("filterColumn")
6986
6987 def _write_filters(self, filters):
6988 # Write the <filters> element.
6989 non_blanks = [filter for filter in filters if str(filter).lower() != "blanks"]
6990 attributes = []
6991
6992 if len(filters) != len(non_blanks):
6993 attributes = [("blank", 1)]
6994
6995 if len(filters) == 1 and len(non_blanks) == 0:
6996 # Special case for blank cells only.
6997 self._xml_empty_tag("filters", attributes)
6998 else:
6999 # General case.
7000 self._xml_start_tag("filters", attributes)
7001
7002 for autofilter in sorted(non_blanks):
7003 self._write_filter(autofilter)
7004
7005 self._xml_end_tag("filters")
7006
7007 def _write_filter(self, val):
7008 # Write the <filter> element.
7009 attributes = [("val", val)]
7010
7011 self._xml_empty_tag("filter", attributes)
7012
7013 def _write_custom_filters(self, tokens):
7014 # Write the <customFilters> element.
7015 if len(tokens) == 2:
7016 # One filter expression only.
7017 self._xml_start_tag("customFilters")
7018 self._write_custom_filter(*tokens)
7019 self._xml_end_tag("customFilters")
7020 else:
7021 # Two filter expressions.
7022 attributes = []
7023
7024 # Check if the "join" operand is "and" or "or".
7025 if tokens[2] == 0:
7026 attributes = [("and", 1)]
7027 else:
7028 attributes = [("and", 0)]
7029
7030 # Write the two custom filters.
7031 self._xml_start_tag("customFilters", attributes)
7032 self._write_custom_filter(tokens[0], tokens[1])
7033 self._write_custom_filter(tokens[3], tokens[4])
7034 self._xml_end_tag("customFilters")
7035
7036 def _write_custom_filter(self, operator, val):
7037 # Write the <customFilter> element.
7038 attributes = []
7039
7040 operators = {
7041 1: "lessThan",
7042 2: "equal",
7043 3: "lessThanOrEqual",
7044 4: "greaterThan",
7045 5: "notEqual",
7046 6: "greaterThanOrEqual",
7047 22: "equal",
7048 }
7049
7050 # Convert the operator from a number to a descriptive string.
7051 if operators[operator] is not None:
7052 operator = operators[operator]
7053 else:
7054 warn(f"Unknown operator = {operator}")
7055
7056 # The 'equal' operator is the default attribute and isn't stored.
7057 if operator != "equal":
7058 attributes.append(("operator", operator))
7059 attributes.append(("val", val))
7060
7061 self._xml_empty_tag("customFilter", attributes)
7062
7063 def _write_sheet_protection(self):
7064 # Write the <sheetProtection> element.
7065 attributes = []
7066
7067 if not self.protect_options:
7068 return
7069
7070 options = self.protect_options
7071
7072 if options["password"]:
7073 attributes.append(("password", options["password"]))
7074 if options["sheet"]:
7075 attributes.append(("sheet", 1))
7076 if options["content"]:
7077 attributes.append(("content", 1))
7078 if not options["objects"]:
7079 attributes.append(("objects", 1))
7080 if not options["scenarios"]:
7081 attributes.append(("scenarios", 1))
7082 if options["format_cells"]:
7083 attributes.append(("formatCells", 0))
7084 if options["format_columns"]:
7085 attributes.append(("formatColumns", 0))
7086 if options["format_rows"]:
7087 attributes.append(("formatRows", 0))
7088 if options["insert_columns"]:
7089 attributes.append(("insertColumns", 0))
7090 if options["insert_rows"]:
7091 attributes.append(("insertRows", 0))
7092 if options["insert_hyperlinks"]:
7093 attributes.append(("insertHyperlinks", 0))
7094 if options["delete_columns"]:
7095 attributes.append(("deleteColumns", 0))
7096 if options["delete_rows"]:
7097 attributes.append(("deleteRows", 0))
7098 if not options["select_locked_cells"]:
7099 attributes.append(("selectLockedCells", 1))
7100 if options["sort"]:
7101 attributes.append(("sort", 0))
7102 if options["autofilter"]:
7103 attributes.append(("autoFilter", 0))
7104 if options["pivot_tables"]:
7105 attributes.append(("pivotTables", 0))
7106 if not options["select_unlocked_cells"]:
7107 attributes.append(("selectUnlockedCells", 1))
7108
7109 self._xml_empty_tag("sheetProtection", attributes)
7110
7111 def _write_protected_ranges(self):
7112 # Write the <protectedRanges> element.
7113 if self.num_protected_ranges == 0:
7114 return
7115
7116 self._xml_start_tag("protectedRanges")
7117
7118 for cell_range, range_name, password in self.protected_ranges:
7119 self._write_protected_range(cell_range, range_name, password)
7120
7121 self._xml_end_tag("protectedRanges")
7122
7123 def _write_protected_range(self, cell_range, range_name, password):
7124 # Write the <protectedRange> element.
7125 attributes = []
7126
7127 if password:
7128 attributes.append(("password", password))
7129
7130 attributes.append(("sqref", cell_range))
7131 attributes.append(("name", range_name))
7132
7133 self._xml_empty_tag("protectedRange", attributes)
7134
7135 def _write_drawings(self):
7136 # Write the <drawing> elements.
7137 if not self.drawing:
7138 return
7139
7140 self.rel_count += 1
7141 self._write_drawing(self.rel_count)
7142
7143 def _write_drawing(self, drawing_id):
7144 # Write the <drawing> element.
7145 r_id = "rId" + str(drawing_id)
7146
7147 attributes = [("r:id", r_id)]
7148
7149 self._xml_empty_tag("drawing", attributes)
7150
7151 def _write_legacy_drawing(self):
7152 # Write the <legacyDrawing> element.
7153 if not self.has_vml:
7154 return
7155
7156 # Increment the relationship id for any drawings or comments.
7157 self.rel_count += 1
7158 r_id = "rId" + str(self.rel_count)
7159
7160 attributes = [("r:id", r_id)]
7161
7162 self._xml_empty_tag("legacyDrawing", attributes)
7163
7164 def _write_legacy_drawing_hf(self):
7165 # Write the <legacyDrawingHF> element.
7166 if not self.has_header_vml:
7167 return
7168
7169 # Increment the relationship id for any drawings or comments.
7170 self.rel_count += 1
7171 r_id = "rId" + str(self.rel_count)
7172
7173 attributes = [("r:id", r_id)]
7174
7175 self._xml_empty_tag("legacyDrawingHF", attributes)
7176
7177 def _write_picture(self):
7178 # Write the <picture> element.
7179 if not self.background_image:
7180 return
7181
7182 # Increment the relationship id.
7183 self.rel_count += 1
7184 r_id = "rId" + str(self.rel_count)
7185
7186 attributes = [("r:id", r_id)]
7187
7188 self._xml_empty_tag("picture", attributes)
7189
7190 def _write_data_validations(self):
7191 # Write the <dataValidations> element.
7192 validations = self.validations
7193 count = len(validations)
7194
7195 if not count:
7196 return
7197
7198 attributes = [("count", count)]
7199
7200 self._xml_start_tag("dataValidations", attributes)
7201
7202 for validation in validations:
7203 # Write the dataValidation element.
7204 self._write_data_validation(validation)
7205
7206 self._xml_end_tag("dataValidations")
7207
7208 def _write_data_validation(self, options):
7209 # Write the <dataValidation> element.
7210 sqref = ""
7211 attributes = []
7212
7213 # Set the cell range(s) for the data validation.
7214 for cells in options["cells"]:
7215 # Add a space between multiple cell ranges.
7216 if sqref != "":
7217 sqref += " "
7218
7219 (row_first, col_first, row_last, col_last) = cells
7220
7221 # Swap last row/col for first row/col as necessary
7222 if row_first > row_last:
7223 (row_first, row_last) = (row_last, row_first)
7224
7225 if col_first > col_last:
7226 (col_first, col_last) = (col_last, col_first)
7227
7228 sqref += xl_range(row_first, col_first, row_last, col_last)
7229
7230 if options.get("multi_range"):
7231 sqref = options["multi_range"]
7232
7233 if options["validate"] != "none":
7234 attributes.append(("type", options["validate"]))
7235
7236 if options["criteria"] != "between":
7237 attributes.append(("operator", options["criteria"]))
7238
7239 if "error_type" in options:
7240 if options["error_type"] == 1:
7241 attributes.append(("errorStyle", "warning"))
7242 if options["error_type"] == 2:
7243 attributes.append(("errorStyle", "information"))
7244
7245 if options["ignore_blank"]:
7246 attributes.append(("allowBlank", 1))
7247
7248 if not options["dropdown"]:
7249 attributes.append(("showDropDown", 1))
7250
7251 if options["show_input"]:
7252 attributes.append(("showInputMessage", 1))
7253
7254 if options["show_error"]:
7255 attributes.append(("showErrorMessage", 1))
7256
7257 if "error_title" in options:
7258 attributes.append(("errorTitle", options["error_title"]))
7259
7260 if "error_message" in options:
7261 attributes.append(("error", options["error_message"]))
7262
7263 if "input_title" in options:
7264 attributes.append(("promptTitle", options["input_title"]))
7265
7266 if "input_message" in options:
7267 attributes.append(("prompt", options["input_message"]))
7268
7269 attributes.append(("sqref", sqref))
7270
7271 if options["validate"] == "none":
7272 self._xml_empty_tag("dataValidation", attributes)
7273 else:
7274 self._xml_start_tag("dataValidation", attributes)
7275
7276 # Write the formula1 element.
7277 self._write_formula_1(options["value"])
7278
7279 # Write the formula2 element.
7280 if options["maximum"] is not None:
7281 self._write_formula_2(options["maximum"])
7282
7283 self._xml_end_tag("dataValidation")
7284
7285 def _write_formula_1(self, formula):
7286 # Write the <formula1> element.
7287
7288 if isinstance(formula, list):
7289 formula = self._csv_join(*formula)
7290 formula = f'"{formula}"'
7291 else:
7292 # Check if the formula is a number.
7293 try:
7294 float(formula)
7295 except ValueError:
7296 # Not a number. Remove the formula '=' sign if it exists.
7297 if formula.startswith("="):
7298 formula = formula.lstrip("=")
7299
7300 self._xml_data_element("formula1", formula)
7301
7302 def _write_formula_2(self, formula):
7303 # Write the <formula2> element.
7304
7305 # Check if the formula is a number.
7306 try:
7307 float(formula)
7308 except ValueError:
7309 # Not a number. Remove the formula '=' sign if it exists.
7310 if formula.startswith("="):
7311 formula = formula.lstrip("=")
7312
7313 self._xml_data_element("formula2", formula)
7314
7315 def _write_conditional_formats(self):
7316 # Write the Worksheet conditional formats.
7317 ranges = sorted(self.cond_formats.keys())
7318
7319 if not ranges:
7320 return
7321
7322 for cond_range in ranges:
7323 self._write_conditional_formatting(
7324 cond_range, self.cond_formats[cond_range]
7325 )
7326
7327 def _write_conditional_formatting(self, cond_range, params):
7328 # Write the <conditionalFormatting> element.
7329 attributes = [("sqref", cond_range)]
7330 self._xml_start_tag("conditionalFormatting", attributes)
7331 for param in params:
7332 # Write the cfRule element.
7333 self._write_cf_rule(param)
7334 self._xml_end_tag("conditionalFormatting")
7335
7336 def _write_cf_rule(self, params):
7337 # Write the <cfRule> element.
7338 attributes = [("type", params["type"])]
7339
7340 if "format" in params and params["format"] is not None:
7341 attributes.append(("dxfId", params["format"]))
7342
7343 attributes.append(("priority", params["priority"]))
7344
7345 if params.get("stop_if_true"):
7346 attributes.append(("stopIfTrue", 1))
7347
7348 if params["type"] == "cellIs":
7349 attributes.append(("operator", params["criteria"]))
7350
7351 self._xml_start_tag("cfRule", attributes)
7352
7353 if "minimum" in params and "maximum" in params:
7354 self._write_formula_element(params["minimum"])
7355 self._write_formula_element(params["maximum"])
7356 else:
7357 self._write_formula_element(params["value"])
7358
7359 self._xml_end_tag("cfRule")
7360
7361 elif params["type"] == "aboveAverage":
7362 if re.search("below", params["criteria"]):
7363 attributes.append(("aboveAverage", 0))
7364
7365 if re.search("equal", params["criteria"]):
7366 attributes.append(("equalAverage", 1))
7367
7368 if re.search("[123] std dev", params["criteria"]):
7369 match = re.search("([123]) std dev", params["criteria"])
7370 attributes.append(("stdDev", match.group(1)))
7371
7372 self._xml_empty_tag("cfRule", attributes)
7373
7374 elif params["type"] == "top10":
7375 if "criteria" in params and params["criteria"] == "%":
7376 attributes.append(("percent", 1))
7377
7378 if "direction" in params:
7379 attributes.append(("bottom", 1))
7380
7381 rank = params["value"] or 10
7382 attributes.append(("rank", rank))
7383
7384 self._xml_empty_tag("cfRule", attributes)
7385
7386 elif params["type"] == "duplicateValues":
7387 self._xml_empty_tag("cfRule", attributes)
7388
7389 elif params["type"] == "uniqueValues":
7390 self._xml_empty_tag("cfRule", attributes)
7391
7392 elif (
7393 params["type"] == "containsText"
7394 or params["type"] == "notContainsText"
7395 or params["type"] == "beginsWith"
7396 or params["type"] == "endsWith"
7397 ):
7398 attributes.append(("operator", params["criteria"]))
7399 attributes.append(("text", params["value"]))
7400 self._xml_start_tag("cfRule", attributes)
7401 self._write_formula_element(params["formula"])
7402 self._xml_end_tag("cfRule")
7403
7404 elif params["type"] == "timePeriod":
7405 attributes.append(("timePeriod", params["criteria"]))
7406 self._xml_start_tag("cfRule", attributes)
7407 self._write_formula_element(params["formula"])
7408 self._xml_end_tag("cfRule")
7409
7410 elif (
7411 params["type"] == "containsBlanks"
7412 or params["type"] == "notContainsBlanks"
7413 or params["type"] == "containsErrors"
7414 or params["type"] == "notContainsErrors"
7415 ):
7416 self._xml_start_tag("cfRule", attributes)
7417 self._write_formula_element(params["formula"])
7418 self._xml_end_tag("cfRule")
7419
7420 elif params["type"] == "colorScale":
7421 self._xml_start_tag("cfRule", attributes)
7422 self._write_color_scale(params)
7423 self._xml_end_tag("cfRule")
7424
7425 elif params["type"] == "dataBar":
7426 self._xml_start_tag("cfRule", attributes)
7427 self._write_data_bar(params)
7428
7429 if params.get("is_data_bar_2010"):
7430 self._write_data_bar_ext(params)
7431
7432 self._xml_end_tag("cfRule")
7433
7434 elif params["type"] == "expression":
7435 self._xml_start_tag("cfRule", attributes)
7436 self._write_formula_element(params["criteria"])
7437 self._xml_end_tag("cfRule")
7438
7439 elif params["type"] == "iconSet":
7440 self._xml_start_tag("cfRule", attributes)
7441 self._write_icon_set(params)
7442 self._xml_end_tag("cfRule")
7443
7444 def _write_formula_element(self, formula):
7445 # Write the <formula> element.
7446
7447 # Check if the formula is a number.
7448 try:
7449 float(formula)
7450 except ValueError:
7451 # Not a number. Remove the formula '=' sign if it exists.
7452 if formula.startswith("="):
7453 formula = formula.lstrip("=")
7454
7455 self._xml_data_element("formula", formula)
7456
7457 def _write_color_scale(self, param):
7458 # Write the <colorScale> element.
7459
7460 self._xml_start_tag("colorScale")
7461
7462 self._write_cfvo(param["min_type"], param["min_value"])
7463
7464 if param["mid_type"] is not None:
7465 self._write_cfvo(param["mid_type"], param["mid_value"])
7466
7467 self._write_cfvo(param["max_type"], param["max_value"])
7468
7469 self._write_color("color", param["min_color"]._attributes())
7470
7471 if param["mid_color"] is not None:
7472 self._write_color("color", param["mid_color"]._attributes())
7473
7474 self._write_color("color", param["max_color"]._attributes())
7475
7476 self._xml_end_tag("colorScale")
7477
7478 def _write_data_bar(self, param):
7479 # Write the <dataBar> element.
7480 attributes = []
7481
7482 # Min and max bar lengths in in the spec but not supported directly by
7483 # Excel.
7484 if "min_length" in param:
7485 attributes.append(("minLength", param["min_length"]))
7486
7487 if "max_length" in param:
7488 attributes.append(("maxLength", param["max_length"]))
7489
7490 if param.get("bar_only"):
7491 attributes.append(("showValue", 0))
7492
7493 self._xml_start_tag("dataBar", attributes)
7494
7495 self._write_cfvo(param["min_type"], param["min_value"])
7496 self._write_cfvo(param["max_type"], param["max_value"])
7497 self._write_color("color", param["bar_color"]._attributes())
7498
7499 self._xml_end_tag("dataBar")
7500
7501 def _write_data_bar_ext(self, param):
7502 # Write the <extLst> dataBar extension element.
7503
7504 # Create a pseudo GUID for each unique Excel 2010 data bar.
7505 worksheet_count = self.index + 1
7506 data_bar_count = len(self.data_bars_2010) + 1
7507 guid = "{DA7ABA51-AAAA-BBBB-%04X-%012X}" % (worksheet_count, data_bar_count)
7508
7509 # Store the 2010 data bar parameters to write the extLst elements.
7510 param["guid"] = guid
7511 self.data_bars_2010.append(param)
7512
7513 self._xml_start_tag("extLst")
7514 self._write_ext("{B025F937-C7B1-47D3-B67F-A62EFF666E3E}")
7515 self._xml_data_element("x14:id", guid)
7516 self._xml_end_tag("ext")
7517 self._xml_end_tag("extLst")
7518
7519 def _write_icon_set(self, param):
7520 # Write the <iconSet> element.
7521 attributes = []
7522
7523 # Don't set attribute for default style.
7524 if param["icon_style"] != "3TrafficLights":
7525 attributes = [("iconSet", param["icon_style"])]
7526
7527 if param.get("icons_only"):
7528 attributes.append(("showValue", 0))
7529
7530 if param.get("reverse_icons"):
7531 attributes.append(("reverse", 1))
7532
7533 self._xml_start_tag("iconSet", attributes)
7534
7535 # Write the properties for different icon styles.
7536 for icon in reversed(param["icons"]):
7537 self._write_cfvo(icon["type"], icon["value"], icon["criteria"])
7538
7539 self._xml_end_tag("iconSet")
7540
7541 def _write_cfvo(self, cf_type, val, criteria=None):
7542 # Write the <cfvo> element.
7543 attributes = [("type", cf_type)]
7544
7545 if val is not None:
7546 attributes.append(("val", val))
7547
7548 if criteria:
7549 attributes.append(("gte", 0))
7550
7551 self._xml_empty_tag("cfvo", attributes)
7552
7553 def _write_color(self, name, attributes):
7554 # Write the <color> element.
7555 self._xml_empty_tag(name, attributes)
7556
7557 def _write_selections(self):
7558 # Write the <selection> elements.
7559 for selection in self.selections:
7560 self._write_selection(*selection)
7561
7562 def _write_selection(self, pane, active_cell, sqref):
7563 # Write the <selection> element.
7564 attributes = []
7565
7566 if pane:
7567 attributes.append(("pane", pane))
7568
7569 if active_cell:
7570 attributes.append(("activeCell", active_cell))
7571
7572 if sqref:
7573 attributes.append(("sqref", sqref))
7574
7575 self._xml_empty_tag("selection", attributes)
7576
7577 def _write_panes(self):
7578 # Write the frozen or split <pane> elements.
7579 panes = self.panes
7580
7581 if not panes:
7582 return
7583
7584 if panes[4] == 2:
7585 self._write_split_panes(*panes)
7586 else:
7587 self._write_freeze_panes(*panes)
7588
7589 def _write_freeze_panes(self, row, col, top_row, left_col, pane_type):
7590 # Write the <pane> element for freeze panes.
7591 attributes = []
7592
7593 y_split = row
7594 x_split = col
7595 top_left_cell = xl_rowcol_to_cell(top_row, left_col)
7596 active_pane = ""
7597 state = ""
7598 active_cell = ""
7599 sqref = ""
7600
7601 # Move user cell selection to the panes.
7602 if self.selections:
7603 (_, active_cell, sqref) = self.selections[0]
7604 self.selections = []
7605
7606 # Set the active pane.
7607 if row and col:
7608 active_pane = "bottomRight"
7609
7610 row_cell = xl_rowcol_to_cell(row, 0)
7611 col_cell = xl_rowcol_to_cell(0, col)
7612
7613 self.selections.append(["topRight", col_cell, col_cell])
7614 self.selections.append(["bottomLeft", row_cell, row_cell])
7615 self.selections.append(["bottomRight", active_cell, sqref])
7616
7617 elif col:
7618 active_pane = "topRight"
7619 self.selections.append(["topRight", active_cell, sqref])
7620
7621 else:
7622 active_pane = "bottomLeft"
7623 self.selections.append(["bottomLeft", active_cell, sqref])
7624
7625 # Set the pane type.
7626 if pane_type == 0:
7627 state = "frozen"
7628 elif pane_type == 1:
7629 state = "frozenSplit"
7630 else:
7631 state = "split"
7632
7633 if x_split:
7634 attributes.append(("xSplit", x_split))
7635
7636 if y_split:
7637 attributes.append(("ySplit", y_split))
7638
7639 attributes.append(("topLeftCell", top_left_cell))
7640 attributes.append(("activePane", active_pane))
7641 attributes.append(("state", state))
7642
7643 self._xml_empty_tag("pane", attributes)
7644
7645 def _write_split_panes(self, row, col, top_row, left_col, _):
7646 # Write the <pane> element for split panes.
7647 attributes = []
7648 has_selection = False
7649 active_pane = ""
7650 active_cell = ""
7651 sqref = ""
7652
7653 y_split = row
7654 x_split = col
7655
7656 # Move user cell selection to the panes.
7657 if self.selections:
7658 (_, active_cell, sqref) = self.selections[0]
7659 self.selections = []
7660 has_selection = True
7661
7662 # Convert the row and col to 1/20 twip units with padding.
7663 if y_split:
7664 y_split = int(20 * y_split + 300)
7665
7666 if x_split:
7667 x_split = self._calculate_x_split_width(x_split)
7668
7669 # For non-explicit topLeft definitions, estimate the cell offset based
7670 # on the pixels dimensions. This is only a workaround and doesn't take
7671 # adjusted cell dimensions into account.
7672 if top_row == row and left_col == col:
7673 top_row = int(0.5 + (y_split - 300) / 20 / 15)
7674 left_col = int(0.5 + (x_split - 390) / 20 / 3 * 4 / 64)
7675
7676 top_left_cell = xl_rowcol_to_cell(top_row, left_col)
7677
7678 # If there is no selection set the active cell to the top left cell.
7679 if not has_selection:
7680 active_cell = top_left_cell
7681 sqref = top_left_cell
7682
7683 # Set the Cell selections.
7684 if row and col:
7685 active_pane = "bottomRight"
7686
7687 row_cell = xl_rowcol_to_cell(top_row, 0)
7688 col_cell = xl_rowcol_to_cell(0, left_col)
7689
7690 self.selections.append(["topRight", col_cell, col_cell])
7691 self.selections.append(["bottomLeft", row_cell, row_cell])
7692 self.selections.append(["bottomRight", active_cell, sqref])
7693
7694 elif col:
7695 active_pane = "topRight"
7696 self.selections.append(["topRight", active_cell, sqref])
7697
7698 else:
7699 active_pane = "bottomLeft"
7700 self.selections.append(["bottomLeft", active_cell, sqref])
7701
7702 # Format splits to the same precision as Excel.
7703 if x_split:
7704 attributes.append(("xSplit", f"{x_split:.16g}"))
7705
7706 if y_split:
7707 attributes.append(("ySplit", f"{y_split:.16g}"))
7708
7709 attributes.append(("topLeftCell", top_left_cell))
7710
7711 if has_selection:
7712 attributes.append(("activePane", active_pane))
7713
7714 self._xml_empty_tag("pane", attributes)
7715
7716 def _calculate_x_split_width(self, width):
7717 # Convert column width from user units to pane split width.
7718
7719 max_digit_width = 7 # For Calabri 11.
7720 padding = 5
7721
7722 # Convert to pixels.
7723 if width < 1:
7724 pixels = int(width * (max_digit_width + padding) + 0.5)
7725 else:
7726 pixels = int(width * max_digit_width + 0.5) + padding
7727
7728 # Convert to points.
7729 points = pixels * 3 / 4
7730
7731 # Convert to twips (twentieths of a point).
7732 twips = points * 20
7733
7734 # Add offset/padding.
7735 width = twips + 390
7736
7737 return width
7738
7739 def _write_table_parts(self):
7740 # Write the <tableParts> element.
7741 tables = self.tables
7742 count = len(tables)
7743
7744 # Return if worksheet doesn't contain any tables.
7745 if not count:
7746 return
7747
7748 attributes = [
7749 (
7750 "count",
7751 count,
7752 )
7753 ]
7754
7755 self._xml_start_tag("tableParts", attributes)
7756
7757 for _ in tables:
7758 # Write the tablePart element.
7759 self.rel_count += 1
7760 self._write_table_part(self.rel_count)
7761
7762 self._xml_end_tag("tableParts")
7763
7764 def _write_table_part(self, r_id):
7765 # Write the <tablePart> element.
7766
7767 r_id = "rId" + str(r_id)
7768
7769 attributes = [
7770 (
7771 "r:id",
7772 r_id,
7773 )
7774 ]
7775
7776 self._xml_empty_tag("tablePart", attributes)
7777
7778 def _write_ext_list(self):
7779 # Write the <extLst> element for data bars and sparklines.
7780 has_data_bars = len(self.data_bars_2010)
7781 has_sparklines = len(self.sparklines)
7782
7783 if not has_data_bars and not has_sparklines:
7784 return
7785
7786 # Write the extLst element.
7787 self._xml_start_tag("extLst")
7788
7789 if has_data_bars:
7790 self._write_ext_list_data_bars()
7791
7792 if has_sparklines:
7793 self._write_ext_list_sparklines()
7794
7795 self._xml_end_tag("extLst")
7796
7797 def _write_ext_list_data_bars(self):
7798 # Write the Excel 2010 data_bar subelements.
7799 self._write_ext("{78C0D931-6437-407d-A8EE-F0AAD7539E65}")
7800
7801 self._xml_start_tag("x14:conditionalFormattings")
7802
7803 # Write the Excel 2010 conditional formatting data bar elements.
7804 for data_bar in self.data_bars_2010:
7805 # Write the x14:conditionalFormatting element.
7806 self._write_conditional_formatting_2010(data_bar)
7807
7808 self._xml_end_tag("x14:conditionalFormattings")
7809 self._xml_end_tag("ext")
7810
7811 def _write_conditional_formatting_2010(self, data_bar):
7812 # Write the <x14:conditionalFormatting> element.
7813 xmlns_xm = "http://schemas.microsoft.com/office/excel/2006/main"
7814
7815 attributes = [("xmlns:xm", xmlns_xm)]
7816
7817 self._xml_start_tag("x14:conditionalFormatting", attributes)
7818
7819 # Write the x14:cfRule element.
7820 self._write_x14_cf_rule(data_bar)
7821
7822 # Write the x14:dataBar element.
7823 self._write_x14_data_bar(data_bar)
7824
7825 # Write the x14 max and min data bars.
7826 self._write_x14_cfvo(data_bar["x14_min_type"], data_bar["min_value"])
7827 self._write_x14_cfvo(data_bar["x14_max_type"], data_bar["max_value"])
7828
7829 if not data_bar["bar_no_border"]:
7830 # Write the x14:borderColor element.
7831 self._write_x14_border_color(data_bar["bar_border_color"])
7832
7833 # Write the x14:negativeFillColor element.
7834 if not data_bar["bar_negative_color_same"]:
7835 self._write_x14_negative_fill_color(data_bar["bar_negative_color"])
7836
7837 # Write the x14:negativeBorderColor element.
7838 if (
7839 not data_bar["bar_no_border"]
7840 and not data_bar["bar_negative_border_color_same"]
7841 ):
7842 self._write_x14_negative_border_color(data_bar["bar_negative_border_color"])
7843
7844 # Write the x14:axisColor element.
7845 if data_bar["bar_axis_position"] != "none":
7846 self._write_x14_axis_color(data_bar["bar_axis_color"])
7847
7848 self._xml_end_tag("x14:dataBar")
7849 self._xml_end_tag("x14:cfRule")
7850
7851 # Write the xm:sqref element.
7852 self._xml_data_element("xm:sqref", data_bar["range"])
7853
7854 self._xml_end_tag("x14:conditionalFormatting")
7855
7856 def _write_x14_cf_rule(self, data_bar):
7857 # Write the <x14:cfRule> element.
7858 rule_type = "dataBar"
7859 guid = data_bar["guid"]
7860 attributes = [("type", rule_type), ("id", guid)]
7861
7862 self._xml_start_tag("x14:cfRule", attributes)
7863
7864 def _write_x14_data_bar(self, data_bar):
7865 # Write the <x14:dataBar> element.
7866 min_length = 0
7867 max_length = 100
7868
7869 attributes = [
7870 ("minLength", min_length),
7871 ("maxLength", max_length),
7872 ]
7873
7874 if not data_bar["bar_no_border"]:
7875 attributes.append(("border", 1))
7876
7877 if data_bar["bar_solid"]:
7878 attributes.append(("gradient", 0))
7879
7880 if data_bar["bar_direction"] == "left":
7881 attributes.append(("direction", "leftToRight"))
7882
7883 if data_bar["bar_direction"] == "right":
7884 attributes.append(("direction", "rightToLeft"))
7885
7886 if data_bar["bar_negative_color_same"]:
7887 attributes.append(("negativeBarColorSameAsPositive", 1))
7888
7889 if (
7890 not data_bar["bar_no_border"]
7891 and not data_bar["bar_negative_border_color_same"]
7892 ):
7893 attributes.append(("negativeBarBorderColorSameAsPositive", 0))
7894
7895 if data_bar["bar_axis_position"] == "middle":
7896 attributes.append(("axisPosition", "middle"))
7897
7898 if data_bar["bar_axis_position"] == "none":
7899 attributes.append(("axisPosition", "none"))
7900
7901 self._xml_start_tag("x14:dataBar", attributes)
7902
7903 def _write_x14_cfvo(self, rule_type, value):
7904 # Write the <x14:cfvo> element.
7905 attributes = [("type", rule_type)]
7906
7907 if rule_type in ("min", "max", "autoMin", "autoMax"):
7908 self._xml_empty_tag("x14:cfvo", attributes)
7909 else:
7910 self._xml_start_tag("x14:cfvo", attributes)
7911 self._xml_data_element("xm:f", value)
7912 self._xml_end_tag("x14:cfvo")
7913
7914 def _write_x14_border_color(self, color):
7915 # Write the <x14:borderColor> element.
7916 self._write_color("x14:borderColor", color._attributes())
7917
7918 def _write_x14_negative_fill_color(self, color):
7919 # Write the <x14:negativeFillColor> element.
7920 self._xml_empty_tag("x14:negativeFillColor", color._attributes())
7921
7922 def _write_x14_negative_border_color(self, color):
7923 # Write the <x14:negativeBorderColor> element.
7924 self._xml_empty_tag("x14:negativeBorderColor", color._attributes())
7925
7926 def _write_x14_axis_color(self, color):
7927 # Write the <x14:axisColor> element.
7928 self._xml_empty_tag("x14:axisColor", color._attributes())
7929
7930 def _write_ext_list_sparklines(self):
7931 # Write the sparkline extension sub-elements.
7932 self._write_ext("{05C60535-1F16-4fd2-B633-F4F36F0B64E0}")
7933
7934 # Write the x14:sparklineGroups element.
7935 self._write_sparkline_groups()
7936
7937 # Write the sparkline elements.
7938 for sparkline in reversed(self.sparklines):
7939 # Write the x14:sparklineGroup element.
7940 self._write_sparkline_group(sparkline)
7941
7942 # Write the x14:colorSeries element.
7943 self._write_color_series(sparkline["series_color"])
7944
7945 # Write the x14:colorNegative element.
7946 self._write_color_negative(sparkline["negative_color"])
7947
7948 # Write the x14:colorAxis element.
7949 self._write_color_axis()
7950
7951 # Write the x14:colorMarkers element.
7952 self._write_color_markers(sparkline["markers_color"])
7953
7954 # Write the x14:colorFirst element.
7955 self._write_color_first(sparkline["first_color"])
7956
7957 # Write the x14:colorLast element.
7958 self._write_color_last(sparkline["last_color"])
7959
7960 # Write the x14:colorHigh element.
7961 self._write_color_high(sparkline["high_color"])
7962
7963 # Write the x14:colorLow element.
7964 self._write_color_low(sparkline["low_color"])
7965
7966 if sparkline["date_axis"]:
7967 self._xml_data_element("xm:f", sparkline["date_axis"])
7968
7969 self._write_sparklines(sparkline)
7970
7971 self._xml_end_tag("x14:sparklineGroup")
7972
7973 self._xml_end_tag("x14:sparklineGroups")
7974 self._xml_end_tag("ext")
7975
7976 def _write_sparklines(self, sparkline):
7977 # Write the <x14:sparklines> element and <x14:sparkline> sub-elements.
7978
7979 # Write the sparkline elements.
7980 self._xml_start_tag("x14:sparklines")
7981
7982 for i in range(sparkline["count"]):
7983 spark_range = sparkline["ranges"][i]
7984 location = sparkline["locations"][i]
7985
7986 self._xml_start_tag("x14:sparkline")
7987 self._xml_data_element("xm:f", spark_range)
7988 self._xml_data_element("xm:sqref", location)
7989 self._xml_end_tag("x14:sparkline")
7990
7991 self._xml_end_tag("x14:sparklines")
7992
7993 def _write_ext(self, uri):
7994 # Write the <ext> element.
7995 schema = "http://schemas.microsoft.com/office/"
7996 xmlns_x14 = schema + "spreadsheetml/2009/9/main"
7997
7998 attributes = [
7999 ("xmlns:x14", xmlns_x14),
8000 ("uri", uri),
8001 ]
8002
8003 self._xml_start_tag("ext", attributes)
8004
8005 def _write_sparkline_groups(self):
8006 # Write the <x14:sparklineGroups> element.
8007 xmlns_xm = "http://schemas.microsoft.com/office/excel/2006/main"
8008
8009 attributes = [("xmlns:xm", xmlns_xm)]
8010
8011 self._xml_start_tag("x14:sparklineGroups", attributes)
8012
8013 def _write_sparkline_group(self, options):
8014 # Write the <x14:sparklineGroup> element.
8015 #
8016 # Example for order.
8017 #
8018 # <x14:sparklineGroup
8019 # manualMax="0"
8020 # manualMin="0"
8021 # lineWeight="2.25"
8022 # type="column"
8023 # dateAxis="1"
8024 # displayEmptyCellsAs="span"
8025 # markers="1"
8026 # high="1"
8027 # low="1"
8028 # first="1"
8029 # last="1"
8030 # negative="1"
8031 # displayXAxis="1"
8032 # displayHidden="1"
8033 # minAxisType="custom"
8034 # maxAxisType="custom"
8035 # rightToLeft="1">
8036 #
8037 empty = options.get("empty")
8038 attributes = []
8039
8040 if options.get("max") is not None:
8041 if options["max"] == "group":
8042 options["cust_max"] = "group"
8043 else:
8044 attributes.append(("manualMax", options["max"]))
8045 options["cust_max"] = "custom"
8046
8047 if options.get("min") is not None:
8048 if options["min"] == "group":
8049 options["cust_min"] = "group"
8050 else:
8051 attributes.append(("manualMin", options["min"]))
8052 options["cust_min"] = "custom"
8053
8054 # Ignore the default type attribute (line).
8055 if options["type"] != "line":
8056 attributes.append(("type", options["type"]))
8057
8058 if options.get("weight"):
8059 attributes.append(("lineWeight", options["weight"]))
8060
8061 if options.get("date_axis"):
8062 attributes.append(("dateAxis", 1))
8063
8064 if empty:
8065 attributes.append(("displayEmptyCellsAs", empty))
8066
8067 if options.get("markers"):
8068 attributes.append(("markers", 1))
8069
8070 if options.get("high"):
8071 attributes.append(("high", 1))
8072
8073 if options.get("low"):
8074 attributes.append(("low", 1))
8075
8076 if options.get("first"):
8077 attributes.append(("first", 1))
8078
8079 if options.get("last"):
8080 attributes.append(("last", 1))
8081
8082 if options.get("negative"):
8083 attributes.append(("negative", 1))
8084
8085 if options.get("axis"):
8086 attributes.append(("displayXAxis", 1))
8087
8088 if options.get("hidden"):
8089 attributes.append(("displayHidden", 1))
8090
8091 if options.get("cust_min"):
8092 attributes.append(("minAxisType", options["cust_min"]))
8093
8094 if options.get("cust_max"):
8095 attributes.append(("maxAxisType", options["cust_max"]))
8096
8097 if options.get("reverse"):
8098 attributes.append(("rightToLeft", 1))
8099
8100 self._xml_start_tag("x14:sparklineGroup", attributes)
8101
8102 def _write_spark_color(self, tag, color):
8103 # Helper function for the sparkline color functions below.
8104 if color:
8105 self._write_color(tag, color._attributes())
8106
8107 def _write_color_series(self, color):
8108 # Write the <x14:colorSeries> element.
8109 self._write_spark_color("x14:colorSeries", color)
8110
8111 def _write_color_negative(self, color):
8112 # Write the <x14:colorNegative> element.
8113 self._write_spark_color("x14:colorNegative", color)
8114
8115 def _write_color_axis(self):
8116 # Write the <x14:colorAxis> element.
8117 self._write_spark_color("x14:colorAxis", Color("#000000"))
8118
8119 def _write_color_markers(self, color):
8120 # Write the <x14:colorMarkers> element.
8121 self._write_spark_color("x14:colorMarkers", color)
8122
8123 def _write_color_first(self, color):
8124 # Write the <x14:colorFirst> element.
8125 self._write_spark_color("x14:colorFirst", color)
8126
8127 def _write_color_last(self, color):
8128 # Write the <x14:colorLast> element.
8129 self._write_spark_color("x14:colorLast", color)
8130
8131 def _write_color_high(self, color):
8132 # Write the <x14:colorHigh> element.
8133 self._write_spark_color("x14:colorHigh", color)
8134
8135 def _write_color_low(self, color):
8136 # Write the <x14:colorLow> element.
8137 self._write_spark_color("x14:colorLow", color)
8138
8139 def _write_phonetic_pr(self):
8140 # Write the <phoneticPr> element.
8141 attributes = [
8142 ("fontId", "0"),
8143 ("type", "noConversion"),
8144 ]
8145
8146 self._xml_empty_tag("phoneticPr", attributes)
8147
8148 def _write_ignored_errors(self):
8149 # Write the <ignoredErrors> element.
8150 if not self.ignored_errors:
8151 return
8152
8153 self._xml_start_tag("ignoredErrors")
8154
8155 if self.ignored_errors.get("number_stored_as_text"):
8156 ignored_range = self.ignored_errors["number_stored_as_text"]
8157 self._write_ignored_error("numberStoredAsText", ignored_range)
8158
8159 if self.ignored_errors.get("eval_error"):
8160 ignored_range = self.ignored_errors["eval_error"]
8161 self._write_ignored_error("evalError", ignored_range)
8162
8163 if self.ignored_errors.get("formula_differs"):
8164 ignored_range = self.ignored_errors["formula_differs"]
8165 self._write_ignored_error("formula", ignored_range)
8166
8167 if self.ignored_errors.get("formula_range"):
8168 ignored_range = self.ignored_errors["formula_range"]
8169 self._write_ignored_error("formulaRange", ignored_range)
8170
8171 if self.ignored_errors.get("formula_unlocked"):
8172 ignored_range = self.ignored_errors["formula_unlocked"]
8173 self._write_ignored_error("unlockedFormula", ignored_range)
8174
8175 if self.ignored_errors.get("empty_cell_reference"):
8176 ignored_range = self.ignored_errors["empty_cell_reference"]
8177 self._write_ignored_error("emptyCellReference", ignored_range)
8178
8179 if self.ignored_errors.get("list_data_validation"):
8180 ignored_range = self.ignored_errors["list_data_validation"]
8181 self._write_ignored_error("listDataValidation", ignored_range)
8182
8183 if self.ignored_errors.get("calculated_column"):
8184 ignored_range = self.ignored_errors["calculated_column"]
8185 self._write_ignored_error("calculatedColumn", ignored_range)
8186
8187 if self.ignored_errors.get("two_digit_text_year"):
8188 ignored_range = self.ignored_errors["two_digit_text_year"]
8189 self._write_ignored_error("twoDigitTextYear", ignored_range)
8190
8191 self._xml_end_tag("ignoredErrors")
8192
8193 def _write_ignored_error(self, error_type, ignored_range):
8194 # Write the <ignoredError> element.
8195 attributes = [
8196 ("sqref", ignored_range),
8197 (error_type, 1),
8198 ]
8199
8200 self._xml_empty_tag("ignoredError", attributes)