1###############################################################################
2#
3# Comments - A class for writing the Excel XLSX Worksheet file.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9
10from typing import Dict, List, Optional, Union
11
12from xlsxwriter.color import Color
13
14from . import xmlwriter
15from .utility import _preserve_whitespace, xl_cell_to_rowcol, xl_rowcol_to_cell
16
17
18###########################################################################
19#
20# A comment type class.
21#
22###########################################################################
23class CommentType:
24 """
25 A class to represent a comment in an Excel worksheet.
26
27 """
28
29 def __init__(
30 self,
31 row: int,
32 col: int,
33 text: str,
34 options: Optional[Dict[str, Union[str, int, float]]] = None,
35 ):
36 """
37 Initialize a Comment instance.
38
39 Args:
40 row (int): The row number of the comment.
41 col (int): The column number of the comment.
42 text (str): The text of the comment.
43 options (dict): Additional options for the comment.
44 """
45 self.row: int = row
46 self.col: int = col
47 self.text: str = text
48
49 self.author: Optional[str] = None
50 self.color: Color = Color("#ffffe1")
51
52 self.start_row: int = 0
53 self.start_col: int = 0
54
55 self.is_visible: Optional[bool] = None
56
57 self.width: float = 128
58 self.height: float = 74
59
60 self.x_scale: float = 1
61 self.y_scale: float = 1
62 self.x_offset: int = 0
63 self.y_offset: int = 0
64
65 self.font_size: float = 8
66 self.font_name: str = "Tahoma"
67 self.font_family: int = 2
68
69 self.vertices: List[Union[int, float]] = []
70
71 # Set the default start cell and offsets for the comment.
72 self.set_offsets(self.row, self.col)
73
74 # Set any user supplied options.
75 self._set_user_options(options)
76
77 def _set_user_options(
78 self, options: Optional[Dict[str, Union[str, int, float]]] = None
79 ):
80 """
81 This method handles the additional optional parameters to
82 ``write_comment()``.
83 """
84 if options is None:
85 return
86
87 # Overwrite the defaults with any user supplied values. Incorrect or
88 # misspelled parameters are silently ignored.
89 width = options.get("width")
90 if width and isinstance(width, (int, float)):
91 self.width = width
92
93 height = options.get("height")
94 if height and isinstance(height, (int, float)):
95 self.height = height
96
97 x_offset = options.get("x_offset")
98 if x_offset and isinstance(x_offset, int):
99 self.x_offset = x_offset
100
101 y_offset = options.get("y_offset")
102 if y_offset and isinstance(y_offset, int):
103 self.y_offset = y_offset
104
105 start_col = options.get("start_col")
106 if start_col and isinstance(start_col, int):
107 self.start_col = start_col
108
109 start_row = options.get("start_row")
110 if start_row and isinstance(start_row, int):
111 self.start_row = start_row
112
113 font_size = options.get("font_size")
114 if font_size and isinstance(font_size, (int, float)):
115 self.font_size = font_size
116
117 font_name = options.get("font_name")
118 if font_name and isinstance(font_name, str):
119 self.font_name = font_name
120
121 font_family = options.get("font_family")
122 if font_family and isinstance(font_family, int):
123 self.font_family = font_family
124
125 author = options.get("author")
126 if author and isinstance(author, str):
127 self.author = author
128
129 visible = options.get("visible")
130 if visible is not None and isinstance(visible, bool):
131 self.is_visible = visible
132
133 if options.get("color"):
134 # Set the comment background color.
135 self.color = Color._from_value(options["color"])
136
137 # Convert a cell reference to a row and column.
138 start_cell = options.get("start_cell")
139 if start_cell and isinstance(start_cell, str):
140 (start_row, start_col) = xl_cell_to_rowcol(start_cell)
141 self.start_row = start_row
142 self.start_col = start_col
143
144 # Scale the size of the comment box if required.
145 x_scale = options.get("x_scale")
146 if x_scale and isinstance(x_scale, (int, float)):
147 self.width = self.width * x_scale
148
149 y_scale = options.get("y_scale")
150 if y_scale and isinstance(y_scale, (int, float)):
151 self.height = self.height * y_scale
152
153 # Round the dimensions to the nearest pixel.
154 self.width = int(0.5 + self.width)
155 self.height = int(0.5 + self.height)
156
157 def set_offsets(self, row: int, col: int):
158 """
159 Set the default start cell and offsets for the comment. These are
160 generally a fixed offset relative to the parent cell. However there are
161 some edge cases for cells at the, well, edges.
162 """
163 row_max = 1048576
164 col_max = 16384
165
166 if self.row == 0:
167 self.y_offset = 2
168 self.start_row = 0
169 elif self.row == row_max - 3:
170 self.y_offset = 16
171 self.start_row = row_max - 7
172 elif self.row == row_max - 2:
173 self.y_offset = 16
174 self.start_row = row_max - 6
175 elif self.row == row_max - 1:
176 self.y_offset = 14
177 self.start_row = row_max - 5
178 else:
179 self.y_offset = 10
180 self.start_row = row - 1
181
182 if self.col == col_max - 3:
183 self.x_offset = 49
184 self.start_col = col_max - 6
185 elif self.col == col_max - 2:
186 self.x_offset = 49
187 self.start_col = col_max - 5
188 elif self.col == col_max - 1:
189 self.x_offset = 49
190 self.start_col = col_max - 4
191 else:
192 self.x_offset = 15
193 self.start_col = col + 1
194
195
196###########################################################################
197#
198# The file writer class for the Excel XLSX Comments file.
199#
200###########################################################################
201class Comments(xmlwriter.XMLwriter):
202 """
203 A class for writing the Excel XLSX Comments file.
204
205
206 """
207
208 ###########################################################################
209 #
210 # Public API.
211 #
212 ###########################################################################
213
214 def __init__(self):
215 """
216 Constructor.
217
218 """
219
220 super().__init__()
221 self.author_ids = {}
222
223 ###########################################################################
224 #
225 # Private API.
226 #
227 ###########################################################################
228
229 def _assemble_xml_file(self, comments_data: Optional[List[CommentType]] = None):
230 # Assemble and write the XML file.
231
232 if comments_data is None:
233 comments_data = []
234
235 # Write the XML declaration.
236 self._xml_declaration()
237
238 # Write the comments element.
239 self._write_comments()
240
241 # Write the authors element.
242 self._write_authors(comments_data)
243
244 # Write the commentList element.
245 self._write_comment_list(comments_data)
246
247 self._xml_end_tag("comments")
248
249 # Close the file.
250 self._xml_close()
251
252 ###########################################################################
253 #
254 # XML methods.
255 #
256 ###########################################################################
257
258 def _write_comments(self):
259 # Write the <comments> element.
260 xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
261
262 attributes = [("xmlns", xmlns)]
263
264 self._xml_start_tag("comments", attributes)
265
266 def _write_authors(self, comment_data: List[CommentType]):
267 # Write the <authors> element.
268 author_count = 0
269
270 self._xml_start_tag("authors")
271
272 for comment in comment_data:
273 author = comment.author
274
275 if author is not None and author not in self.author_ids:
276 # Store the author id.
277 self.author_ids[author] = author_count
278 author_count += 1
279
280 # Write the author element.
281 self._write_author(author)
282
283 self._xml_end_tag("authors")
284
285 def _write_author(self, data: str):
286 # Write the <author> element.
287 self._xml_data_element("author", data)
288
289 def _write_comment_list(self, comment_data: List[CommentType]):
290 # Write the <commentList> element.
291 self._xml_start_tag("commentList")
292
293 for comment in comment_data:
294 # Look up the author id.
295 author_id = -1
296 if comment.author is not None:
297 author_id = self.author_ids[comment.author]
298
299 # Write the comment element.
300 self._write_comment(comment, author_id)
301
302 self._xml_end_tag("commentList")
303
304 def _write_comment(self, comment: CommentType, author_id: int):
305 # Write the <comment> element.
306 ref = xl_rowcol_to_cell(comment.row, comment.col)
307
308 attributes = [("ref", ref)]
309
310 if author_id != -1:
311 attributes.append(("authorId", f"{author_id}"))
312
313 self._xml_start_tag("comment", attributes)
314
315 # Write the text element.
316 self._write_text(comment)
317
318 self._xml_end_tag("comment")
319
320 def _write_text(self, comment: CommentType):
321 # Write the <text> element.
322 self._xml_start_tag("text")
323
324 # Write the text r element.
325 self._write_text_r(comment)
326
327 self._xml_end_tag("text")
328
329 def _write_text_r(self, comment: CommentType):
330 # Write the <r> element.
331 self._xml_start_tag("r")
332
333 # Write the rPr element.
334 self._write_r_pr(comment)
335
336 # Write the text r element.
337 self._write_text_t(comment.text)
338
339 self._xml_end_tag("r")
340
341 def _write_text_t(self, text: str):
342 # Write the text <t> element.
343 attributes = []
344
345 if _preserve_whitespace(text):
346 attributes.append(("xml:space", "preserve"))
347
348 self._xml_data_element("t", text, attributes)
349
350 def _write_r_pr(self, comment: CommentType):
351 # Write the <rPr> element.
352 self._xml_start_tag("rPr")
353
354 # Write the sz element.
355 self._write_sz(comment.font_size)
356
357 # Write the color element.
358 self._write_color()
359
360 # Write the rFont element.
361 self._write_r_font(comment.font_name)
362
363 # Write the family element.
364 self._write_family(comment.font_family)
365
366 self._xml_end_tag("rPr")
367
368 def _write_sz(self, font_size: float):
369 # Write the <sz> element.
370 attributes = [("val", font_size)]
371
372 self._xml_empty_tag("sz", attributes)
373
374 def _write_color(self):
375 # Write the <color> element.
376 attributes = [("indexed", 81)]
377
378 self._xml_empty_tag("color", attributes)
379
380 def _write_r_font(self, font_name: str):
381 # Write the <rFont> element.
382 attributes = [("val", font_name)]
383
384 self._xml_empty_tag("rFont", attributes)
385
386 def _write_family(self, font_family: int):
387 # Write the <family> element.
388 attributes = [("val", font_family)]
389
390 self._xml_empty_tag("family", attributes)