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 ) -> None:
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 ) -> None:
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) -> None:
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) -> None:
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(
230 self, comments_data: Optional[List[CommentType]] = None
231 ) -> None:
232 # Assemble and write the XML file.
233
234 if comments_data is None:
235 comments_data = []
236
237 # Write the XML declaration.
238 self._xml_declaration()
239
240 # Write the comments element.
241 self._write_comments()
242
243 # Write the authors element.
244 self._write_authors(comments_data)
245
246 # Write the commentList element.
247 self._write_comment_list(comments_data)
248
249 self._xml_end_tag("comments")
250
251 # Close the file.
252 self._xml_close()
253
254 ###########################################################################
255 #
256 # XML methods.
257 #
258 ###########################################################################
259
260 def _write_comments(self) -> None:
261 # Write the <comments> element.
262 xmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
263
264 attributes = [("xmlns", xmlns)]
265
266 self._xml_start_tag("comments", attributes)
267
268 def _write_authors(self, comment_data: List[CommentType]) -> None:
269 # Write the <authors> element.
270 author_count = 0
271
272 self._xml_start_tag("authors")
273
274 for comment in comment_data:
275 author = comment.author
276
277 if author is not None and author not in self.author_ids:
278 # Store the author id.
279 self.author_ids[author] = author_count
280 author_count += 1
281
282 # Write the author element.
283 self._write_author(author)
284
285 self._xml_end_tag("authors")
286
287 def _write_author(self, data: str) -> None:
288 # Write the <author> element.
289 self._xml_data_element("author", data)
290
291 def _write_comment_list(self, comment_data: List[CommentType]) -> None:
292 # Write the <commentList> element.
293 self._xml_start_tag("commentList")
294
295 for comment in comment_data:
296 # Look up the author id.
297 author_id = -1
298 if comment.author is not None:
299 author_id = self.author_ids[comment.author]
300
301 # Write the comment element.
302 self._write_comment(comment, author_id)
303
304 self._xml_end_tag("commentList")
305
306 def _write_comment(self, comment: CommentType, author_id: int) -> None:
307 # Write the <comment> element.
308 ref = xl_rowcol_to_cell(comment.row, comment.col)
309
310 attributes = [("ref", ref)]
311
312 if author_id != -1:
313 attributes.append(("authorId", f"{author_id}"))
314
315 self._xml_start_tag("comment", attributes)
316
317 # Write the text element.
318 self._write_text(comment)
319
320 self._xml_end_tag("comment")
321
322 def _write_text(self, comment: CommentType) -> None:
323 # Write the <text> element.
324 self._xml_start_tag("text")
325
326 # Write the text r element.
327 self._write_text_r(comment)
328
329 self._xml_end_tag("text")
330
331 def _write_text_r(self, comment: CommentType) -> None:
332 # Write the <r> element.
333 self._xml_start_tag("r")
334
335 # Write the rPr element.
336 self._write_r_pr(comment)
337
338 # Write the text r element.
339 self._write_text_t(comment.text)
340
341 self._xml_end_tag("r")
342
343 def _write_text_t(self, text: str) -> None:
344 # Write the text <t> element.
345 attributes = []
346
347 if _preserve_whitespace(text):
348 attributes.append(("xml:space", "preserve"))
349
350 self._xml_data_element("t", text, attributes)
351
352 def _write_r_pr(self, comment: CommentType) -> None:
353 # Write the <rPr> element.
354 self._xml_start_tag("rPr")
355
356 # Write the sz element.
357 self._write_sz(comment.font_size)
358
359 # Write the color element.
360 self._write_color()
361
362 # Write the rFont element.
363 self._write_r_font(comment.font_name)
364
365 # Write the family element.
366 self._write_family(comment.font_family)
367
368 self._xml_end_tag("rPr")
369
370 def _write_sz(self, font_size: float) -> None:
371 # Write the <sz> element.
372 attributes = [("val", font_size)]
373
374 self._xml_empty_tag("sz", attributes)
375
376 def _write_color(self) -> None:
377 # Write the <color> element.
378 attributes = [("indexed", 81)]
379
380 self._xml_empty_tag("color", attributes)
381
382 def _write_r_font(self, font_name: str) -> None:
383 # Write the <rFont> element.
384 attributes = [("val", font_name)]
385
386 self._xml_empty_tag("rFont", attributes)
387
388 def _write_family(self, font_family: int) -> None:
389 # Write the <family> element.
390 attributes = [("val", font_family)]
391
392 self._xml_empty_tag("family", attributes)