1###############################################################################
2#
3# Shape - A class for to represent Excel XLSX shape objects.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9import copy
10from warnings import warn
11
12from xlsxwriter.color import Color
13
14
15class Shape:
16 """
17 A class for to represent Excel XLSX shape objects.
18
19
20 """
21
22 ###########################################################################
23 #
24 # Public API.
25 #
26 ###########################################################################
27
28 def __init__(self, shape_type, name: str, options) -> None:
29 """
30 Constructor.
31
32 """
33 super().__init__()
34 self.name = name
35 self.shape_type = shape_type
36 self.connect = 0
37 self.drawing = 0
38 self.edit_as = ""
39 self.id = 0
40 self.text = ""
41 self.textlink = ""
42 self.stencil = 1
43 self.element = -1
44 self.start = None
45 self.start_index = None
46 self.end = None
47 self.end_index = None
48 self.adjustments = []
49 self.start_side = ""
50 self.end_side = ""
51 self.flip_h = 0
52 self.flip_v = 0
53 self.rotation = 0
54 self.text_rotation = 0
55 self.textbox = False
56
57 self.align = None
58 self.fill = None
59 self.font = None
60 self.format = None
61 self.line = None
62
63 self._set_options(options)
64
65 ###########################################################################
66 #
67 # Private API.
68 #
69 ###########################################################################
70
71 def _set_options(self, options) -> None:
72 self.align = self._get_align_properties(options.get("align"))
73 self.fill = self._get_fill_properties(options.get("fill"))
74 self.font = self._get_font_properties(options.get("font"))
75 self.gradient = self._get_gradient_properties(options.get("gradient"))
76 self.line = self._get_line_properties(options)
77
78 self.text_rotation = options.get("text_rotation", 0)
79
80 self.textlink = options.get("textlink", "")
81 if self.textlink.startswith("="):
82 self.textlink = self.textlink.lstrip("=")
83
84 # Gradient fill overrides solid fill.
85 if self.gradient:
86 self.fill = None
87
88 ###########################################################################
89 #
90 # Static methods for processing chart/shape style properties.
91 #
92 ###########################################################################
93
94 @staticmethod
95 def _get_line_properties(options: dict) -> dict:
96 # Convert user line properties to the structure required internally.
97 if not options.get("line") and not options.get("border"):
98 return {"defined": False}
99
100 # Copy the user defined properties since they will be modified.
101 # Depending on the context, the Excel UI property may be called 'line'
102 # or 'border'. Internally they are the same so we handle both.
103 if options.get("line"):
104 line = copy.deepcopy(options["line"])
105 else:
106 line = copy.deepcopy(options["border"])
107
108 dash_types = {
109 "solid": "solid",
110 "round_dot": "sysDot",
111 "square_dot": "sysDash",
112 "dash": "dash",
113 "dash_dot": "dashDot",
114 "long_dash": "lgDash",
115 "long_dash_dot": "lgDashDot",
116 "long_dash_dot_dot": "lgDashDotDot",
117 "dot": "dot",
118 "system_dash_dot": "sysDashDot",
119 "system_dash_dot_dot": "sysDashDotDot",
120 }
121
122 # Check the dash type.
123 dash_type = line.get("dash_type")
124
125 if dash_type is not None:
126 if dash_type in dash_types:
127 line["dash_type"] = dash_types[dash_type]
128 else:
129 warn(f"Unknown dash type '{dash_type}'")
130 return {}
131
132 if line.get("color"):
133 line["color"] = Color._from_value(line["color"])
134
135 line["defined"] = True
136
137 return line
138
139 @staticmethod
140 def _get_fill_properties(fill):
141 # Convert user fill properties to the structure required internally.
142
143 if not fill:
144 return {"defined": False}
145
146 # Copy the user defined properties since they will be modified.
147 fill = copy.deepcopy(fill)
148
149 if fill.get("color"):
150 fill["color"] = Color._from_value(fill["color"])
151
152 fill["defined"] = True
153
154 return fill
155
156 @staticmethod
157 def _get_pattern_properties(pattern):
158 # Convert user defined pattern to the structure required internally.
159
160 if not pattern:
161 return {}
162
163 # Copy the user defined properties since they will be modified.
164 pattern = copy.deepcopy(pattern)
165
166 if not pattern.get("pattern"):
167 warn("Pattern must include 'pattern'")
168 return {}
169
170 if not pattern.get("fg_color"):
171 warn("Pattern must include 'fg_color'")
172 return {}
173
174 types = {
175 "percent_5": "pct5",
176 "percent_10": "pct10",
177 "percent_20": "pct20",
178 "percent_25": "pct25",
179 "percent_30": "pct30",
180 "percent_40": "pct40",
181 "percent_50": "pct50",
182 "percent_60": "pct60",
183 "percent_70": "pct70",
184 "percent_75": "pct75",
185 "percent_80": "pct80",
186 "percent_90": "pct90",
187 "light_downward_diagonal": "ltDnDiag",
188 "light_upward_diagonal": "ltUpDiag",
189 "dark_downward_diagonal": "dkDnDiag",
190 "dark_upward_diagonal": "dkUpDiag",
191 "wide_downward_diagonal": "wdDnDiag",
192 "wide_upward_diagonal": "wdUpDiag",
193 "light_vertical": "ltVert",
194 "light_horizontal": "ltHorz",
195 "narrow_vertical": "narVert",
196 "narrow_horizontal": "narHorz",
197 "dark_vertical": "dkVert",
198 "dark_horizontal": "dkHorz",
199 "dashed_downward_diagonal": "dashDnDiag",
200 "dashed_upward_diagonal": "dashUpDiag",
201 "dashed_horizontal": "dashHorz",
202 "dashed_vertical": "dashVert",
203 "small_confetti": "smConfetti",
204 "large_confetti": "lgConfetti",
205 "zigzag": "zigZag",
206 "wave": "wave",
207 "diagonal_brick": "diagBrick",
208 "horizontal_brick": "horzBrick",
209 "weave": "weave",
210 "plaid": "plaid",
211 "divot": "divot",
212 "dotted_grid": "dotGrid",
213 "dotted_diamond": "dotDmnd",
214 "shingle": "shingle",
215 "trellis": "trellis",
216 "sphere": "sphere",
217 "small_grid": "smGrid",
218 "large_grid": "lgGrid",
219 "small_check": "smCheck",
220 "large_check": "lgCheck",
221 "outlined_diamond": "openDmnd",
222 "solid_diamond": "solidDmnd",
223 }
224
225 # Check for valid types.
226 if pattern["pattern"] not in types:
227 warn(f"unknown pattern type '{pattern['pattern']}'")
228 return {}
229
230 pattern["pattern"] = types[pattern["pattern"]]
231
232 if pattern.get("fg_color"):
233 pattern["fg_color"] = Color._from_value(pattern["fg_color"])
234
235 if pattern.get("bg_color"):
236 pattern["bg_color"] = Color._from_value(pattern["bg_color"])
237 else:
238 pattern["bg_color"] = Color("#FFFFFF")
239
240 return pattern
241
242 @staticmethod
243 def _get_gradient_properties(gradient):
244 # pylint: disable=too-many-return-statements
245 # Convert user defined gradient to the structure required internally.
246
247 if not gradient:
248 return {}
249
250 # Copy the user defined properties since they will be modified.
251 gradient = copy.deepcopy(gradient)
252
253 types = {
254 "linear": "linear",
255 "radial": "circle",
256 "rectangular": "rect",
257 "path": "shape",
258 }
259
260 # Check the colors array exists and is valid.
261 if "colors" not in gradient or not isinstance(gradient["colors"], list):
262 warn("Gradient must include colors list")
263 return {}
264
265 # Check the colors array has the required number of entries.
266 if not 2 <= len(gradient["colors"]) <= 10:
267 warn("Gradient colors list must at least 2 values and not more than 10")
268 return {}
269
270 if "positions" in gradient:
271 # Check the positions array has the right number of entries.
272 if len(gradient["positions"]) != len(gradient["colors"]):
273 warn("Gradient positions not equal to number of colors")
274 return {}
275
276 # Check the positions are in the correct range.
277 for pos in gradient["positions"]:
278 if not 0 <= pos <= 100:
279 warn("Gradient position must be in the range 0 <= position <= 100")
280 return {}
281 else:
282 # Use the default gradient positions.
283 if len(gradient["colors"]) == 2:
284 gradient["positions"] = [0, 100]
285
286 elif len(gradient["colors"]) == 3:
287 gradient["positions"] = [0, 50, 100]
288
289 elif len(gradient["colors"]) == 4:
290 gradient["positions"] = [0, 33, 66, 100]
291
292 else:
293 warn("Must specify gradient positions")
294 return {}
295
296 angle = gradient.get("angle")
297 if angle:
298 if not 0 <= angle < 360:
299 warn("Gradient angle must be in the range 0 <= angle < 360")
300 return {}
301 else:
302 gradient["angle"] = 90
303
304 # Check for valid types.
305 gradient_type = gradient.get("type")
306
307 if gradient_type is not None:
308 if gradient_type in types:
309 gradient["type"] = types[gradient_type]
310 else:
311 warn(f"Unknown gradient type '{gradient_type}")
312 return {}
313 else:
314 gradient["type"] = "linear"
315
316 gradient["colors"] = [Color._from_value(color) for color in gradient["colors"]]
317
318 return gradient
319
320 @staticmethod
321 def _get_font_properties(options):
322 # Convert user defined font values into private dict values.
323 if options is None:
324 options = {}
325
326 font = {
327 "name": options.get("name"),
328 "color": options.get("color"),
329 "size": options.get("size", 11),
330 "bold": options.get("bold"),
331 "italic": options.get("italic"),
332 "underline": options.get("underline"),
333 "pitch_family": options.get("pitch_family"),
334 "charset": options.get("charset"),
335 "baseline": options.get("baseline", -1),
336 "lang": options.get("lang", "en-US"),
337 }
338
339 # Convert font size units.
340 if font["size"]:
341 font["size"] = int(font["size"] * 100)
342
343 if font.get("color"):
344 font["color"] = Color._from_value(font["color"])
345
346 return font
347
348 @staticmethod
349 def _get_font_style_attributes(font):
350 # _get_font_style_attributes.
351 attributes = []
352
353 if not font:
354 return attributes
355
356 if font.get("size"):
357 attributes.append(("sz", font["size"]))
358
359 if font.get("bold") is not None:
360 attributes.append(("b", 0 + font["bold"]))
361
362 if font.get("italic") is not None:
363 attributes.append(("i", 0 + font["italic"]))
364
365 if font.get("underline") is not None:
366 attributes.append(("u", "sng"))
367
368 if font.get("baseline") != -1:
369 attributes.append(("baseline", font["baseline"]))
370
371 return attributes
372
373 @staticmethod
374 def _get_font_latin_attributes(font):
375 # _get_font_latin_attributes.
376 attributes = []
377
378 if not font:
379 return attributes
380
381 if font.get("name") is not None:
382 attributes.append(("typeface", font["name"]))
383
384 if font.get("pitch_family") is not None:
385 attributes.append(("pitchFamily", font["pitch_family"]))
386
387 if font.get("charset") is not None:
388 attributes.append(("charset", font["charset"]))
389
390 return attributes
391
392 @staticmethod
393 def _get_align_properties(align):
394 # Convert user defined align to the structure required internally.
395 if not align:
396 return {"defined": False}
397
398 # Copy the user defined properties since they will be modified.
399 align = copy.deepcopy(align)
400
401 if "vertical" in align:
402 align_type = align["vertical"]
403
404 align_types = {
405 "top": "top",
406 "middle": "middle",
407 "bottom": "bottom",
408 }
409
410 if align_type in align_types:
411 align["vertical"] = align_types[align_type]
412 else:
413 warn(f"Unknown alignment type '{align_type}'")
414 return {"defined": False}
415
416 if "horizontal" in align:
417 align_type = align["horizontal"]
418
419 align_types = {
420 "left": "left",
421 "center": "center",
422 "right": "right",
423 }
424
425 if align_type in align_types:
426 align["horizontal"] = align_types[align_type]
427 else:
428 warn(f"Unknown alignment type '{align_type}'")
429 return {"defined": False}
430
431 align["defined"] = True
432
433 return align