Add xlsxwriter-based Excel generation scripts with openpyxl implementation

- Created create_excel_xlsxwriter.py and update_excel_xlsxwriter.py
- Uses openpyxl exclusively to preserve Excel formatting and formulas
- Updated server.js to use new xlsxwriter scripts for form submissions
- Maintains all original functionality while ensuring proper Excel file handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andrei
2025-09-22 13:53:06 +00:00
commit 0e2e1bddba
842 changed files with 316330 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
__version__ = "3.2.9"
__VERSION__ = __version__
from .workbook import Workbook # noqa
__all__ = ["Workbook"]

View File

@@ -0,0 +1,202 @@
###############################################################################
#
# App - A class for writing the Excel XLSX App file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Dict, List, Tuple
from . import xmlwriter
class App(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX App file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.part_names = []
self.heading_pairs = []
self.properties = {}
self.doc_security = 0
def _add_part_name(self, part_name: str) -> None:
# Add the name of a workbook Part such as 'Sheet1' or 'Print_Titles'.
self.part_names.append(part_name)
def _add_heading_pair(self, heading_pair: Tuple[str, int]) -> None:
# Add the name of a workbook Heading Pair such as 'Worksheets',
# 'Charts' or 'Named Ranges'.
# Ignore empty pairs such as chartsheets.
if not heading_pair[1]:
return
self.heading_pairs.append(("lpstr", heading_pair[0]))
self.heading_pairs.append(("i4", heading_pair[1]))
def _set_properties(self, properties: Dict[str, str]) -> None:
# Set the document properties.
self.properties = properties
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
self._write_properties()
self._write_application()
self._write_doc_security()
self._write_scale_crop()
self._write_heading_pairs()
self._write_titles_of_parts()
self._write_manager()
self._write_company()
self._write_links_up_to_date()
self._write_shared_doc()
self._write_hyperlink_base()
self._write_hyperlinks_changed()
self._write_app_version()
self._xml_end_tag("Properties")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_properties(self) -> None:
# Write the <Properties> element.
schema = "http://schemas.openxmlformats.org/officeDocument/2006/"
xmlns = schema + "extended-properties"
xmlns_vt = schema + "docPropsVTypes"
attributes = [
("xmlns", xmlns),
("xmlns:vt", xmlns_vt),
]
self._xml_start_tag("Properties", attributes)
def _write_application(self) -> None:
# Write the <Application> element.
self._xml_data_element("Application", "Microsoft Excel")
def _write_doc_security(self) -> None:
# Write the <DocSecurity> element.
self._xml_data_element("DocSecurity", self.doc_security)
def _write_scale_crop(self) -> None:
# Write the <ScaleCrop> element.
self._xml_data_element("ScaleCrop", "false")
def _write_heading_pairs(self) -> None:
# Write the <HeadingPairs> element.
self._xml_start_tag("HeadingPairs")
self._write_vt_vector("variant", self.heading_pairs)
self._xml_end_tag("HeadingPairs")
def _write_titles_of_parts(self) -> None:
# Write the <TitlesOfParts> element.
parts_data = []
self._xml_start_tag("TitlesOfParts")
for part_name in self.part_names:
parts_data.append(("lpstr", part_name))
self._write_vt_vector("lpstr", parts_data)
self._xml_end_tag("TitlesOfParts")
def _write_vt_vector(
self, base_type: str, vector_data: List[Tuple[str, int]]
) -> None:
# Write the <vt:vector> element.
attributes = [
("size", len(vector_data)),
("baseType", base_type),
]
self._xml_start_tag("vt:vector", attributes)
for vt_data in vector_data:
if base_type == "variant":
self._xml_start_tag("vt:variant")
self._write_vt_data(vt_data)
if base_type == "variant":
self._xml_end_tag("vt:variant")
self._xml_end_tag("vt:vector")
def _write_vt_data(self, vt_data: Tuple[str, int]) -> None:
# Write the <vt:*> elements such as <vt:lpstr> and <vt:if>.
self._xml_data_element(f"vt:{vt_data[0]}", vt_data[1])
def _write_company(self) -> None:
company = self.properties.get("company", "")
self._xml_data_element("Company", company)
def _write_manager(self) -> None:
# Write the <Manager> element.
if "manager" not in self.properties:
return
self._xml_data_element("Manager", self.properties["manager"])
def _write_links_up_to_date(self) -> None:
# Write the <LinksUpToDate> element.
self._xml_data_element("LinksUpToDate", "false")
def _write_shared_doc(self) -> None:
# Write the <SharedDoc> element.
self._xml_data_element("SharedDoc", "false")
def _write_hyperlink_base(self) -> None:
# Write the <HyperlinkBase> element.
hyperlink_base = self.properties.get("hyperlink_base")
if hyperlink_base is None:
return
self._xml_data_element("HyperlinkBase", hyperlink_base)
def _write_hyperlinks_changed(self) -> None:
# Write the <HyperlinksChanged> element.
self._xml_data_element("HyperlinksChanged", "false")
def _write_app_version(self) -> None:
# Write the <AppVersion> element.
self._xml_data_element("AppVersion", "12.0000")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
###############################################################################
#
# ChartArea - A class for writing the Excel XLSX Area charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from . import chart
class ChartArea(chart.Chart):
"""
A class for writing the Excel XLSX Area charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "standard"
self.cross_between = "midCat"
self.show_crosses = False
# Override and reset the default axis values.
if self.subtype == "percent_stacked":
self.y_axis["defaults"]["num_format"] = "0%"
# Set the available data label positions for this chart type.
self.label_position_default = "center"
self.label_positions = {"center": "ctr"}
self.set_y_axis({})
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:areaChart element.
self._write_area_chart(args)
###########################################################################
#
# XML methods.
#
###########################################################################
#
def _write_area_chart(self, args) -> None:
# Write the <c:areaChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
subtype = self.subtype
if subtype == "percent_stacked":
subtype = "percentStacked"
self._xml_start_tag("c:areaChart")
# Write the c:grouping element.
self._write_grouping(subtype)
# Write the series elements.
for data in series:
self._write_ser(data)
# Write the c:dropLines element.
self._write_drop_lines()
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:areaChart")

View File

@@ -0,0 +1,177 @@
###############################################################################
#
# ChartBar - A class for writing the Excel XLSX Bar charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from warnings import warn
from . import chart
class ChartBar(chart.Chart):
"""
A class for writing the Excel XLSX Bar charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "clustered"
self.cat_axis_position = "l"
self.val_axis_position = "b"
self.horiz_val_axis = 0
self.horiz_cat_axis = 1
self.show_crosses = False
# Override and reset the default axis values.
self.x_axis["defaults"]["major_gridlines"] = {"visible": 1}
self.y_axis["defaults"]["major_gridlines"] = {"visible": 0}
if self.subtype == "percent_stacked":
self.x_axis["defaults"]["num_format"] = "0%"
# Set the available data label positions for this chart type.
self.label_position_default = "outside_end"
self.label_positions = {
"center": "ctr",
"inside_base": "inBase",
"inside_end": "inEnd",
"outside_end": "outEnd",
}
self.set_x_axis({})
self.set_y_axis({})
def combine(self, chart: Optional[chart.Chart] = None) -> None:
# pylint: disable=redefined-outer-name
"""
Create a combination chart with a secondary chart.
Note: Override parent method to add an extra check that is required
for Bar charts to ensure that their combined chart is on a secondary
axis.
Args:
chart: The secondary chart to combine with the primary chart.
Returns:
Nothing.
"""
if chart is None:
return
if not chart.is_secondary:
warn("Charts combined with Bar charts must be on a secondary axis")
self.combined = chart
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
if args["primary_axes"]:
# Reverse X and Y axes for Bar charts.
tmp = self.y_axis
self.y_axis = self.x_axis
self.x_axis = tmp
if self.y2_axis["position"] == "r":
self.y2_axis["position"] = "t"
# Write the c:barChart element.
self._write_bar_chart(args)
def _write_bar_chart(self, args) -> None:
# Write the <c:barChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
subtype = self.subtype
if subtype == "percent_stacked":
subtype = "percentStacked"
# Set a default overlap for stacked charts.
if "stacked" in self.subtype and self.series_overlap_1 is None:
self.series_overlap_1 = 100
self._xml_start_tag("c:barChart")
# Write the c:barDir element.
self._write_bar_dir()
# Write the c:grouping element.
self._write_grouping(subtype)
# Write the c:ser elements.
for data in series:
self._write_ser(data)
# Write the c:gapWidth element.
if args["primary_axes"]:
self._write_gap_width(self.series_gap_1)
else:
self._write_gap_width(self.series_gap_2)
# Write the c:overlap element.
if args["primary_axes"]:
self._write_overlap(self.series_overlap_1)
else:
self._write_overlap(self.series_overlap_2)
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:barChart")
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_bar_dir(self) -> None:
# Write the <c:barDir> element.
val = "bar"
attributes = [("val", val)]
self._xml_empty_tag("c:barDir", attributes)
def _write_err_dir(self, val) -> None:
# Overridden from Chart class since it is not used in Bar charts.
pass

View File

@@ -0,0 +1,135 @@
###############################################################################
#
# ChartColumn - A class for writing the Excel XLSX Column charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from . import chart
class ChartColumn(chart.Chart):
"""
A class for writing the Excel XLSX Column charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "clustered"
self.horiz_val_axis = 0
if self.subtype == "percent_stacked":
self.y_axis["defaults"]["num_format"] = "0%"
# Set the available data label positions for this chart type.
self.label_position_default = "outside_end"
self.label_positions = {
"center": "ctr",
"inside_base": "inBase",
"inside_end": "inEnd",
"outside_end": "outEnd",
}
self.set_y_axis({})
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:barChart element.
self._write_bar_chart(args)
def _write_bar_chart(self, args) -> None:
# Write the <c:barChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
subtype = self.subtype
if subtype == "percent_stacked":
subtype = "percentStacked"
# Set a default overlap for stacked charts.
if "stacked" in self.subtype and self.series_overlap_1 is None:
self.series_overlap_1 = 100
self._xml_start_tag("c:barChart")
# Write the c:barDir element.
self._write_bar_dir()
# Write the c:grouping element.
self._write_grouping(subtype)
# Write the c:ser elements.
for data in series:
self._write_ser(data)
# Write the c:gapWidth element.
if args["primary_axes"]:
self._write_gap_width(self.series_gap_1)
else:
self._write_gap_width(self.series_gap_2)
# Write the c:overlap element.
if args["primary_axes"]:
self._write_overlap(self.series_overlap_1)
else:
self._write_overlap(self.series_overlap_2)
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:barChart")
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_bar_dir(self) -> None:
# Write the <c:barDir> element.
val = "col"
attributes = [("val", val)]
self._xml_empty_tag("c:barDir", attributes)
def _write_err_dir(self, val) -> None:
# Overridden from Chart class since it is not used in Column charts.
pass

View File

@@ -0,0 +1,101 @@
###############################################################################
#
# ChartDoughnut - A class for writing the Excel XLSX Doughnut charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from warnings import warn
from . import chart_pie
class ChartDoughnut(chart_pie.ChartPie):
"""
A class for writing the Excel XLSX Doughnut charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.vary_data_color = 1
self.rotation = 0
self.hole_size = 50
def set_hole_size(self, size: int) -> None:
"""
Set the Doughnut chart hole size.
Args:
size: 10 <= size <= 90.
Returns:
Nothing.
"""
if size is None:
return
# Ensure the size is in Excel's range.
if size < 10 or size > 90:
warn("Chart hole size '{size}' outside Excel range: 10 <= size <= 90")
return
self.hole_size = int(size)
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:doughnutChart element.
self._write_doughnut_chart()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_doughnut_chart(self) -> None:
# Write the <c:doughnutChart> element. Over-ridden method to remove
# axis_id code since Doughnut charts don't require val and cat axes.
self._xml_start_tag("c:doughnutChart")
# Write the c:varyColors element.
self._write_vary_colors()
# Write the series elements.
for data in self.series:
self._write_ser(data)
# Write the c:firstSliceAng element.
self._write_first_slice_ang()
# Write the c:holeSize element.
self._write_c_hole_size()
self._xml_end_tag("c:doughnutChart")
def _write_c_hole_size(self) -> None:
# Write the <c:holeSize> element.
attributes = [("val", self.hole_size)]
self._xml_empty_tag("c:holeSize", attributes)

View File

@@ -0,0 +1,146 @@
###############################################################################
#
# ChartLine - A class for writing the Excel XLSX Line charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from . import chart
class ChartLine(chart.Chart):
"""
A class for writing the Excel XLSX Line charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "standard"
self.default_marker = {"type": "none"}
self.smooth_allowed = True
# Override and reset the default axis values.
if self.subtype == "percent_stacked":
self.y_axis["defaults"]["num_format"] = "0%"
# Set the available data label positions for this chart type.
self.label_position_default = "right"
self.label_positions = {
"center": "ctr",
"right": "r",
"left": "l",
"above": "t",
"below": "b",
# For backward compatibility.
"top": "t",
"bottom": "b",
}
self.set_y_axis({})
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:lineChart element.
self._write_line_chart(args)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_line_chart(self, args) -> None:
# Write the <c:lineChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
subtype = self.subtype
if subtype == "percent_stacked":
subtype = "percentStacked"
self._xml_start_tag("c:lineChart")
# Write the c:grouping element.
self._write_grouping(subtype)
# Write the series elements.
for data in series:
self._write_ser(data)
# Write the c:dropLines element.
self._write_drop_lines()
# Write the c:hiLowLines element.
self._write_hi_low_lines()
# Write the c:upDownBars element.
self._write_up_down_bars()
# Write the c:marker element.
self._write_marker_value()
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:lineChart")
def _write_d_pt_point(self, index, point) -> None:
# Write an individual <c:dPt> element. Override the parent method to
# add markers.
self._xml_start_tag("c:dPt")
# Write the c:idx element.
self._write_idx(index)
self._xml_start_tag("c:marker")
# Write the c:spPr element.
self._write_sp_pr(point)
self._xml_end_tag("c:marker")
self._xml_end_tag("c:dPt")
def _write_marker_value(self) -> None:
# Write the <c:marker> element without a sub-element.
attributes = [("val", 1)]
self._xml_empty_tag("c:marker", attributes)

View File

@@ -0,0 +1,263 @@
###############################################################################
#
# ChartPie - A class for writing the Excel XLSX Pie charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from warnings import warn
from . import chart
class ChartPie(chart.Chart):
"""
A class for writing the Excel XLSX Pie charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.vary_data_color = 1
self.rotation = 0
# Set the available data label positions for this chart type.
self.label_position_default = "best_fit"
self.label_positions = {
"center": "ctr",
"inside_end": "inEnd",
"outside_end": "outEnd",
"best_fit": "bestFit",
}
def set_rotation(self, rotation: int) -> None:
"""
Set the Pie/Doughnut chart rotation: the angle of the first slice.
Args:
rotation: First segment angle: 0 <= rotation <= 360.
Returns:
Nothing.
"""
if rotation is None:
return
# Ensure the rotation is in Excel's range.
if rotation < 0 or rotation > 360:
warn(
f"Chart rotation '{rotation}' outside Excel range: 0 <= rotation <= 360"
)
return
self.rotation = int(rotation)
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:pieChart element.
self._write_pie_chart()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_pie_chart(self) -> None:
# Write the <c:pieChart> element. Over-ridden method to remove
# axis_id code since Pie charts don't require val and cat axes.
self._xml_start_tag("c:pieChart")
# Write the c:varyColors element.
self._write_vary_colors()
# Write the series elements.
for data in self.series:
self._write_ser(data)
# Write the c:firstSliceAng element.
self._write_first_slice_ang()
self._xml_end_tag("c:pieChart")
def _write_plot_area(self) -> None:
# Over-ridden method to remove the cat_axis() and val_axis() code
# since Pie charts don't require those axes.
#
# Write the <c:plotArea> element.
self._xml_start_tag("c:plotArea")
# Write the c:layout element.
self._write_layout(self.plotarea.get("layout"), "plot")
# Write the subclass chart type element.
self._write_chart_type(None)
# Configure a combined chart if present.
second_chart = self.combined
if second_chart:
# Secondary axis has unique id otherwise use same as primary.
if second_chart.is_secondary:
second_chart.id = 1000 + self.id
else:
second_chart.id = self.id
# Share the same filehandle for writing.
second_chart.fh = self.fh
# Share series index with primary chart.
second_chart.series_index = self.series_index
# Write the subclass chart type elements for combined chart.
# pylint: disable-next=protected-access
second_chart._write_chart_type(None)
# Write the c:spPr element for the plotarea formatting.
self._write_sp_pr(self.plotarea)
self._xml_end_tag("c:plotArea")
def _write_legend(self) -> None:
# Over-ridden method to add <c:txPr> to legend.
# Write the <c:legend> element.
legend = self.legend
position = legend.get("position", "right")
font = legend.get("font")
delete_series = []
overlay = 0
if legend.get("delete_series") and isinstance(legend["delete_series"], list):
delete_series = legend["delete_series"]
if position.startswith("overlay_"):
position = position.replace("overlay_", "")
overlay = 1
allowed = {
"right": "r",
"left": "l",
"top": "t",
"bottom": "b",
"top_right": "tr",
}
if position == "none":
return
if position not in allowed:
return
position = allowed[position]
self._xml_start_tag("c:legend")
# Write the c:legendPos element.
self._write_legend_pos(position)
# Remove series labels from the legend.
for index in delete_series:
# Write the c:legendEntry element.
self._write_legend_entry(index)
# Write the c:layout element.
self._write_layout(legend.get("layout"), "legend")
# Write the c:overlay element.
if overlay:
self._write_overlay()
# Write the c:spPr element.
self._write_sp_pr(legend)
# Write the c:txPr element. Over-ridden.
self._write_tx_pr_legend(None, font)
self._xml_end_tag("c:legend")
def _write_tx_pr_legend(self, horiz, font) -> None:
# Write the <c:txPr> element for legends.
if font and font.get("rotation"):
rotation = font["rotation"]
else:
rotation = None
self._xml_start_tag("c:txPr")
# Write the a:bodyPr element.
self._write_a_body_pr(rotation, horiz)
# Write the a:lstStyle element.
self._write_a_lst_style()
# Write the a:p element.
self._write_a_p_legend(font)
self._xml_end_tag("c:txPr")
def _write_a_p_legend(self, font) -> None:
# Write the <a:p> element for legends.
self._xml_start_tag("a:p")
# Write the a:pPr element.
self._write_a_p_pr_legend(font)
# Write the a:endParaRPr element.
self._write_a_end_para_rpr()
self._xml_end_tag("a:p")
def _write_a_p_pr_legend(self, font) -> None:
# Write the <a:pPr> element for legends.
attributes = [("rtl", 0)]
self._xml_start_tag("a:pPr", attributes)
# Write the a:defRPr element.
self._write_a_def_rpr(font)
self._xml_end_tag("a:pPr")
def _write_vary_colors(self) -> None:
# Write the <c:varyColors> element.
attributes = [("val", 1)]
self._xml_empty_tag("c:varyColors", attributes)
def _write_first_slice_ang(self) -> None:
# Write the <c:firstSliceAng> element.
attributes = [("val", self.rotation)]
self._xml_empty_tag("c:firstSliceAng", attributes)
def _write_show_leader_lines(self) -> None:
# Write the <c:showLeaderLines> element.
#
# This is for Pie/Doughnut charts. Other chart types only supported
# leader lines after Excel 2015 via an extension element.
attributes = [("val", 1)]
self._xml_empty_tag("c:showLeaderLines", attributes)

View File

@@ -0,0 +1,105 @@
###############################################################################
#
# ChartRadar - A class for writing the Excel XLSX Radar charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from . import chart
class ChartRadar(chart.Chart):
"""
A class for writing the Excel XLSX Radar charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "marker"
self.default_marker = {"type": "none"}
# Override and reset the default axis values.
self.x_axis["defaults"]["major_gridlines"] = {"visible": 1}
self.set_x_axis({})
# Set the available data label positions for this chart type.
self.label_position_default = "center"
self.label_positions = {"center": "ctr"}
# Hardcode major_tick_mark for now until there is an accessor.
self.y_axis["major_tick_mark"] = "cross"
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Write the c:radarChart element.
self._write_radar_chart(args)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_radar_chart(self, args) -> None:
# Write the <c:radarChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
self._xml_start_tag("c:radarChart")
# Write the c:radarStyle element.
self._write_radar_style()
# Write the series elements.
for data in series:
self._write_ser(data)
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:radarChart")
def _write_radar_style(self) -> None:
# Write the <c:radarStyle> element.
val = "marker"
if self.subtype == "filled":
val = "filled"
attributes = [("val", val)]
self._xml_empty_tag("c:radarStyle", attributes)

View File

@@ -0,0 +1,337 @@
###############################################################################
#
# ChartScatter - A class for writing the Excel XLSX Scatter charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from warnings import warn
from . import chart
class ChartScatter(chart.Chart):
"""
A class for writing the Excel XLSX Scatter charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""
Constructor.
"""
super().__init__()
if options is None:
options = {}
self.subtype = options.get("subtype")
if not self.subtype:
self.subtype = "marker_only"
self.cross_between = "midCat"
self.horiz_val_axis = 0
self.val_axis_position = "b"
self.smooth_allowed = True
self.requires_category = True
# Set the available data label positions for this chart type.
self.label_position_default = "right"
self.label_positions = {
"center": "ctr",
"right": "r",
"left": "l",
"above": "t",
"below": "b",
# For backward compatibility.
"top": "t",
"bottom": "b",
}
def combine(self, chart: Optional[chart.Chart] = None) -> None:
# pylint: disable=redefined-outer-name
"""
Create a combination chart with a secondary chart.
Note: Override parent method to add a warning.
Args:
chart: The secondary chart to combine with the primary chart.
Returns:
Nothing.
"""
if chart is None:
return
warn(
"Combined chart not currently supported with scatter chart "
"as the primary chart"
)
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:scatterChart element.
self._write_scatter_chart(args)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_scatter_chart(self, args) -> None:
# Write the <c:scatterChart> element.
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
style = "lineMarker"
subtype = self.subtype
# Set the user defined chart subtype.
if subtype == "marker_only":
style = "lineMarker"
if subtype == "straight_with_markers":
style = "lineMarker"
if subtype == "straight":
style = "lineMarker"
self.default_marker = {"type": "none"}
if subtype == "smooth_with_markers":
style = "smoothMarker"
if subtype == "smooth":
style = "smoothMarker"
self.default_marker = {"type": "none"}
# Add default formatting to the series data.
self._modify_series_formatting()
self._xml_start_tag("c:scatterChart")
# Write the c:scatterStyle element.
self._write_scatter_style(style)
# Write the series elements.
for data in series:
self._write_ser(data)
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:scatterChart")
def _write_ser(self, series) -> None:
# Over-ridden to write c:xVal/c:yVal instead of c:cat/c:val elements.
# Write the <c:ser> element.
index = self.series_index
self.series_index += 1
self._xml_start_tag("c:ser")
# Write the c:idx element.
self._write_idx(index)
# Write the c:order element.
self._write_order(index)
# Write the series name.
self._write_series_name(series)
# Write the c:spPr element.
self._write_sp_pr(series)
# Write the c:marker element.
self._write_marker(series.get("marker"))
# Write the c:dPt element.
self._write_d_pt(series.get("points"))
# Write the c:dLbls element.
self._write_d_lbls(series.get("labels"))
# Write the c:trendline element.
self._write_trendline(series.get("trendline"))
# Write the c:errBars element.
self._write_error_bars(series.get("error_bars"))
# Write the c:xVal element.
self._write_x_val(series)
# Write the c:yVal element.
self._write_y_val(series)
# Write the c:smooth element.
if "smooth" in self.subtype and series["smooth"] is None:
# Default is on for smooth scatter charts.
self._write_c_smooth(True)
else:
self._write_c_smooth(series["smooth"])
self._xml_end_tag("c:ser")
def _write_plot_area(self) -> None:
# Over-ridden to have 2 valAx elements for scatter charts instead
# of catAx/valAx.
#
# Write the <c:plotArea> element.
self._xml_start_tag("c:plotArea")
# Write the c:layout element.
self._write_layout(self.plotarea.get("layout"), "plot")
# Write the subclass chart elements for primary and secondary axes.
self._write_chart_type({"primary_axes": 1})
self._write_chart_type({"primary_axes": 0})
# Write c:catAx and c:valAx elements for series using primary axes.
self._write_cat_val_axis(
{
"x_axis": self.x_axis,
"y_axis": self.y_axis,
"axis_ids": self.axis_ids,
"position": "b",
}
)
tmp = self.horiz_val_axis
self.horiz_val_axis = 1
self._write_val_axis(
{
"x_axis": self.x_axis,
"y_axis": self.y_axis,
"axis_ids": self.axis_ids,
"position": "l",
}
)
self.horiz_val_axis = tmp
# Write c:valAx and c:catAx elements for series using secondary axes
self._write_cat_val_axis(
{
"x_axis": self.x2_axis,
"y_axis": self.y2_axis,
"axis_ids": self.axis2_ids,
"position": "b",
}
)
self.horiz_val_axis = 1
self._write_val_axis(
{
"x_axis": self.x2_axis,
"y_axis": self.y2_axis,
"axis_ids": self.axis2_ids,
"position": "l",
}
)
# Write the c:spPr element for the plotarea formatting.
self._write_sp_pr(self.plotarea)
self._xml_end_tag("c:plotArea")
def _write_x_val(self, series) -> None:
# Write the <c:xVal> element.
formula = series.get("categories")
data_id = series.get("cat_data_id")
data = self.formula_data[data_id]
self._xml_start_tag("c:xVal")
# Check the type of cached data.
data_type = self._get_data_type(data)
if data_type == "str":
# Write the c:numRef element.
self._write_str_ref(formula, data, data_type)
else:
# Write the c:numRef element.
self._write_num_ref(formula, data, data_type)
self._xml_end_tag("c:xVal")
def _write_y_val(self, series) -> None:
# Write the <c:yVal> element.
formula = series.get("values")
data_id = series.get("val_data_id")
data = self.formula_data[data_id]
self._xml_start_tag("c:yVal")
# Unlike Cat axes data should only be numeric.
# Write the c:numRef element.
self._write_num_ref(formula, data, "num")
self._xml_end_tag("c:yVal")
def _write_scatter_style(self, val) -> None:
# Write the <c:scatterStyle> element.
attributes = [("val", val)]
self._xml_empty_tag("c:scatterStyle", attributes)
def _modify_series_formatting(self) -> None:
# Add default formatting to the series data unless it has already been
# specified by the user.
subtype = self.subtype
# The default scatter style "markers only" requires a line type.
if subtype == "marker_only":
# Go through each series and define default values.
for series in self.series:
# Set a line type unless there is already a user defined type.
if not series["line"]["defined"]:
series["line"] = {
"width": 2.25,
"none": 1,
"defined": 1,
}
def _write_d_pt_point(self, index, point) -> None:
# Write an individual <c:dPt> element. Override the parent method to
# add markers.
self._xml_start_tag("c:dPt")
# Write the c:idx element.
self._write_idx(index)
self._xml_start_tag("c:marker")
# Write the c:spPr element.
self._write_sp_pr(point)
self._xml_end_tag("c:marker")
self._xml_end_tag("c:dPt")

View File

@@ -0,0 +1,125 @@
###############################################################################
#
# ChartStock - A class for writing the Excel XLSX Stock charts.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from . import chart
class ChartStock(chart.Chart):
"""
A class for writing the Excel XLSX Stock charts.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.show_crosses = False
self.hi_low_lines = {}
self.date_category = True
# Override and reset the default axis values.
self.x_axis["defaults"]["num_format"] = "dd/mm/yyyy"
self.x2_axis["defaults"]["num_format"] = "dd/mm/yyyy"
# Set the available data label positions for this chart type.
self.label_position_default = "right"
self.label_positions = {
"center": "ctr",
"right": "r",
"left": "l",
"above": "t",
"below": "b",
# For backward compatibility.
"top": "t",
"bottom": "b",
}
self.set_x_axis({})
self.set_x2_axis({})
###########################################################################
#
# Private API.
#
###########################################################################
def _write_chart_type(self, args) -> None:
# Override the virtual superclass method with a chart specific method.
# Write the c:stockChart element.
self._write_stock_chart(args)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_stock_chart(self, args) -> None:
# Write the <c:stockChart> element.
# Overridden to add hi_low_lines().
if args["primary_axes"]:
series = self._get_primary_axes_series()
else:
series = self._get_secondary_axes_series()
if not series:
return
# Add default formatting to the series data.
self._modify_series_formatting()
self._xml_start_tag("c:stockChart")
# Write the series elements.
for data in series:
self._write_ser(data)
# Write the c:dropLines element.
self._write_drop_lines()
# Write the c:hiLowLines element.
if args.get("primary_axes"):
self._write_hi_low_lines()
# Write the c:upDownBars element.
self._write_up_down_bars()
# Write the c:axId elements
self._write_axis_ids(args)
self._xml_end_tag("c:stockChart")
def _modify_series_formatting(self) -> None:
# Add default formatting to the series data.
index = 0
for series in self.series:
if index % 4 != 3:
if not series["line"]["defined"]:
series["line"] = {"width": 2.25, "none": 1, "defined": 1}
if series["marker"] is None:
if index % 4 == 2:
series["marker"] = {"type": "dot", "size": 3}
else:
series["marker"] = {"type": "none"}
index += 1

View File

@@ -0,0 +1,110 @@
###############################################################################
#
# ChartTitle - A class for representing Excel chart titles.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
class ChartTitle:
"""
A class to represent an Excel chart title.
This class encapsulates all title related properties and methods for the
chart title and axis titles.
"""
def __init__(self) -> None:
"""
Initialize a ChartTitle instance.
"""
self.font: Optional[Dict[str, Any]] = None
self.name: Optional[str] = None
self.formula: Optional[str] = None
self.data_id: Optional[int] = None
self.layout: Optional[Dict[str, Any]] = None
self.overlay: Optional[bool] = None
self.hidden: bool = False
self.line: Optional[Dict[str, Any]] = None
self.fill: Optional[Dict[str, Any]] = None
self.pattern: Optional[Dict[str, Any]] = None
self.gradient: Optional[Dict[str, Any]] = None
def has_name(self) -> bool:
"""
Check if the title has a text name set.
Returns:
True if name has been set.
"""
return self.name is not None and self.name != ""
def has_formula(self) -> bool:
"""
Check if the title has a formula set.
Returns:
True if formula has been set.
"""
return self.formula is not None
def has_formatting(self) -> bool:
"""
Check if the title has any formatting properties set.
Returns:
True if the title has line, fill, pattern, or gradient formatting.
"""
has_line = self.line is not None and self.line.get("defined", False)
has_fill = self.fill is not None and self.fill.get("defined", False)
has_pattern = self.pattern
has_gradient = self.gradient
return has_line or has_fill or has_pattern or has_gradient
def get_formatting(self) -> Dict[str, Any]:
"""
Get a dictionary containing the formatting properties.
Returns:
A dictionary with line, fill, pattern, and gradient properties.
"""
return {
"line": self.line,
"fill": self.fill,
"pattern": self.pattern,
"gradient": self.gradient,
}
def is_hidden(self) -> bool:
"""
Check if the title is explicitly hidden.
Returns:
True if title is hidden.
"""
return self.hidden
def __repr__(self) -> str:
"""
Return a string representation of the ChartTitle.
"""
return (
f"ChartTitle(\n"
f" name = {self.name!r},\n"
f" formula = {self.formula!r},\n"
f" hidden = {self.hidden!r},\n"
f" font = {self.font!r},\n"
f" line = {self.line!r},\n"
f" fill = {self.fill!r},\n"
f" pattern = {self.pattern!r},\n"
f" gradient = {self.gradient!r},\n"
f" layout = {self.layout!r},\n"
f" overlay = {self.overlay!r},\n"
f" has_formatting = {self.has_formatting()!r},\n"
f")\n"
)

View File

@@ -0,0 +1,203 @@
###############################################################################
#
# Chartsheet - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Any, Dict, Optional
from xlsxwriter.chart import Chart
from . import worksheet
from .drawing import Drawing
class Chartsheet(worksheet.Worksheet):
"""
A class for writing the Excel XLSX Chartsheet file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.is_chartsheet = True
self.drawing = None
self.chart = None
self.charts = []
self.zoom_scale_normal = 0
self.orientation = 0
self.protection = False
def set_chart(self, chart: Chart) -> Chart:
"""
Set the chart object for the chartsheet.
Args:
chart: Chart object.
Returns:
chart: A reference to the chart object.
"""
chart.embedded = False
chart.protection = self.protection
self.chart = chart
self.charts.append([0, 0, chart, 0, 0, 1, 1])
return chart
def protect(
self, password: str = "", options: Optional[Dict[str, Any]] = None
) -> None:
"""
Set the password and protection options of the worksheet.
Args:
password: An optional password string.
options: A dictionary of worksheet objects to protect.
Returns:
Nothing.
"""
# This method is overridden from parent worksheet class.
# Chartsheets only allow a reduced set of protect options.
copy = {}
if not options:
options = {}
if options.get("objects") is None:
copy["objects"] = False
else:
# Objects are default on for chartsheets, so reverse state.
copy["objects"] = not options["objects"]
if options.get("content") is None:
copy["content"] = True
else:
copy["content"] = options["content"]
copy["sheet"] = False
copy["scenarios"] = True
# If objects and content are both off then the chartsheet isn't
# protected, unless it has a password.
if password == "" and copy["objects"] and not copy["content"]:
return
if self.chart:
self.chart.protection = True
else:
self.protection = True
# Call the parent method.
super().protect(password, copy)
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the root worksheet element.
self._write_chartsheet()
# Write the worksheet properties.
self._write_sheet_pr()
# Write the sheet view properties.
self._write_sheet_views()
# Write the sheetProtection element.
self._write_sheet_protection()
# Write the printOptions element.
self._write_print_options()
# Write the worksheet page_margins.
self._write_page_margins()
# Write the worksheet page setup.
self._write_page_setup()
# Write the headerFooter element.
self._write_header_footer()
# Write the drawing element.
self._write_drawings()
# Write the legacyDrawingHF element.
self._write_legacy_drawing_hf()
# Close the worksheet tag.
self._xml_end_tag("chartsheet")
# Close the file.
self._xml_close()
def _prepare_chart(self, index, chart_id, drawing_id) -> None:
# Set up chart/drawings.
self.chart.id = chart_id - 1
self.drawing = Drawing()
self.drawing.orientation = self.orientation
self.external_drawing_links.append(
["/drawing", "../drawings/drawing" + str(drawing_id) + ".xml"]
)
self.drawing_links.append(
["/chart", "../charts/chart" + str(chart_id) + ".xml"]
)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_chartsheet(self) -> None:
# Write the <worksheet> element. This is the root element.
schema = "http://schemas.openxmlformats.org/"
xmlns = schema + "spreadsheetml/2006/main"
xmlns_r = schema + "officeDocument/2006/relationships"
attributes = [("xmlns", xmlns), ("xmlns:r", xmlns_r)]
self._xml_start_tag("chartsheet", attributes)
def _write_sheet_pr(self) -> None:
# Write the <sheetPr> element for Sheet level properties.
attributes = []
if self.filter_on:
attributes.append(("filterMode", 1))
if self.fit_page or self.tab_color:
self._xml_start_tag("sheetPr", attributes)
self._write_tab_color()
self._write_page_set_up_pr()
self._xml_end_tag("sheetPr")
else:
self._xml_empty_tag("sheetPr", attributes)

View File

@@ -0,0 +1,431 @@
###############################################################################
#
# Color - A class to represent Excel colors.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from enum import Enum
from typing import List, Tuple, Union
CHART_THEMES = [
# Color 0 (bg1).
[
("bg1", 0, 0),
("bg1", 95000, 0),
("bg1", 85000, 0),
("bg1", 75000, 0),
("bg1", 65000, 0),
("bg1", 50000, 0),
],
# Color 1 (tx1).
[
("tx1", 0, 0),
("tx1", 50000, 50000),
("tx1", 65000, 35000),
("tx1", 75000, 25000),
("tx1", 85000, 15000),
("tx1", 95000, 5000),
],
# Color 2 (bg2).
[
("bg2", 0, 0),
("bg2", 90000, 0),
("bg2", 75000, 0),
("bg2", 50000, 0),
("bg2", 25000, 0),
("bg2", 10000, 0),
],
# Color 3 (tx2).
[
("tx2", 0, 0),
("tx2", 20000, 80000),
("tx2", 40000, 60000),
("tx2", 60000, 40000),
("tx2", 75000, 0),
("tx2", 50000, 0),
],
# Color 4 (accent1).
[
("accent1", 0, 0),
("accent1", 20000, 80000),
("accent1", 40000, 60000),
("accent1", 60000, 40000),
("accent1", 75000, 0),
("accent1", 50000, 0),
],
# Color 5 (accent2).
[
("accent2", 0, 0),
("accent2", 20000, 80000),
("accent2", 40000, 60000),
("accent2", 60000, 40000),
("accent2", 75000, 0),
("accent2", 50000, 0),
],
# Color 6 (accent3).
[
("accent3", 0, 0),
("accent3", 20000, 80000),
("accent3", 40000, 60000),
("accent3", 60000, 40000),
("accent3", 75000, 0),
("accent3", 50000, 0),
],
# Color 7 (accent4).
[
("accent4", 0, 0),
("accent4", 20000, 80000),
("accent4", 40000, 60000),
("accent4", 60000, 40000),
("accent4", 75000, 0),
("accent4", 50000, 0),
],
# Color 8 (accent5).
[
("accent5", 0, 0),
("accent5", 20000, 80000),
("accent5", 40000, 60000),
("accent5", 60000, 40000),
("accent5", 75000, 0),
("accent5", 50000, 0),
],
# Color 9 (accent6).
[
("accent6", 0, 0),
("accent6", 20000, 80000),
("accent6", 40000, 60000),
("accent6", 60000, 40000),
("accent6", 75000, 0),
("accent6", 50000, 0),
],
]
class ColorTypes(Enum):
"""
Enum to represent different types of URLS.
"""
RGB = 1
THEME = 2
class Color:
"""
A class to represent an Excel color.
"""
def __init__(self, color: Union[str, int, Tuple[int, int]]) -> None:
"""
Initialize a Color instance.
Args:
color (Union[str, int, Tuple[int, int]]): The value of the color
(e.g., a hex string, an integer, or a tuple of two integers).
"""
self._rgb_value: int = 0x000000
self._type: ColorTypes = ColorTypes.RGB
self._theme_color: Tuple[int, int] = (0, 0)
self._is_automatic: bool = False
if isinstance(color, str):
self._parse_string_color(color)
self._type = ColorTypes.RGB
elif isinstance(color, int):
if color > 0xFFFFFF:
raise ValueError("RGB color must be in the range 0x000000 - 0xFFFFFF.")
self._rgb_value = color
self._type = ColorTypes.RGB
elif (
isinstance(color, tuple)
and len(color) == 2
and all(isinstance(v, int) for v in color)
):
if color[0] > 9:
raise ValueError("Theme color must be in the range 0-9.")
if color[1] > 5:
raise ValueError("Theme shade must be in the range 0-5.")
self._theme_color = color
self._type = ColorTypes.THEME
else:
raise ValueError(
"Invalid color value. Must be a string, integer, or tuple."
)
def __repr__(self) -> str:
"""
Return a string representation of the Color instance.
"""
if self._type == ColorTypes.RGB:
value = f"0x{self._rgb_value:06X}"
else:
value = f"Theme({self._theme_color[0]}, {self._theme_color[1]})"
return (
f"Color("
f"value={value}, "
f"type={self._type.name}, "
f"is_automatic={self._is_automatic})"
)
@staticmethod
def _from_value(value: Union["Color", str]) -> "Color":
"""
Internal method to convert a string to a Color instance or return the
Color instance if already provided. This is mainly used for backward
compatibility support in the XlsxWriter API.
Args:
value (Union[Color, str]): A Color instance or a string representing
a color.
Returns:
Color: A Color instance.
"""
if isinstance(value, Color):
return value
if isinstance(value, str):
return Color(value)
raise TypeError("Value must be a Color instance or a string.")
@staticmethod
def rgb(color: str) -> "Color":
"""
Create a user-defined RGB color from a Html color string.
Args:
color (int): An RGB value in the range 0x000000 (black) to 0xFFFFFF (white).
Returns:
Color: A Color object representing an Excel RGB color.
"""
return Color(color)
@staticmethod
def rgb_integer(color: int) -> "Color":
"""
Create a user-defined RGB color from an integer value.
Args:
color (int): An RGB value in the range 0x000000 (black) to 0xFFFFFF (white).
Returns:
Color: A Color object representing an Excel RGB color.
"""
if color > 0xFFFFFF:
raise ValueError("RGB color must be in the range 0x000000 - 0xFFFFFF.")
return Color(color)
@staticmethod
def theme(color: int, shade: int) -> "Color":
"""
Create a theme color.
Args:
color (int): The theme color index (0-9).
shade (int): The theme shade index (0-5).
Returns:
Color: A Color object representing an Excel Theme color.
"""
if color > 9:
raise ValueError("Theme color must be in the range 0-9.")
if shade > 5:
raise ValueError("Theme shade must be in the range 0-5.")
return Color((color, shade))
@staticmethod
def automatic() -> "Color":
"""
Create an Excel color representing an "Automatic" color.
The Automatic color for an Excel property is usually the same as the
Default color but can vary according to system settings. This method and
color type are rarely used in practice but are included for completeness.
Returns:
Color: A Color object representing an Excel Automatic color.
"""
color = Color(0x000000)
color._is_automatic = True
return color
def _parse_string_color(self, value: str) -> None:
"""
Convert a hex string or named color to an RGB value.
Returns:
int: The RGB value.
"""
# Named colors used in conjunction with various set_xxx_color methods to
# convert a color name into an RGB value. These colors are for backward
# compatibility with older versions of Excel.
named_colors = {
"red": 0xFF0000,
"blue": 0x0000FF,
"cyan": 0x00FFFF,
"gray": 0x808080,
"lime": 0x00FF00,
"navy": 0x000080,
"pink": 0xFF00FF,
"black": 0x000000,
"brown": 0x800000,
"green": 0x008000,
"white": 0xFFFFFF,
"orange": 0xFF6600,
"purple": 0x800080,
"silver": 0xC0C0C0,
"yellow": 0xFFFF00,
"magenta": 0xFF00FF,
}
color = value.lstrip("#").lower()
if color == "automatic":
self._is_automatic = True
self._rgb_value = 0x000000
elif color in named_colors:
self._rgb_value = named_colors[color]
else:
try:
self._rgb_value = int(color, 16)
except ValueError as e:
raise ValueError(f"Invalid color value: {value}") from e
def _rgb_hex_value(self) -> str:
"""
Get the RGB hex value for the color.
Returns:
str: The RGB hex value as a string.
"""
if self._is_automatic:
# Default to black for automatic colors.
return "000000"
if self._type == ColorTypes.THEME:
# Default to black for theme colors.
return "000000"
return f"{self._rgb_value:06X}"
def _vml_rgb_hex_value(self) -> str:
"""
Get the RGB hex value for a VML fill color in "#rrggbb" format.
Returns:
str: The RGB hex value as a string.
"""
if self._is_automatic:
# Default VML color for non-RGB colors.
return "#ffffe1"
return f"#{self._rgb_hex_value().lower()}"
def _argb_hex_value(self) -> str:
"""
Get the ARGB hex value for the color. The alpha channel is always FF.
Returns:
str: The ARGB hex value as a string.
"""
return f"FF{self._rgb_hex_value()}"
def _attributes(self) -> List[Tuple[str, str]]:
"""
Convert the color into a set of "rgb" or "theme/tint" attributes used in
color-related Style XML elements.
Returns:
list[tuple[str, str]]: A list of key-value pairs representing the
attributes.
"""
# pylint: disable=too-many-return-statements
# pylint: disable=no-else-return
if self._type == ColorTypes.THEME:
color, shade = self._theme_color
# The first 3 columns of colors in the theme palette are different
# from the others.
if color == 0:
if shade == 1:
return [("theme", str(color)), ("tint", "-4.9989318521683403E-2")]
elif shade == 2:
return [("theme", str(color)), ("tint", "-0.14999847407452621")]
elif shade == 3:
return [("theme", str(color)), ("tint", "-0.249977111117893")]
elif shade == 4:
return [("theme", str(color)), ("tint", "-0.34998626667073579")]
elif shade == 5:
return [("theme", str(color)), ("tint", "-0.499984740745262")]
else:
return [("theme", str(color))]
elif color == 1:
if shade == 1:
return [("theme", str(color)), ("tint", "0.499984740745262")]
elif shade == 2:
return [("theme", str(color)), ("tint", "0.34998626667073579")]
elif shade == 3:
return [("theme", str(color)), ("tint", "0.249977111117893")]
elif shade == 4:
return [("theme", str(color)), ("tint", "0.14999847407452621")]
elif shade == 5:
return [("theme", str(color)), ("tint", "4.9989318521683403E-2")]
else:
return [("theme", str(color))]
elif color == 2:
if shade == 1:
return [("theme", str(color)), ("tint", "-9.9978637043366805E-2")]
elif shade == 2:
return [("theme", str(color)), ("tint", "-0.249977111117893")]
elif shade == 3:
return [("theme", str(color)), ("tint", "-0.499984740745262")]
elif shade == 4:
return [("theme", str(color)), ("tint", "-0.749992370372631")]
elif shade == 5:
return [("theme", str(color)), ("tint", "-0.89999084444715716")]
else:
return [("theme", str(color))]
else:
if shade == 1:
return [("theme", str(color)), ("tint", "0.79998168889431442")]
elif shade == 2:
return [("theme", str(color)), ("tint", "0.59999389629810485")]
elif shade == 3:
return [("theme", str(color)), ("tint", "0.39997558519241921")]
elif shade == 4:
return [("theme", str(color)), ("tint", "-0.249977111117893")]
elif shade == 5:
return [("theme", str(color)), ("tint", "-0.499984740745262")]
else:
return [("theme", str(color))]
# Handle RGB color.
elif self._type == ColorTypes.RGB:
return [("rgb", self._argb_hex_value())]
# Default case for other colors.
return []
def _chart_scheme(self) -> Tuple[str, int, int]:
"""
Return the chart theme based on color and shade.
Returns:
Tuple[str, int, int]: The corresponding tuple of values from CHART_THEMES.
"""
return CHART_THEMES[self._theme_color[0]][self._theme_color[1]]

View File

@@ -0,0 +1,392 @@
###############################################################################
#
# Comments - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from typing import Dict, List, Optional, Union
from xlsxwriter.color import Color
from . import xmlwriter
from .utility import _preserve_whitespace, xl_cell_to_rowcol, xl_rowcol_to_cell
###########################################################################
#
# A comment type class.
#
###########################################################################
class CommentType:
"""
A class to represent a comment in an Excel worksheet.
"""
def __init__(
self,
row: int,
col: int,
text: str,
options: Optional[Dict[str, Union[str, int, float]]] = None,
) -> None:
"""
Initialize a Comment instance.
Args:
row (int): The row number of the comment.
col (int): The column number of the comment.
text (str): The text of the comment.
options (dict): Additional options for the comment.
"""
self.row: int = row
self.col: int = col
self.text: str = text
self.author: Optional[str] = None
self.color: Color = Color("#ffffe1")
self.start_row: int = 0
self.start_col: int = 0
self.is_visible: Optional[bool] = None
self.width: float = 128
self.height: float = 74
self.x_scale: float = 1
self.y_scale: float = 1
self.x_offset: int = 0
self.y_offset: int = 0
self.font_size: float = 8
self.font_name: str = "Tahoma"
self.font_family: int = 2
self.vertices: List[Union[int, float]] = []
# Set the default start cell and offsets for the comment.
self.set_offsets(self.row, self.col)
# Set any user supplied options.
self._set_user_options(options)
def _set_user_options(
self, options: Optional[Dict[str, Union[str, int, float]]] = None
) -> None:
"""
This method handles the additional optional parameters to
``write_comment()``.
"""
if options is None:
return
# Overwrite the defaults with any user supplied values. Incorrect or
# misspelled parameters are silently ignored.
width = options.get("width")
if width and isinstance(width, (int, float)):
self.width = width
height = options.get("height")
if height and isinstance(height, (int, float)):
self.height = height
x_offset = options.get("x_offset")
if x_offset and isinstance(x_offset, int):
self.x_offset = x_offset
y_offset = options.get("y_offset")
if y_offset and isinstance(y_offset, int):
self.y_offset = y_offset
start_col = options.get("start_col")
if start_col and isinstance(start_col, int):
self.start_col = start_col
start_row = options.get("start_row")
if start_row and isinstance(start_row, int):
self.start_row = start_row
font_size = options.get("font_size")
if font_size and isinstance(font_size, (int, float)):
self.font_size = font_size
font_name = options.get("font_name")
if font_name and isinstance(font_name, str):
self.font_name = font_name
font_family = options.get("font_family")
if font_family and isinstance(font_family, int):
self.font_family = font_family
author = options.get("author")
if author and isinstance(author, str):
self.author = author
visible = options.get("visible")
if visible is not None and isinstance(visible, bool):
self.is_visible = visible
if options.get("color"):
# Set the comment background color.
self.color = Color._from_value(options["color"])
# Convert a cell reference to a row and column.
start_cell = options.get("start_cell")
if start_cell and isinstance(start_cell, str):
(start_row, start_col) = xl_cell_to_rowcol(start_cell)
self.start_row = start_row
self.start_col = start_col
# Scale the size of the comment box if required.
x_scale = options.get("x_scale")
if x_scale and isinstance(x_scale, (int, float)):
self.width = self.width * x_scale
y_scale = options.get("y_scale")
if y_scale and isinstance(y_scale, (int, float)):
self.height = self.height * y_scale
# Round the dimensions to the nearest pixel.
self.width = int(0.5 + self.width)
self.height = int(0.5 + self.height)
def set_offsets(self, row: int, col: int) -> None:
"""
Set the default start cell and offsets for the comment. These are
generally a fixed offset relative to the parent cell. However there are
some edge cases for cells at the, well, edges.
"""
row_max = 1048576
col_max = 16384
if self.row == 0:
self.y_offset = 2
self.start_row = 0
elif self.row == row_max - 3:
self.y_offset = 16
self.start_row = row_max - 7
elif self.row == row_max - 2:
self.y_offset = 16
self.start_row = row_max - 6
elif self.row == row_max - 1:
self.y_offset = 14
self.start_row = row_max - 5
else:
self.y_offset = 10
self.start_row = row - 1
if self.col == col_max - 3:
self.x_offset = 49
self.start_col = col_max - 6
elif self.col == col_max - 2:
self.x_offset = 49
self.start_col = col_max - 5
elif self.col == col_max - 1:
self.x_offset = 49
self.start_col = col_max - 4
else:
self.x_offset = 15
self.start_col = col + 1
###########################################################################
#
# The file writer class for the Excel XLSX Comments file.
#
###########################################################################
class Comments(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Comments file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.author_ids = {}
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(
self, comments_data: Optional[List[CommentType]] = None
) -> None:
# Assemble and write the XML file.
if comments_data is None:
comments_data = []
# Write the XML declaration.
self._xml_declaration()
# Write the comments element.
self._write_comments()
# Write the authors element.
self._write_authors(comments_data)
# Write the commentList element.
self._write_comment_list(comments_data)
self._xml_end_tag("comments")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_comments(self) -> None:
# Write the <comments> element.
xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
attributes = [("xmlns", xmlns)]
self._xml_start_tag("comments", attributes)
def _write_authors(self, comment_data: List[CommentType]) -> None:
# Write the <authors> element.
author_count = 0
self._xml_start_tag("authors")
for comment in comment_data:
author = comment.author
if author is not None and author not in self.author_ids:
# Store the author id.
self.author_ids[author] = author_count
author_count += 1
# Write the author element.
self._write_author(author)
self._xml_end_tag("authors")
def _write_author(self, data: str) -> None:
# Write the <author> element.
self._xml_data_element("author", data)
def _write_comment_list(self, comment_data: List[CommentType]) -> None:
# Write the <commentList> element.
self._xml_start_tag("commentList")
for comment in comment_data:
# Look up the author id.
author_id = -1
if comment.author is not None:
author_id = self.author_ids[comment.author]
# Write the comment element.
self._write_comment(comment, author_id)
self._xml_end_tag("commentList")
def _write_comment(self, comment: CommentType, author_id: int) -> None:
# Write the <comment> element.
ref = xl_rowcol_to_cell(comment.row, comment.col)
attributes = [("ref", ref)]
if author_id != -1:
attributes.append(("authorId", f"{author_id}"))
self._xml_start_tag("comment", attributes)
# Write the text element.
self._write_text(comment)
self._xml_end_tag("comment")
def _write_text(self, comment: CommentType) -> None:
# Write the <text> element.
self._xml_start_tag("text")
# Write the text r element.
self._write_text_r(comment)
self._xml_end_tag("text")
def _write_text_r(self, comment: CommentType) -> None:
# Write the <r> element.
self._xml_start_tag("r")
# Write the rPr element.
self._write_r_pr(comment)
# Write the text r element.
self._write_text_t(comment.text)
self._xml_end_tag("r")
def _write_text_t(self, text: str) -> None:
# Write the text <t> element.
attributes = []
if _preserve_whitespace(text):
attributes.append(("xml:space", "preserve"))
self._xml_data_element("t", text, attributes)
def _write_r_pr(self, comment: CommentType) -> None:
# Write the <rPr> element.
self._xml_start_tag("rPr")
# Write the sz element.
self._write_sz(comment.font_size)
# Write the color element.
self._write_color()
# Write the rFont element.
self._write_r_font(comment.font_name)
# Write the family element.
self._write_family(comment.font_family)
self._xml_end_tag("rPr")
def _write_sz(self, font_size: float) -> None:
# Write the <sz> element.
attributes = [("val", font_size)]
self._xml_empty_tag("sz", attributes)
def _write_color(self) -> None:
# Write the <color> element.
attributes = [("indexed", 81)]
self._xml_empty_tag("color", attributes)
def _write_r_font(self, font_name: str) -> None:
# Write the <rFont> element.
attributes = [("val", font_name)]
self._xml_empty_tag("rFont", attributes)
def _write_family(self, font_family: int) -> None:
# Write the <family> element.
attributes = [("val", font_family)]
self._xml_empty_tag("family", attributes)

View File

@@ -0,0 +1,270 @@
###############################################################################
#
# ContentTypes - A class for writing the Excel XLSX ContentTypes file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
import copy
from typing import Dict, Tuple
from . import xmlwriter
# Long namespace strings used in the class.
APP_PACKAGE = "application/vnd.openxmlformats-package."
APP_DOCUMENT = "application/vnd.openxmlformats-officedocument."
defaults = [
("rels", APP_PACKAGE + "relationships+xml"),
("xml", "application/xml"),
]
overrides = [
("/docProps/app.xml", APP_DOCUMENT + "extended-properties+xml"),
("/docProps/core.xml", APP_PACKAGE + "core-properties+xml"),
("/xl/styles.xml", APP_DOCUMENT + "spreadsheetml.styles+xml"),
("/xl/theme/theme1.xml", APP_DOCUMENT + "theme+xml"),
("/xl/workbook.xml", APP_DOCUMENT + "spreadsheetml.sheet.main+xml"),
]
class ContentTypes(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX ContentTypes file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
# Copy the defaults in case we need to change them.
self.defaults = copy.deepcopy(defaults)
self.overrides = copy.deepcopy(overrides)
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
self._write_types()
self._write_defaults()
self._write_overrides()
self._xml_end_tag("Types")
# Close the file.
self._xml_close()
def _add_default(self, default: Tuple[str, str]) -> None:
# Add elements to the ContentTypes defaults.
self.defaults.append(default)
def _add_override(self, override: Tuple[str, str]) -> None:
# Add elements to the ContentTypes overrides.
self.overrides.append(override)
def _add_worksheet_name(self, worksheet_name: str) -> None:
# Add the name of a worksheet to the ContentTypes overrides.
worksheet_name = "/xl/worksheets/" + worksheet_name + ".xml"
self._add_override(
(worksheet_name, APP_DOCUMENT + "spreadsheetml.worksheet+xml")
)
def _add_chartsheet_name(self, chartsheet_name: str) -> None:
# Add the name of a chartsheet to the ContentTypes overrides.
chartsheet_name = "/xl/chartsheets/" + chartsheet_name + ".xml"
self._add_override(
(chartsheet_name, APP_DOCUMENT + "spreadsheetml.chartsheet+xml")
)
def _add_chart_name(self, chart_name: str) -> None:
# Add the name of a chart to the ContentTypes overrides.
chart_name = "/xl/charts/" + chart_name + ".xml"
self._add_override((chart_name, APP_DOCUMENT + "drawingml.chart+xml"))
def _add_drawing_name(self, drawing_name: str) -> None:
# Add the name of a drawing to the ContentTypes overrides.
drawing_name = "/xl/drawings/" + drawing_name + ".xml"
self._add_override((drawing_name, APP_DOCUMENT + "drawing+xml"))
def _add_vml_name(self) -> None:
# Add the name of a VML drawing to the ContentTypes defaults.
self._add_default(("vml", APP_DOCUMENT + "vmlDrawing"))
def _add_comment_name(self, comment_name: str) -> None:
# Add the name of a comment to the ContentTypes overrides.
comment_name = "/xl/" + comment_name + ".xml"
self._add_override((comment_name, APP_DOCUMENT + "spreadsheetml.comments+xml"))
def _add_shared_strings(self) -> None:
# Add the sharedStrings link to the ContentTypes overrides.
self._add_override(
("/xl/sharedStrings.xml", APP_DOCUMENT + "spreadsheetml.sharedStrings+xml")
)
def _add_calc_chain(self) -> None:
# Add the calcChain link to the ContentTypes overrides.
self._add_override(
("/xl/calcChain.xml", APP_DOCUMENT + "spreadsheetml.calcChain+xml")
)
def _add_image_types(self, image_types: Dict[str, bool]) -> None:
# Add the image default types.
for image_type in image_types:
extension = image_type
if image_type in ("wmf", "emf"):
image_type = "x-" + image_type
self._add_default((extension, "image/" + image_type))
def _add_table_name(self, table_name: str) -> None:
# Add the name of a table to the ContentTypes overrides.
table_name = "/xl/tables/" + table_name + ".xml"
self._add_override((table_name, APP_DOCUMENT + "spreadsheetml.table+xml"))
def _add_vba_project(self) -> None:
# Add a vbaProject to the ContentTypes defaults.
# Change the workbook.xml content-type from xlsx to xlsm.
for i, override in enumerate(self.overrides):
if override[0] == "/xl/workbook.xml":
xlsm = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"
self.overrides[i] = ("/xl/workbook.xml", xlsm)
self._add_default(("bin", "application/vnd.ms-office.vbaProject"))
def _add_vba_project_signature(self) -> None:
# Add a vbaProjectSignature to the ContentTypes overrides.
self._add_override(
(
"/xl/vbaProjectSignature.bin",
"application/vnd.ms-office.vbaProjectSignature",
)
)
def _add_custom_properties(self) -> None:
# Add the custom properties to the ContentTypes overrides.
self._add_override(
("/docProps/custom.xml", APP_DOCUMENT + "custom-properties+xml")
)
def _add_metadata(self) -> None:
# Add the metadata file to the ContentTypes overrides.
self._add_override(
("/xl/metadata.xml", APP_DOCUMENT + "spreadsheetml.sheetMetadata+xml")
)
def _add_feature_bag_property(self) -> None:
# Add the featurePropertyBag file to the ContentTypes overrides.
self._add_override(
(
"/xl/featurePropertyBag/featurePropertyBag.xml",
"application/vnd.ms-excel.featurepropertybag+xml",
)
)
def _add_rich_value(self) -> None:
# Add the richValue files to the ContentTypes overrides.
self._add_override(
(
"/xl/richData/rdRichValueTypes.xml",
"application/vnd.ms-excel.rdrichvaluetypes+xml",
)
)
self._add_override(
("/xl/richData/rdrichvalue.xml", "application/vnd.ms-excel.rdrichvalue+xml")
)
self._add_override(
(
"/xl/richData/rdrichvaluestructure.xml",
"application/vnd.ms-excel.rdrichvaluestructure+xml",
)
)
self._add_override(
(
"/xl/richData/richValueRel.xml",
"application/vnd.ms-excel.richvaluerel+xml",
)
)
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_defaults(self) -> None:
# Write out all of the <Default> types.
for extension, content_type in self.defaults:
self._xml_empty_tag(
"Default", [("Extension", extension), ("ContentType", content_type)]
)
def _write_overrides(self) -> None:
# Write out all of the <Override> types.
for part_name, content_type in self.overrides:
self._xml_empty_tag(
"Override", [("PartName", part_name), ("ContentType", content_type)]
)
def _write_types(self) -> None:
# Write the <Types> element.
xmlns = "http://schemas.openxmlformats.org/package/2006/content-types"
attributes = [
(
"xmlns",
xmlns,
)
]
self._xml_start_tag("Types", attributes)
def _write_default(self, extension, content_type) -> None:
# Write the <Default> element.
attributes = [
("Extension", extension),
("ContentType", content_type),
]
self._xml_empty_tag("Default", attributes)
def _write_override(self, part_name, content_type) -> None:
# Write the <Override> element.
attributes = [
("PartName", part_name),
("ContentType", content_type),
]
self._xml_empty_tag("Override", attributes)

View File

@@ -0,0 +1,182 @@
###############################################################################
#
# Core - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from datetime import datetime, timezone
from typing import Dict, Union
from . import xmlwriter
class Core(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Core file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.properties = {}
self.iso_date = ""
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Set the creation date for the file.
date = self.properties.get("created")
if not isinstance(date, datetime):
date = datetime.now(timezone.utc)
self.iso_date = date.strftime("%Y-%m-%dT%H:%M:%SZ")
# Write the XML declaration.
self._xml_declaration()
self._write_cp_core_properties()
self._write_dc_title()
self._write_dc_subject()
self._write_dc_creator()
self._write_cp_keywords()
self._write_dc_description()
self._write_cp_last_modified_by()
self._write_dcterms_created()
self._write_dcterms_modified()
self._write_cp_category()
self._write_cp_content_status()
self._xml_end_tag("cp:coreProperties")
# Close the file.
self._xml_close()
def _set_properties(self, properties: Dict[str, Union[str, datetime]]) -> None:
# Set the document properties.
self.properties = properties
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_cp_core_properties(self) -> None:
# Write the <cp:coreProperties> element.
xmlns_cp = (
"http://schemas.openxmlformats.org/package/2006/"
+ "metadata/core-properties"
)
xmlns_dc = "http://purl.org/dc/elements/1.1/"
xmlns_dcterms = "http://purl.org/dc/terms/"
xmlns_dcmitype = "http://purl.org/dc/dcmitype/"
xmlns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
attributes = [
("xmlns:cp", xmlns_cp),
("xmlns:dc", xmlns_dc),
("xmlns:dcterms", xmlns_dcterms),
("xmlns:dcmitype", xmlns_dcmitype),
("xmlns:xsi", xmlns_xsi),
]
self._xml_start_tag("cp:coreProperties", attributes)
def _write_dc_creator(self) -> None:
# Write the <dc:creator> element.
data = self.properties.get("author", "")
self._xml_data_element("dc:creator", data)
def _write_cp_last_modified_by(self) -> None:
# Write the <cp:lastModifiedBy> element.
data = self.properties.get("author", "")
self._xml_data_element("cp:lastModifiedBy", data)
def _write_dcterms_created(self) -> None:
# Write the <dcterms:created> element.
attributes = [("xsi:type", "dcterms:W3CDTF")]
self._xml_data_element("dcterms:created", self.iso_date, attributes)
def _write_dcterms_modified(self) -> None:
# Write the <dcterms:modified> element.
attributes = [("xsi:type", "dcterms:W3CDTF")]
self._xml_data_element("dcterms:modified", self.iso_date, attributes)
def _write_dc_title(self) -> None:
# Write the <dc:title> element.
if "title" in self.properties:
data = self.properties["title"]
else:
return
self._xml_data_element("dc:title", data)
def _write_dc_subject(self) -> None:
# Write the <dc:subject> element.
if "subject" in self.properties:
data = self.properties["subject"]
else:
return
self._xml_data_element("dc:subject", data)
def _write_cp_keywords(self) -> None:
# Write the <cp:keywords> element.
if "keywords" in self.properties:
data = self.properties["keywords"]
else:
return
self._xml_data_element("cp:keywords", data)
def _write_dc_description(self) -> None:
# Write the <dc:description> element.
if "comments" in self.properties:
data = self.properties["comments"]
else:
return
self._xml_data_element("dc:description", data)
def _write_cp_category(self) -> None:
# Write the <cp:category> element.
if "category" in self.properties:
data = self.properties["category"]
else:
return
self._xml_data_element("cp:category", data)
def _write_cp_content_status(self) -> None:
# Write the <cp:contentStatus> element.
if "status" in self.properties:
data = self.properties["status"]
else:
return
self._xml_data_element("cp:contentStatus", data)

View File

@@ -0,0 +1,138 @@
###############################################################################
#
# Custom - A class for writing the Excel XLSX Custom Property file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from typing import List, Tuple
from . import xmlwriter
class Custom(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Custom Workbook Property file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.properties = []
self.pid = 1
def _set_properties(self, properties: List[Tuple[str, str, str]]) -> None:
# Set the document properties.
self.properties = properties
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
self._write_properties()
self._xml_end_tag("Properties")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_properties(self) -> None:
# Write the <Properties> element.
schema = "http://schemas.openxmlformats.org/officeDocument/2006/"
xmlns = schema + "custom-properties"
xmlns_vt = schema + "docPropsVTypes"
attributes = [
("xmlns", xmlns),
("xmlns:vt", xmlns_vt),
]
self._xml_start_tag("Properties", attributes)
for custom_property in self.properties:
# Write the property element.
self._write_property(custom_property)
def _write_property(self, custom_property: Tuple[str, str, str]) -> None:
# Write the <property> element.
fmtid = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"
name, value, property_type = custom_property
self.pid += 1
attributes = [
("fmtid", fmtid),
("pid", self.pid),
("name", name),
]
self._xml_start_tag("property", attributes)
if property_type == "number_int":
# Write the vt:i4 element.
self._write_vt_i4(value)
elif property_type == "number":
# Write the vt:r8 element.
self._write_vt_r8(value)
elif property_type == "date":
# Write the vt:filetime element.
self._write_vt_filetime(value)
elif property_type == "bool":
# Write the vt:bool element.
self._write_vt_bool(value)
else:
# Write the vt:lpwstr element.
self._write_vt_lpwstr(value)
self._xml_end_tag("property")
def _write_vt_lpwstr(self, value: str) -> None:
# Write the <vt:lpwstr> element.
self._xml_data_element("vt:lpwstr", value)
def _write_vt_filetime(self, value: str) -> None:
# Write the <vt:filetime> element.
self._xml_data_element("vt:filetime", value)
def _write_vt_i4(self, value: str) -> None:
# Write the <vt:i4> element.
self._xml_data_element("vt:i4", value)
def _write_vt_r8(self, value: str) -> None:
# Write the <vt:r8> element.
self._xml_data_element("vt:r8", value)
def _write_vt_bool(self, value: str) -> None:
# Write the <vt:bool> element.
self._xml_data_element("vt:bool", value)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
###############################################################################
#
# Exceptions - A class for XlsxWriter exceptions.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
class XlsxWriterException(Exception):
"""Base exception for XlsxWriter."""
class XlsxInputError(XlsxWriterException):
"""Base exception for all input data related errors."""
class XlsxFileError(XlsxWriterException):
"""Base exception for all file related errors."""
class EmptyChartSeries(XlsxInputError):
"""Chart must contain at least one data series."""
class DuplicateTableName(XlsxInputError):
"""Worksheet table name already exists."""
class InvalidWorksheetName(XlsxInputError):
"""Worksheet name is too long or contains restricted characters."""
class DuplicateWorksheetName(XlsxInputError):
"""Worksheet name already exists."""
class OverlappingRange(XlsxInputError):
"""Worksheet merge range or table overlaps previous range."""
class UndefinedImageSize(XlsxFileError):
"""No size data found in image file."""
class UnsupportedImageFormat(XlsxFileError):
"""Unsupported image file format."""
class FileCreateError(XlsxFileError):
"""IO error when creating xlsx file."""
class FileSizeError(XlsxFileError):
"""Filesize would require ZIP64 extensions. Use workbook.use_zip64()."""

View File

@@ -0,0 +1,156 @@
###############################################################################
#
# FeaturePropertyBag - A class for writing the Excel XLSX featurePropertyBag.xml
# file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
class FeaturePropertyBag(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX FeaturePropertyBag file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.feature_property_bags = set()
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the FeaturePropertyBags element.
self._write_feature_property_bags()
# Write the Checkbox bag element.
self._write_checkbox_bag()
# Write the XFControls bag element.
self._write_xf_control_bag()
# Write the XFComplement bag element.
self._write_xf_compliment_bag()
# Write the XFComplements bag element.
self._write_xf_compliments_bag()
# Write the DXFComplements bag element.
if "DXFComplements" in self.feature_property_bags:
self._write_dxf_compliments_bag()
self._xml_end_tag("FeaturePropertyBags")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_feature_property_bags(self) -> None:
# Write the <FeaturePropertyBags> element.
xmlns = (
"http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag"
)
attributes = [("xmlns", xmlns)]
self._xml_start_tag("FeaturePropertyBags", attributes)
def _write_checkbox_bag(self) -> None:
# Write the Checkbox <bag> element.
attributes = [("type", "Checkbox")]
self._xml_empty_tag("bag", attributes)
def _write_xf_control_bag(self) -> None:
# Write the XFControls<bag> element.
attributes = [("type", "XFControls")]
self._xml_start_tag("bag", attributes)
# Write the bagId element.
self._write_bag_id("CellControl", 0)
self._xml_end_tag("bag")
def _write_xf_compliment_bag(self) -> None:
# Write the XFComplement <bag> element.
attributes = [("type", "XFComplement")]
self._xml_start_tag("bag", attributes)
# Write the bagId element.
self._write_bag_id("XFControls", 1)
self._xml_end_tag("bag")
def _write_xf_compliments_bag(self) -> None:
# Write the XFComplements <bag> element.
attributes = [
("type", "XFComplements"),
("extRef", "XFComplementsMapperExtRef"),
]
self._xml_start_tag("bag", attributes)
self._xml_start_tag("a", [("k", "MappedFeaturePropertyBags")])
self._write_bag_id("", 2)
self._xml_end_tag("a")
self._xml_end_tag("bag")
def _write_dxf_compliments_bag(self) -> None:
# Write the DXFComplements <bag> element.
attributes = [
("type", "DXFComplements"),
("extRef", "DXFComplementsMapperExtRef"),
]
self._xml_start_tag("bag", attributes)
self._xml_start_tag("a", [("k", "MappedFeaturePropertyBags")])
self._write_bag_id("", 2)
self._xml_end_tag("a")
self._xml_end_tag("bag")
def _write_bag_id(self, key, bag_id) -> None:
# Write the <bagId> element.
attributes = []
if key:
attributes = [("k", key)]
self._xml_data_element("bagId", bag_id, attributes)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,401 @@
###############################################################################
#
# Image - A class for representing image objects in Excel.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
import hashlib
import os
from io import BytesIO
from pathlib import Path
from struct import unpack
from typing import Tuple, Union
from xlsxwriter.url import Url
from .exceptions import UndefinedImageSize, UnsupportedImageFormat
DEFAULT_DPI = 96.0
class Image:
"""
A class to represent an image in an Excel worksheet.
"""
def __init__(self, source: Union[str, Path, BytesIO]) -> None:
"""
Initialize an Image instance.
Args:
source (Union[str, Path, BytesIO]): The filename, Path or BytesIO
object of the image.
"""
if isinstance(source, (str, Path)):
self.filename = source
self.image_data = None
self.image_name = os.path.basename(source)
elif isinstance(source, BytesIO):
self.filename = ""
self.image_data = source
self.image_name = ""
else:
raise ValueError("Source must be a filename (str) or a BytesIO object.")
self._row: int = 0
self._col: int = 0
self._x_offset: int = 0
self._y_offset: int = 0
self._x_scale: float = 1.0
self._y_scale: float = 1.0
self._url: Union[Url, None] = None
self._anchor: int = 2
self._description: Union[str, None] = None
self._decorative: bool = False
self._header_position: Union[str, None] = None
self._ref_id: Union[str, None] = None
# Derived properties.
self._image_extension: str = ""
self._width: float = 0.0
self._height: float = 0.0
self._x_dpi: float = DEFAULT_DPI
self._y_dpi: float = DEFAULT_DPI
self._digest: Union[str, None] = None
self._get_image_properties()
def __repr__(self) -> str:
"""
Return a string representation of the main properties of the Image
instance.
"""
return (
f"Image:\n"
f" filename = {self.filename!r}\n"
f" image_name = {self.image_name!r}\n"
f" image_type = {self.image_type!r}\n"
f" width = {self._width}\n"
f" height = {self._height}\n"
f" x_dpi = {self._x_dpi}\n"
f" y_dpi = {self._y_dpi}\n"
)
@property
def image_type(self) -> str:
"""Get the image type (e.g., 'PNG', 'JPEG')."""
return self._image_extension.upper()
@property
def width(self) -> float:
"""Get the width of the image."""
return self._width
@property
def height(self) -> float:
"""Get the height of the image."""
return self._height
@property
def x_dpi(self) -> float:
"""Get the horizontal DPI of the image."""
return self._x_dpi
@property
def y_dpi(self) -> float:
"""Get the vertical DPI of the image."""
return self._y_dpi
@property
def description(self) -> Union[str, None]:
"""Get the description/alt-text of the image."""
return self._description
@description.setter
def description(self, value: str) -> None:
"""Set the description/alt-text of the image."""
if value:
self._description = value
@property
def decorative(self) -> bool:
"""Get whether the image is decorative."""
return self._decorative
@decorative.setter
def decorative(self, value: bool) -> None:
"""Set whether the image is decorative."""
self._decorative = value
@property
def url(self) -> Union[Url, None]:
"""Get the image url."""
return self._url
@url.setter
def url(self, value: Url) -> None:
"""Set the image url."""
if value:
self._url = value
def _set_user_options(self, options=None) -> None:
"""
This handles the additional optional parameters to ``insert_button()``.
"""
if options is None:
return
if not self._url:
self._url = Url.from_options(options)
if self._url:
self._url._set_object_link()
self._anchor = options.get("object_position", self._anchor)
self._x_scale = options.get("x_scale", self._x_scale)
self._y_scale = options.get("y_scale", self._y_scale)
self._x_offset = options.get("x_offset", self._x_offset)
self._y_offset = options.get("y_offset", self._y_offset)
self._decorative = options.get("decorative", self._decorative)
self.image_data = options.get("image_data", self.image_data)
self._description = options.get("description", self._description)
# For backward compatibility with older parameter name.
self._anchor = options.get("positioning", self._anchor)
def _get_image_properties(self) -> None:
# Extract dimension information from the image file.
height = 0.0
width = 0.0
x_dpi = DEFAULT_DPI
y_dpi = DEFAULT_DPI
if self.image_data:
# Read the image data from the user supplied byte stream.
data = self.image_data.getvalue()
else:
# Open the image file and read in the data.
with open(self.filename, "rb") as fh:
data = fh.read()
# Get the image digest to check for duplicates.
digest = hashlib.sha256(data).hexdigest()
# Look for some common image file markers.
png_marker = unpack("3s", data[1:4])[0]
jpg_marker = unpack(">H", data[:2])[0]
bmp_marker = unpack("2s", data[:2])[0]
gif_marker = unpack("4s", data[:4])[0]
emf_marker = (unpack("4s", data[40:44]))[0]
emf_marker1 = unpack("<L", data[:4])[0]
if png_marker == b"PNG":
(image_type, width, height, x_dpi, y_dpi) = self._process_png(data)
elif jpg_marker == 0xFFD8:
(image_type, width, height, x_dpi, y_dpi) = self._process_jpg(data)
elif bmp_marker == b"BM":
(image_type, width, height) = self._process_bmp(data)
elif emf_marker1 == 0x9AC6CDD7:
(image_type, width, height, x_dpi, y_dpi) = self._process_wmf(data)
elif emf_marker1 == 1 and emf_marker == b" EMF":
(image_type, width, height, x_dpi, y_dpi) = self._process_emf(data)
elif gif_marker == b"GIF8":
(image_type, width, height, x_dpi, y_dpi) = self._process_gif(data)
else:
raise UnsupportedImageFormat(
f"{self.filename}: Unknown or unsupported image file format."
)
# Check that we found the required data.
if not height or not width:
raise UndefinedImageSize(
f"{self.filename}: no size data found in image file."
)
# Set a default dpi for images with 0 dpi.
if x_dpi == 0:
x_dpi = DEFAULT_DPI
if y_dpi == 0:
y_dpi = DEFAULT_DPI
self._image_extension = image_type
self._width = width
self._height = height
self._x_dpi = x_dpi
self._y_dpi = y_dpi
self._digest = digest
def _process_png(
self,
data: bytes,
) -> Tuple[str, float, float, float, float]:
# Extract width and height information from a PNG file.
offset = 8
data_length = len(data)
end_marker = False
width = 0.0
height = 0.0
x_dpi = DEFAULT_DPI
y_dpi = DEFAULT_DPI
# Search through the image data to read the height and width in the
# IHDR element. Also read the DPI in the pHYs element.
while not end_marker and offset < data_length:
length = unpack(">I", data[offset + 0 : offset + 4])[0]
marker = unpack("4s", data[offset + 4 : offset + 8])[0]
# Read the image dimensions.
if marker == b"IHDR":
width = unpack(">I", data[offset + 8 : offset + 12])[0]
height = unpack(">I", data[offset + 12 : offset + 16])[0]
# Read the image DPI.
if marker == b"pHYs":
x_density = unpack(">I", data[offset + 8 : offset + 12])[0]
y_density = unpack(">I", data[offset + 12 : offset + 16])[0]
units = unpack("b", data[offset + 16 : offset + 17])[0]
if units == 1 and x_density > 0 and y_density > 0:
x_dpi = x_density * 0.0254
y_dpi = y_density * 0.0254
if marker == b"IEND":
end_marker = True
continue
offset = offset + length + 12
return "png", width, height, x_dpi, y_dpi
def _process_jpg(self, data: bytes) -> Tuple[str, float, float, float, float]:
# Extract width and height information from a JPEG file.
offset = 2
data_length = len(data)
end_marker = False
width = 0.0
height = 0.0
x_dpi = DEFAULT_DPI
y_dpi = DEFAULT_DPI
# Search through the image data to read the JPEG markers.
while not end_marker and offset < data_length:
marker = unpack(">H", data[offset + 0 : offset + 2])[0]
length = unpack(">H", data[offset + 2 : offset + 4])[0]
# Read the height and width in the 0xFFCn elements (except C4, C8
# and CC which aren't SOF markers).
if (
(marker & 0xFFF0) == 0xFFC0
and marker != 0xFFC4
and marker != 0xFFC8
and marker != 0xFFCC
):
height = unpack(">H", data[offset + 5 : offset + 7])[0]
width = unpack(">H", data[offset + 7 : offset + 9])[0]
# Read the DPI in the 0xFFE0 element.
if marker == 0xFFE0:
units = unpack("b", data[offset + 11 : offset + 12])[0]
x_density = unpack(">H", data[offset + 12 : offset + 14])[0]
y_density = unpack(">H", data[offset + 14 : offset + 16])[0]
if units == 1:
x_dpi = x_density
y_dpi = y_density
if units == 2:
x_dpi = x_density * 2.54
y_dpi = y_density * 2.54
# Workaround for incorrect dpi.
if x_dpi == 1:
x_dpi = DEFAULT_DPI
if y_dpi == 1:
y_dpi = DEFAULT_DPI
if marker == 0xFFDA:
end_marker = True
continue
offset = offset + length + 2
return "jpeg", width, height, x_dpi, y_dpi
def _process_gif(self, data: bytes) -> Tuple[str, float, float, float, float]:
# Extract width and height information from a GIF file.
x_dpi = DEFAULT_DPI
y_dpi = DEFAULT_DPI
width = unpack("<h", data[6:8])[0]
height = unpack("<h", data[8:10])[0]
return "gif", width, height, x_dpi, y_dpi
def _process_bmp(self, data: bytes) -> Tuple[str, float, float]:
# Extract width and height information from a BMP file.
width = unpack("<L", data[18:22])[0]
height = unpack("<L", data[22:26])[0]
return "bmp", width, height
def _process_wmf(self, data: bytes) -> Tuple[str, float, float, float, float]:
# Extract width and height information from a WMF file.
x_dpi = DEFAULT_DPI
y_dpi = DEFAULT_DPI
# Read the bounding box, measured in logical units.
x1 = unpack("<h", data[6:8])[0]
y1 = unpack("<h", data[8:10])[0]
x2 = unpack("<h", data[10:12])[0]
y2 = unpack("<h", data[12:14])[0]
# Read the number of logical units per inch. Used to scale the image.
inch = unpack("<H", data[14:16])[0]
# Convert to rendered height and width.
width = float((x2 - x1) * x_dpi) / inch
height = float((y2 - y1) * y_dpi) / inch
return "wmf", width, height, x_dpi, y_dpi
def _process_emf(self, data: bytes) -> Tuple[str, float, float, float, float]:
# Extract width and height information from a EMF file.
# Read the bounding box, measured in logical units.
bound_x1 = unpack("<l", data[8:12])[0]
bound_y1 = unpack("<l", data[12:16])[0]
bound_x2 = unpack("<l", data[16:20])[0]
bound_y2 = unpack("<l", data[20:24])[0]
# Convert the bounds to width and height.
width = bound_x2 - bound_x1
height = bound_y2 - bound_y1
# Read the rectangular frame in units of 0.01mm.
frame_x1 = unpack("<l", data[24:28])[0]
frame_y1 = unpack("<l", data[28:32])[0]
frame_x2 = unpack("<l", data[32:36])[0]
frame_y2 = unpack("<l", data[36:40])[0]
# Convert the frame bounds to mm width and height.
width_mm = 0.01 * (frame_x2 - frame_x1)
height_mm = 0.01 * (frame_y2 - frame_y1)
# Get the dpi based on the logical size.
x_dpi = width * 25.4 / width_mm
y_dpi = height * 25.4 / height_mm
# This is to match Excel's calculation. It is probably to account for
# the fact that the bounding box is inclusive-inclusive. Or a bug.
width += 1
height += 1
return "emf", width, height, x_dpi, y_dpi

View File

@@ -0,0 +1,266 @@
###############################################################################
#
# Metadata - A class for writing the Excel XLSX Metadata file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from . import xmlwriter
class Metadata(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Metadata file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.has_dynamic_functions = False
self.has_embedded_images = False
self.num_embedded_images = 0
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
if self.num_embedded_images > 0:
self.has_embedded_images = True
# Write the XML declaration.
self._xml_declaration()
# Write the metadata element.
self._write_metadata()
# Write the metadataTypes element.
self._write_metadata_types()
# Write the futureMetadata elements.
if self.has_dynamic_functions:
self._write_cell_future_metadata()
if self.has_embedded_images:
self._write_value_future_metadata()
# Write the cellMetadata element.
if self.has_dynamic_functions:
self._write_cell_metadata()
if self.has_embedded_images:
self._write_value_metadata()
self._xml_end_tag("metadata")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_metadata(self) -> None:
# Write the <metadata> element.
xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
schema = "http://schemas.microsoft.com/office/spreadsheetml"
attributes = [("xmlns", xmlns)]
if self.has_embedded_images:
attributes.append(("xmlns:xlrd", schema + "/2017/richdata"))
if self.has_dynamic_functions:
attributes.append(("xmlns:xda", schema + "/2017/dynamicarray"))
self._xml_start_tag("metadata", attributes)
def _write_metadata_types(self) -> None:
# Write the <metadataTypes> element.
count = 0
if self.has_dynamic_functions:
count += 1
if self.has_embedded_images:
count += 1
attributes = [("count", count)]
self._xml_start_tag("metadataTypes", attributes)
# Write the metadataType element.
if self.has_dynamic_functions:
self._write_cell_metadata_type()
if self.has_embedded_images:
self._write_value_metadata_type()
self._xml_end_tag("metadataTypes")
def _write_cell_metadata_type(self) -> None:
# Write the <metadataType> element.
attributes = [
("name", "XLDAPR"),
("minSupportedVersion", 120000),
("copy", 1),
("pasteAll", 1),
("pasteValues", 1),
("merge", 1),
("splitFirst", 1),
("rowColShift", 1),
("clearFormats", 1),
("clearComments", 1),
("assign", 1),
("coerce", 1),
("cellMeta", 1),
]
self._xml_empty_tag("metadataType", attributes)
def _write_value_metadata_type(self) -> None:
# Write the <metadataType> element.
attributes = [
("name", "XLRICHVALUE"),
("minSupportedVersion", 120000),
("copy", 1),
("pasteAll", 1),
("pasteValues", 1),
("merge", 1),
("splitFirst", 1),
("rowColShift", 1),
("clearFormats", 1),
("clearComments", 1),
("assign", 1),
("coerce", 1),
]
self._xml_empty_tag("metadataType", attributes)
def _write_cell_future_metadata(self) -> None:
# Write the <futureMetadata> element.
attributes = [
("name", "XLDAPR"),
("count", 1),
]
self._xml_start_tag("futureMetadata", attributes)
self._xml_start_tag("bk")
self._xml_start_tag("extLst")
self._write_cell_ext()
self._xml_end_tag("extLst")
self._xml_end_tag("bk")
self._xml_end_tag("futureMetadata")
def _write_value_future_metadata(self) -> None:
# Write the <futureMetadata> element.
attributes = [
("name", "XLRICHVALUE"),
("count", self.num_embedded_images),
]
self._xml_start_tag("futureMetadata", attributes)
for index in range(self.num_embedded_images):
self._xml_start_tag("bk")
self._xml_start_tag("extLst")
self._write_value_ext(index)
self._xml_end_tag("extLst")
self._xml_end_tag("bk")
self._xml_end_tag("futureMetadata")
def _write_cell_ext(self) -> None:
# Write the <ext> element.
attributes = [("uri", "{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}")]
self._xml_start_tag("ext", attributes)
# Write the xda:dynamicArrayProperties element.
self._write_xda_dynamic_array_properties()
self._xml_end_tag("ext")
def _write_xda_dynamic_array_properties(self) -> None:
# Write the <xda:dynamicArrayProperties> element.
attributes = [
("fDynamic", 1),
("fCollapsed", 0),
]
self._xml_empty_tag("xda:dynamicArrayProperties", attributes)
def _write_value_ext(self, index) -> None:
# Write the <ext> element.
attributes = [("uri", "{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}")]
self._xml_start_tag("ext", attributes)
# Write the xlrd:rvb element.
self._write_xlrd_rvb(index)
self._xml_end_tag("ext")
def _write_xlrd_rvb(self, index) -> None:
# Write the <xlrd:rvb> element.
attributes = [("i", index)]
self._xml_empty_tag("xlrd:rvb", attributes)
def _write_cell_metadata(self) -> None:
# Write the <cellMetadata> element.
attributes = [("count", 1)]
self._xml_start_tag("cellMetadata", attributes)
self._xml_start_tag("bk")
# Write the rc element.
self._write_rc(1, 0)
self._xml_end_tag("bk")
self._xml_end_tag("cellMetadata")
def _write_value_metadata(self) -> None:
# Write the <valueMetadata> element.
count = self.num_embedded_images
rc_type = 1
if self.has_dynamic_functions:
rc_type = 2
attributes = [("count", count)]
self._xml_start_tag("valueMetadata", attributes)
# Write the rc elements.
for index in range(self.num_embedded_images):
self._xml_start_tag("bk")
self._write_rc(rc_type, index)
self._xml_end_tag("bk")
self._xml_end_tag("valueMetadata")
def _write_rc(self, rc_type, index) -> None:
# Write the <rc> element.
attributes = [
("t", rc_type),
("v", index),
]
self._xml_empty_tag("rc", attributes)

View File

@@ -0,0 +1,878 @@
###############################################################################
#
# Packager - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Standard packages.
import os
import stat
import tempfile
from io import BytesIO, StringIO
from shutil import copy
# Package imports.
from .app import App
from .comments import Comments
from .contenttypes import ContentTypes
from .core import Core
from .custom import Custom
from .exceptions import EmptyChartSeries
from .feature_property_bag import FeaturePropertyBag
from .metadata import Metadata
from .relationships import Relationships
from .rich_value import RichValue
from .rich_value_rel import RichValueRel
from .rich_value_structure import RichValueStructure
from .rich_value_types import RichValueTypes
from .sharedstrings import SharedStrings
from .styles import Styles
from .table import Table
from .theme import Theme
from .vml import Vml
class Packager:
"""
A class for writing the Excel XLSX Packager file.
This module is used in conjunction with XlsxWriter to create an
Excel XLSX container file.
From Wikipedia: The Open Packaging Conventions (OPC) is a
container-file technology initially created by Microsoft to store
a combination of XML and non-XML files that together form a single
entity such as an Open XML Paper Specification (OpenXPS)
document. http://en.wikipedia.org/wiki/Open_Packaging_Conventions.
At its simplest an Excel XLSX file contains the following elements::
____ [Content_Types].xml
|
|____ docProps
| |____ app.xml
| |____ core.xml
|
|____ xl
| |____ workbook.xml
| |____ worksheets
| | |____ sheet1.xml
| |
| |____ styles.xml
| |
| |____ theme
| | |____ theme1.xml
| |
| |_____rels
| |____ workbook.xml.rels
|
|_____rels
|____ .rels
The Packager class coordinates the classes that represent the
elements of the package and writes them into the XLSX file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.tmpdir = ""
self.in_memory = False
self.workbook = None
self.worksheet_count = 0
self.chartsheet_count = 0
self.chart_count = 0
self.drawing_count = 0
self.table_count = 0
self.num_vml_files = 0
self.num_comment_files = 0
self.named_ranges = []
self.filenames = []
###########################################################################
#
# Private API.
#
###########################################################################
def _set_tmpdir(self, tmpdir) -> None:
# Set an optional user defined temp directory.
self.tmpdir = tmpdir
def _set_in_memory(self, in_memory) -> None:
# Set the optional 'in_memory' mode.
self.in_memory = in_memory
def _add_workbook(self, workbook) -> None:
# Add the Excel::Writer::XLSX::Workbook object to the package.
self.workbook = workbook
self.chart_count = len(workbook.charts)
self.drawing_count = len(workbook.drawings)
self.num_vml_files = workbook.num_vml_files
self.num_comment_files = workbook.num_comment_files
self.named_ranges = workbook.named_ranges
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
self.chartsheet_count += 1
else:
self.worksheet_count += 1
def _create_package(self):
# Write the xml files that make up the XLSX OPC package.
self._write_content_types_file()
self._write_root_rels_file()
self._write_workbook_rels_file()
self._write_worksheet_files()
self._write_chartsheet_files()
self._write_workbook_file()
self._write_chart_files()
self._write_drawing_files()
self._write_vml_files()
self._write_comment_files()
self._write_table_files()
self._write_shared_strings_file()
self._write_styles_file()
self._write_custom_file()
self._write_theme_file()
self._write_worksheet_rels_files()
self._write_chartsheet_rels_files()
self._write_drawing_rels_files()
self._write_rich_value_rels_files()
self._add_image_files()
self._add_vba_project()
self._add_vba_project_signature()
self._write_vba_project_rels_file()
self._write_core_file()
self._write_app_file()
self._write_metadata_file()
self._write_feature_bag_property()
self._write_rich_value_files()
return self.filenames
def _filename(self, xml_filename):
# Create a temp filename to write the XML data to and store the Excel
# filename to use as the name in the Zip container.
if self.in_memory:
os_filename = StringIO()
else:
(fd, os_filename) = tempfile.mkstemp(dir=self.tmpdir)
os.close(fd)
self.filenames.append((os_filename, xml_filename, False))
return os_filename
def _write_workbook_file(self) -> None:
# Write the workbook.xml file.
workbook = self.workbook
workbook._set_xml_writer(self._filename("xl/workbook.xml"))
workbook._assemble_xml_file()
def _write_worksheet_files(self) -> None:
# Write the worksheet files.
index = 1
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
continue
if worksheet.constant_memory:
worksheet._opt_reopen()
worksheet._write_single_row()
worksheet._set_xml_writer(
self._filename("xl/worksheets/sheet" + str(index) + ".xml")
)
worksheet._assemble_xml_file()
index += 1
def _write_chartsheet_files(self) -> None:
# Write the chartsheet files.
index = 1
for worksheet in self.workbook.worksheets():
if not worksheet.is_chartsheet:
continue
worksheet._set_xml_writer(
self._filename("xl/chartsheets/sheet" + str(index) + ".xml")
)
worksheet._assemble_xml_file()
index += 1
def _write_chart_files(self) -> None:
# Write the chart files.
if not self.workbook.charts:
return
index = 1
for chart in self.workbook.charts:
# Check that the chart has at least one data series.
if not chart.series:
raise EmptyChartSeries(
f"Chart{index} must contain at least one "
f"data series. See chart.add_series()."
)
chart._set_xml_writer(
self._filename("xl/charts/chart" + str(index) + ".xml")
)
chart._assemble_xml_file()
index += 1
def _write_drawing_files(self) -> None:
# Write the drawing files.
if not self.drawing_count:
return
index = 1
for drawing in self.workbook.drawings:
drawing._set_xml_writer(
self._filename("xl/drawings/drawing" + str(index) + ".xml")
)
drawing._assemble_xml_file()
index += 1
def _write_vml_files(self) -> None:
# Write the comment VML files.
index = 1
for worksheet in self.workbook.worksheets():
if not worksheet.has_vml and not worksheet.has_header_vml:
continue
if worksheet.has_vml:
vml = Vml()
vml._set_xml_writer(
self._filename("xl/drawings/vmlDrawing" + str(index) + ".vml")
)
vml._assemble_xml_file(
worksheet.vml_data_id,
worksheet.vml_shape_id,
worksheet.comments_list,
worksheet.buttons_list,
)
index += 1
if worksheet.has_header_vml:
vml = Vml()
vml._set_xml_writer(
self._filename("xl/drawings/vmlDrawing" + str(index) + ".vml")
)
vml._assemble_xml_file(
worksheet.vml_header_id,
worksheet.vml_header_id * 1024,
None,
None,
worksheet.header_images_list,
)
self._write_vml_drawing_rels_file(worksheet, index)
index += 1
def _write_comment_files(self) -> None:
# Write the comment files.
index = 1
for worksheet in self.workbook.worksheets():
if not worksheet.has_comments:
continue
comment = Comments()
comment._set_xml_writer(self._filename("xl/comments" + str(index) + ".xml"))
comment._assemble_xml_file(worksheet.comments_list)
index += 1
def _write_shared_strings_file(self) -> None:
# Write the sharedStrings.xml file.
sst = SharedStrings()
sst.string_table = self.workbook.str_table
if not self.workbook.str_table.count:
return
sst._set_xml_writer(self._filename("xl/sharedStrings.xml"))
sst._assemble_xml_file()
def _write_app_file(self) -> None:
# Write the app.xml file.
properties = self.workbook.doc_properties
app = App()
# Add the Worksheet parts.
worksheet_count = 0
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
continue
# Don't write/count veryHidden sheets.
if worksheet.hidden != 2:
app._add_part_name(worksheet.name)
worksheet_count += 1
# Add the Worksheet heading pairs.
app._add_heading_pair(["Worksheets", worksheet_count])
# Add the Chartsheet parts.
for worksheet in self.workbook.worksheets():
if not worksheet.is_chartsheet:
continue
app._add_part_name(worksheet.name)
# Add the Chartsheet heading pairs.
app._add_heading_pair(["Charts", self.chartsheet_count])
# Add the Named Range heading pairs.
if self.named_ranges:
app._add_heading_pair(["Named Ranges", len(self.named_ranges)])
# Add the Named Ranges parts.
for named_range in self.named_ranges:
app._add_part_name(named_range)
app._set_properties(properties)
app.doc_security = self.workbook.read_only
app._set_xml_writer(self._filename("docProps/app.xml"))
app._assemble_xml_file()
def _write_core_file(self) -> None:
# Write the core.xml file.
properties = self.workbook.doc_properties
core = Core()
core._set_properties(properties)
core._set_xml_writer(self._filename("docProps/core.xml"))
core._assemble_xml_file()
def _write_metadata_file(self) -> None:
# Write the metadata.xml file.
if not self.workbook.has_metadata:
return
metadata = Metadata()
metadata.has_dynamic_functions = self.workbook.has_dynamic_functions
metadata.num_embedded_images = len(self.workbook.embedded_images.images)
metadata._set_xml_writer(self._filename("xl/metadata.xml"))
metadata._assemble_xml_file()
def _write_feature_bag_property(self) -> None:
# Write the featurePropertyBag.xml file.
feature_property_bags = self.workbook._has_feature_property_bags()
if not feature_property_bags:
return
property_bag = FeaturePropertyBag()
property_bag.feature_property_bags = feature_property_bags
property_bag._set_xml_writer(
self._filename("xl/featurePropertyBag/featurePropertyBag.xml")
)
property_bag._assemble_xml_file()
def _write_rich_value_files(self) -> None:
if not self.workbook.embedded_images.has_images():
return
self._write_rich_value()
self._write_rich_value_types()
self._write_rich_value_structure()
self._write_rich_value_rel()
def _write_rich_value(self) -> None:
# Write the rdrichvalue.xml file.
filename = self._filename("xl/richData/rdrichvalue.xml")
xml_file = RichValue()
xml_file.embedded_images = self.workbook.embedded_images.images
xml_file._set_xml_writer(filename)
xml_file._assemble_xml_file()
def _write_rich_value_types(self) -> None:
# Write the rdRichValueTypes.xml file.
filename = self._filename("xl/richData/rdRichValueTypes.xml")
xml_file = RichValueTypes()
xml_file._set_xml_writer(filename)
xml_file._assemble_xml_file()
def _write_rich_value_structure(self) -> None:
# Write the rdrichvaluestructure.xml file.
filename = self._filename("xl/richData/rdrichvaluestructure.xml")
xml_file = RichValueStructure()
xml_file.has_embedded_descriptions = self.workbook.has_embedded_descriptions
xml_file._set_xml_writer(filename)
xml_file._assemble_xml_file()
def _write_rich_value_rel(self) -> None:
# Write the richValueRel.xml file.
filename = self._filename("xl/richData/richValueRel.xml")
xml_file = RichValueRel()
xml_file.num_embedded_images = len(self.workbook.embedded_images.images)
xml_file._set_xml_writer(filename)
xml_file._assemble_xml_file()
def _write_custom_file(self) -> None:
# Write the custom.xml file.
properties = self.workbook.custom_properties
custom = Custom()
if not properties:
return
custom._set_properties(properties)
custom._set_xml_writer(self._filename("docProps/custom.xml"))
custom._assemble_xml_file()
def _write_content_types_file(self) -> None:
# Write the ContentTypes.xml file.
content = ContentTypes()
content._add_image_types(self.workbook.image_types)
self._get_table_count()
worksheet_index = 1
chartsheet_index = 1
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
content._add_chartsheet_name("sheet" + str(chartsheet_index))
chartsheet_index += 1
else:
content._add_worksheet_name("sheet" + str(worksheet_index))
worksheet_index += 1
for i in range(1, self.chart_count + 1):
content._add_chart_name("chart" + str(i))
for i in range(1, self.drawing_count + 1):
content._add_drawing_name("drawing" + str(i))
if self.num_vml_files:
content._add_vml_name()
for i in range(1, self.table_count + 1):
content._add_table_name("table" + str(i))
for i in range(1, self.num_comment_files + 1):
content._add_comment_name("comments" + str(i))
# Add the sharedString rel if there is string data in the workbook.
if self.workbook.str_table.count:
content._add_shared_strings()
# Add vbaProject (and optionally vbaProjectSignature) if present.
if self.workbook.vba_project:
content._add_vba_project()
if self.workbook.vba_project_signature:
content._add_vba_project_signature()
# Add the custom properties if present.
if self.workbook.custom_properties:
content._add_custom_properties()
# Add the metadata file if present.
if self.workbook.has_metadata:
content._add_metadata()
# Add the metadata file if present.
if self.workbook._has_feature_property_bags():
content._add_feature_bag_property()
# Add the RichValue file if present.
if self.workbook.embedded_images.has_images():
content._add_rich_value()
content._set_xml_writer(self._filename("[Content_Types].xml"))
content._assemble_xml_file()
def _write_styles_file(self) -> None:
# Write the style xml file.
xf_formats = self.workbook.xf_formats
palette = self.workbook.palette
font_count = self.workbook.font_count
num_formats = self.workbook.num_formats
border_count = self.workbook.border_count
fill_count = self.workbook.fill_count
custom_colors = self.workbook.custom_colors
dxf_formats = self.workbook.dxf_formats
has_comments = self.workbook.has_comments
styles = Styles()
styles._set_style_properties(
[
xf_formats,
palette,
font_count,
num_formats,
border_count,
fill_count,
custom_colors,
dxf_formats,
has_comments,
]
)
styles._set_xml_writer(self._filename("xl/styles.xml"))
styles._assemble_xml_file()
def _write_theme_file(self) -> None:
# Write the theme xml file.
theme = Theme()
theme._set_xml_writer(self._filename("xl/theme/theme1.xml"))
theme._assemble_xml_file()
def _write_table_files(self) -> None:
# Write the table files.
index = 1
for worksheet in self.workbook.worksheets():
table_props = worksheet.tables
if not table_props:
continue
for table_props in table_props:
table = Table()
table._set_xml_writer(
self._filename("xl/tables/table" + str(index) + ".xml")
)
table._set_properties(table_props)
table._assemble_xml_file()
index += 1
def _get_table_count(self) -> None:
# Count the table files. Required for the [Content_Types] file.
for worksheet in self.workbook.worksheets():
for _ in worksheet.tables:
self.table_count += 1
def _write_root_rels_file(self) -> None:
# Write the _rels/.rels xml file.
rels = Relationships()
rels._add_document_relationship("/officeDocument", "xl/workbook.xml")
rels._add_package_relationship("/metadata/core-properties", "docProps/core.xml")
rels._add_document_relationship("/extended-properties", "docProps/app.xml")
if self.workbook.custom_properties:
rels._add_document_relationship("/custom-properties", "docProps/custom.xml")
rels._set_xml_writer(self._filename("_rels/.rels"))
rels._assemble_xml_file()
def _write_workbook_rels_file(self) -> None:
# Write the _rels/.rels xml file.
rels = Relationships()
worksheet_index = 1
chartsheet_index = 1
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
rels._add_document_relationship(
"/chartsheet", "chartsheets/sheet" + str(chartsheet_index) + ".xml"
)
chartsheet_index += 1
else:
rels._add_document_relationship(
"/worksheet", "worksheets/sheet" + str(worksheet_index) + ".xml"
)
worksheet_index += 1
rels._add_document_relationship("/theme", "theme/theme1.xml")
rels._add_document_relationship("/styles", "styles.xml")
# Add the sharedString rel if there is string data in the workbook.
if self.workbook.str_table.count:
rels._add_document_relationship("/sharedStrings", "sharedStrings.xml")
# Add vbaProject if present.
if self.workbook.vba_project:
rels._add_ms_package_relationship("/vbaProject", "vbaProject.bin")
# Add the metadata file if required.
if self.workbook.has_metadata:
rels._add_document_relationship("/sheetMetadata", "metadata.xml")
# Add the RichValue files if present.
if self.workbook.embedded_images.has_images():
rels._add_rich_value_relationship()
# Add the checkbox/FeaturePropertyBag file if present.
if self.workbook._has_feature_property_bags():
rels._add_feature_bag_relationship()
rels._set_xml_writer(self._filename("xl/_rels/workbook.xml.rels"))
rels._assemble_xml_file()
def _write_worksheet_rels_files(self) -> None:
# Write data such as hyperlinks or drawings.
index = 0
for worksheet in self.workbook.worksheets():
if worksheet.is_chartsheet:
continue
index += 1
external_links = (
worksheet.external_hyper_links
+ worksheet.external_drawing_links
+ worksheet.external_vml_links
+ worksheet.external_background_links
+ worksheet.external_table_links
+ worksheet.external_comment_links
)
if not external_links:
continue
# Create the worksheet .rels dirs.
rels = Relationships()
for link_data in external_links:
rels._add_document_relationship(*link_data)
# Create .rels file such as /xl/worksheets/_rels/sheet1.xml.rels.
rels._set_xml_writer(
self._filename("xl/worksheets/_rels/sheet" + str(index) + ".xml.rels")
)
rels._assemble_xml_file()
def _write_chartsheet_rels_files(self) -> None:
# Write the chartsheet .rels files for links to drawing files.
index = 0
for worksheet in self.workbook.worksheets():
if not worksheet.is_chartsheet:
continue
index += 1
external_links = (
worksheet.external_drawing_links + worksheet.external_vml_links
)
if not external_links:
continue
# Create the chartsheet .rels xlsx_dir.
rels = Relationships()
for link_data in external_links:
rels._add_document_relationship(*link_data)
# Create .rels file such as /xl/chartsheets/_rels/sheet1.xml.rels.
rels._set_xml_writer(
self._filename("xl/chartsheets/_rels/sheet" + str(index) + ".xml.rels")
)
rels._assemble_xml_file()
def _write_drawing_rels_files(self) -> None:
# Write the drawing .rels files for worksheets with charts or drawings.
index = 0
for worksheet in self.workbook.worksheets():
if worksheet.drawing:
index += 1
if not worksheet.drawing_links:
continue
# Create the drawing .rels xlsx_dir.
rels = Relationships()
for drawing_data in worksheet.drawing_links:
rels._add_document_relationship(*drawing_data)
# Create .rels file such as /xl/drawings/_rels/sheet1.xml.rels.
rels._set_xml_writer(
self._filename("xl/drawings/_rels/drawing" + str(index) + ".xml.rels")
)
rels._assemble_xml_file()
def _write_vml_drawing_rels_file(self, worksheet, index) -> None:
# Write the vmlDdrawing .rels files for worksheets with images in
# headers or footers.
# Create the drawing .rels dir.
rels = Relationships()
for drawing_data in worksheet.vml_drawing_links:
rels._add_document_relationship(*drawing_data)
# Create .rels file such as /xl/drawings/_rels/vmlDrawing1.vml.rels.
rels._set_xml_writer(
self._filename("xl/drawings/_rels/vmlDrawing" + str(index) + ".vml.rels")
)
rels._assemble_xml_file()
def _write_vba_project_rels_file(self) -> None:
# Write the vbaProject.rels xml file if signed macros exist.
vba_project_signature = self.workbook.vba_project_signature
if not vba_project_signature:
return
# Create the vbaProject .rels dir.
rels = Relationships()
rels._add_ms_package_relationship(
"/vbaProjectSignature", "vbaProjectSignature.bin"
)
rels._set_xml_writer(self._filename("xl/_rels/vbaProject.bin.rels"))
rels._assemble_xml_file()
def _write_rich_value_rels_files(self) -> None:
# Write the richValueRel.xml.rels for embedded images.
if not self.workbook.embedded_images.has_images():
return
# Create the worksheet .rels dirs.
rels = Relationships()
index = 1
for image in self.workbook.embedded_images.images:
image_extension = image.image_type.lower()
image_file = f"../media/image{index}.{image_extension}"
rels._add_document_relationship("/image", image_file)
index += 1
# Create .rels file such as /xl/worksheets/_rels/sheet1.xml.rels.
rels._set_xml_writer(self._filename("/xl/richData/_rels/richValueRel.xml.rels"))
rels._assemble_xml_file()
def _add_image_files(self) -> None:
# pylint: disable=consider-using-with
# Write the /xl/media/image?.xml files.
workbook = self.workbook
index = 1
images = workbook.embedded_images.images + workbook.images
for image in images:
xml_image_name = (
"xl/media/image" + str(index) + "." + image._image_extension
)
if not self.in_memory:
# In file mode we just write or copy the image file.
os_filename = self._filename(xml_image_name)
if image.image_data:
# The data is in a byte stream. Write it to the target.
os_file = open(os_filename, mode="wb")
os_file.write(image.image_data.getvalue())
os_file.close()
else:
copy(image.filename, os_filename)
# Allow copies of Windows read-only images to be deleted.
try:
os.chmod(
os_filename, os.stat(os_filename).st_mode | stat.S_IWRITE
)
except OSError:
pass
else:
# For in-memory mode we read the image into a stream.
if image.image_data:
# The data is already in a byte stream.
os_filename = image.image_data
else:
image_file = open(image.filename, mode="rb")
image_data = image_file.read()
os_filename = BytesIO(image_data)
image_file.close()
self.filenames.append((os_filename, xml_image_name, True))
index += 1
def _add_vba_project_signature(self) -> None:
# pylint: disable=consider-using-with
# Copy in a vbaProjectSignature.bin file.
vba_project_signature = self.workbook.vba_project_signature
vba_project_signature_is_stream = self.workbook.vba_project_signature_is_stream
if not vba_project_signature:
return
xml_vba_signature_name = "xl/vbaProjectSignature.bin"
if not self.in_memory:
# In file mode we just write or copy the VBA project signature file.
os_filename = self._filename(xml_vba_signature_name)
if vba_project_signature_is_stream:
# The data is in a byte stream. Write it to the target.
os_file = open(os_filename, mode="wb")
os_file.write(vba_project_signature.getvalue())
os_file.close()
else:
copy(vba_project_signature, os_filename)
else:
# For in-memory mode we read the vba into a stream.
if vba_project_signature_is_stream:
# The data is already in a byte stream.
os_filename = vba_project_signature
else:
vba_file = open(vba_project_signature, mode="rb")
vba_data = vba_file.read()
os_filename = BytesIO(vba_data)
vba_file.close()
self.filenames.append((os_filename, xml_vba_signature_name, True))
def _add_vba_project(self) -> None:
# pylint: disable=consider-using-with
# Copy in a vbaProject.bin file.
vba_project = self.workbook.vba_project
vba_project_is_stream = self.workbook.vba_project_is_stream
if not vba_project:
return
xml_vba_name = "xl/vbaProject.bin"
if not self.in_memory:
# In file mode we just write or copy the VBA file.
os_filename = self._filename(xml_vba_name)
if vba_project_is_stream:
# The data is in a byte stream. Write it to the target.
os_file = open(os_filename, mode="wb")
os_file.write(vba_project.getvalue())
os_file.close()
else:
copy(vba_project, os_filename)
else:
# For in-memory mode we read the vba into a stream.
if vba_project_is_stream:
# The data is already in a byte stream.
os_filename = vba_project
else:
vba_file = open(vba_project, mode="rb")
vba_data = vba_file.read()
os_filename = BytesIO(vba_data)
vba_file.close()
self.filenames.append((os_filename, xml_vba_name, True))

View File

@@ -0,0 +1,143 @@
###############################################################################
#
# Relationships - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
# Long namespace strings used in the class.
SCHEMA_ROOT = "http://schemas.openxmlformats.org"
PACKAGE_SCHEMA = SCHEMA_ROOT + "/package/2006/relationships"
DOCUMENT_SCHEMA = SCHEMA_ROOT + "/officeDocument/2006/relationships"
class Relationships(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Relationships file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.relationships = []
self.id = 1
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
self._write_relationships()
# Close the file.
self._xml_close()
def _add_document_relationship(self, rel_type, target, target_mode=None) -> None:
# Add container relationship to XLSX .rels xml files.
rel_type = DOCUMENT_SCHEMA + rel_type
self.relationships.append((rel_type, target, target_mode))
def _add_package_relationship(self, rel_type, target) -> None:
# Add container relationship to XLSX .rels xml files.
rel_type = PACKAGE_SCHEMA + rel_type
self.relationships.append((rel_type, target, None))
def _add_ms_package_relationship(self, rel_type, target) -> None:
# Add container relationship to XLSX .rels xml files. Uses MS schema.
schema = "http://schemas.microsoft.com/office/2006/relationships"
rel_type = schema + rel_type
self.relationships.append((rel_type, target, None))
def _add_rich_value_relationship(self) -> None:
# Add RichValue relationship to XLSX .rels xml files.
schema = "http://schemas.microsoft.com/office/2022/10/relationships/"
rel_type = schema + "richValueRel"
target = "richData/richValueRel.xml"
self.relationships.append((rel_type, target, None))
schema = "http://schemas.microsoft.com/office/2017/06/relationships/"
rel_type = schema + "rdRichValue"
target = "richData/rdrichvalue.xml"
self.relationships.append((rel_type, target, None))
rel_type = schema + "rdRichValueStructure"
target = "richData/rdrichvaluestructure.xml"
self.relationships.append((rel_type, target, None))
rel_type = schema + "rdRichValueTypes"
target = "richData/rdRichValueTypes.xml"
self.relationships.append((rel_type, target, None))
def _add_feature_bag_relationship(self) -> None:
# Add FeaturePropertyBag relationship to XLSX .rels xml files.
schema = "http://schemas.microsoft.com/office/2022/11/relationships/"
rel_type = schema + "FeaturePropertyBag"
target = "featurePropertyBag/featurePropertyBag.xml"
self.relationships.append((rel_type, target, None))
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_relationships(self) -> None:
# Write the <Relationships> element.
attributes = [
(
"xmlns",
PACKAGE_SCHEMA,
)
]
self._xml_start_tag("Relationships", attributes)
for relationship in self.relationships:
self._write_relationship(relationship)
self._xml_end_tag("Relationships")
def _write_relationship(self, relationship) -> None:
# Write the <Relationship> element.
rel_type, target, target_mode = relationship
attributes = [
("Id", "rId" + str(self.id)),
("Type", rel_type),
("Target", target),
]
self.id += 1
if target_mode:
attributes.append(("TargetMode", target_mode))
self._xml_empty_tag("Relationship", attributes)

View File

@@ -0,0 +1,98 @@
###############################################################################
#
# RichValue - A class for writing the Excel XLSX rdrichvalue.xml file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from xlsxwriter.image import Image
from . import xmlwriter
class RichValue(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX rdrichvalue.xml file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.embedded_images = []
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the rvData element.
self._write_rv_data()
self._xml_end_tag("rvData")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_rv_data(self) -> None:
# Write the <rvData> element.
xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"
attributes = [
("xmlns", xmlns),
("count", len(self.embedded_images)),
]
self._xml_start_tag("rvData", attributes)
for index, image in enumerate(self.embedded_images):
# Write the rv element.
self._write_rv(index, image)
def _write_rv(self, index, image: Image) -> None:
# Write the <rv> element.
attributes = [("s", 0)]
value = 5
if image.decorative:
value = 6
self._xml_start_tag("rv", attributes)
# Write the v elements.
self._write_v(index)
self._write_v(value)
if image.description:
self._write_v(image.description)
self._xml_end_tag("rv")
def _write_v(self, data) -> None:
# Write the <v> element.
self._xml_data_element("v", data)

View File

@@ -0,0 +1,82 @@
###############################################################################
#
# RichValueRel - A class for writing the Excel XLSX richValueRel.xml file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
class RichValueRel(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX richValueRel.xml file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.num_embedded_images = 0
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the richValueRels element.
self._write_rich_value_rels()
self._xml_end_tag("richValueRels")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_rich_value_rels(self) -> None:
# Write the <richValueRels> element.
xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"
xmlns_r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
attributes = [
("xmlns", xmlns),
("xmlns:r", xmlns_r),
]
self._xml_start_tag("richValueRels", attributes)
# Write the rel elements.
for index in range(self.num_embedded_images):
self._write_rel(index + 1)
def _write_rel(self, index) -> None:
# Write the <rel> element.
r_id = f"rId{index}"
attributes = [("r:id", r_id)]
self._xml_empty_tag("rel", attributes)

View File

@@ -0,0 +1,99 @@
###############################################################################
#
# RichValueStructure - A class for writing the Excel XLSX rdrichvaluestructure.xml file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
class RichValueStructure(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX rdrichvaluestructure.xml file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.has_embedded_descriptions = False
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the rvStructures element.
self._write_rv_structures()
self._xml_end_tag("rvStructures")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_rv_structures(self) -> None:
# Write the <rvStructures> element.
xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"
count = "1"
attributes = [
("xmlns", xmlns),
("count", count),
]
self._xml_start_tag("rvStructures", attributes)
# Write the s element.
self._write_s()
def _write_s(self) -> None:
# Write the <s> element.
t = "_localImage"
attributes = [("t", t)]
self._xml_start_tag("s", attributes)
# Write the k elements.
self._write_k("_rvRel:LocalImageIdentifier", "i")
self._write_k("CalcOrigin", "i")
if self.has_embedded_descriptions:
self._write_k("Text", "s")
self._xml_end_tag("s")
def _write_k(self, name, k_type) -> None:
# Write the <k> element.
attributes = [
("n", name),
("t", k_type),
]
self._xml_empty_tag("k", attributes)

View File

@@ -0,0 +1,111 @@
###############################################################################
#
# RichValueTypes - A class for writing the Excel XLSX rdRichValueTypes.xml file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
class RichValueTypes(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX rdRichValueTypes.xml file.
"""
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the rvTypesInfo element.
self._write_rv_types_info()
# Write the global element.
self._write_global()
self._xml_end_tag("rvTypesInfo")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_rv_types_info(self) -> None:
# Write the <rvTypesInfo> element.
xmlns = "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata2"
xmlns_x = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns_mc = "http://schemas.openxmlformats.org/markup-compatibility/2006"
mc_ignorable = "x"
attributes = [
("xmlns", xmlns),
("xmlns:mc", xmlns_mc),
("mc:Ignorable", mc_ignorable),
("xmlns:x", xmlns_x),
]
self._xml_start_tag("rvTypesInfo", attributes)
def _write_global(self) -> None:
# Write the <global> element.
key_flags = [
["_Self", ["ExcludeFromFile", "ExcludeFromCalcComparison"]],
["_DisplayString", ["ExcludeFromCalcComparison"]],
["_Flags", ["ExcludeFromCalcComparison"]],
["_Format", ["ExcludeFromCalcComparison"]],
["_SubLabel", ["ExcludeFromCalcComparison"]],
["_Attribution", ["ExcludeFromCalcComparison"]],
["_Icon", ["ExcludeFromCalcComparison"]],
["_Display", ["ExcludeFromCalcComparison"]],
["_CanonicalPropertyNames", ["ExcludeFromCalcComparison"]],
["_ClassificationId", ["ExcludeFromCalcComparison"]],
]
self._xml_start_tag("global")
self._xml_start_tag("keyFlags")
for key_flag in key_flags:
# Write the key element.
self._write_key(key_flag)
self._xml_end_tag("keyFlags")
self._xml_end_tag("global")
def _write_key(self, key_flag) -> None:
# Write the <key> element.
name = key_flag[0]
attributes = [("name", name)]
self._xml_start_tag("key", attributes)
# Write the flag element.
for name in key_flag[1]:
self._write_flag(name)
self._xml_end_tag("key")
def _write_flag(self, name) -> None:
# Write the <flag> element.
attributes = [
("name", name),
("value", "1"),
]
self._xml_empty_tag("flag", attributes)

View File

@@ -0,0 +1,433 @@
###############################################################################
#
# Shape - A class for to represent Excel XLSX shape objects.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
import copy
from warnings import warn
from xlsxwriter.color import Color
class Shape:
"""
A class for to represent Excel XLSX shape objects.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self, shape_type, name: str, options) -> None:
"""
Constructor.
"""
super().__init__()
self.name = name
self.shape_type = shape_type
self.connect = 0
self.drawing = 0
self.edit_as = ""
self.id = 0
self.text = ""
self.textlink = ""
self.stencil = 1
self.element = -1
self.start = None
self.start_index = None
self.end = None
self.end_index = None
self.adjustments = []
self.start_side = ""
self.end_side = ""
self.flip_h = 0
self.flip_v = 0
self.rotation = 0
self.text_rotation = 0
self.textbox = False
self.align = None
self.fill = None
self.font = None
self.format = None
self.line = None
self._set_options(options)
###########################################################################
#
# Private API.
#
###########################################################################
def _set_options(self, options) -> None:
self.align = self._get_align_properties(options.get("align"))
self.fill = self._get_fill_properties(options.get("fill"))
self.font = self._get_font_properties(options.get("font"))
self.gradient = self._get_gradient_properties(options.get("gradient"))
self.line = self._get_line_properties(options)
self.text_rotation = options.get("text_rotation", 0)
self.textlink = options.get("textlink", "")
if self.textlink.startswith("="):
self.textlink = self.textlink.lstrip("=")
# Gradient fill overrides solid fill.
if self.gradient:
self.fill = None
###########################################################################
#
# Static methods for processing chart/shape style properties.
#
###########################################################################
@staticmethod
def _get_line_properties(options: dict) -> dict:
# Convert user line properties to the structure required internally.
if not options.get("line") and not options.get("border"):
return {"defined": False}
# Copy the user defined properties since they will be modified.
# Depending on the context, the Excel UI property may be called 'line'
# or 'border'. Internally they are the same so we handle both.
if options.get("line"):
line = copy.deepcopy(options["line"])
else:
line = copy.deepcopy(options["border"])
dash_types = {
"solid": "solid",
"round_dot": "sysDot",
"square_dot": "sysDash",
"dash": "dash",
"dash_dot": "dashDot",
"long_dash": "lgDash",
"long_dash_dot": "lgDashDot",
"long_dash_dot_dot": "lgDashDotDot",
"dot": "dot",
"system_dash_dot": "sysDashDot",
"system_dash_dot_dot": "sysDashDotDot",
}
# Check the dash type.
dash_type = line.get("dash_type")
if dash_type is not None:
if dash_type in dash_types:
line["dash_type"] = dash_types[dash_type]
else:
warn(f"Unknown dash type '{dash_type}'")
return {}
if line.get("color"):
line["color"] = Color._from_value(line["color"])
line["defined"] = True
return line
@staticmethod
def _get_fill_properties(fill):
# Convert user fill properties to the structure required internally.
if not fill:
return {"defined": False}
# Copy the user defined properties since they will be modified.
fill = copy.deepcopy(fill)
if fill.get("color"):
fill["color"] = Color._from_value(fill["color"])
fill["defined"] = True
return fill
@staticmethod
def _get_pattern_properties(pattern):
# Convert user defined pattern to the structure required internally.
if not pattern:
return {}
# Copy the user defined properties since they will be modified.
pattern = copy.deepcopy(pattern)
if not pattern.get("pattern"):
warn("Pattern must include 'pattern'")
return {}
if not pattern.get("fg_color"):
warn("Pattern must include 'fg_color'")
return {}
types = {
"percent_5": "pct5",
"percent_10": "pct10",
"percent_20": "pct20",
"percent_25": "pct25",
"percent_30": "pct30",
"percent_40": "pct40",
"percent_50": "pct50",
"percent_60": "pct60",
"percent_70": "pct70",
"percent_75": "pct75",
"percent_80": "pct80",
"percent_90": "pct90",
"light_downward_diagonal": "ltDnDiag",
"light_upward_diagonal": "ltUpDiag",
"dark_downward_diagonal": "dkDnDiag",
"dark_upward_diagonal": "dkUpDiag",
"wide_downward_diagonal": "wdDnDiag",
"wide_upward_diagonal": "wdUpDiag",
"light_vertical": "ltVert",
"light_horizontal": "ltHorz",
"narrow_vertical": "narVert",
"narrow_horizontal": "narHorz",
"dark_vertical": "dkVert",
"dark_horizontal": "dkHorz",
"dashed_downward_diagonal": "dashDnDiag",
"dashed_upward_diagonal": "dashUpDiag",
"dashed_horizontal": "dashHorz",
"dashed_vertical": "dashVert",
"small_confetti": "smConfetti",
"large_confetti": "lgConfetti",
"zigzag": "zigZag",
"wave": "wave",
"diagonal_brick": "diagBrick",
"horizontal_brick": "horzBrick",
"weave": "weave",
"plaid": "plaid",
"divot": "divot",
"dotted_grid": "dotGrid",
"dotted_diamond": "dotDmnd",
"shingle": "shingle",
"trellis": "trellis",
"sphere": "sphere",
"small_grid": "smGrid",
"large_grid": "lgGrid",
"small_check": "smCheck",
"large_check": "lgCheck",
"outlined_diamond": "openDmnd",
"solid_diamond": "solidDmnd",
}
# Check for valid types.
if pattern["pattern"] not in types:
warn(f"unknown pattern type '{pattern['pattern']}'")
return {}
pattern["pattern"] = types[pattern["pattern"]]
if pattern.get("fg_color"):
pattern["fg_color"] = Color._from_value(pattern["fg_color"])
if pattern.get("bg_color"):
pattern["bg_color"] = Color._from_value(pattern["bg_color"])
else:
pattern["bg_color"] = Color("#FFFFFF")
return pattern
@staticmethod
def _get_gradient_properties(gradient):
# pylint: disable=too-many-return-statements
# Convert user defined gradient to the structure required internally.
if not gradient:
return {}
# Copy the user defined properties since they will be modified.
gradient = copy.deepcopy(gradient)
types = {
"linear": "linear",
"radial": "circle",
"rectangular": "rect",
"path": "shape",
}
# Check the colors array exists and is valid.
if "colors" not in gradient or not isinstance(gradient["colors"], list):
warn("Gradient must include colors list")
return {}
# Check the colors array has the required number of entries.
if not 2 <= len(gradient["colors"]) <= 10:
warn("Gradient colors list must at least 2 values and not more than 10")
return {}
if "positions" in gradient:
# Check the positions array has the right number of entries.
if len(gradient["positions"]) != len(gradient["colors"]):
warn("Gradient positions not equal to number of colors")
return {}
# Check the positions are in the correct range.
for pos in gradient["positions"]:
if not 0 <= pos <= 100:
warn("Gradient position must be in the range 0 <= position <= 100")
return {}
else:
# Use the default gradient positions.
if len(gradient["colors"]) == 2:
gradient["positions"] = [0, 100]
elif len(gradient["colors"]) == 3:
gradient["positions"] = [0, 50, 100]
elif len(gradient["colors"]) == 4:
gradient["positions"] = [0, 33, 66, 100]
else:
warn("Must specify gradient positions")
return {}
angle = gradient.get("angle")
if angle:
if not 0 <= angle < 360:
warn("Gradient angle must be in the range 0 <= angle < 360")
return {}
else:
gradient["angle"] = 90
# Check for valid types.
gradient_type = gradient.get("type")
if gradient_type is not None:
if gradient_type in types:
gradient["type"] = types[gradient_type]
else:
warn(f"Unknown gradient type '{gradient_type}")
return {}
else:
gradient["type"] = "linear"
gradient["colors"] = [Color._from_value(color) for color in gradient["colors"]]
return gradient
@staticmethod
def _get_font_properties(options):
# Convert user defined font values into private dict values.
if options is None:
options = {}
font = {
"name": options.get("name"),
"color": options.get("color"),
"size": options.get("size", 11),
"bold": options.get("bold"),
"italic": options.get("italic"),
"underline": options.get("underline"),
"pitch_family": options.get("pitch_family"),
"charset": options.get("charset"),
"baseline": options.get("baseline", -1),
"lang": options.get("lang", "en-US"),
}
# Convert font size units.
if font["size"]:
font["size"] = int(font["size"] * 100)
if font.get("color"):
font["color"] = Color._from_value(font["color"])
return font
@staticmethod
def _get_font_style_attributes(font):
# _get_font_style_attributes.
attributes = []
if not font:
return attributes
if font.get("size"):
attributes.append(("sz", font["size"]))
if font.get("bold") is not None:
attributes.append(("b", 0 + font["bold"]))
if font.get("italic") is not None:
attributes.append(("i", 0 + font["italic"]))
if font.get("underline") is not None:
attributes.append(("u", "sng"))
if font.get("baseline") != -1:
attributes.append(("baseline", font["baseline"]))
return attributes
@staticmethod
def _get_font_latin_attributes(font):
# _get_font_latin_attributes.
attributes = []
if not font:
return attributes
if font.get("name") is not None:
attributes.append(("typeface", font["name"]))
if font.get("pitch_family") is not None:
attributes.append(("pitchFamily", font["pitch_family"]))
if font.get("charset") is not None:
attributes.append(("charset", font["charset"]))
return attributes
@staticmethod
def _get_align_properties(align):
# Convert user defined align to the structure required internally.
if not align:
return {"defined": False}
# Copy the user defined properties since they will be modified.
align = copy.deepcopy(align)
if "vertical" in align:
align_type = align["vertical"]
align_types = {
"top": "top",
"middle": "middle",
"bottom": "bottom",
}
if align_type in align_types:
align["vertical"] = align_types[align_type]
else:
warn(f"Unknown alignment type '{align_type}'")
return {"defined": False}
if "horizontal" in align:
align_type = align["horizontal"]
align_types = {
"left": "left",
"center": "center",
"right": "right",
}
if align_type in align_types:
align["horizontal"] = align_types[align_type]
else:
warn(f"Unknown alignment type '{align_type}'")
return {"defined": False}
align["defined"] = True
return align

View File

@@ -0,0 +1,138 @@
###############################################################################
#
# SharedStrings - A class for writing the Excel XLSX sharedStrings file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
from .utility import _preserve_whitespace
class SharedStrings(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX sharedStrings file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.string_table = None
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the sst element.
self._write_sst()
# Write the sst strings.
self._write_sst_strings()
# Close the sst tag.
self._xml_end_tag("sst")
# Close the file.
self._xml_close()
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_sst(self) -> None:
# Write the <sst> element.
xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
attributes = [
("xmlns", xmlns),
("count", self.string_table.count),
("uniqueCount", self.string_table.unique_count),
]
self._xml_start_tag("sst", attributes)
def _write_sst_strings(self) -> None:
# Write the sst string elements.
for string in self.string_table.string_array:
self._write_si(string)
def _write_si(self, string) -> None:
# Write the <si> element.
attributes = []
# Convert control character to a _xHHHH_ escape.
string = self._escape_control_characters(string)
# Add attribute to preserve leading or trailing whitespace.
if _preserve_whitespace(string):
attributes.append(("xml:space", "preserve"))
# Write any rich strings without further tags.
if string.startswith("<r>") and string.endswith("</r>"):
self._xml_rich_si_element(string)
else:
self._xml_si_element(string, attributes)
# A metadata class to store Excel strings between worksheets.
class SharedStringTable:
"""
A class to track Excel shared strings between worksheets.
"""
def __init__(self) -> None:
self.count = 0
self.unique_count = 0
self.string_table = {}
self.string_array = []
def _get_shared_string_index(self, string):
""" " Get the index of the string in the Shared String table."""
if string not in self.string_table:
# String isn't already stored in the table so add it.
index = self.unique_count
self.string_table[string] = index
self.count += 1
self.unique_count += 1
return index
# String exists in the table.
index = self.string_table[string]
self.count += 1
return index
def _get_shared_string(self, index):
""" " Get a shared string from the index."""
return self.string_array[index]
def _sort_string_data(self) -> None:
""" " Sort the shared string data and convert from dict to list."""
self.string_array = sorted(self.string_table, key=self.string_table.__getitem__)
self.string_table = {}

View File

@@ -0,0 +1,788 @@
###############################################################################
#
# Styles - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from . import xmlwriter
class Styles(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Styles file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.xf_formats = []
self.palette = []
self.font_count = 0
self.num_formats = []
self.border_count = 0
self.fill_count = 0
self.custom_colors = []
self.dxf_formats = []
self.has_hyperlink = False
self.hyperlink_font_id = 0
self.has_comments = False
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Add the style sheet.
self._write_style_sheet()
# Write the number formats.
self._write_num_fmts()
# Write the fonts.
self._write_fonts()
# Write the fills.
self._write_fills()
# Write the borders element.
self._write_borders()
# Write the cellStyleXfs element.
self._write_cell_style_xfs()
# Write the cellXfs element.
self._write_cell_xfs()
# Write the cellStyles element.
self._write_cell_styles()
# Write the dxfs element.
self._write_dxfs()
# Write the tableStyles element.
self._write_table_styles()
# Write the colors element.
self._write_colors()
# Close the style sheet tag.
self._xml_end_tag("styleSheet")
# Close the file.
self._xml_close()
def _set_style_properties(self, properties) -> None:
# Pass in the Format objects and other properties used in the styles.
self.xf_formats = properties[0]
self.palette = properties[1]
self.font_count = properties[2]
self.num_formats = properties[3]
self.border_count = properties[4]
self.fill_count = properties[5]
self.custom_colors = properties[6]
self.dxf_formats = properties[7]
self.has_comments = properties[8]
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_style_sheet(self) -> None:
# Write the <styleSheet> element.
xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
attributes = [("xmlns", xmlns)]
self._xml_start_tag("styleSheet", attributes)
def _write_num_fmts(self) -> None:
# Write the <numFmts> element.
if not self.num_formats:
return
attributes = [("count", len(self.num_formats))]
self._xml_start_tag("numFmts", attributes)
# Write the numFmts elements.
for index, num_format in enumerate(self.num_formats, 164):
self._write_num_fmt(index, num_format)
self._xml_end_tag("numFmts")
def _write_num_fmt(self, num_fmt_id, format_code) -> None:
# Write the <numFmt> element.
format_codes = {
0: "General",
1: "0",
2: "0.00",
3: "#,##0",
4: "#,##0.00",
5: "($#,##0_);($#,##0)",
6: "($#,##0_);[Red]($#,##0)",
7: "($#,##0.00_);($#,##0.00)",
8: "($#,##0.00_);[Red]($#,##0.00)",
9: "0%",
10: "0.00%",
11: "0.00E+00",
12: "# ?/?",
13: "# ??/??",
14: "m/d/yy",
15: "d-mmm-yy",
16: "d-mmm",
17: "mmm-yy",
18: "h:mm AM/PM",
19: "h:mm:ss AM/PM",
20: "h:mm",
21: "h:mm:ss",
22: "m/d/yy h:mm",
37: "(#,##0_);(#,##0)",
38: "(#,##0_);[Red](#,##0)",
39: "(#,##0.00_);(#,##0.00)",
40: "(#,##0.00_);[Red](#,##0.00)",
41: '_(* #,##0_);_(* (#,##0);_(* "-"_);_(_)',
42: '_($* #,##0_);_($* (#,##0);_($* "-"_);_(_)',
43: '_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(_)',
44: '_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(_)',
45: "mm:ss",
46: "[h]:mm:ss",
47: "mm:ss.0",
48: "##0.0E+0",
49: "@",
}
# Set the format code for built-in number formats.
if num_fmt_id < 164:
format_code = format_codes.get(num_fmt_id, "General")
attributes = [
("numFmtId", num_fmt_id),
("formatCode", format_code),
]
self._xml_empty_tag("numFmt", attributes)
def _write_fonts(self) -> None:
# Write the <fonts> element.
if self.has_comments:
# Add extra font for comments.
attributes = [("count", self.font_count + 1)]
else:
attributes = [("count", self.font_count)]
self._xml_start_tag("fonts", attributes)
# Write the font elements for xf_format objects that have them.
for xf_format in self.xf_formats:
if xf_format.has_font:
self._write_font(xf_format)
if self.has_comments:
self._write_comment_font()
self._xml_end_tag("fonts")
def _write_font(self, xf_format, is_dxf_format=False) -> None:
# Write the <font> element.
self._xml_start_tag("font")
# The condense and extend elements are mainly used in dxf formats.
if xf_format.font_condense:
self._write_condense()
if xf_format.font_extend:
self._write_extend()
if xf_format.bold:
self._xml_empty_tag("b")
if xf_format.italic:
self._xml_empty_tag("i")
if xf_format.font_strikeout:
self._xml_empty_tag("strike")
if xf_format.font_outline:
self._xml_empty_tag("outline")
if xf_format.font_shadow:
self._xml_empty_tag("shadow")
# Handle the underline variants.
if xf_format.underline:
self._write_underline(xf_format.underline)
if xf_format.font_script == 1:
self._write_vert_align("superscript")
if xf_format.font_script == 2:
self._write_vert_align("subscript")
if not is_dxf_format:
self._xml_empty_tag("sz", [("val", xf_format.font_size)])
if xf_format.theme == -1:
# Ignore for excel2003_style.
pass
elif xf_format.theme:
self._write_color([("theme", xf_format.theme)])
elif xf_format.color_indexed:
self._write_color([("indexed", xf_format.color_indexed)])
elif xf_format.font_color:
color = xf_format.font_color
if not color._is_automatic:
self._write_color(color._attributes())
elif not is_dxf_format:
self._write_color([("theme", 1)])
if not is_dxf_format:
self._xml_empty_tag("name", [("val", xf_format.font_name)])
if xf_format.font_family:
self._xml_empty_tag("family", [("val", xf_format.font_family)])
if xf_format.font_charset:
self._xml_empty_tag("charset", [("val", xf_format.font_charset)])
if xf_format.font_name == "Calibri" and not xf_format.hyperlink:
self._xml_empty_tag("scheme", [("val", xf_format.font_scheme)])
if xf_format.hyperlink:
self.has_hyperlink = True
if self.hyperlink_font_id == 0:
self.hyperlink_font_id = xf_format.font_index
self._xml_end_tag("font")
def _write_comment_font(self) -> None:
# Write the <font> element for comments.
self._xml_start_tag("font")
self._xml_empty_tag("sz", [("val", 8)])
self._write_color([("indexed", 81)])
self._xml_empty_tag("name", [("val", "Tahoma")])
self._xml_empty_tag("family", [("val", 2)])
self._xml_end_tag("font")
def _write_underline(self, underline) -> None:
# Write the underline font element.
if underline == 2:
attributes = [("val", "double")]
elif underline == 33:
attributes = [("val", "singleAccounting")]
elif underline == 34:
attributes = [("val", "doubleAccounting")]
else:
# Default to single underline.
attributes = []
self._xml_empty_tag("u", attributes)
def _write_vert_align(self, val) -> None:
# Write the <vertAlign> font sub-element.
attributes = [("val", val)]
self._xml_empty_tag("vertAlign", attributes)
def _write_color(self, attributes) -> None:
# Write the <color> element.
self._xml_empty_tag("color", attributes)
def _write_fills(self) -> None:
# Write the <fills> element.
attributes = [("count", self.fill_count)]
self._xml_start_tag("fills", attributes)
# Write the default fill element.
self._write_default_fill("none")
self._write_default_fill("gray125")
# Write the fill elements for xf_format objects that have them.
for xf_format in self.xf_formats:
if xf_format.has_fill:
self._write_fill(xf_format)
self._xml_end_tag("fills")
def _write_default_fill(self, pattern_type) -> None:
# Write the <fill> element for the default fills.
self._xml_start_tag("fill")
self._xml_empty_tag("patternFill", [("patternType", pattern_type)])
self._xml_end_tag("fill")
def _write_fill(self, xf_format, is_dxf_format=False) -> None:
# Write the <fill> element.
pattern = xf_format.pattern
bg_color = xf_format.bg_color
fg_color = xf_format.fg_color
# Colors for dxf formats are handled differently from normal formats
# since the normal xf_format reverses the meaning of BG and FG for
# solid fills.
if is_dxf_format:
bg_color = xf_format.dxf_bg_color
fg_color = xf_format.dxf_fg_color
patterns = (
"none",
"solid",
"mediumGray",
"darkGray",
"lightGray",
"darkHorizontal",
"darkVertical",
"darkDown",
"darkUp",
"darkGrid",
"darkTrellis",
"lightHorizontal",
"lightVertical",
"lightDown",
"lightUp",
"lightGrid",
"lightTrellis",
"gray125",
"gray0625",
)
# Special handling for pattern only case.
if not fg_color and not bg_color and patterns[pattern]:
self._write_default_fill(patterns[pattern])
return
self._xml_start_tag("fill")
# The "none" pattern is handled differently for dxf formats.
if is_dxf_format and pattern <= 1:
self._xml_start_tag("patternFill")
else:
self._xml_start_tag("patternFill", [("patternType", patterns[pattern])])
if fg_color:
if not fg_color._is_automatic:
self._xml_empty_tag("fgColor", fg_color._attributes())
if bg_color:
if not bg_color._is_automatic:
self._xml_empty_tag("bgColor", bg_color._attributes())
else:
if not is_dxf_format and pattern <= 1:
self._xml_empty_tag("bgColor", [("indexed", 64)])
self._xml_end_tag("patternFill")
self._xml_end_tag("fill")
def _write_borders(self) -> None:
# Write the <borders> element.
attributes = [("count", self.border_count)]
self._xml_start_tag("borders", attributes)
# Write the border elements for xf_format objects that have them.
for xf_format in self.xf_formats:
if xf_format.has_border:
self._write_border(xf_format)
self._xml_end_tag("borders")
def _write_border(self, xf_format, is_dxf_format=False) -> None:
# Write the <border> element.
attributes = []
# Diagonal borders add attributes to the <border> element.
if xf_format.diag_type == 1:
attributes.append(("diagonalUp", 1))
elif xf_format.diag_type == 2:
attributes.append(("diagonalDown", 1))
elif xf_format.diag_type == 3:
attributes.append(("diagonalUp", 1))
attributes.append(("diagonalDown", 1))
# Ensure that a default diag border is set if the diag type is set.
if xf_format.diag_type and not xf_format.diag_border:
xf_format.diag_border = 1
# Write the start border tag.
self._xml_start_tag("border", attributes)
# Write the <border> sub elements.
self._write_sub_border("left", xf_format.left, xf_format.left_color)
self._write_sub_border("right", xf_format.right, xf_format.right_color)
self._write_sub_border("top", xf_format.top, xf_format.top_color)
self._write_sub_border("bottom", xf_format.bottom, xf_format.bottom_color)
# Condition DXF formats don't allow diagonal borders.
if not is_dxf_format:
self._write_sub_border(
"diagonal", xf_format.diag_border, xf_format.diag_color
)
if is_dxf_format:
self._write_sub_border("vertical", None, None)
self._write_sub_border("horizontal", None, None)
self._xml_end_tag("border")
def _write_sub_border(self, border_type, style, color) -> None:
# Write the <border> sub elements such as <right>, <top>, etc.
attributes = []
if not style:
self._xml_empty_tag(border_type)
return
border_styles = (
"none",
"thin",
"medium",
"dashed",
"dotted",
"thick",
"double",
"hair",
"mediumDashed",
"dashDot",
"mediumDashDot",
"dashDotDot",
"mediumDashDotDot",
"slantDashDot",
)
attributes.append(("style", border_styles[style]))
self._xml_start_tag(border_type, attributes)
if color and not color._is_automatic:
self._xml_empty_tag("color", color._attributes())
else:
self._xml_empty_tag("color", [("auto", 1)])
self._xml_end_tag(border_type)
def _write_cell_style_xfs(self) -> None:
# Write the <cellStyleXfs> element.
count = 1
if self.has_hyperlink:
count = 2
attributes = [("count", count)]
self._xml_start_tag("cellStyleXfs", attributes)
self._write_style_xf()
if self.has_hyperlink:
self._write_style_xf(True, self.hyperlink_font_id)
self._xml_end_tag("cellStyleXfs")
def _write_cell_xfs(self) -> None:
# Write the <cellXfs> element.
formats = self.xf_formats
# Workaround for when the last xf_format is used for the comment font
# and shouldn't be used for cellXfs.
last_format = formats[-1]
if last_format.font_only:
formats.pop()
attributes = [("count", len(formats))]
self._xml_start_tag("cellXfs", attributes)
# Write the xf elements.
for xf_format in formats:
self._write_xf(xf_format)
self._xml_end_tag("cellXfs")
def _write_style_xf(self, has_hyperlink=False, font_id=0) -> None:
# Write the style <xf> element.
num_fmt_id = 0
fill_id = 0
border_id = 0
attributes = [
("numFmtId", num_fmt_id),
("fontId", font_id),
("fillId", fill_id),
("borderId", border_id),
]
if has_hyperlink:
attributes.append(("applyNumberFormat", 0))
attributes.append(("applyFill", 0))
attributes.append(("applyBorder", 0))
attributes.append(("applyAlignment", 0))
attributes.append(("applyProtection", 0))
self._xml_start_tag("xf", attributes)
self._xml_empty_tag("alignment", [("vertical", "top")])
self._xml_empty_tag("protection", [("locked", 0)])
self._xml_end_tag("xf")
else:
self._xml_empty_tag("xf", attributes)
def _write_xf(self, xf_format) -> None:
# Write the <xf> element.
xf_id = xf_format.xf_id
font_id = xf_format.font_index
fill_id = xf_format.fill_index
border_id = xf_format.border_index
num_fmt_id = xf_format.num_format_index
has_checkbox = xf_format.checkbox
has_alignment = False
has_protection = False
attributes = [
("numFmtId", num_fmt_id),
("fontId", font_id),
("fillId", fill_id),
("borderId", border_id),
("xfId", xf_id),
]
if xf_format.quote_prefix:
attributes.append(("quotePrefix", 1))
if xf_format.num_format_index > 0:
attributes.append(("applyNumberFormat", 1))
# Add applyFont attribute if XF format uses a font element.
if xf_format.font_index > 0 and not xf_format.hyperlink:
attributes.append(("applyFont", 1))
# Add applyFill attribute if XF format uses a fill element.
if xf_format.fill_index > 0:
attributes.append(("applyFill", 1))
# Add applyBorder attribute if XF format uses a border element.
if xf_format.border_index > 0:
attributes.append(("applyBorder", 1))
# Check if XF format has alignment properties set.
(apply_align, align) = xf_format._get_align_properties()
# Check if an alignment sub-element should be written.
if apply_align and align:
has_alignment = True
# We can also have applyAlignment without a sub-element.
if apply_align or xf_format.hyperlink:
attributes.append(("applyAlignment", 1))
# Check for cell protection properties.
protection = xf_format._get_protection_properties()
if protection or xf_format.hyperlink:
attributes.append(("applyProtection", 1))
if not xf_format.hyperlink:
has_protection = True
# Write XF with sub-elements if required.
if has_alignment or has_protection or has_checkbox:
self._xml_start_tag("xf", attributes)
if has_alignment:
self._xml_empty_tag("alignment", align)
if has_protection:
self._xml_empty_tag("protection", protection)
if has_checkbox:
self._write_xf_format_extensions()
self._xml_end_tag("xf")
else:
self._xml_empty_tag("xf", attributes)
def _write_cell_styles(self) -> None:
# Write the <cellStyles> element.
count = 1
if self.has_hyperlink:
count = 2
attributes = [("count", count)]
self._xml_start_tag("cellStyles", attributes)
if self.has_hyperlink:
self._write_cell_style("Hyperlink", 1, 8)
self._write_cell_style()
self._xml_end_tag("cellStyles")
def _write_cell_style(self, name="Normal", xf_id=0, builtin_id=0) -> None:
# Write the <cellStyle> element.
attributes = [
("name", name),
("xfId", xf_id),
("builtinId", builtin_id),
]
self._xml_empty_tag("cellStyle", attributes)
def _write_dxfs(self) -> None:
# Write the <dxfs> element.
formats = self.dxf_formats
count = len(formats)
attributes = [("count", len(formats))]
if count:
self._xml_start_tag("dxfs", attributes)
# Write the font elements for xf_format objects that have them.
for dxf_format in self.dxf_formats:
self._xml_start_tag("dxf")
if dxf_format.has_dxf_font:
self._write_font(dxf_format, True)
if dxf_format.num_format_index:
self._write_num_fmt(
dxf_format.num_format_index, dxf_format.num_format
)
if dxf_format.has_dxf_fill:
self._write_fill(dxf_format, True)
if dxf_format.has_dxf_border:
self._write_border(dxf_format, True)
if dxf_format.checkbox:
self._write_dxf_format_extensions()
self._xml_end_tag("dxf")
self._xml_end_tag("dxfs")
else:
self._xml_empty_tag("dxfs", attributes)
def _write_table_styles(self) -> None:
# Write the <tableStyles> element.
count = 0
default_table_style = "TableStyleMedium9"
default_pivot_style = "PivotStyleLight16"
attributes = [
("count", count),
("defaultTableStyle", default_table_style),
("defaultPivotStyle", default_pivot_style),
]
self._xml_empty_tag("tableStyles", attributes)
def _write_colors(self) -> None:
# Write the <colors> element.
custom_colors = self.custom_colors
if not custom_colors:
return
self._xml_start_tag("colors")
self._write_mru_colors(custom_colors)
self._xml_end_tag("colors")
def _write_mru_colors(self, custom_colors) -> None:
# Write the <mruColors> element for the most recently used colors.
# Write the custom custom_colors in reverse order.
custom_colors.reverse()
# Limit the mruColors to the last 10.
if len(custom_colors) > 10:
custom_colors = custom_colors[0:10]
self._xml_start_tag("mruColors")
# Write the custom custom_colors in reverse order.
for color in custom_colors:
# For backwards compatibility convert possible
self._write_color(color._attributes())
self._xml_end_tag("mruColors")
def _write_condense(self) -> None:
# Write the <condense> element.
attributes = [("val", 0)]
self._xml_empty_tag("condense", attributes)
def _write_extend(self) -> None:
# Write the <extend> element.
attributes = [("val", 0)]
self._xml_empty_tag("extend", attributes)
def _write_xf_format_extensions(self) -> None:
# Write the xfComplement <extLst> elements.
schema = "http://schemas.microsoft.com/office/spreadsheetml"
attributes = [
("uri", "{C7286773-470A-42A8-94C5-96B5CB345126}"),
(
"xmlns:xfpb",
schema + "/2022/featurepropertybag",
),
]
self._xml_start_tag("extLst")
self._xml_start_tag("ext", attributes)
self._xml_empty_tag("xfpb:xfComplement", [("i", "0")])
self._xml_end_tag("ext")
self._xml_end_tag("extLst")
def _write_dxf_format_extensions(self) -> None:
# Write the DXFComplement <extLst> elements.
schema = "http://schemas.microsoft.com/office/spreadsheetml"
attributes = [
("uri", "{0417FA29-78FA-4A13-93AC-8FF0FAFDF519}"),
(
"xmlns:xfpb",
schema + "/2022/featurepropertybag",
),
]
self._xml_start_tag("extLst")
self._xml_start_tag("ext", attributes)
self._xml_empty_tag("xfpb:DXFComplement", [("i", "0")])
self._xml_end_tag("ext")
self._xml_end_tag("extLst")

View File

@@ -0,0 +1,194 @@
###############################################################################
#
# Table - A class for writing the Excel XLSX Worksheet file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
from . import xmlwriter
class Table(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Table file.
"""
###########################################################################
#
# Public API.
#
###########################################################################
def __init__(self) -> None:
"""
Constructor.
"""
super().__init__()
self.properties = {}
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(self) -> None:
# Assemble and write the XML file.
# Write the XML declaration.
self._xml_declaration()
# Write the table element.
self._write_table()
# Write the autoFilter element.
self._write_auto_filter()
# Write the tableColumns element.
self._write_table_columns()
# Write the tableStyleInfo element.
self._write_table_style_info()
# Close the table tag.
self._xml_end_tag("table")
# Close the file.
self._xml_close()
def _set_properties(self, properties) -> None:
# Set the document properties.
self.properties = properties
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_table(self) -> None:
# Write the <table> element.
schema = "http://schemas.openxmlformats.org/"
xmlns = schema + "spreadsheetml/2006/main"
table_id = self.properties["id"]
name = self.properties["name"]
display_name = self.properties["name"]
ref = self.properties["range"]
totals_row_shown = self.properties["totals_row_shown"]
header_row_count = self.properties["header_row_count"]
attributes = [
("xmlns", xmlns),
("id", table_id),
("name", name),
("displayName", display_name),
("ref", ref),
]
if not header_row_count:
attributes.append(("headerRowCount", 0))
if totals_row_shown:
attributes.append(("totalsRowCount", 1))
else:
attributes.append(("totalsRowShown", 0))
self._xml_start_tag("table", attributes)
def _write_auto_filter(self) -> None:
# Write the <autoFilter> element.
autofilter = self.properties.get("autofilter", 0)
if not autofilter:
return
attributes = [
(
"ref",
autofilter,
)
]
self._xml_empty_tag("autoFilter", attributes)
def _write_table_columns(self) -> None:
# Write the <tableColumns> element.
columns = self.properties["columns"]
count = len(columns)
attributes = [("count", count)]
self._xml_start_tag("tableColumns", attributes)
for col_data in columns:
# Write the tableColumn element.
self._write_table_column(col_data)
self._xml_end_tag("tableColumns")
def _write_table_column(self, col_data) -> None:
# Write the <tableColumn> element.
attributes = [
("id", col_data["id"]),
("name", col_data["name"]),
]
if col_data.get("total_string"):
attributes.append(("totalsRowLabel", col_data["total_string"]))
elif col_data.get("total_function"):
attributes.append(("totalsRowFunction", col_data["total_function"]))
if "format" in col_data and col_data["format"] is not None:
attributes.append(("dataDxfId", col_data["format"]))
if col_data.get("formula") or col_data.get("custom_total"):
self._xml_start_tag("tableColumn", attributes)
if col_data.get("formula"):
# Write the calculatedColumnFormula element.
self._write_calculated_column_formula(col_data["formula"])
if col_data.get("custom_total"):
# Write the totalsRowFormula element.
self._write_totals_row_formula(col_data.get("custom_total"))
self._xml_end_tag("tableColumn")
else:
self._xml_empty_tag("tableColumn", attributes)
def _write_table_style_info(self) -> None:
# Write the <tableStyleInfo> element.
props = self.properties
attributes = []
name = props["style"]
show_first_column = 0 + props["show_first_col"]
show_last_column = 0 + props["show_last_col"]
show_row_stripes = 0 + props["show_row_stripes"]
show_column_stripes = 0 + props["show_col_stripes"]
if name is not None and name != "" and name != "None":
attributes.append(("name", name))
attributes.append(("showFirstColumn", show_first_column))
attributes.append(("showLastColumn", show_last_column))
attributes.append(("showRowStripes", show_row_stripes))
attributes.append(("showColumnStripes", show_column_stripes))
self._xml_empty_tag("tableStyleInfo", attributes)
def _write_calculated_column_formula(self, formula) -> None:
# Write the <calculatedColumnFormula> element.
self._xml_data_element("calculatedColumnFormula", formula)
def _write_totals_row_formula(self, formula) -> None:
# Write the <totalsRowFormula> element.
self._xml_data_element("totalsRowFormula", formula)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,268 @@
###############################################################################
#
# Url - A class to represent URLs in Excel.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
import re
from enum import Enum
from typing import Any, Dict, Optional
class UrlTypes(Enum):
"""
Enum to represent different types of URLS.
"""
UNKNOWN = 0
URL = 1
INTERNAL = 2
EXTERNAL = 3
class Url:
"""
A class to represent URLs in Excel.
"""
MAX_URL_LEN = 2080
MAX_PARAMETER_LEN = 255
def __init__(self, link: str) -> None:
self._link_type: UrlTypes = UrlTypes.UNKNOWN
self._original_url: str = link
self._link: str = link
self._relationship_link: str = link
self._text: str = ""
self._tip: str = ""
self._anchor: str = ""
self._is_object_link: bool = False
self._rel_index: int = 0
self._parse_url()
if len(self._link) > self.MAX_URL_LEN:
raise ValueError("URL exceeds Excel's maximum length.")
if len(self._anchor) > self.MAX_URL_LEN:
raise ValueError("Anchor segment or url exceeds Excel's maximum length.")
if len(self._tip) > self.MAX_PARAMETER_LEN:
raise ValueError("Hyperlink tool tip exceeds Excel's maximum length.")
self._escape_strings()
def __repr__(self) -> str:
"""
Return a string representation of the Url instance.
"""
return (
"\n"
f"Url:\n"
f" _link_type = {self._link_type.name}\n"
f" _original_url = {self._original_url}\n"
f" _link = {self._link}\n"
f" _relationship_link = {self._relationship_link}\n"
f" _text = {self._text}\n"
f" _tip = {self._tip}\n"
f" _anchor = {self._anchor}\n"
f" _is_object_link = {self._is_object_link}\n"
f" _rel_index = {self._rel_index}\n"
)
@classmethod
def from_options(cls, options: Dict[str, Any]) -> Optional["Url"]:
"""
For backward compatibility, convert the 'url' key and 'tip' keys in an
options dictionary to a Url object, or return the Url object if already
an instance.
Args:
options (dict): A dictionary that may contain a 'url' key.
Returns:
url: A Url object or None.
"""
if not isinstance(options, dict):
raise TypeError("The 'options' parameter must be a dictionary.")
url = options.get("url")
if isinstance(url, str):
url = cls(options["url"])
if options.get("tip"):
url._tip = options["tip"]
return url
@property
def text(self) -> str:
"""Get the alternative, user-friendly, text for the URL."""
return self._text
@text.setter
def text(self, value: str) -> None:
"""Set the alternative, user-friendly, text for the URL."""
self._text = value
@property
def tip(self) -> str:
"""Get the screen tip for the URL."""
return self._tip
@tip.setter
def tip(self, value: str) -> None:
"""Set the screen tip for the URL."""
self._tip = value
def _parse_url(self) -> None:
"""Parse the URL and determine its type."""
# Handle mail address links.
if self._link.startswith("mailto:"):
self._link_type = UrlTypes.URL
if not self._text:
self._text = self._link.replace("mailto:", "", 1)
# Handle links to cells within the workbook.
elif self._link.startswith("internal:"):
self._link_type = UrlTypes.INTERNAL
self._relationship_link = self._link.replace("internal:", "#", 1)
self._link = self._link.replace("internal:", "", 1)
self._anchor = self._link
if not self._text:
self._text = self._anchor
# Handle links to other files or cells in other Excel files.
elif self._link.startswith("file://") or self._link.startswith("external:"):
self._link_type = UrlTypes.EXTERNAL
# Handle backward compatibility with external: links.
file_url = self._original_url.replace("external:", "file:///", 1)
link_path = file_url
link_path = link_path.replace("file:///", "", 1)
link_path = link_path.replace("file://", "", 1)
link_path = link_path.replace("/", "\\")
if self._is_relative_path(link_path):
self._link = link_path
else:
self._link = "file:///" + link_path
if not self._text:
self._text = link_path
if "#" in self._link:
self._link, self._anchor = self._link.split("#", 1)
# Set up the relationship link. This doesn't usually contain the
# anchor unless it is a link from an object like an image.
if self._is_object_link:
if self._is_relative_path(link_path):
self._relationship_link = self._link.replace("\\", "/")
else:
self._relationship_link = file_url
else:
self._relationship_link = self._link
# Convert a .\dir\file.xlsx link to dir\file.xlsx.
if self._relationship_link.startswith(".\\"):
self._relationship_link = self._relationship_link.replace(".\\", "", 1)
# Handle standard Excel links like http://, https://, ftp://, ftps://
# but also allow custom "foo://bar" URLs.
elif "://" in self._link:
self._link_type = UrlTypes.URL
if not self._text:
self._text = self._link
if "#" in self._link:
self._link, self._anchor = self._link.split("#", 1)
# Set up the relationship link. This doesn't usually contain the
# anchor unless it is a link from an object like an image.
if self._is_object_link:
self._relationship_link = self._original_url
else:
self._relationship_link = self._link
else:
raise ValueError(f"Unknown URL type: {self._original_url}")
def _set_object_link(self) -> None:
"""
Set the _is_object_link flag and re-parse the URL since the relationship
link is different for object links.
"""
self._is_object_link = True
self._link = self._original_url
self._parse_url()
self._escape_strings()
def _escape_strings(self) -> None:
"""Escape special characters in the URL strings."""
if self._link_type != UrlTypes.INTERNAL:
self._link = self._escape_url(self._link)
self._relationship_link = self._escape_url(self._relationship_link)
# Excel additionally escapes # to %23 in file paths.
if self._link_type == UrlTypes.EXTERNAL:
self._relationship_link = self._relationship_link.replace("#", "%23")
def _target(self) -> str:
"""Get the target for relationship IDs."""
return self._relationship_link
def _target_mode(self) -> str:
"""Get the target mode for relationship IDs."""
if self._link_type == UrlTypes.INTERNAL:
return ""
return "External"
@staticmethod
def _is_relative_path(url: str) -> bool:
"""Check if a URL is a relative path."""
if url.startswith(r"\\"):
return False
if url[0].isalpha() and url[1] == ":":
return False
return True
@staticmethod
def _escape_url(url: str) -> str:
"""Escape special characters in a URL."""
# Don't escape URL if it looks already escaped.
if re.search("%[0-9a-fA-F]{2}", url):
return url
# Can't use url.quote() here because it doesn't match Excel.
return (
url.replace("%", "%25")
.replace('"', "%22")
.replace(" ", "%20")
.replace("<", "%3c")
.replace(">", "%3e")
.replace("[", "%5b")
.replace("]", "%5d")
.replace("^", "%5e")
.replace("`", "%60")
.replace("{", "%7b")
.replace("}", "%7d")
)

View File

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

View File

@@ -0,0 +1,783 @@
###############################################################################
#
# Vml - A class for writing the Excel XLSX Vml file.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# Package imports.
from xlsxwriter.comments import CommentType
from xlsxwriter.image import Image
from . import xmlwriter
###########################################################################
#
# A button type class.
#
###########################################################################
class ButtonType:
"""
A class to represent a button in an Excel worksheet.
"""
def __init__(
self,
row: int,
col: int,
height: int,
width: int,
button_number: int,
options: dict = None,
) -> None:
"""
Initialize a ButtonType instance.
Args:
row (int): The row number of the button.
col (int): The column number of the button.
height (int): The height of the button.
width (int): The width of the button.
button_number (int): The button number.
options (dict): Additional options for the button.
"""
self.row = row
self.col = col
self.width = width
self.height = height
self.macro = f"[0]!Button{button_number}_Click"
self.caption = f"Button {button_number}"
self.description = None
self.x_scale = 1
self.y_scale = 1
self.x_offset = 0
self.y_offset = 0
self.vertices = []
# Set any user supplied options.
self._set_user_options(options)
def _set_user_options(self, options=None) -> None:
"""
This method handles the additional optional parameters to
``insert_button()``.
"""
if options is None:
return
# Overwrite the defaults with any user supplied values. Incorrect or
# misspelled parameters are silently ignored.
self.width = options.get("width", self.width)
self.height = options.get("height", self.height)
self.caption = options.get("caption", self.caption)
self.x_offset = options.get("x_offset", self.x_offset)
self.y_offset = options.get("y_offset", self.y_offset)
self.description = options.get("description", self.description)
# Set the macro name.
if options.get("macro"):
self.macro = "[0]!" + options["macro"]
# Scale the size of the button box if required.
if options.get("x_scale"):
self.width = self.width * options["x_scale"]
if options.get("y_scale"):
self.height = self.height * options["y_scale"]
# Round the dimensions to the nearest pixel.
self.width = int(0.5 + self.width)
self.height = int(0.5 + self.height)
###########################################################################
#
# The file writer class for the Excel XLSX VML file.
#
###########################################################################
class Vml(xmlwriter.XMLwriter):
"""
A class for writing the Excel XLSX Vml file.
"""
###########################################################################
#
# Private API.
#
###########################################################################
def _assemble_xml_file(
self,
data_id,
vml_shape_id,
comments_data=None,
buttons_data=None,
header_images=None,
) -> None:
# Assemble and write the XML file.
z_index = 1
self._write_xml_namespace()
# Write the o:shapelayout element.
self._write_shapelayout(data_id)
if buttons_data:
# Write the v:shapetype element.
self._write_button_shapetype()
for button in buttons_data:
# Write the v:shape element.
vml_shape_id += 1
self._write_button_shape(vml_shape_id, z_index, button)
z_index += 1
if comments_data:
# Write the v:shapetype element.
self._write_comment_shapetype()
for comment in comments_data:
# Write the v:shape element.
vml_shape_id += 1
self._write_comment_shape(vml_shape_id, z_index, comment)
z_index += 1
if header_images:
# Write the v:shapetype element.
self._write_image_shapetype()
index = 1
for image in header_images:
# Write the v:shape element.
vml_shape_id += 1
self._write_image_shape(vml_shape_id, index, image)
index += 1
self._xml_end_tag("xml")
# Close the XML writer filehandle.
self._xml_close()
def _pixels_to_points(self, vertices):
# Convert comment vertices from pixels to points.
left, top, width, height = vertices[8:12]
# Scale to pixels.
left *= 0.75
top *= 0.75
width *= 0.75
height *= 0.75
return left, top, width, height
###########################################################################
#
# XML methods.
#
###########################################################################
def _write_xml_namespace(self) -> None:
# Write the <xml> element. This is the root element of VML.
schema = "urn:schemas-microsoft-com:"
xmlns = schema + "vml"
xmlns_o = schema + "office:office"
xmlns_x = schema + "office:excel"
attributes = [
("xmlns:v", xmlns),
("xmlns:o", xmlns_o),
("xmlns:x", xmlns_x),
]
self._xml_start_tag("xml", attributes)
def _write_shapelayout(self, data_id) -> None:
# Write the <o:shapelayout> element.
attributes = [("v:ext", "edit")]
self._xml_start_tag("o:shapelayout", attributes)
# Write the o:idmap element.
self._write_idmap(data_id)
self._xml_end_tag("o:shapelayout")
def _write_idmap(self, data_id) -> None:
# Write the <o:idmap> element.
attributes = [
("v:ext", "edit"),
("data", data_id),
]
self._xml_empty_tag("o:idmap", attributes)
def _write_comment_shapetype(self) -> None:
# Write the <v:shapetype> element.
shape_id = "_x0000_t202"
coordsize = "21600,21600"
spt = 202
path = "m,l,21600r21600,l21600,xe"
attributes = [
("id", shape_id),
("coordsize", coordsize),
("o:spt", spt),
("path", path),
]
self._xml_start_tag("v:shapetype", attributes)
# Write the v:stroke element.
self._write_stroke()
# Write the v:path element.
self._write_comment_path("t", "rect")
self._xml_end_tag("v:shapetype")
def _write_button_shapetype(self) -> None:
# Write the <v:shapetype> element.
shape_id = "_x0000_t201"
coordsize = "21600,21600"
spt = 201
path = "m,l,21600r21600,l21600,xe"
attributes = [
("id", shape_id),
("coordsize", coordsize),
("o:spt", spt),
("path", path),
]
self._xml_start_tag("v:shapetype", attributes)
# Write the v:stroke element.
self._write_stroke()
# Write the v:path element.
self._write_button_path()
# Write the o:lock element.
self._write_shapetype_lock()
self._xml_end_tag("v:shapetype")
def _write_image_shapetype(self) -> None:
# Write the <v:shapetype> element.
shape_id = "_x0000_t75"
coordsize = "21600,21600"
spt = 75
o_preferrelative = "t"
path = "m@4@5l@4@11@9@11@9@5xe"
filled = "f"
stroked = "f"
attributes = [
("id", shape_id),
("coordsize", coordsize),
("o:spt", spt),
("o:preferrelative", o_preferrelative),
("path", path),
("filled", filled),
("stroked", stroked),
]
self._xml_start_tag("v:shapetype", attributes)
# Write the v:stroke element.
self._write_stroke()
# Write the v:formulas element.
self._write_formulas()
# Write the v:path element.
self._write_image_path()
# Write the o:lock element.
self._write_aspect_ratio_lock()
self._xml_end_tag("v:shapetype")
def _write_stroke(self) -> None:
# Write the <v:stroke> element.
joinstyle = "miter"
attributes = [("joinstyle", joinstyle)]
self._xml_empty_tag("v:stroke", attributes)
def _write_comment_path(self, gradientshapeok, connecttype) -> None:
# Write the <v:path> element.
attributes = []
if gradientshapeok:
attributes.append(("gradientshapeok", "t"))
attributes.append(("o:connecttype", connecttype))
self._xml_empty_tag("v:path", attributes)
def _write_button_path(self) -> None:
# Write the <v:path> element.
shadowok = "f"
extrusionok = "f"
strokeok = "f"
fillok = "f"
connecttype = "rect"
attributes = [
("shadowok", shadowok),
("o:extrusionok", extrusionok),
("strokeok", strokeok),
("fillok", fillok),
("o:connecttype", connecttype),
]
self._xml_empty_tag("v:path", attributes)
def _write_image_path(self) -> None:
# Write the <v:path> element.
extrusionok = "f"
gradientshapeok = "t"
connecttype = "rect"
attributes = [
("o:extrusionok", extrusionok),
("gradientshapeok", gradientshapeok),
("o:connecttype", connecttype),
]
self._xml_empty_tag("v:path", attributes)
def _write_shapetype_lock(self) -> None:
# Write the <o:lock> element.
ext = "edit"
shapetype = "t"
attributes = [
("v:ext", ext),
("shapetype", shapetype),
]
self._xml_empty_tag("o:lock", attributes)
def _write_rotation_lock(self) -> None:
# Write the <o:lock> element.
ext = "edit"
rotation = "t"
attributes = [
("v:ext", ext),
("rotation", rotation),
]
self._xml_empty_tag("o:lock", attributes)
def _write_aspect_ratio_lock(self) -> None:
# Write the <o:lock> element.
ext = "edit"
aspectratio = "t"
attributes = [
("v:ext", ext),
("aspectratio", aspectratio),
]
self._xml_empty_tag("o:lock", attributes)
def _write_comment_shape(self, shape_id, z_index, comment: CommentType) -> None:
# Write the <v:shape> element.
shape_type = "#_x0000_t202"
insetmode = "auto"
visibility = "hidden"
# Set the shape index.
shape_id = "_x0000_s" + str(shape_id)
(left, top, width, height) = self._pixels_to_points(comment.vertices)
# Set the visibility.
if comment.is_visible:
visibility = "visible"
style = (
f"position:absolute;"
f"margin-left:{left:.15g}pt;"
f"margin-top:{top:.15g}pt;"
f"width:{width:.15g}pt;"
f"height:{height:.15g}pt;"
f"z-index:{z_index};"
f"visibility:{visibility}"
)
attributes = [
("id", shape_id),
("type", shape_type),
("style", style),
("fillcolor", comment.color._vml_rgb_hex_value()),
("o:insetmode", insetmode),
]
self._xml_start_tag("v:shape", attributes)
# Write the v:fill element.
self._write_comment_fill()
# Write the v:shadow element.
self._write_shadow()
# Write the v:path element.
self._write_comment_path(None, "none")
# Write the v:textbox element.
self._write_comment_textbox()
# Write the x:ClientData element.
self._write_comment_client_data(comment)
self._xml_end_tag("v:shape")
def _write_button_shape(self, shape_id, z_index, button: ButtonType) -> None:
# Write the <v:shape> element.
shape_type = "#_x0000_t201"
# Set the shape index.
shape_id = "_x0000_s" + str(shape_id)
(left, top, width, height) = self._pixels_to_points(button.vertices)
style = (
f"position:absolute;"
f"margin-left:{left:.15g}pt;"
f"margin-top:{top:.15g}pt;"
f"width:{width:.15g}pt;"
f"height:{height:.15g}pt;"
f"z-index:{z_index};"
f"mso-wrap-style:tight"
)
attributes = [
("id", shape_id),
("type", shape_type),
]
if button.description is not None:
attributes.append(("alt", button.description))
attributes.append(("style", style))
attributes.append(("o:button", "t"))
attributes.append(("fillcolor", "buttonFace [67]"))
attributes.append(("strokecolor", "windowText [64]"))
attributes.append(("o:insetmode", "auto"))
self._xml_start_tag("v:shape", attributes)
# Write the v:fill element.
self._write_button_fill()
# Write the o:lock element.
self._write_rotation_lock()
# Write the v:textbox element.
self._write_button_textbox(button)
# Write the x:ClientData element.
self._write_button_client_data(button)
self._xml_end_tag("v:shape")
def _write_image_shape(self, shape_id, z_index, image: Image) -> None:
# Write the <v:shape> element.
shape_type = "#_x0000_t75"
# Set the shape index.
shape_id = "_x0000_s" + str(shape_id)
# Get the image parameters
name = image.image_name
width = image._width
x_dpi = image._x_dpi
y_dpi = image._y_dpi
height = image._height
ref_id = image._ref_id
position = image._header_position
# Scale the height/width by the resolution, relative to 72dpi.
width = width * 72.0 / x_dpi
height = height * 72.0 / y_dpi
# Excel uses a rounding based around 72 and 96 dpi.
width = 72.0 / 96 * int(width * 96.0 / 72 + 0.25)
height = 72.0 / 96 * int(height * 96.0 / 72 + 0.25)
style = (
f"position:absolute;"
f"margin-left:0;"
f"margin-top:0;"
f"width:{width:.15g}pt;"
f"height:{height:.15g}pt;"
f"z-index:{z_index}"
)
attributes = [
("id", position),
("o:spid", shape_id),
("type", shape_type),
("style", style),
]
self._xml_start_tag("v:shape", attributes)
# Write the v:imagedata element.
self._write_imagedata(ref_id, name)
# Write the o:lock element.
self._write_rotation_lock()
self._xml_end_tag("v:shape")
def _write_comment_fill(self) -> None:
# Write the <v:fill> element.
color_2 = "#ffffe1"
attributes = [("color2", color_2)]
self._xml_empty_tag("v:fill", attributes)
def _write_button_fill(self) -> None:
# Write the <v:fill> element.
color_2 = "buttonFace [67]"
detectmouseclick = "t"
attributes = [
("color2", color_2),
("o:detectmouseclick", detectmouseclick),
]
self._xml_empty_tag("v:fill", attributes)
def _write_shadow(self) -> None:
# Write the <v:shadow> element.
on = "t"
color = "black"
obscured = "t"
attributes = [
("on", on),
("color", color),
("obscured", obscured),
]
self._xml_empty_tag("v:shadow", attributes)
def _write_comment_textbox(self) -> None:
# Write the <v:textbox> element.
style = "mso-direction-alt:auto"
attributes = [("style", style)]
self._xml_start_tag("v:textbox", attributes)
# Write the div element.
self._write_div("left")
self._xml_end_tag("v:textbox")
def _write_button_textbox(self, button: ButtonType) -> None:
# Write the <v:textbox> element.
style = "mso-direction-alt:auto"
attributes = [("style", style), ("o:singleclick", "f")]
self._xml_start_tag("v:textbox", attributes)
# Write the div element.
self._write_div("center", button.caption)
self._xml_end_tag("v:textbox")
def _write_div(self, align: str, caption: str = None) -> None:
# Write the <div> element.
style = "text-align:" + align
attributes = [("style", style)]
self._xml_start_tag("div", attributes)
if caption:
self._write_button_font(caption)
self._xml_end_tag("div")
def _write_button_font(self, caption: str) -> None:
# Write the <font> element.
face = "Calibri"
size = 220
color = "#000000"
attributes = [
("face", face),
("size", size),
("color", color),
]
self._xml_data_element("font", caption, attributes)
def _write_comment_client_data(self, comment: CommentType) -> None:
# Write the <x:ClientData> element.
object_type = "Note"
attributes = [("ObjectType", object_type)]
self._xml_start_tag("x:ClientData", attributes)
# Write the x:MoveWithCells element.
self._write_move_with_cells()
# Write the x:SizeWithCells element.
self._write_size_with_cells()
# Write the x:Anchor element.
self._write_anchor(comment.vertices)
# Write the x:AutoFill element.
self._write_auto_fill()
# Write the x:Row element.
self._write_row(comment.row)
# Write the x:Column element.
self._write_column(comment.col)
# Write the x:Visible element.
if comment.is_visible:
self._write_visible()
self._xml_end_tag("x:ClientData")
def _write_button_client_data(self, button) -> None:
# Write the <x:ClientData> element.
object_type = "Button"
attributes = [("ObjectType", object_type)]
self._xml_start_tag("x:ClientData", attributes)
# Write the x:Anchor element.
self._write_anchor(button.vertices)
# Write the x:PrintObject element.
self._write_print_object()
# Write the x:AutoFill element.
self._write_auto_fill()
# Write the x:FmlaMacro element.
self._write_fmla_macro(button.macro)
# Write the x:TextHAlign element.
self._write_text_halign()
# Write the x:TextVAlign element.
self._write_text_valign()
self._xml_end_tag("x:ClientData")
def _write_move_with_cells(self) -> None:
# Write the <x:MoveWithCells> element.
self._xml_empty_tag("x:MoveWithCells")
def _write_size_with_cells(self) -> None:
# Write the <x:SizeWithCells> element.
self._xml_empty_tag("x:SizeWithCells")
def _write_visible(self) -> None:
# Write the <x:Visible> element.
self._xml_empty_tag("x:Visible")
def _write_anchor(self, vertices) -> None:
# Write the <x:Anchor> element.
(col_start, row_start, x1, y1, col_end, row_end, x2, y2) = vertices[:8]
strings = [col_start, x1, row_start, y1, col_end, x2, row_end, y2]
strings = [str(i) for i in strings]
data = ", ".join(strings)
self._xml_data_element("x:Anchor", data)
def _write_auto_fill(self) -> None:
# Write the <x:AutoFill> element.
data = "False"
self._xml_data_element("x:AutoFill", data)
def _write_row(self, data) -> None:
# Write the <x:Row> element.
self._xml_data_element("x:Row", data)
def _write_column(self, data) -> None:
# Write the <x:Column> element.
self._xml_data_element("x:Column", data)
def _write_print_object(self) -> None:
# Write the <x:PrintObject> element.
self._xml_data_element("x:PrintObject", "False")
def _write_text_halign(self) -> None:
# Write the <x:TextHAlign> element.
self._xml_data_element("x:TextHAlign", "Center")
def _write_text_valign(self) -> None:
# Write the <x:TextVAlign> element.
self._xml_data_element("x:TextVAlign", "Center")
def _write_fmla_macro(self, data) -> None:
# Write the <x:FmlaMacro> element.
self._xml_data_element("x:FmlaMacro", data)
def _write_imagedata(self, ref_id, o_title) -> None:
# Write the <v:imagedata> element.
attributes = [
("o:relid", "rId" + str(ref_id)),
("o:title", o_title),
]
self._xml_empty_tag("v:imagedata", attributes)
def _write_formulas(self) -> None:
# Write the <v:formulas> element.
self._xml_start_tag("v:formulas")
# Write the v:f elements.
self._write_formula("if lineDrawn pixelLineWidth 0")
self._write_formula("sum @0 1 0")
self._write_formula("sum 0 0 @1")
self._write_formula("prod @2 1 2")
self._write_formula("prod @3 21600 pixelWidth")
self._write_formula("prod @3 21600 pixelHeight")
self._write_formula("sum @0 0 1")
self._write_formula("prod @6 1 2")
self._write_formula("prod @7 21600 pixelWidth")
self._write_formula("sum @8 21600 0")
self._write_formula("prod @7 21600 pixelHeight")
self._write_formula("sum @10 21600 0")
self._xml_end_tag("v:formulas")
def _write_formula(self, eqn) -> None:
# Write the <v:f> element.
attributes = [("eqn", eqn)]
self._xml_empty_tag("v:f", attributes)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
###############################################################################
#
# XMLwriter - A base class for XlsxWriter classes.
#
# Used in conjunction with XlsxWriter.
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
#
# pylint: disable=dangerous-default-value
# Standard packages.
import re
from io import StringIO
# Compile performance critical regular expressions.
re_control_chars_1 = re.compile("(_x[0-9a-fA-F]{4}_)")
re_control_chars_2 = re.compile(r"([\x00-\x08\x0b-\x1f])")
xml_escapes = re.compile('["&<>\n]')
class XMLwriter:
"""
Simple XML writer class.
"""
def __init__(self) -> None:
self.fh = None
self.internal_fh = False
def _set_filehandle(self, filehandle) -> None:
# Set the writer filehandle directly. Mainly for testing.
self.fh = filehandle
self.internal_fh = False
def _set_xml_writer(self, filename) -> None:
# Set the XML writer filehandle for the object.
if isinstance(filename, StringIO):
self.internal_fh = False
self.fh = filename
else:
self.internal_fh = True
# pylint: disable-next=consider-using-with
self.fh = open(filename, "w", encoding="utf-8")
def _xml_close(self) -> None:
# Close the XML filehandle if we created it.
if self.internal_fh:
self.fh.close()
def _xml_declaration(self) -> None:
# Write the XML declaration.
self.fh.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
def _xml_start_tag(self, tag, attributes=[]) -> None:
# Write an XML start tag with optional attributes.
for key, value in attributes:
value = self._escape_attributes(value)
tag += f' {key}="{value}"'
self.fh.write(f"<{tag}>")
def _xml_start_tag_unencoded(self, tag, attributes=[]) -> None:
# Write an XML start tag with optional, unencoded, attributes.
# This is a minor speed optimization for elements that don't
# need encoding.
for key, value in attributes:
tag += f' {key}="{value}"'
self.fh.write(f"<{tag}>")
def _xml_end_tag(self, tag) -> None:
# Write an XML end tag.
self.fh.write(f"</{tag}>")
def _xml_empty_tag(self, tag, attributes=[]) -> None:
# Write an empty XML tag with optional attributes.
for key, value in attributes:
value = self._escape_attributes(value)
tag += f' {key}="{value}"'
self.fh.write(f"<{tag}/>")
def _xml_empty_tag_unencoded(self, tag, attributes=[]) -> None:
# Write an empty XML tag with optional, unencoded, attributes.
# This is a minor speed optimization for elements that don't
# need encoding.
for key, value in attributes:
tag += f' {key}="{value}"'
self.fh.write(f"<{tag}/>")
def _xml_data_element(self, tag, data, attributes=[]) -> None:
# Write an XML element containing data with optional attributes.
end_tag = tag
for key, value in attributes:
value = self._escape_attributes(value)
tag += f' {key}="{value}"'
data = self._escape_data(data)
data = self._escape_control_characters(data)
self.fh.write(f"<{tag}>{data}</{end_tag}>")
def _xml_string_element(self, index, attributes=[]) -> None:
# Optimized tag writer for <c> cell string elements in the inner loop.
attr = ""
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
self.fh.write(f'<c{attr} t="s"><v>{index}</v></c>')
def _xml_si_element(self, string, attributes=[]) -> None:
# Optimized tag writer for shared strings <si> elements.
attr = ""
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
string = self._escape_data(string)
self.fh.write(f"<si><t{attr}>{string}</t></si>")
def _xml_rich_si_element(self, string) -> None:
# Optimized tag writer for shared strings <si> rich string elements.
self.fh.write(f"<si>{string}</si>")
def _xml_number_element(self, number, attributes=[]) -> None:
# Optimized tag writer for <c> cell number elements in the inner loop.
attr = ""
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
self.fh.write(f"<c{attr}><v>{number:.16G}</v></c>")
def _xml_formula_element(self, formula, result, attributes=[]) -> None:
# Optimized tag writer for <c> cell formula elements in the inner loop.
attr = ""
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
formula = self._escape_data(formula)
result = self._escape_data(result)
self.fh.write(f"<c{attr}><f>{formula}</f><v>{result}</v></c>")
def _xml_inline_string(self, string, preserve, attributes=[]) -> None:
# Optimized tag writer for inlineStr cell elements in the inner loop.
attr = ""
t_attr = ""
# Set the <t> attribute to preserve whitespace.
if preserve:
t_attr = ' xml:space="preserve"'
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
string = self._escape_data(string)
self.fh.write(f'<c{attr} t="inlineStr"><is><t{t_attr}>{string}</t></is></c>')
def _xml_rich_inline_string(self, string, attributes=[]) -> None:
# Optimized tag writer for rich inlineStr in the inner loop.
attr = ""
for key, value in attributes:
value = self._escape_attributes(value)
attr += f' {key}="{value}"'
self.fh.write(f'<c{attr} t="inlineStr"><is>{string}</is></c>')
def _escape_attributes(self, attribute):
# Escape XML characters in attributes.
try:
if not xml_escapes.search(attribute):
return attribute
except TypeError:
return attribute
attribute = (
attribute.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "&#xA;")
)
return attribute
def _escape_data(self, data):
# Escape XML characters in data sections of tags. Note, this
# is different from _escape_attributes() in that double quotes
# are not escaped by Excel.
try:
if not xml_escapes.search(data):
return data
except TypeError:
return data
data = data.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return data
@staticmethod
def _escape_control_characters(data):
# Excel escapes control characters with _xHHHH_ and also escapes any
# literal strings of that type by encoding the leading underscore.
# So "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
# The following substitutions deal with those cases.
try:
# Escape the escape.
data = re_control_chars_1.sub(r"_x005F\1", data)
except TypeError:
return data
# Convert control character to the _xHHHH_ escape.
data = re_control_chars_2.sub(
lambda match: f"_x{ord(match.group(1)):04X}_", data
)
# Escapes non characters in strings.
data = data.replace("\ufffe", "_xFFFE_").replace("\uffff", "_xFFFF_")
return data