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