1###############################################################################
2#
3# Workbook - A class for writing the Excel XLSX Workbook file.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9
10# Standard packages.
11import operator
12import os
13import re
14import time
15from datetime import datetime, timezone
16from decimal import Decimal
17from fractions import Fraction
18from typing import Any, Dict, List, Literal, Optional, Union
19from warnings import warn
20from zipfile import ZIP_DEFLATED, LargeZipFile, ZipFile, ZipInfo
21
22from xlsxwriter.image import Image
23
24# Package imports.
25from . import xmlwriter
26from .chart_area import ChartArea
27from .chart_bar import ChartBar
28from .chart_column import ChartColumn
29from .chart_doughnut import ChartDoughnut
30from .chart_line import ChartLine
31from .chart_pie import ChartPie
32from .chart_radar import ChartRadar
33from .chart_scatter import ChartScatter
34from .chart_stock import ChartStock
35from .chartsheet import Chartsheet
36from .exceptions import (
37 DuplicateWorksheetName,
38 FileCreateError,
39 FileSizeError,
40 InvalidWorksheetName,
41)
42from .format import Format
43from .packager import Packager
44from .sharedstrings import SharedStringTable
45from .utility import xl_cell_to_rowcol
46from .worksheet import Worksheet
47
48
49class Workbook(xmlwriter.XMLwriter):
50 """
51 A class for writing the Excel XLSX Workbook file.
52
53
54 """
55
56 ###########################################################################
57 #
58 # Public API.
59 #
60 ###########################################################################
61 chartsheet_class = Chartsheet
62 worksheet_class = Worksheet
63
64 def __init__(
65 self, filename: Optional[str] = None, options: Optional[Dict[str, Any]] = None
66 ) -> None:
67 """
68 Constructor.
69
70 """
71 if options is None:
72 options = {}
73
74 super().__init__()
75
76 self.filename = filename
77
78 self.tmpdir = options.get("tmpdir", None)
79 self.date_1904 = options.get("date_1904", False)
80 self.strings_to_numbers = options.get("strings_to_numbers", False)
81 self.strings_to_formulas = options.get("strings_to_formulas", True)
82 self.strings_to_urls = options.get("strings_to_urls", True)
83 self.nan_inf_to_errors = options.get("nan_inf_to_errors", False)
84 self.default_date_format = options.get("default_date_format", None)
85 self.constant_memory = options.get("constant_memory", False)
86 self.in_memory = options.get("in_memory", False)
87 self.excel2003_style = options.get("excel2003_style", False)
88 self.remove_timezone = options.get("remove_timezone", False)
89 self.use_future_functions = options.get("use_future_functions", False)
90 self.default_format_properties = options.get("default_format_properties", {})
91
92 self.max_url_length = options.get("max_url_length", 2079)
93 if self.max_url_length < 255:
94 self.max_url_length = 2079
95
96 if options.get("use_zip64"):
97 self.allow_zip64 = True
98 else:
99 self.allow_zip64 = False
100
101 self.worksheet_meta = WorksheetMeta()
102 self.selected = 0
103 self.fileclosed = 0
104 self.filehandle = None
105 self.internal_fh = 0
106 self.sheet_name = "Sheet"
107 self.chart_name = "Chart"
108 self.sheetname_count = 0
109 self.chartname_count = 0
110 self.worksheets_objs = []
111 self.charts = []
112 self.drawings = []
113 self.sheetnames = {}
114 self.formats = []
115 self.xf_formats = []
116 self.xf_format_indices = {}
117 self.dxf_formats = []
118 self.dxf_format_indices = {}
119 self.palette = []
120 self.font_count = 0
121 self.num_formats = []
122 self.defined_names = []
123 self.named_ranges = []
124 self.custom_colors = []
125 self.doc_properties = {}
126 self.custom_properties = []
127 self.createtime = datetime.now(timezone.utc)
128 self.num_vml_files = 0
129 self.num_comment_files = 0
130 self.x_window = 240
131 self.y_window = 15
132 self.window_width = 16095
133 self.window_height = 9660
134 self.tab_ratio = 600
135 self.str_table = SharedStringTable()
136 self.vba_project = None
137 self.vba_project_is_stream = False
138 self.vba_project_signature = None
139 self.vba_project_signature_is_stream = False
140 self.vba_codename = None
141 self.image_types = {}
142 self.images = []
143 self.border_count = 0
144 self.fill_count = 0
145 self.drawing_count = 0
146 self.calc_mode = "auto"
147 self.calc_on_load = True
148 self.calc_id = 124519
149 self.has_comments = False
150 self.read_only = 0
151 self.has_metadata = False
152 self.has_embedded_images = False
153 self.has_dynamic_functions = False
154 self.has_embedded_descriptions = False
155 self.embedded_images = EmbeddedImages()
156 self.feature_property_bags = set()
157
158 # We can't do 'constant_memory' mode while doing 'in_memory' mode.
159 if self.in_memory:
160 self.constant_memory = False
161
162 # Add the default cell format.
163 if self.excel2003_style:
164 self.add_format({"xf_index": 0, "font_family": 0})
165 else:
166 self.add_format({"xf_index": 0})
167
168 # Add a default URL format.
169 self.default_url_format = self.add_format({"hyperlink": True})
170
171 # Add the default date format.
172 if self.default_date_format is not None:
173 self.default_date_format = self.add_format(
174 {"num_format": self.default_date_format}
175 )
176
177 def __enter__(self):
178 """Return self object to use with "with" statement."""
179 return self
180
181 def __exit__(self, type, value, traceback) -> None:
182 # pylint: disable=redefined-builtin
183 """Close workbook when exiting "with" statement."""
184 self.close()
185
186 def add_worksheet(
187 self, name: Optional[str] = None, worksheet_class=None
188 ) -> Worksheet:
189 """
190 Add a new worksheet to the Excel workbook.
191
192 Args:
193 name: The worksheet name. Defaults to 'Sheet1', etc.
194
195 Returns:
196 Reference to a worksheet object.
197
198 """
199 if worksheet_class is None:
200 worksheet_class = self.worksheet_class
201
202 return self._add_sheet(name, worksheet_class=worksheet_class)
203
204 def add_chartsheet(
205 self, name: Optional[str] = None, chartsheet_class=None
206 ) -> Chartsheet:
207 """
208 Add a new chartsheet to the Excel workbook.
209
210 Args:
211 name: The chartsheet name. Defaults to 'Sheet1', etc.
212
213 Returns:
214 Reference to a chartsheet object.
215
216 """
217 if chartsheet_class is None:
218 chartsheet_class = self.chartsheet_class
219
220 return self._add_sheet(name, worksheet_class=chartsheet_class)
221
222 def add_format(self, properties=None) -> Format:
223 """
224 Add a new Format to the Excel Workbook.
225
226 Args:
227 properties: The format properties.
228
229 Returns:
230 Reference to a Format object.
231
232 """
233 format_properties = self.default_format_properties.copy()
234
235 if self.excel2003_style:
236 format_properties = {"font_name": "Arial", "font_size": 10, "theme": 1 * -1}
237
238 if properties:
239 format_properties.update(properties)
240
241 xf_format = Format(
242 format_properties, self.xf_format_indices, self.dxf_format_indices
243 )
244
245 # Store the format reference.
246 self.formats.append(xf_format)
247
248 return xf_format
249
250 def add_chart(self, options: Dict[str, Any]) -> Optional[
251 Union[
252 ChartArea,
253 ChartBar,
254 ChartColumn,
255 ChartDoughnut,
256 ChartLine,
257 ChartPie,
258 ChartRadar,
259 ChartScatter,
260 ChartStock,
261 ]
262 ]:
263 """
264 Create a chart object.
265
266 Args:
267 options: The chart type and subtype options.
268
269 Returns:
270 Reference to a Chart object.
271
272 """
273
274 # Type must be specified so we can create the required chart instance.
275 chart_type = options.get("type")
276 if chart_type is None:
277 warn("Chart type must be defined in add_chart()")
278 return None
279
280 if chart_type == "area":
281 chart = ChartArea(options)
282 elif chart_type == "bar":
283 chart = ChartBar(options)
284 elif chart_type == "column":
285 chart = ChartColumn(options)
286 elif chart_type == "doughnut":
287 chart = ChartDoughnut()
288 elif chart_type == "line":
289 chart = ChartLine(options)
290 elif chart_type == "pie":
291 chart = ChartPie()
292 elif chart_type == "radar":
293 chart = ChartRadar(options)
294 elif chart_type == "scatter":
295 chart = ChartScatter(options)
296 elif chart_type == "stock":
297 chart = ChartStock()
298 else:
299 warn(f"Unknown chart type '{chart_type}' in add_chart()")
300 return None
301
302 # Set the embedded chart name if present.
303 if "name" in options:
304 chart.chart_name = options["name"]
305
306 chart.embedded = True
307 chart.date_1904 = self.date_1904
308 chart.remove_timezone = self.remove_timezone
309
310 self.charts.append(chart)
311
312 return chart
313
314 def add_vba_project(self, vba_project: str, is_stream: bool = False) -> int:
315 """
316 Add a vbaProject binary to the Excel workbook.
317
318 Args:
319 vba_project: The vbaProject binary file name.
320 is_stream: vba_project is an in memory byte stream.
321
322 Returns:
323 0 on success.
324
325 """
326 if not is_stream and not os.path.exists(vba_project):
327 warn(f"VBA project binary file '{vba_project}' not found.")
328 return -1
329
330 if self.vba_codename is None:
331 self.vba_codename = "ThisWorkbook"
332
333 self.vba_project = vba_project
334 self.vba_project_is_stream = is_stream
335
336 return 0
337
338 def add_signed_vba_project(
339 self,
340 vba_project: str,
341 signature: str,
342 project_is_stream: bool = False,
343 signature_is_stream: bool = False,
344 ) -> Literal[0, -1]:
345 """
346 Add a vbaProject binary and a vbaProjectSignature binary to the
347 Excel workbook.
348
349 Args:
350 vba_project: The vbaProject binary file name.
351 signature: The vbaProjectSignature binary file name.
352 project_is_stream: vba_project is an in memory byte stream.
353 signature_is_stream: signature is an in memory byte stream.
354
355 Returns:
356 0 on success.
357
358 """
359 if self.add_vba_project(vba_project, project_is_stream) == -1:
360 return -1
361
362 if not signature_is_stream and not os.path.exists(signature):
363 warn(f"VBA project signature binary file '{signature}' not found.")
364 return -1
365
366 self.vba_project_signature = signature
367 self.vba_project_signature_is_stream = signature_is_stream
368
369 return 0
370
371 def close(self) -> None:
372 """
373 Call finalization code and close file.
374
375 Args:
376 None.
377
378 Returns:
379 Nothing.
380
381 """
382 # pylint: disable=raise-missing-from
383 if not self.fileclosed:
384 try:
385 self._store_workbook()
386 except IOError as e:
387 raise FileCreateError(e)
388 except LargeZipFile:
389 raise FileSizeError(
390 "Filesize would require ZIP64 extensions. "
391 "Use workbook.use_zip64()."
392 )
393
394 self.fileclosed = True
395
396 # Ensure all constant_memory temp files are closed.
397 if self.constant_memory:
398 for worksheet in self.worksheets():
399 worksheet._opt_close()
400
401 else:
402 warn("Calling close() on already closed file.")
403
404 def set_size(self, width: int, height: int) -> None:
405 """
406 Set the size of a workbook window.
407
408 Args:
409 width: Width of the window in pixels.
410 height: Height of the window in pixels.
411
412 Returns:
413 Nothing.
414
415 """
416 # Convert the width/height to twips at 96 dpi.
417 if width:
418 self.window_width = int(width * 1440 / 96)
419 else:
420 self.window_width = 16095
421
422 if height:
423 self.window_height = int(height * 1440 / 96)
424 else:
425 self.window_height = 9660
426
427 def set_tab_ratio(self, tab_ratio: Optional[Union[int, float]] = None) -> None:
428 """
429 Set the ratio between worksheet tabs and the horizontal slider.
430
431 Args:
432 tab_ratio: The tab ratio, 0 <= tab_ratio <= 100
433
434 Returns:
435 Nothing.
436
437 """
438 if tab_ratio is None:
439 return
440
441 if tab_ratio < 0 or tab_ratio > 100:
442 warn(f"Tab ratio '{tab_ratio}' outside: 0 <= tab_ratio <= 100")
443 else:
444 self.tab_ratio = int(tab_ratio * 10)
445
446 def set_properties(self, properties) -> None:
447 """
448 Set the document properties such as Title, Author etc.
449
450 Args:
451 properties: Dictionary of document properties.
452
453 Returns:
454 Nothing.
455
456 """
457 self.doc_properties = properties
458
459 def set_custom_property(
460 self,
461 name: str,
462 value: Union[bool, datetime, int, float, Decimal, Fraction, Any],
463 property_type: Optional[
464 Literal["bool", "date", "number", "number_int", "text"]
465 ] = None,
466 ) -> Literal[0, -1]:
467 """
468 Set a custom document property.
469
470 Args:
471 name: The name of the custom property.
472 value: The value of the custom property.
473 property_type: The type of the custom property. Optional.
474
475 Returns:
476 0 on success.
477
478 """
479 if name is None or value is None:
480 warn(
481 "The name and value parameters must be non-None in "
482 "set_custom_property()"
483 )
484 return -1
485
486 if property_type is None:
487 # Determine the property type from the Python type.
488 if isinstance(value, bool):
489 property_type = "bool"
490 elif isinstance(value, datetime):
491 property_type = "date"
492 elif isinstance(value, int):
493 property_type = "number_int"
494 elif isinstance(value, (float, int, Decimal, Fraction)):
495 property_type = "number"
496 else:
497 property_type = "text"
498
499 # Convert non-string values to strings to have a single data type.
500 if property_type == "bool":
501 value = str(value).lower()
502
503 if property_type == "date":
504 value = value.strftime("%Y-%m-%dT%H:%M:%SZ")
505
506 if property_type in ("number", "number_int"):
507 value = str(value)
508
509 if property_type == "text" and len(value) > 255:
510 warn(
511 f"Length of 'value' parameter exceeds Excel's limit of 255 "
512 f"characters in set_custom_property(): '{value}'"
513 )
514
515 if len(name) > 255:
516 warn(
517 f"Length of 'name' parameter exceeds Excel's limit of 255 "
518 f"characters in set_custom_property(): '{name}'"
519 )
520
521 self.custom_properties.append((name, value, property_type))
522
523 return 0
524
525 def set_calc_mode(
526 self, mode: Literal["manual", "auto_except_tables", "auto"], calc_id=None
527 ) -> None:
528 """
529 Set the Excel calculation mode for the workbook.
530
531 Args:
532 mode: String containing one of:
533 * manual
534 * auto_except_tables
535 * auto
536
537 Returns:
538 Nothing.
539
540 """
541 self.calc_mode = mode
542
543 if mode == "manual":
544 self.calc_on_load = False
545 elif mode == "auto_except_tables":
546 self.calc_mode = "autoNoTable"
547
548 # Leave undocumented for now. Rarely required.
549 if calc_id:
550 self.calc_id = calc_id
551
552 def define_name(self, name: str, formula: str) -> Literal[0, -1]:
553 # Create a defined name in Excel. We handle global/workbook level
554 # names and local/worksheet names.
555 """
556 Create a defined name in the workbook.
557
558 Args:
559 name: The defined name.
560 formula: The cell or range that the defined name refers to.
561
562 Returns:
563 0 on success.
564
565 """
566 sheet_index = None
567 sheetname = ""
568
569 # Remove the = sign from the formula if it exists.
570 if formula.startswith("="):
571 formula = formula.lstrip("=")
572
573 # Local defined names are formatted like "Sheet1!name".
574 sheet_parts = re.compile(r"^([^!]+)!([^!]+)$")
575 match = sheet_parts.match(name)
576
577 if match:
578 sheetname = match.group(1)
579 name = match.group(2)
580 sheet_index = self._get_sheet_index(sheetname)
581
582 # Warn if the sheet index wasn't found.
583 if sheet_index is None:
584 warn(f"Unknown sheet name '{sheetname}' in defined_name()")
585 return -1
586 else:
587 # Use -1 to indicate global names.
588 sheet_index = -1
589
590 # Warn if the defined name contains invalid chars as defined by Excel.
591 if not re.match(r"^[\w\\][\w\\.]*$", name, re.UNICODE) or re.match(
592 r"^\d", name
593 ):
594 warn(f"Invalid Excel characters in defined_name(): '{name}'")
595 return -1
596
597 # Warn if the defined name looks like a cell name.
598 if re.match(r"^[a-zA-Z][a-zA-Z]?[a-dA-D]?\d+$", name):
599 warn(f"Name looks like a cell name in defined_name(): '{name}'")
600 return -1
601
602 # Warn if the name looks like a R1C1 cell reference.
603 if re.match(r"^[rcRC]$", name) or re.match(r"^[rcRC]\d+[rcRC]\d+$", name):
604 warn(f"Invalid name '{name}' like a RC cell ref in defined_name()")
605 return -1
606
607 self.defined_names.append([name, sheet_index, formula, False])
608
609 return 0
610
611 def worksheets(self) -> List[Worksheet]:
612 """
613 Return a list of the worksheet objects in the workbook.
614
615 Args:
616 None.
617
618 Returns:
619 A list of worksheet objects.
620
621 """
622 return self.worksheets_objs
623
624 def get_worksheet_by_name(self, name: str) -> Optional[Worksheet]:
625 """
626 Return a worksheet object in the workbook using the sheetname.
627
628 Args:
629 name: The name of the worksheet.
630
631 Returns:
632 A worksheet object or None.
633
634 """
635 return self.sheetnames.get(name)
636
637 def get_default_url_format(self) -> Format:
638 """
639 Get the default url format used when a user defined format isn't
640 specified with write_url(). The format is the hyperlink style defined
641 by Excel for the default theme.
642
643 Args:
644 None.
645
646 Returns:
647 A format object.
648
649 """
650 return self.default_url_format
651
652 def use_zip64(self) -> None:
653 """
654 Allow ZIP64 extensions when writing xlsx file zip container.
655
656 Args:
657 None.
658
659 Returns:
660 Nothing.
661
662 """
663 self.allow_zip64 = True
664
665 def set_vba_name(self, name: Optional[str] = None) -> None:
666 """
667 Set the VBA name for the workbook. By default the workbook is referred
668 to as ThisWorkbook in VBA.
669
670 Args:
671 name: The VBA name for the workbook.
672
673 Returns:
674 Nothing.
675
676 """
677 if name is not None:
678 self.vba_codename = name
679 else:
680 self.vba_codename = "ThisWorkbook"
681
682 def read_only_recommended(self) -> None:
683 """
684 Set the Excel "Read-only recommended" option when saving a file.
685
686 Args:
687 None.
688
689 Returns:
690 Nothing.
691
692 """
693 self.read_only = 2
694
695 ###########################################################################
696 #
697 # Private API.
698 #
699 ###########################################################################
700
701 def _assemble_xml_file(self) -> None:
702 # Assemble and write the XML file.
703
704 # Prepare format object for passing to Style.pm.
705 self._prepare_format_properties()
706
707 # Write the XML declaration.
708 self._xml_declaration()
709
710 # Write the workbook element.
711 self._write_workbook()
712
713 # Write the fileVersion element.
714 self._write_file_version()
715
716 # Write the fileSharing element.
717 self._write_file_sharing()
718
719 # Write the workbookPr element.
720 self._write_workbook_pr()
721
722 # Write the bookViews element.
723 self._write_book_views()
724
725 # Write the sheets element.
726 self._write_sheets()
727
728 # Write the workbook defined names.
729 self._write_defined_names()
730
731 # Write the calcPr element.
732 self._write_calc_pr()
733
734 # Close the workbook tag.
735 self._xml_end_tag("workbook")
736
737 # Close the file.
738 self._xml_close()
739
740 def _store_workbook(self) -> None:
741 # pylint: disable=consider-using-with
742 # Create the xlsx/zip file.
743 try:
744 xlsx_file = ZipFile(
745 self.filename,
746 "w",
747 compression=ZIP_DEFLATED,
748 allowZip64=self.allow_zip64,
749 )
750 except IOError as e:
751 raise e
752
753 # Assemble worksheets into a workbook.
754 packager = self._get_packager()
755
756 # Add a default worksheet if non have been added.
757 if not self.worksheets():
758 self.add_worksheet()
759
760 # Ensure that at least one worksheet has been selected.
761 if self.worksheet_meta.activesheet == 0:
762 self.worksheets_objs[0].selected = 1
763 self.worksheets_objs[0].hidden = 0
764
765 # Set the active sheet.
766 for sheet in self.worksheets():
767 if sheet.index == self.worksheet_meta.activesheet:
768 sheet.active = 1
769
770 # Set the sheet vba_codename the workbook has a vbaProject binary.
771 if self.vba_project:
772 for sheet in self.worksheets():
773 if sheet.vba_codename is None:
774 sheet.set_vba_name()
775
776 # Convert the SST strings data structure.
777 self._prepare_sst_string_data()
778
779 # Prepare the worksheet VML elements such as comments and buttons.
780 self._prepare_vml()
781
782 # Set the defined names for the worksheets such as Print Titles.
783 self._prepare_defined_names()
784
785 # Prepare the drawings, charts and images.
786 self._prepare_drawings()
787
788 # Add cached data to charts.
789 self._add_chart_data()
790
791 # Prepare the worksheet tables.
792 self._prepare_tables()
793
794 # Prepare the metadata file links.
795 self._prepare_metadata()
796
797 # Package the workbook.
798 packager._add_workbook(self)
799 packager._set_tmpdir(self.tmpdir)
800 packager._set_in_memory(self.in_memory)
801 xml_files = packager._create_package()
802
803 # Free up the Packager object.
804 packager = None
805
806 # Add XML sub-files to the Zip file with their Excel filename.
807 for file_id, file_data in enumerate(xml_files):
808 os_filename, xml_filename, is_binary = file_data
809
810 if self.in_memory:
811 # Set sub-file timestamp to Excel's timestamp of 1/1/1980.
812 zipinfo = ZipInfo(xml_filename, (1980, 1, 1, 0, 0, 0))
813
814 # Copy compression type from parent ZipFile.
815 zipinfo.compress_type = xlsx_file.compression
816
817 if is_binary:
818 xlsx_file.writestr(zipinfo, os_filename.getvalue())
819 else:
820 xlsx_file.writestr(zipinfo, os_filename.getvalue().encode("utf-8"))
821 else:
822 # The sub-files are tempfiles on disk, i.e, not in memory.
823
824 # Set sub-file timestamp to 31/1/1980 due to portability
825 # issues setting it to Excel's timestamp of 1/1/1980.
826 timestamp = time.mktime((1980, 1, 31, 0, 0, 0, 0, 0, -1))
827 os.utime(os_filename, (timestamp, timestamp))
828
829 try:
830 xlsx_file.write(os_filename, xml_filename)
831 os.remove(os_filename)
832 except LargeZipFile as e:
833 # Close open temp files on zipfile.LargeZipFile exception.
834 for i in range(file_id, len(xml_files) - 1):
835 os.remove(xml_files[i][0])
836 raise e
837
838 xlsx_file.close()
839
840 def _add_sheet(self, name, worksheet_class=None):
841 # Utility for shared code in add_worksheet() and add_chartsheet().
842
843 if worksheet_class:
844 worksheet = worksheet_class()
845 else:
846 worksheet = self.worksheet_class()
847
848 sheet_index = len(self.worksheets_objs)
849 name = self._check_sheetname(name, isinstance(worksheet, Chartsheet))
850
851 # Initialization data to pass to the worksheet.
852 init_data = {
853 "name": name,
854 "index": sheet_index,
855 "str_table": self.str_table,
856 "worksheet_meta": self.worksheet_meta,
857 "constant_memory": self.constant_memory,
858 "tmpdir": self.tmpdir,
859 "date_1904": self.date_1904,
860 "strings_to_numbers": self.strings_to_numbers,
861 "strings_to_formulas": self.strings_to_formulas,
862 "strings_to_urls": self.strings_to_urls,
863 "nan_inf_to_errors": self.nan_inf_to_errors,
864 "default_date_format": self.default_date_format,
865 "default_url_format": self.default_url_format,
866 "workbook_add_format": self.add_format,
867 "excel2003_style": self.excel2003_style,
868 "remove_timezone": self.remove_timezone,
869 "max_url_length": self.max_url_length,
870 "use_future_functions": self.use_future_functions,
871 "embedded_images": self.embedded_images,
872 }
873
874 worksheet._initialize(init_data)
875
876 self.worksheets_objs.append(worksheet)
877 self.sheetnames[name] = worksheet
878
879 return worksheet
880
881 def _check_sheetname(self, sheetname, is_chartsheet=False):
882 # Check for valid worksheet names. We check the length, if it contains
883 # any invalid chars and if the sheetname is unique in the workbook.
884 invalid_char = re.compile(r"[\[\]:*?/\\]")
885
886 # Increment the Sheet/Chart number used for default sheet names below.
887 if is_chartsheet:
888 self.chartname_count += 1
889 else:
890 self.sheetname_count += 1
891
892 # Supply default Sheet/Chart sheetname if none has been defined.
893 if sheetname is None or sheetname == "":
894 if is_chartsheet:
895 sheetname = self.chart_name + str(self.chartname_count)
896 else:
897 sheetname = self.sheet_name + str(self.sheetname_count)
898
899 # Check that sheet sheetname is <= 31. Excel limit.
900 if len(sheetname) > 31:
901 raise InvalidWorksheetName(
902 f"Excel worksheet name '{sheetname}' must be <= 31 chars."
903 )
904
905 # Check that sheetname doesn't contain any invalid characters.
906 if invalid_char.search(sheetname):
907 raise InvalidWorksheetName(
908 f"Invalid Excel character '[]:*?/\\' in sheetname '{sheetname}'."
909 )
910
911 # Check that sheetname doesn't start or end with an apostrophe.
912 if sheetname.startswith("'") or sheetname.endswith("'"):
913 raise InvalidWorksheetName(
914 f'Sheet name cannot start or end with an apostrophe "{sheetname}".'
915 )
916
917 # Check that the worksheet name doesn't already exist since this is a
918 # fatal Excel error. The check must be case insensitive like Excel.
919 for worksheet in self.worksheets():
920 if sheetname.lower() == worksheet.name.lower():
921 raise DuplicateWorksheetName(
922 f"Sheetname '{sheetname}', with case ignored, is already in use."
923 )
924
925 return sheetname
926
927 def _prepare_format_properties(self) -> None:
928 # Prepare all Format properties prior to passing them to styles.py.
929
930 # Separate format objects into XF and DXF formats.
931 self._prepare_formats()
932
933 # Set the font index for the format objects.
934 self._prepare_fonts()
935
936 # Set the number format index for the format objects.
937 self._prepare_num_formats()
938
939 # Set the border index for the format objects.
940 self._prepare_borders()
941
942 # Set the fill index for the format objects.
943 self._prepare_fills()
944
945 def _prepare_formats(self) -> None:
946 # Iterate through the XF Format objects and separate them into
947 # XF and DXF formats. The XF and DF formats then need to be sorted
948 # back into index order rather than creation order.
949 xf_formats = []
950 dxf_formats = []
951
952 # Sort into XF and DXF formats.
953 for xf_format in self.formats:
954 if xf_format.xf_index is not None:
955 xf_formats.append(xf_format)
956
957 if xf_format.dxf_index is not None:
958 dxf_formats.append(xf_format)
959
960 # Pre-extend the format lists.
961 self.xf_formats = [None] * len(xf_formats)
962 self.dxf_formats = [None] * len(dxf_formats)
963
964 # Rearrange formats into index order.
965 for xf_format in xf_formats:
966 index = xf_format.xf_index
967 self.xf_formats[index] = xf_format
968
969 for dxf_format in dxf_formats:
970 index = dxf_format.dxf_index
971 self.dxf_formats[index] = dxf_format
972
973 def _set_default_xf_indices(self) -> None:
974 # Set the default index for each format. Only used for testing.
975
976 formats = list(self.formats)
977
978 # Delete the default url format.
979 del formats[1]
980
981 # Skip the default date format if set.
982 if self.default_date_format is not None:
983 del formats[1]
984
985 # Set the remaining formats.
986 for xf_format in formats:
987 xf_format._get_xf_index()
988
989 def _prepare_fonts(self) -> None:
990 # Iterate through the XF Format objects and give them an index to
991 # non-default font elements.
992 fonts = {}
993 index = 0
994
995 for xf_format in self.xf_formats:
996 key = xf_format._get_font_key()
997 if key in fonts:
998 # Font has already been used.
999 xf_format.font_index = fonts[key]
1000 xf_format.has_font = False
1001 else:
1002 # This is a new font.
1003 fonts[key] = index
1004 xf_format.font_index = index
1005 xf_format.has_font = True
1006 index += 1
1007
1008 self.font_count = index
1009
1010 # For DXF formats we only need to check if the properties have changed.
1011 for xf_format in self.dxf_formats:
1012 # The only font properties that can change for a DXF format are:
1013 # color, bold, italic, underline and strikethrough.
1014 if (
1015 xf_format.font_color
1016 or xf_format.bold
1017 or xf_format.italic
1018 or xf_format.underline
1019 or xf_format.font_strikeout
1020 ):
1021 xf_format.has_dxf_font = True
1022
1023 def _prepare_num_formats(self) -> None:
1024 # User defined records in Excel start from index 0xA4.
1025 unique_num_formats = {}
1026 num_formats = []
1027 index = 164
1028
1029 for xf_format in self.xf_formats + self.dxf_formats:
1030 num_format = xf_format.num_format
1031
1032 # Check if num_format is an index to a built-in number format.
1033 if not isinstance(num_format, str):
1034 num_format = int(num_format)
1035
1036 # Number format '0' is indexed as 1 in Excel.
1037 if num_format == 0:
1038 num_format = 1
1039
1040 xf_format.num_format_index = num_format
1041 continue
1042
1043 if num_format == "0":
1044 # Number format '0' is indexed as 1 in Excel.
1045 xf_format.num_format_index = 1
1046 continue
1047
1048 if num_format == "General":
1049 # The 'General' format has an number format index of 0.
1050 xf_format.num_format_index = 0
1051 continue
1052
1053 if num_format in unique_num_formats:
1054 # Number xf_format has already been used.
1055 xf_format.num_format_index = unique_num_formats[num_format]
1056 else:
1057 # Add a new number xf_format.
1058 unique_num_formats[num_format] = index
1059 xf_format.num_format_index = index
1060 index += 1
1061
1062 # Only increase font count for XF formats (not DXF formats).
1063 if xf_format.xf_index:
1064 num_formats.append(num_format)
1065
1066 self.num_formats = num_formats
1067
1068 def _prepare_borders(self) -> None:
1069 # Iterate through the XF Format objects and give them an index to
1070 # non-default border elements.
1071 borders = {}
1072 index = 0
1073
1074 for xf_format in self.xf_formats:
1075 key = xf_format._get_border_key()
1076
1077 if key in borders:
1078 # Border has already been used.
1079 xf_format.border_index = borders[key]
1080 xf_format.has_border = False
1081 else:
1082 # This is a new border.
1083 borders[key] = index
1084 xf_format.border_index = index
1085 xf_format.has_border = True
1086 index += 1
1087
1088 self.border_count = index
1089
1090 # For DXF formats we only need to check if the properties have changed.
1091 has_border = re.compile(r"[^0None:]")
1092
1093 for xf_format in self.dxf_formats:
1094 key = xf_format._get_border_key()
1095
1096 if has_border.search(key):
1097 xf_format.has_dxf_border = True
1098
1099 def _prepare_fills(self) -> None:
1100 # Iterate through the XF Format objects and give them an index to
1101 # non-default fill elements.
1102 # The user defined fill properties start from 2 since there are 2
1103 # default fills: patternType="none" and patternType="gray125".
1104 fills = {}
1105 index = 2 # Start from 2. See above.
1106
1107 # Add the default fills.
1108 fills["0:None:None"] = 0
1109 fills["17:None:None"] = 1
1110
1111 # Store the DXF colors separately since them may be reversed below.
1112 for xf_format in self.dxf_formats:
1113 if xf_format.pattern or xf_format.bg_color or xf_format.fg_color:
1114 xf_format.has_dxf_fill = True
1115 xf_format.dxf_bg_color = xf_format.bg_color
1116 xf_format.dxf_fg_color = xf_format.fg_color
1117
1118 for xf_format in self.xf_formats:
1119 # The following logical statements jointly take care of special
1120 # cases in relation to cell colors and patterns:
1121 # 1. For a solid fill (_pattern == 1) Excel reverses the role of
1122 # foreground and background colors, and
1123 # 2. If the user specifies a foreground or background color
1124 # without a pattern they probably wanted a solid fill, so we fill
1125 # in the defaults.
1126 if xf_format.pattern == 1 and xf_format.bg_color and xf_format.fg_color:
1127 tmp = xf_format.fg_color
1128 xf_format.fg_color = xf_format.bg_color
1129 xf_format.bg_color = tmp
1130
1131 if xf_format.pattern <= 1 and xf_format.bg_color and not xf_format.fg_color:
1132 xf_format.fg_color = xf_format.bg_color
1133 xf_format.bg_color = None
1134 xf_format.pattern = 1
1135
1136 if xf_format.pattern <= 1 and not xf_format.bg_color and xf_format.fg_color:
1137 xf_format.pattern = 1
1138
1139 key = xf_format._get_fill_key()
1140
1141 if key in fills:
1142 # Fill has already been used.
1143 xf_format.fill_index = fills[key]
1144 xf_format.has_fill = False
1145 else:
1146 # This is a new fill.
1147 fills[key] = index
1148 xf_format.fill_index = index
1149 xf_format.has_fill = True
1150 index += 1
1151
1152 self.fill_count = index
1153
1154 def _has_feature_property_bags(self):
1155 # Check for any format properties that require a feature bag. Currently
1156 # this only applies to checkboxes.
1157 if not self.feature_property_bags:
1158 for xf_format in self.formats:
1159 if xf_format.checkbox:
1160 self.feature_property_bags.add("XFComplements")
1161
1162 if xf_format.dxf_index is not None and xf_format.checkbox:
1163 self.feature_property_bags.add("DXFComplements")
1164
1165 return self.feature_property_bags
1166
1167 def _prepare_defined_names(self) -> None:
1168 # Iterate through the worksheets and store any defined names in
1169 # addition to any user defined names. Stores the defined names
1170 # for the Workbook.xml and the named ranges for App.xml.
1171 defined_names = self.defined_names
1172
1173 for sheet in self.worksheets():
1174 # Check for Print Area settings.
1175 if sheet.autofilter_area:
1176 hidden = 1
1177 sheet_range = sheet.autofilter_area
1178 # Store the defined names.
1179 defined_names.append(
1180 ["_xlnm._FilterDatabase", sheet.index, sheet_range, hidden]
1181 )
1182
1183 # Check for Print Area settings.
1184 if sheet.print_area_range:
1185 hidden = 0
1186 sheet_range = sheet.print_area_range
1187 # Store the defined names.
1188 defined_names.append(
1189 ["_xlnm.Print_Area", sheet.index, sheet_range, hidden]
1190 )
1191
1192 # Check for repeat rows/cols referred to as Print Titles.
1193 if sheet.repeat_col_range or sheet.repeat_row_range:
1194 hidden = 0
1195 sheet_range = ""
1196 if sheet.repeat_col_range and sheet.repeat_row_range:
1197 sheet_range = sheet.repeat_col_range + "," + sheet.repeat_row_range
1198 else:
1199 sheet_range = sheet.repeat_col_range + sheet.repeat_row_range
1200 # Store the defined names.
1201 defined_names.append(
1202 ["_xlnm.Print_Titles", sheet.index, sheet_range, hidden]
1203 )
1204
1205 defined_names = self._sort_defined_names(defined_names)
1206 self.defined_names = defined_names
1207 self.named_ranges = self._extract_named_ranges(defined_names)
1208
1209 def _sort_defined_names(self, names):
1210 # Sort the list of list of internal and user defined names in
1211 # the same order as used by Excel.
1212
1213 # Add a normalize name string to each list for sorting.
1214 for name_list in names:
1215 (defined_name, _, sheet_name, _) = name_list
1216
1217 # Normalize the defined name by removing any leading '_xmln.'
1218 # from internal names and lowercasing the string.
1219 defined_name = defined_name.replace("_xlnm.", "").lower()
1220
1221 # Normalize the sheetname by removing the leading quote and
1222 # lowercasing the string.
1223 sheet_name = sheet_name.lstrip("'").lower()
1224
1225 name_list.append(defined_name + "::" + sheet_name)
1226
1227 # Sort based on the normalized key.
1228 names.sort(key=operator.itemgetter(4))
1229
1230 # Remove the extra key used for sorting.
1231 for name_list in names:
1232 name_list.pop()
1233
1234 return names
1235
1236 def _prepare_drawings(self) -> None:
1237 # Iterate through the worksheets and set up chart and image drawings.
1238 chart_ref_id = 0
1239 ref_id = 0
1240 drawing_id = 0
1241 image_ids = {}
1242 header_image_ids = {}
1243 background_ids = {}
1244
1245 # Store the image types for any embedded images.
1246 for image in self.embedded_images.images:
1247 image_extension = image._image_extension
1248 self.image_types[image_extension] = True
1249
1250 if image.description is not None:
1251 self.has_embedded_descriptions = True
1252
1253 image_ref_id = len(self.embedded_images.images)
1254
1255 for sheet in self.worksheets():
1256 chart_count = len(sheet.charts)
1257 image_count = len(sheet.images)
1258 shape_count = len(sheet.shapes)
1259
1260 header_image_count = len(sheet.header_images)
1261 footer_image_count = len(sheet.footer_images)
1262 has_background = sheet.background_image
1263 has_drawing = False
1264
1265 if not (
1266 chart_count
1267 or image_count
1268 or shape_count
1269 or header_image_count
1270 or footer_image_count
1271 or has_background
1272 ):
1273 continue
1274
1275 # Don't increase the drawing_id header/footer images.
1276 if chart_count or image_count or shape_count:
1277 drawing_id += 1
1278 has_drawing = True
1279
1280 # Prepare the background images.
1281 if sheet.background_image:
1282 image = sheet.background_image
1283 image_extension = image._image_extension
1284 image_digest = image._digest
1285
1286 self.image_types[image_extension] = True
1287
1288 if image_digest in background_ids:
1289 ref_id = background_ids[image_digest]
1290 else:
1291 image_ref_id += 1
1292 ref_id = image_ref_id
1293 background_ids[image_digest] = image_ref_id
1294 self.images.append(image)
1295
1296 sheet._prepare_background(ref_id, image_extension)
1297
1298 # Prepare the worksheet images.
1299 for index in range(image_count):
1300 image = sheet.images[index]
1301 image_extension = image._image_extension
1302 image_digest = image._digest
1303
1304 self.image_types[image_extension] = True
1305
1306 if image_digest in image_ids:
1307 ref_id = image_ids[image_digest]
1308 else:
1309 image_ref_id += 1
1310 ref_id = image_ref_id
1311 image_ids[image_digest] = image_ref_id
1312 self.images.append(image)
1313
1314 sheet._prepare_image(
1315 image,
1316 ref_id,
1317 drawing_id,
1318 )
1319
1320 # Prepare the worksheet charts.
1321 for index in range(chart_count):
1322 chart_ref_id += 1
1323 sheet._prepare_chart(index, chart_ref_id, drawing_id)
1324
1325 # Prepare the worksheet shapes.
1326 for index in range(shape_count):
1327 sheet._prepare_shape(index, drawing_id)
1328
1329 # Prepare the header images.
1330 for image in sheet.header_images:
1331 image_extension = image._image_extension
1332 image_digest = image._digest
1333
1334 self.image_types[image_extension] = True
1335
1336 if image_digest in header_image_ids:
1337 ref_id = header_image_ids[image_digest]
1338 else:
1339 image_ref_id += 1
1340 ref_id = image_ref_id
1341 header_image_ids[image_digest] = image_ref_id
1342 self.images.append(image)
1343
1344 sheet._prepare_header_image(ref_id, image)
1345
1346 # Prepare the footer images.
1347 for image in sheet.footer_images:
1348 image_extension = image._image_extension
1349 image_digest = image._digest
1350
1351 self.image_types[image_extension] = True
1352
1353 if image_digest in header_image_ids:
1354 ref_id = header_image_ids[image_digest]
1355 else:
1356 image_ref_id += 1
1357 ref_id = image_ref_id
1358 header_image_ids[image_digest] = image_ref_id
1359 self.images.append(image)
1360
1361 sheet._prepare_header_image(ref_id, image)
1362
1363 if has_drawing:
1364 drawing = sheet.drawing
1365 self.drawings.append(drawing)
1366
1367 # Remove charts that were created but not inserted into worksheets.
1368 for chart in self.charts[:]:
1369 if chart.id == -1:
1370 self.charts.remove(chart)
1371
1372 # Sort the workbook charts references into the order that the were
1373 # written to the worksheets above.
1374 self.charts = sorted(self.charts, key=lambda chart: chart.id)
1375
1376 self.drawing_count = drawing_id
1377
1378 def _extract_named_ranges(self, defined_names):
1379 # Extract the named ranges from the sorted list of defined names.
1380 # These are used in the App.xml file.
1381 named_ranges = []
1382
1383 for defined_name in defined_names:
1384 name = defined_name[0]
1385 index = defined_name[1]
1386 sheet_range = defined_name[2]
1387
1388 # Skip autoFilter ranges.
1389 if name == "_xlnm._FilterDatabase":
1390 continue
1391
1392 # We are only interested in defined names with ranges.
1393 if "!" in sheet_range:
1394 sheet_name, _ = sheet_range.split("!", 1)
1395
1396 # Match Print_Area and Print_Titles xlnm types.
1397 if name.startswith("_xlnm."):
1398 xlnm_type = name.replace("_xlnm.", "")
1399 name = sheet_name + "!" + xlnm_type
1400 elif index != -1:
1401 name = sheet_name + "!" + name
1402
1403 named_ranges.append(name)
1404
1405 return named_ranges
1406
1407 def _get_sheet_index(self, sheetname):
1408 # Convert a sheet name to its index. Return None otherwise.
1409 sheetname = sheetname.strip("'")
1410
1411 if sheetname in self.sheetnames:
1412 return self.sheetnames[sheetname].index
1413
1414 return None
1415
1416 def _prepare_vml(self) -> None:
1417 # Iterate through the worksheets and set up the VML objects.
1418 comment_id = 0
1419 vml_drawing_id = 0
1420 vml_data_id = 1
1421 vml_header_id = 0
1422 vml_shape_id = 1024
1423 vml_files = 0
1424 comment_files = 0
1425
1426 for sheet in self.worksheets():
1427 if not sheet.has_vml and not sheet.has_header_vml:
1428 continue
1429
1430 vml_files += 1
1431
1432 if sheet.has_vml:
1433 if sheet.has_comments:
1434 comment_files += 1
1435 comment_id += 1
1436 self.has_comments = True
1437
1438 vml_drawing_id += 1
1439
1440 count = sheet._prepare_vml_objects(
1441 vml_data_id, vml_shape_id, vml_drawing_id, comment_id
1442 )
1443
1444 # Each VML should start with a shape id incremented by 1024.
1445 vml_data_id += 1 * int((1024 + count) / 1024)
1446 vml_shape_id += 1024 * int((1024 + count) / 1024)
1447
1448 if sheet.has_header_vml:
1449 vml_header_id += 1
1450 vml_drawing_id += 1
1451 sheet._prepare_header_vml_objects(vml_header_id, vml_drawing_id)
1452
1453 self.num_vml_files = vml_files
1454 self.num_comment_files = comment_files
1455
1456 def _prepare_tables(self) -> None:
1457 # Set the table ids for the worksheet tables.
1458 table_id = 0
1459 seen = {}
1460
1461 for sheet in self.worksheets():
1462 table_count = len(sheet.tables)
1463
1464 if not table_count:
1465 continue
1466
1467 sheet._prepare_tables(table_id + 1, seen)
1468 table_id += table_count
1469
1470 def _prepare_metadata(self) -> None:
1471 # Set the metadata rel link.
1472 self.has_embedded_images = self.embedded_images.has_images()
1473 self.has_metadata = self.has_embedded_images
1474
1475 for sheet in self.worksheets():
1476 if sheet.has_dynamic_arrays:
1477 self.has_metadata = True
1478 self.has_dynamic_functions = True
1479
1480 def _add_chart_data(self) -> None:
1481 # Add "cached" data to charts to provide the numCache and strCache
1482 # data for series and title/axis ranges.
1483 worksheets = {}
1484 seen_ranges = {}
1485 charts = []
1486
1487 # Map worksheet names to worksheet objects.
1488 for worksheet in self.worksheets():
1489 worksheets[worksheet.name] = worksheet
1490
1491 # Build a list of the worksheet charts including any combined charts.
1492 for chart in self.charts:
1493 charts.append(chart)
1494 if chart.combined:
1495 charts.append(chart.combined)
1496
1497 for chart in charts:
1498 for c_range in chart.formula_ids.keys():
1499 r_id = chart.formula_ids[c_range]
1500
1501 # Skip if the series has user defined data.
1502 if chart.formula_data[r_id] is not None:
1503 if c_range not in seen_ranges or seen_ranges[c_range] is None:
1504 data = chart.formula_data[r_id]
1505 seen_ranges[c_range] = data
1506 continue
1507
1508 # Check to see if the data is already cached locally.
1509 if c_range in seen_ranges:
1510 chart.formula_data[r_id] = seen_ranges[c_range]
1511 continue
1512
1513 # Convert the range formula to a sheet name and cell range.
1514 (sheetname, cells) = self._get_chart_range(c_range)
1515
1516 # Skip if we couldn't parse the formula.
1517 if sheetname is None:
1518 continue
1519
1520 # Handle non-contiguous ranges like:
1521 # (Sheet1!$A$1:$A$2,Sheet1!$A$4:$A$5).
1522 # We don't try to parse them. We just return an empty list.
1523 if sheetname.startswith("("):
1524 chart.formula_data[r_id] = []
1525 seen_ranges[c_range] = []
1526 continue
1527
1528 # Warn if the name is unknown since it indicates a user error
1529 # in a chart series formula.
1530 if sheetname not in worksheets:
1531 warn(
1532 f"Unknown worksheet reference '{sheetname}' in range "
1533 f"'{c_range}' passed to add_series()"
1534 )
1535 chart.formula_data[r_id] = []
1536 seen_ranges[c_range] = []
1537 continue
1538
1539 # Find the worksheet object based on the sheet name.
1540 worksheet = worksheets[sheetname]
1541
1542 # Get the data from the worksheet table.
1543 data = worksheet._get_range_data(*cells)
1544
1545 # Add the data to the chart.
1546 chart.formula_data[r_id] = data
1547
1548 # Store range data locally to avoid lookup if seen again.
1549 seen_ranges[c_range] = data
1550
1551 def _get_chart_range(self, c_range):
1552 # Convert a range formula such as Sheet1!$B$1:$B$5 into a sheet name
1553 # and cell range such as ( 'Sheet1', 0, 1, 4, 1 ).
1554
1555 # Split the range formula into sheetname and cells at the last '!'.
1556 pos = c_range.rfind("!")
1557 if pos > 0:
1558 sheetname = c_range[:pos]
1559 cells = c_range[pos + 1 :]
1560 else:
1561 return None, None
1562
1563 # Split the cell range into 2 cells or else use single cell for both.
1564 if cells.find(":") > 0:
1565 (cell_1, cell_2) = cells.split(":", 1)
1566 else:
1567 (cell_1, cell_2) = (cells, cells)
1568
1569 # Remove leading/trailing quotes and convert escaped quotes to single.
1570 sheetname = sheetname.strip("'")
1571 sheetname = sheetname.replace("''", "'")
1572
1573 try:
1574 # Get the row, col values from the Excel ranges. We do this in a
1575 # try block for ranges that can't be parsed such as defined names.
1576 (row_start, col_start) = xl_cell_to_rowcol(cell_1)
1577 (row_end, col_end) = xl_cell_to_rowcol(cell_2)
1578 except AttributeError:
1579 return None, None
1580
1581 # We only handle 1D ranges.
1582 if row_start != row_end and col_start != col_end:
1583 return None, None
1584
1585 return sheetname, [row_start, col_start, row_end, col_end]
1586
1587 def _prepare_sst_string_data(self) -> None:
1588 # Convert the SST string data from a dict to a list.
1589 self.str_table._sort_string_data()
1590
1591 def _get_packager(self):
1592 # Get and instance of the Packager class to create the xlsx package.
1593 # This allows the default packager to be over-ridden.
1594 return Packager()
1595
1596 ###########################################################################
1597 #
1598 # XML methods.
1599 #
1600 ###########################################################################
1601
1602 def _write_workbook(self) -> None:
1603 # Write <workbook> element.
1604
1605 schema = "http://schemas.openxmlformats.org"
1606 xmlns = schema + "/spreadsheetml/2006/main"
1607 xmlns_r = schema + "/officeDocument/2006/relationships"
1608
1609 attributes = [
1610 ("xmlns", xmlns),
1611 ("xmlns:r", xmlns_r),
1612 ]
1613
1614 self._xml_start_tag("workbook", attributes)
1615
1616 def _write_file_version(self) -> None:
1617 # Write the <fileVersion> element.
1618
1619 app_name = "xl"
1620 last_edited = 4
1621 lowest_edited = 4
1622 rup_build = 4505
1623
1624 attributes = [
1625 ("appName", app_name),
1626 ("lastEdited", last_edited),
1627 ("lowestEdited", lowest_edited),
1628 ("rupBuild", rup_build),
1629 ]
1630
1631 if self.vba_project:
1632 attributes.append(("codeName", "{37E998C4-C9E5-D4B9-71C8-EB1FF731991C}"))
1633
1634 self._xml_empty_tag("fileVersion", attributes)
1635
1636 def _write_file_sharing(self) -> None:
1637 # Write the <fileSharing> element.
1638 if self.read_only == 0:
1639 return
1640
1641 attributes = [("readOnlyRecommended", 1)]
1642
1643 self._xml_empty_tag("fileSharing", attributes)
1644
1645 def _write_workbook_pr(self) -> None:
1646 # Write <workbookPr> element.
1647 default_theme_version = 124226
1648 attributes = []
1649
1650 if self.vba_codename:
1651 attributes.append(("codeName", self.vba_codename))
1652 if self.date_1904:
1653 attributes.append(("date1904", 1))
1654
1655 attributes.append(("defaultThemeVersion", default_theme_version))
1656
1657 self._xml_empty_tag("workbookPr", attributes)
1658
1659 def _write_book_views(self) -> None:
1660 # Write <bookViews> element.
1661 self._xml_start_tag("bookViews")
1662 self._write_workbook_view()
1663 self._xml_end_tag("bookViews")
1664
1665 def _write_workbook_view(self) -> None:
1666 # Write <workbookView> element.
1667 attributes = [
1668 ("xWindow", self.x_window),
1669 ("yWindow", self.y_window),
1670 ("windowWidth", self.window_width),
1671 ("windowHeight", self.window_height),
1672 ]
1673
1674 # Store the tabRatio attribute when it isn't the default.
1675 if self.tab_ratio != 600:
1676 attributes.append(("tabRatio", self.tab_ratio))
1677
1678 # Store the firstSheet attribute when it isn't the default.
1679 if self.worksheet_meta.firstsheet > 0:
1680 firstsheet = self.worksheet_meta.firstsheet + 1
1681 attributes.append(("firstSheet", firstsheet))
1682
1683 # Store the activeTab attribute when it isn't the first sheet.
1684 if self.worksheet_meta.activesheet > 0:
1685 attributes.append(("activeTab", self.worksheet_meta.activesheet))
1686
1687 self._xml_empty_tag("workbookView", attributes)
1688
1689 def _write_sheets(self) -> None:
1690 # Write <sheets> element.
1691 self._xml_start_tag("sheets")
1692
1693 id_num = 1
1694 for worksheet in self.worksheets():
1695 self._write_sheet(worksheet.name, id_num, worksheet.hidden)
1696 id_num += 1
1697
1698 self._xml_end_tag("sheets")
1699
1700 def _write_sheet(self, name, sheet_id, hidden) -> None:
1701 # Write <sheet> element.
1702 attributes = [
1703 ("name", name),
1704 ("sheetId", sheet_id),
1705 ]
1706
1707 if hidden == 1:
1708 attributes.append(("state", "hidden"))
1709 elif hidden == 2:
1710 attributes.append(("state", "veryHidden"))
1711
1712 attributes.append(("r:id", "rId" + str(sheet_id)))
1713
1714 self._xml_empty_tag("sheet", attributes)
1715
1716 def _write_calc_pr(self) -> None:
1717 # Write the <calcPr> element.
1718 attributes = [("calcId", self.calc_id)]
1719
1720 if self.calc_mode == "manual":
1721 attributes.append(("calcMode", self.calc_mode))
1722 attributes.append(("calcOnSave", "0"))
1723 elif self.calc_mode == "autoNoTable":
1724 attributes.append(("calcMode", self.calc_mode))
1725
1726 if self.calc_on_load:
1727 attributes.append(("fullCalcOnLoad", "1"))
1728
1729 self._xml_empty_tag("calcPr", attributes)
1730
1731 def _write_defined_names(self) -> None:
1732 # Write the <definedNames> element.
1733 if not self.defined_names:
1734 return
1735
1736 self._xml_start_tag("definedNames")
1737
1738 for defined_name in self.defined_names:
1739 self._write_defined_name(defined_name)
1740
1741 self._xml_end_tag("definedNames")
1742
1743 def _write_defined_name(self, defined_name) -> None:
1744 # Write the <definedName> element.
1745 name = defined_name[0]
1746 sheet_id = defined_name[1]
1747 sheet_range = defined_name[2]
1748 hidden = defined_name[3]
1749
1750 attributes = [("name", name)]
1751
1752 if sheet_id != -1:
1753 attributes.append(("localSheetId", sheet_id))
1754 if hidden:
1755 attributes.append(("hidden", 1))
1756
1757 self._xml_data_element("definedName", sheet_range, attributes)
1758
1759
1760# A metadata class to share data between worksheets.
1761class WorksheetMeta:
1762 """
1763 A class to track worksheets data such as the active sheet and the
1764 first sheet.
1765
1766 """
1767
1768 def __init__(self) -> None:
1769 self.activesheet = 0
1770 self.firstsheet = 0
1771
1772
1773# A helper class to share embedded images between worksheets.
1774class EmbeddedImages:
1775 """
1776 A class to track duplicate embedded images between worksheets.
1777
1778 """
1779
1780 def __init__(self) -> None:
1781 self.images = []
1782 self.image_indexes = {}
1783
1784 def get_image_index(self, image: Image):
1785 """
1786 Get the index of an embedded image.
1787
1788 Args:
1789 image: The image to lookup.
1790
1791 Returns:
1792 The image index.
1793
1794 """
1795 image_index = self.image_indexes.get(image._digest)
1796
1797 if image_index is None:
1798 self.images.append(image)
1799 image_index = len(self.images)
1800 self.image_indexes[image._digest] = image_index
1801
1802 return image_index
1803
1804 def has_images(self):
1805 """
1806 Check if the worksheet has embedded images.
1807
1808 Args:
1809 None.
1810
1811 Returns:
1812 Boolean.
1813
1814 """
1815 return len(self.images) > 0