aspose-cells-foss 25.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. aspose/__init__.py +14 -0
  2. aspose/cells/__init__.py +31 -0
  3. aspose/cells/cell.py +350 -0
  4. aspose/cells/constants.py +44 -0
  5. aspose/cells/converters/__init__.py +13 -0
  6. aspose/cells/converters/csv_converter.py +55 -0
  7. aspose/cells/converters/json_converter.py +46 -0
  8. aspose/cells/converters/markdown_converter.py +453 -0
  9. aspose/cells/drawing/__init__.py +17 -0
  10. aspose/cells/drawing/anchor.py +172 -0
  11. aspose/cells/drawing/collection.py +233 -0
  12. aspose/cells/drawing/image.py +338 -0
  13. aspose/cells/formats.py +80 -0
  14. aspose/cells/formula/__init__.py +10 -0
  15. aspose/cells/formula/evaluator.py +360 -0
  16. aspose/cells/formula/functions.py +433 -0
  17. aspose/cells/formula/tokenizer.py +340 -0
  18. aspose/cells/io/__init__.py +27 -0
  19. aspose/cells/io/csv/__init__.py +8 -0
  20. aspose/cells/io/csv/reader.py +88 -0
  21. aspose/cells/io/csv/writer.py +98 -0
  22. aspose/cells/io/factory.py +138 -0
  23. aspose/cells/io/interfaces.py +48 -0
  24. aspose/cells/io/json/__init__.py +8 -0
  25. aspose/cells/io/json/reader.py +126 -0
  26. aspose/cells/io/json/writer.py +119 -0
  27. aspose/cells/io/md/__init__.py +8 -0
  28. aspose/cells/io/md/reader.py +161 -0
  29. aspose/cells/io/md/writer.py +334 -0
  30. aspose/cells/io/models.py +64 -0
  31. aspose/cells/io/xlsx/__init__.py +9 -0
  32. aspose/cells/io/xlsx/constants.py +312 -0
  33. aspose/cells/io/xlsx/image_writer.py +311 -0
  34. aspose/cells/io/xlsx/reader.py +284 -0
  35. aspose/cells/io/xlsx/writer.py +931 -0
  36. aspose/cells/plugins/__init__.py +6 -0
  37. aspose/cells/plugins/docling_backend/__init__.py +7 -0
  38. aspose/cells/plugins/docling_backend/backend.py +535 -0
  39. aspose/cells/plugins/markitdown_plugin/__init__.py +15 -0
  40. aspose/cells/plugins/markitdown_plugin/plugin.py +128 -0
  41. aspose/cells/range.py +210 -0
  42. aspose/cells/style.py +287 -0
  43. aspose/cells/utils/__init__.py +54 -0
  44. aspose/cells/utils/coordinates.py +68 -0
  45. aspose/cells/utils/exceptions.py +43 -0
  46. aspose/cells/utils/validation.py +102 -0
  47. aspose/cells/workbook.py +352 -0
  48. aspose/cells/worksheet.py +670 -0
  49. aspose_cells_foss-25.12.1.dist-info/METADATA +189 -0
  50. aspose_cells_foss-25.12.1.dist-info/RECORD +53 -0
  51. aspose_cells_foss-25.12.1.dist-info/WHEEL +5 -0
  52. aspose_cells_foss-25.12.1.dist-info/entry_points.txt +2 -0
  53. aspose_cells_foss-25.12.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,931 @@
1
+ """Excel XLSX file writer with full OOXML implementation."""
2
+
3
+ import zipfile
4
+ import xml.etree.ElementTree as ET
5
+ from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple
6
+ from pathlib import Path
7
+ import io
8
+
9
+ from ...utils import FileFormatError, tuple_to_coordinate
10
+ from .constants import XlsxConstants, XlsxTemplates
11
+ from .image_writer import ImageWriter
12
+
13
+ if TYPE_CHECKING:
14
+ from ...workbook import Workbook, Worksheet
15
+ from ...cell import Cell
16
+
17
+
18
+ class StyleManager:
19
+ """Manages styles for Excel generation."""
20
+
21
+ def __init__(self):
22
+ self.fonts = []
23
+ self.fills = []
24
+ self.borders = []
25
+ self.number_formats = {}
26
+ self.cell_formats = []
27
+ self.font_map = {}
28
+ self.fill_map = {}
29
+ self.border_map = {}
30
+ self.format_map = {}
31
+
32
+ # Initialize default styles
33
+ self._init_default_styles()
34
+
35
+ def _init_default_styles(self):
36
+ """Initialize default Excel styles."""
37
+ # Default font
38
+ self.fonts.append(XlsxConstants.DEFAULT_FONT.copy())
39
+ self.font_map[self._font_key(self.fonts[0])] = 0
40
+
41
+ # Default fills
42
+ self.fills.extend([fill.copy() for fill in XlsxConstants.DEFAULT_FILLS])
43
+ self.fill_map[self._fill_key(self.fills[0])] = 0
44
+ self.fill_map[self._fill_key(self.fills[1])] = 1
45
+
46
+ # Default border
47
+ self.borders.append(XlsxConstants.DEFAULT_BORDER.copy())
48
+ self.border_map[self._border_key(self.borders[0])] = 0
49
+
50
+ # Default cell format
51
+ self.cell_formats.append(XlsxConstants.DEFAULT_CELL_FORMAT.copy())
52
+
53
+ def _font_key(self, font):
54
+ """Generate key for font lookup."""
55
+ return f"{font['name']}|{font['size']}|{font['bold']}|{font['italic']}|{font['color']}"
56
+
57
+ def _fill_key(self, fill):
58
+ """Generate key for fill lookup."""
59
+ return f"{fill['pattern']}|{fill.get('color', '')}"
60
+
61
+ def _border_key(self, border):
62
+ """Generate key for border lookup."""
63
+ if hasattr(border, '_left'):
64
+ # New border format
65
+ left = f"{getattr(border._left, 'style', 'none')}:{getattr(border._left, 'color', 'black')}" if border._left else "none:black"
66
+ right = f"{getattr(border._right, 'style', 'none')}:{getattr(border._right, 'color', 'black')}" if border._right else "none:black"
67
+ top = f"{getattr(border._top, 'style', 'none')}:{getattr(border._top, 'color', 'black')}" if border._top else "none:black"
68
+ bottom = f"{getattr(border._bottom, 'style', 'none')}:{getattr(border._bottom, 'color', 'black')}" if border._bottom else "none:black"
69
+ return f"{left}|{right}|{top}|{bottom}"
70
+ else:
71
+ # Legacy border format
72
+ return f"{border.get('left', '')}|{border.get('right', '')}|{border.get('top', '')}|{border.get('bottom', '')}"
73
+
74
+ def get_font_id(self, font_props):
75
+ """Get or create font ID."""
76
+ font = {
77
+ 'name': getattr(font_props, 'name', 'Calibri'),
78
+ 'size': getattr(font_props, 'size', 11),
79
+ 'bold': getattr(font_props, 'bold', False),
80
+ 'italic': getattr(font_props, 'italic', False),
81
+ 'color': self._normalize_color(getattr(font_props, 'color', 'black'))
82
+ }
83
+
84
+ key = self._font_key(font)
85
+ if key not in self.font_map:
86
+ self.font_map[key] = len(self.fonts)
87
+ self.fonts.append(font)
88
+
89
+ return self.font_map[key]
90
+
91
+ def get_fill_id(self, fill_props):
92
+ """Get or create fill ID."""
93
+ fill = {
94
+ 'pattern': 'solid',
95
+ 'color': self._normalize_color(getattr(fill_props, 'color', 'white'))
96
+ }
97
+
98
+ key = self._fill_key(fill)
99
+ if key not in self.fill_map:
100
+ self.fill_map[key] = len(self.fills)
101
+ self.fills.append(fill)
102
+
103
+ return self.fill_map[key]
104
+
105
+ def get_border_id(self, border_props):
106
+ """Get or create border ID."""
107
+ if hasattr(border_props, '_left'):
108
+ # New Border object
109
+ border = {
110
+ 'left': getattr(border_props._left, 'style', 'none') if border_props._left else 'none',
111
+ 'left_color': getattr(border_props._left, 'color', 'black') if border_props._left else 'black',
112
+ 'right': getattr(border_props._right, 'style', 'none') if border_props._right else 'none',
113
+ 'right_color': getattr(border_props._right, 'color', 'black') if border_props._right else 'black',
114
+ 'top': getattr(border_props._top, 'style', 'none') if border_props._top else 'none',
115
+ 'top_color': getattr(border_props._top, 'color', 'black') if border_props._top else 'black',
116
+ 'bottom': getattr(border_props._bottom, 'style', 'none') if border_props._bottom else 'none',
117
+ 'bottom_color': getattr(border_props._bottom, 'color', 'black') if border_props._bottom else 'black'
118
+ }
119
+ else:
120
+ # Legacy border
121
+ border = {
122
+ 'left': 'none', 'left_color': 'black',
123
+ 'right': 'none', 'right_color': 'black',
124
+ 'top': 'none', 'top_color': 'black',
125
+ 'bottom': 'none', 'bottom_color': 'black'
126
+ }
127
+
128
+ key = self._border_key(border_props)
129
+ if key not in self.border_map:
130
+ self.border_map[key] = len(self.borders)
131
+ self.borders.append(border)
132
+
133
+ return self.border_map[key]
134
+
135
+ def get_number_format_id(self, format_code):
136
+ """Get or create number format ID."""
137
+ if format_code in XlsxConstants.BUILTIN_NUMBER_FORMATS:
138
+ return XlsxConstants.BUILTIN_NUMBER_FORMATS[format_code]
139
+
140
+ # Custom format
141
+ if format_code not in self.format_map:
142
+ format_id = 164 + len(self.number_formats)
143
+ self.number_formats[format_id] = format_code
144
+ self.format_map[format_code] = format_id
145
+
146
+ return self.format_map[format_code]
147
+
148
+ def get_cell_format_id(self, cell):
149
+ """Get cell format ID based on cell styling."""
150
+ font_id = 0
151
+ fill_id = 0
152
+ border_id = 0
153
+ number_format_id = 0
154
+
155
+ if hasattr(cell, '_style') and cell._style:
156
+ style = cell._style
157
+
158
+ if style._font:
159
+ font_id = self.get_font_id(style._font)
160
+
161
+ if style._fill:
162
+ fill_id = self.get_fill_id(style._fill)
163
+
164
+ if style._border:
165
+ border_id = self.get_border_id(style._border)
166
+
167
+ if hasattr(cell, '_number_format') and cell._number_format != "General":
168
+ number_format_id = self.get_number_format_id(cell._number_format)
169
+
170
+ # Find or create cell format
171
+ cell_format = {
172
+ 'font_id': font_id,
173
+ 'fill_id': fill_id,
174
+ 'border_id': border_id,
175
+ 'number_format_id': number_format_id
176
+ }
177
+
178
+ # Check if this format already exists
179
+ for i, existing_format in enumerate(self.cell_formats):
180
+ if existing_format == cell_format:
181
+ return i
182
+
183
+ # Create new format
184
+ format_id = len(self.cell_formats)
185
+ self.cell_formats.append(cell_format)
186
+ return format_id
187
+
188
+ def _normalize_color(self, color):
189
+ """Normalize color to hex format."""
190
+ if color in XlsxConstants.COLOR_MAP:
191
+ return XlsxConstants.COLOR_MAP[color]
192
+ elif color.startswith('#'):
193
+ return color[1:].upper()
194
+ elif len(color) == 6:
195
+ return color.upper()
196
+ else:
197
+ return '000000' # Default to black
198
+
199
+
200
+ class XlsxWriter:
201
+ """Excel XLSX file writer with OOXML protocol support and proper styling."""
202
+
203
+ def __init__(self):
204
+ self.namespaces = XlsxConstants.NAMESPACES
205
+ self.style_manager = StyleManager()
206
+ self.image_writer = ImageWriter()
207
+
208
+ def write(self, file_path: str, workbook: 'Workbook', **kwargs) -> None:
209
+ """Write workbook to Excel XLSX file."""
210
+ self.save_workbook(workbook, file_path, **kwargs)
211
+
212
+ def save_workbook(self, workbook: 'Workbook', filename: str, **kwargs):
213
+ """Save workbook to XLSX file with proper styling."""
214
+ # Reset managers for new file
215
+ self.style_manager = StyleManager()
216
+ self.image_writer = ImageWriter()
217
+
218
+ with zipfile.ZipFile(filename, 'w', zipfile.ZIP_DEFLATED) as zip_file:
219
+ # Pre-process all cells to build styles
220
+ self._analyze_styles(workbook)
221
+
222
+ # Build shared strings
223
+ shared_strings = self._build_shared_strings(workbook)
224
+
225
+ # Check if workbook has images
226
+ has_images = self._has_images(workbook)
227
+
228
+ # Write core structure files
229
+ self._write_content_types(zip_file, workbook, bool(shared_strings), has_images)
230
+ self._write_rels(zip_file)
231
+ self._write_app_properties(zip_file)
232
+ self._write_core_properties(zip_file)
233
+ self._write_workbook_xml(zip_file, workbook)
234
+ self._write_workbook_rels(zip_file, workbook)
235
+
236
+ # Write shared strings only if they exist
237
+ if shared_strings:
238
+ self._write_shared_strings(zip_file, shared_strings)
239
+
240
+ # Write styles with proper formatting
241
+ self._write_styles(zip_file)
242
+
243
+ # Write theme
244
+ self._write_theme(zip_file)
245
+
246
+ # Write worksheets
247
+ for idx, worksheet in enumerate(workbook._worksheets.values(), 1):
248
+ self._write_worksheet(zip_file, worksheet, idx, shared_strings)
249
+
250
+ # Write images if any exist
251
+ if has_images:
252
+ self._write_images(zip_file)
253
+
254
+ def _analyze_styles(self, workbook: 'Workbook'):
255
+ """Pre-analyze all cells to build style tables."""
256
+ for worksheet in workbook._worksheets.values():
257
+ for cell in worksheet._cells.values():
258
+ if cell.value is not None:
259
+ # This will register the style
260
+ self.style_manager.get_cell_format_id(cell)
261
+
262
+ def _build_shared_strings(self, workbook: 'Workbook') -> Dict[str, int]:
263
+ """Build shared strings table."""
264
+ strings = {}
265
+ string_list = []
266
+
267
+ for worksheet in workbook._worksheets.values():
268
+ for cell in worksheet._cells.values():
269
+ if isinstance(cell.value, str) and not cell.is_formula():
270
+ if cell.value not in strings:
271
+ strings[cell.value] = len(string_list)
272
+ string_list.append(cell.value)
273
+
274
+ return strings
275
+
276
+ def _write_content_types(self, zip_file: zipfile.ZipFile, workbook: 'Workbook', has_shared_strings: bool = True, has_images: bool = False):
277
+ """Write [Content_Types].xml with proper formatting."""
278
+ root = ET.Element("Types")
279
+ root.set("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")
280
+
281
+ # Default content types
282
+ for ext, content_type in XlsxConstants.CONTENT_TYPES['defaults']:
283
+ default = ET.SubElement(root, "Default")
284
+ default.set("Extension", ext)
285
+ default.set("ContentType", content_type)
286
+
287
+ # Add image content types if images exist
288
+ if has_images:
289
+ # Get image extensions from all worksheets
290
+ image_extensions = set()
291
+ for worksheet in workbook._worksheets.values():
292
+ if hasattr(worksheet, 'images') and worksheet.images:
293
+ for image in worksheet.images:
294
+ if hasattr(image, 'format') and image.format:
295
+ ext = image.format.value.lower()
296
+ if ext == 'jpg':
297
+ ext = 'jpeg' # Normalize to standard MIME type
298
+ image_extensions.add(ext)
299
+
300
+ # Add content types for all found image extensions
301
+ for ext in image_extensions:
302
+ content_type = XlsxConstants.IMAGE_CONTENT_TYPES.get(ext, f'image/{ext}')
303
+
304
+ default = ET.SubElement(root, "Default")
305
+ default.set("Extension", ext)
306
+ default.set("ContentType", content_type)
307
+
308
+ # Override content types
309
+ overrides = [
310
+ XlsxConstants.CONTENT_TYPES['overrides']['workbook'],
311
+ XlsxConstants.CONTENT_TYPES['overrides']['styles'],
312
+ XlsxConstants.CONTENT_TYPES['overrides']['theme'],
313
+ XlsxConstants.CONTENT_TYPES['overrides']['core_props'],
314
+ XlsxConstants.CONTENT_TYPES['overrides']['app_props']
315
+ ]
316
+
317
+ # Only add shared strings if they exist
318
+ if has_shared_strings:
319
+ overrides.append(XlsxConstants.CONTENT_TYPES['overrides']['shared_strings'])
320
+
321
+ # Add worksheet overrides
322
+ for idx in range(1, len(workbook._worksheets) + 1):
323
+ overrides.append((
324
+ f"/xl/worksheets/sheet{idx}.xml",
325
+ XlsxConstants.CONTENT_TYPES['overrides']['worksheet']
326
+ ))
327
+
328
+ # Add drawing overrides if images exist
329
+ if has_images:
330
+ for idx, worksheet in enumerate(workbook._worksheets.values(), 1):
331
+ if self._worksheet_has_images(worksheet):
332
+ overrides.append((
333
+ f"/xl/drawings/drawing{idx}.xml",
334
+ "application/vnd.openxmlformats-officedocument.drawing+xml"
335
+ ))
336
+
337
+ for part_name, content_type in overrides:
338
+ override = ET.SubElement(root, "Override")
339
+ override.set("PartName", part_name)
340
+ override.set("ContentType", content_type)
341
+
342
+ self._write_xml_to_zip(zip_file, "[Content_Types].xml", root)
343
+
344
+ def _write_rels(self, zip_file: zipfile.ZipFile):
345
+ """Write _rels/.rels."""
346
+ root = ET.Element("Relationships")
347
+ root.set("xmlns", XlsxConstants.NAMESPACES['pkg'])
348
+
349
+ for rel_id, rel_type, target in XlsxTemplates.get_rels_data():
350
+ rel = ET.SubElement(root, "Relationship")
351
+ rel.set("Id", rel_id)
352
+ rel.set("Type", rel_type)
353
+ rel.set("Target", target)
354
+
355
+ self._write_xml_to_zip(zip_file, "_rels/.rels", root)
356
+
357
+ def _write_workbook_xml(self, zip_file: zipfile.ZipFile, workbook: 'Workbook'):
358
+ """Write xl/workbook.xml."""
359
+ root = ET.Element("workbook")
360
+ root.set("xmlns", self.namespaces['main'])
361
+ root.set("xmlns:r", self.namespaces['r'])
362
+
363
+ # Book views
364
+ book_views = ET.SubElement(root, "bookViews")
365
+ work_book_view = ET.SubElement(book_views, "workbookView")
366
+ work_book_view.set("xWindow", XlsxConstants.SHEET_DEFAULTS['window_x'])
367
+ work_book_view.set("yWindow", XlsxConstants.SHEET_DEFAULTS['window_y'])
368
+ work_book_view.set("windowWidth", XlsxConstants.SHEET_DEFAULTS['window_width'])
369
+ work_book_view.set("windowHeight", XlsxConstants.SHEET_DEFAULTS['window_height'])
370
+
371
+ # Sheets
372
+ sheets = ET.SubElement(root, "sheets")
373
+ for idx, (name, worksheet) in enumerate(workbook._worksheets.items(), 1):
374
+ sheet = ET.SubElement(sheets, "sheet")
375
+ sheet.set("name", name)
376
+ sheet.set("sheetId", str(idx))
377
+ sheet.set("r:id", f"rId{idx}")
378
+
379
+ self._write_xml_to_zip(zip_file, "xl/workbook.xml", root)
380
+
381
+ def _write_workbook_rels(self, zip_file: zipfile.ZipFile, workbook: 'Workbook'):
382
+ """Write xl/_rels/workbook.xml.rels."""
383
+ root = ET.Element("Relationships")
384
+ root.set("xmlns", self.namespaces['pkg'])
385
+
386
+ rel_id = 1
387
+
388
+ # Worksheet relationships
389
+ for idx in range(1, len(workbook._worksheets) + 1):
390
+ rel = ET.SubElement(root, "Relationship")
391
+ rel.set("Id", f"rId{rel_id}")
392
+ rel.set("Type", XlsxConstants.REL_TYPES['worksheet'])
393
+ rel.set("Target", f"worksheets/sheet{idx}.xml")
394
+ rel_id += 1
395
+
396
+ # Styles relationship
397
+ styles_rel = ET.SubElement(root, "Relationship")
398
+ styles_rel.set("Id", f"rId{rel_id}")
399
+ styles_rel.set("Type", XlsxConstants.REL_TYPES['styles'])
400
+ styles_rel.set("Target", "styles.xml")
401
+ rel_id += 1
402
+
403
+ # Theme relationship
404
+ theme_rel = ET.SubElement(root, "Relationship")
405
+ theme_rel.set("Id", f"rId{rel_id}")
406
+ theme_rel.set("Type", XlsxConstants.REL_TYPES['theme'])
407
+ theme_rel.set("Target", "theme/theme1.xml")
408
+ rel_id += 1
409
+
410
+ # Only add shared strings relationship if there are shared strings
411
+ shared_strings = self._build_shared_strings(workbook)
412
+ if shared_strings:
413
+ shared_rel = ET.SubElement(root, "Relationship")
414
+ shared_rel.set("Id", f"rId{rel_id}")
415
+ shared_rel.set("Type", XlsxConstants.REL_TYPES['shared_strings'])
416
+ shared_rel.set("Target", "sharedStrings.xml")
417
+
418
+ self._write_xml_to_zip(zip_file, "xl/_rels/workbook.xml.rels", root)
419
+
420
+ def _write_worksheet_rels(self, zip_file: zipfile.ZipFile, sheet_id: int, hyperlinks: list, drawing_id: str = None):
421
+ """Write worksheet relationships for hyperlinks and drawings."""
422
+ if not hyperlinks and not drawing_id:
423
+ return
424
+
425
+ root = ET.Element("Relationships")
426
+ root.set("xmlns", XlsxConstants.NAMESPACES['pkg'])
427
+
428
+ rel_id_counter = 1
429
+
430
+ # Add hyperlink relationships
431
+ for cell in hyperlinks:
432
+ relationship = ET.SubElement(root, "Relationship")
433
+ relationship.set("Id", f"rId{rel_id_counter}")
434
+ relationship.set("Type", XlsxConstants.REL_TYPES['hyperlink'])
435
+ relationship.set("Target", cell.hyperlink)
436
+ relationship.set("TargetMode", "External")
437
+ rel_id_counter += 1
438
+
439
+ # Add drawing relationship
440
+ if drawing_id:
441
+ relationship = ET.SubElement(root, "Relationship")
442
+ relationship.set("Id", drawing_id)
443
+ relationship.set("Type", f"{XlsxConstants.REL_TYPES['worksheet'].rsplit('/', 1)[0]}/drawing")
444
+ relationship.set("Target", f"../drawings/drawing{sheet_id}.xml")
445
+
446
+ self._write_xml_to_zip(zip_file, f"xl/worksheets/_rels/sheet{sheet_id}.xml.rels", root)
447
+
448
+ def _write_worksheet(self, zip_file: zipfile.ZipFile, worksheet: 'Worksheet',
449
+ sheet_id: int, shared_strings: Dict[str, int]):
450
+ """Write individual worksheet XML with proper styling."""
451
+ root = ET.Element("worksheet")
452
+ root.set("xmlns", self.namespaces['main'])
453
+ root.set("xmlns:r", self.namespaces['r'])
454
+
455
+ # Sheet views
456
+ sheet_views = ET.SubElement(root, "sheetViews")
457
+ sheet_view = ET.SubElement(sheet_views, "sheetView")
458
+ sheet_view.set("tabSelected", "1" if worksheet == worksheet._parent.active else "0")
459
+ sheet_view.set("workbookViewId", "0")
460
+
461
+ # Sheet format properties
462
+ sheet_format = ET.SubElement(root, "sheetFormatPr")
463
+ sheet_format.set("defaultRowHeight", XlsxConstants.SHEET_DEFAULTS['default_row_height'])
464
+ sheet_format.set("defaultColWidth", XlsxConstants.SHEET_DEFAULTS['default_col_width'])
465
+
466
+ # Column widths
467
+ if worksheet._column_widths:
468
+ cols = ET.SubElement(root, "cols")
469
+ for col_num, width in sorted(worksheet._column_widths.items()):
470
+ col = ET.SubElement(cols, "col")
471
+ col.set("min", str(col_num))
472
+ col.set("max", str(col_num))
473
+ col.set("width", str(width))
474
+ col.set("customWidth", "1")
475
+
476
+ # Sheet data (always required, even for empty worksheets)
477
+ sheet_data = ET.SubElement(root, "sheetData")
478
+
479
+ if worksheet._cells:
480
+ # Group cells by row
481
+ rows_data = {}
482
+ for (row, col), cell in worksheet._cells.items():
483
+ if row not in rows_data:
484
+ rows_data[row] = {}
485
+ rows_data[row][col] = cell
486
+
487
+ # Write rows
488
+ for row_num in sorted(rows_data.keys()):
489
+ row_elem = ET.SubElement(sheet_data, "row")
490
+ row_elem.set("r", str(row_num))
491
+
492
+ # Add custom row height if set
493
+ if row_num in worksheet._row_heights:
494
+ row_elem.set("ht", str(worksheet._row_heights[row_num]))
495
+ row_elem.set("customHeight", "1")
496
+
497
+ for col_num in sorted(rows_data[row_num].keys()):
498
+ cell = rows_data[row_num][col_num]
499
+ if cell.value is not None:
500
+ self._write_cell(row_elem, cell, shared_strings)
501
+
502
+ # Merged cells
503
+ if worksheet._merged_ranges:
504
+ merge_cells = ET.SubElement(root, "mergeCells")
505
+ merge_cells.set("count", str(len(worksheet._merged_ranges)))
506
+ for range_ref in worksheet._merged_ranges:
507
+ merge_cell = ET.SubElement(merge_cells, "mergeCell")
508
+ merge_cell.set("ref", range_ref)
509
+
510
+ # Hyperlinks
511
+ hyperlinks = []
512
+ if worksheet._cells:
513
+ for cell in worksheet._cells.values():
514
+ if cell.has_hyperlink():
515
+ hyperlinks.append(cell)
516
+
517
+ if hyperlinks:
518
+ hyperlinks_elem = ET.SubElement(root, "hyperlinks")
519
+ for idx, cell in enumerate(hyperlinks, 1):
520
+ hyperlink = ET.SubElement(hyperlinks_elem, "hyperlink")
521
+ hyperlink.set("ref", cell.coordinate)
522
+ hyperlink.set("r:id", f"rId{idx}")
523
+
524
+ # Handle images
525
+ drawing_id = None
526
+ if self._worksheet_has_images(worksheet):
527
+ drawing_id = self._write_drawing_for_worksheet(zip_file, worksheet, sheet_id)
528
+ # Add drawing reference to worksheet
529
+ drawing_elem = ET.SubElement(root, "drawing")
530
+ drawing_elem.set("r:id", drawing_id)
531
+
532
+ # Create worksheet relationships (hyperlinks and drawings)
533
+ if hyperlinks or drawing_id:
534
+ self._write_worksheet_rels(zip_file, sheet_id, hyperlinks, drawing_id)
535
+
536
+ self._write_xml_to_zip(zip_file, f"xl/worksheets/sheet{sheet_id}.xml", root)
537
+
538
+ def _write_cell(self, row_elem: ET.Element, cell, shared_strings: Dict[str, int]):
539
+ """Write individual cell element with proper styling."""
540
+ cell_elem = ET.SubElement(row_elem, "c")
541
+ cell_elem.set("r", cell.coordinate)
542
+
543
+ # Apply style
544
+ style_id = self.style_manager.get_cell_format_id(cell)
545
+ if style_id > 0: # Only set if not default style
546
+ cell_elem.set("s", str(style_id))
547
+
548
+ value = cell.value
549
+ if isinstance(value, str) and not cell.is_formula():
550
+ # Use shared strings for non-formula strings
551
+ if value in shared_strings:
552
+ cell_elem.set("t", "s")
553
+ v_elem = ET.SubElement(cell_elem, "v")
554
+ v_elem.text = str(shared_strings[value])
555
+ else:
556
+ cell_elem.set("t", "inlineStr")
557
+ is_elem = ET.SubElement(cell_elem, "is")
558
+ t_elem = ET.SubElement(is_elem, "t")
559
+ t_elem.text = value
560
+ elif isinstance(value, bool):
561
+ cell_elem.set("t", "b")
562
+ v_elem = ET.SubElement(cell_elem, "v")
563
+ v_elem.text = "1" if value else "0"
564
+ elif isinstance(value, (int, float)):
565
+ v_elem = ET.SubElement(cell_elem, "v")
566
+ v_elem.text = str(value)
567
+ elif cell.is_formula():
568
+ # Write formula
569
+ f_elem = ET.SubElement(cell_elem, "f")
570
+ f_elem.text = str(value)[1:] # Remove = prefix
571
+
572
+ # Always write calculated value for formulas
573
+ calc_value = None
574
+ if hasattr(cell, '_calculated_value') and cell._calculated_value is not None:
575
+ calc_value = cell._calculated_value
576
+ elif hasattr(cell, 'calculated_value') and cell.calculated_value is not None:
577
+ calc_value = cell.calculated_value
578
+ else:
579
+ # Provide fallback calculated value to ensure Excel can display something
580
+ calc_value = self._get_fallback_formula_value(str(value))
581
+
582
+ # Write the calculated value
583
+ if calc_value is not None:
584
+ if isinstance(calc_value, bool):
585
+ cell_elem.set("t", "b")
586
+ v_elem = ET.SubElement(cell_elem, "v")
587
+ v_elem.text = "1" if calc_value else "0"
588
+ elif isinstance(calc_value, (int, float)):
589
+ v_elem = ET.SubElement(cell_elem, "v")
590
+ v_elem.text = str(calc_value)
591
+ elif isinstance(calc_value, str):
592
+ # String result from formula
593
+ if calc_value in shared_strings:
594
+ cell_elem.set("t", "s")
595
+ v_elem = ET.SubElement(cell_elem, "v")
596
+ v_elem.text = str(shared_strings[calc_value])
597
+ else:
598
+ cell_elem.set("t", "str") # Formula string result
599
+ v_elem = ET.SubElement(cell_elem, "v")
600
+ v_elem.text = calc_value
601
+ else:
602
+ # Fallback to string representation
603
+ v_elem = ET.SubElement(cell_elem, "v")
604
+ v_elem.text = str(calc_value)
605
+ else:
606
+ # Fallback to string
607
+ v_elem = ET.SubElement(cell_elem, "v")
608
+ v_elem.text = str(value) if value is not None else ""
609
+
610
+ def _get_fallback_formula_value(self, formula: str):
611
+ """Provide basic fallback calculated values for common formulas."""
612
+ # This method should rarely be used since Cell class now handles this better
613
+ # Keep it simple for edge cases
614
+ formula_upper = formula.upper().strip()
615
+
616
+ # Remove = prefix if present
617
+ if formula_upper.startswith('='):
618
+ formula_upper = formula_upper[1:]
619
+
620
+ # Handle simple cases
621
+ if formula_upper.startswith(('SUM', 'COUNT', 'AVERAGE', 'MAX', 'MIN')):
622
+ return 0
623
+ elif formula_upper.startswith(('NOW', 'TODAY')):
624
+ return "2024-01-01"
625
+ elif formula_upper.startswith('TRUE'):
626
+ return True
627
+ elif formula_upper.startswith('FALSE'):
628
+ return False
629
+ elif formula_upper.startswith(('CONCATENATE', 'TEXT')):
630
+ return ""
631
+ elif all(c in '0123456789+-*/.() ' for c in formula_upper):
632
+ # Pure numeric formula - use safe expression evaluation
633
+ try:
634
+ import ast
635
+ node = ast.parse(formula_upper, mode='eval')
636
+ if self._is_safe_expression(node):
637
+ return eval(compile(node, '<string>', 'eval'))
638
+ else:
639
+ return 0
640
+ except (ValueError, SyntaxError, TypeError):
641
+ return 0
642
+ else:
643
+ # Default - let Excel handle it
644
+ return 0
645
+
646
+ def _write_shared_strings(self, zip_file: zipfile.ZipFile, shared_strings: Dict[str, int]):
647
+ """Write xl/sharedStrings.xml."""
648
+ root = ET.Element("sst")
649
+ root.set("xmlns", self.namespaces['main'])
650
+ root.set("count", str(len(shared_strings)))
651
+ root.set("uniqueCount", str(len(shared_strings)))
652
+
653
+ # Sort by index to maintain order
654
+ sorted_strings = sorted(shared_strings.items(), key=lambda x: x[1])
655
+
656
+ for string_value, _ in sorted_strings:
657
+ si = ET.SubElement(root, "si")
658
+ t = ET.SubElement(si, "t")
659
+ t.text = string_value
660
+
661
+ self._write_xml_to_zip(zip_file, "xl/sharedStrings.xml", root)
662
+
663
+ def _write_styles(self, zip_file: zipfile.ZipFile):
664
+ """Write comprehensive xl/styles.xml with all styling information."""
665
+ root = ET.Element("styleSheet")
666
+ root.set("xmlns", self.namespaces['main'])
667
+
668
+ # Number formats (custom formats only)
669
+ if self.style_manager.number_formats:
670
+ num_fmts = ET.SubElement(root, "numFmts")
671
+ num_fmts.set("count", str(len(self.style_manager.number_formats)))
672
+
673
+ for format_id, format_code in self.style_manager.number_formats.items():
674
+ num_fmt = ET.SubElement(num_fmts, "numFmt")
675
+ num_fmt.set("numFmtId", str(format_id))
676
+ num_fmt.set("formatCode", format_code)
677
+
678
+ # Fonts
679
+ fonts = ET.SubElement(root, "fonts")
680
+ fonts.set("count", str(len(self.style_manager.fonts)))
681
+
682
+ for font in self.style_manager.fonts:
683
+ font_elem = ET.SubElement(fonts, "font")
684
+
685
+ # Font size
686
+ sz = ET.SubElement(font_elem, "sz")
687
+ sz.set("val", str(font['size']))
688
+
689
+ # Font color
690
+ if font['color'] != '000000': # Only add if not black (default)
691
+ color = ET.SubElement(font_elem, "color")
692
+ color.set("rgb", f"FF{font['color']}") # Add alpha channel
693
+
694
+ # Font name
695
+ name = ET.SubElement(font_elem, "name")
696
+ name.set("val", font['name'])
697
+
698
+ # Font attributes
699
+ if font['bold']:
700
+ ET.SubElement(font_elem, "b")
701
+ if font['italic']:
702
+ ET.SubElement(font_elem, "i")
703
+
704
+ # Fills
705
+ fills = ET.SubElement(root, "fills")
706
+ fills.set("count", str(len(self.style_manager.fills)))
707
+
708
+ for fill in self.style_manager.fills:
709
+ fill_elem = ET.SubElement(fills, "fill")
710
+ pattern_fill = ET.SubElement(fill_elem, "patternFill")
711
+ pattern_fill.set("patternType", fill['pattern'])
712
+
713
+ if fill['color'] and fill['pattern'] == 'solid':
714
+ fg_color = ET.SubElement(pattern_fill, "fgColor")
715
+ fg_color.set("rgb", f"FF{fill['color']}")
716
+
717
+ # Borders
718
+ borders = ET.SubElement(root, "borders")
719
+ borders.set("count", str(len(self.style_manager.borders)))
720
+
721
+ for border in self.style_manager.borders:
722
+ border_elem = ET.SubElement(borders, "border")
723
+
724
+ # Write each border side with style and color
725
+ for side in ["left", "right", "top", "bottom", "diagonal"]:
726
+ side_elem = ET.SubElement(border_elem, side)
727
+
728
+ side_style = border.get(side, 'none')
729
+ side_color = border.get(f'{side}_color', 'black')
730
+
731
+ if side_style and side_style != 'none':
732
+ side_elem.set("style", side_style)
733
+ if side_color and side_color != 'black':
734
+ color_elem = ET.SubElement(side_elem, "color")
735
+ color_elem.set("rgb", f"FF{self.style_manager._normalize_color(side_color)}")
736
+
737
+ # Cell style formats (cellStyleXfs)
738
+ cell_style_xfs = ET.SubElement(root, "cellStyleXfs")
739
+ cell_style_xfs.set("count", "1")
740
+ xf = ET.SubElement(cell_style_xfs, "xf")
741
+ xf.set("numFmtId", "0")
742
+ xf.set("fontId", "0")
743
+ xf.set("fillId", "0")
744
+ xf.set("borderId", "0")
745
+
746
+ # Cell formats (cellXfs)
747
+ cell_xfs = ET.SubElement(root, "cellXfs")
748
+ cell_xfs.set("count", str(len(self.style_manager.cell_formats)))
749
+
750
+ for cell_format in self.style_manager.cell_formats:
751
+ xf = ET.SubElement(cell_xfs, "xf")
752
+ xf.set("numFmtId", str(cell_format['number_format_id']))
753
+ xf.set("fontId", str(cell_format['font_id']))
754
+ xf.set("fillId", str(cell_format['fill_id']))
755
+ xf.set("borderId", str(cell_format['border_id']))
756
+ xf.set("xfId", "0")
757
+
758
+ # Apply formatting flags
759
+ if cell_format['font_id'] > 0:
760
+ xf.set("applyFont", "1")
761
+ if cell_format['fill_id'] > 0:
762
+ xf.set("applyFill", "1")
763
+ if cell_format['border_id'] > 0:
764
+ xf.set("applyBorder", "1")
765
+ if cell_format['number_format_id'] > 0:
766
+ xf.set("applyNumberFormat", "1")
767
+
768
+ # Cell styles
769
+ cell_styles = ET.SubElement(root, "cellStyles")
770
+ cell_styles.set("count", "1")
771
+ cell_style = ET.SubElement(cell_styles, "cellStyle")
772
+ cell_style.set("name", "Normal")
773
+ cell_style.set("xfId", "0")
774
+ cell_style.set("builtinId", "0")
775
+
776
+ self._write_xml_to_zip(zip_file, "xl/styles.xml", root)
777
+
778
+ def _write_theme(self, zip_file: zipfile.ZipFile):
779
+ """Write xl/theme/theme1.xml with comprehensive theme."""
780
+ zip_file.writestr("xl/theme/theme1.xml", XlsxTemplates.get_theme_xml())
781
+
782
+ def _write_app_properties(self, zip_file: zipfile.ZipFile):
783
+ """Write docProps/app.xml."""
784
+ root = ET.Element("Properties")
785
+ root.set("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties")
786
+ root.set("xmlns:vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes")
787
+
788
+ app_data = XlsxTemplates.get_app_properties_data()
789
+
790
+ app = ET.SubElement(root, "Application")
791
+ app.text = app_data['application']
792
+
793
+ doc_security = ET.SubElement(root, "DocSecurity")
794
+ doc_security.text = app_data['doc_security']
795
+
796
+ lines_of_text = ET.SubElement(root, "LinksUpToDate")
797
+ lines_of_text.text = app_data['links_up_to_date']
798
+
799
+ shared_doc = ET.SubElement(root, "SharedDoc")
800
+ shared_doc.text = app_data['shared_doc']
801
+
802
+ hyperlinks_changed = ET.SubElement(root, "HyperlinksChanged")
803
+ hyperlinks_changed.text = app_data['hyperlinks_changed']
804
+
805
+ app_version = ET.SubElement(root, "AppVersion")
806
+ app_version.text = app_data['app_version']
807
+
808
+ self._write_xml_to_zip(zip_file, "docProps/app.xml", root)
809
+
810
+ def _write_core_properties(self, zip_file: zipfile.ZipFile):
811
+ """Write docProps/core.xml."""
812
+ root = ET.Element("cp:coreProperties")
813
+ root.set("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties")
814
+ root.set("xmlns:dc", "http://purl.org/dc/elements/1.1/")
815
+ root.set("xmlns:dcterms", "http://purl.org/dc/terms/")
816
+ root.set("xmlns:dcmitype", "http://purl.org/dc/dcmitype/")
817
+ root.set("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
818
+
819
+ core_data = XlsxTemplates.get_core_properties_data()
820
+
821
+ creator = ET.SubElement(root, "dc:creator")
822
+ creator.text = core_data['creator']
823
+
824
+ last_modified = ET.SubElement(root, "dcterms:modified")
825
+ last_modified.set("xsi:type", "dcterms:W3CDTF")
826
+ last_modified.text = core_data['modified']
827
+
828
+ created = ET.SubElement(root, "dcterms:created")
829
+ created.set("xsi:type", "dcterms:W3CDTF")
830
+ created.text = core_data['created']
831
+
832
+ self._write_xml_to_zip(zip_file, "docProps/core.xml", root)
833
+
834
+ def _is_safe_expression(self, node) -> bool:
835
+ """Check if AST node contains only safe mathematical operations."""
836
+ import ast
837
+
838
+ allowed_nodes = (
839
+ ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.Num,
840
+ ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.Pow,
841
+ ast.USub, ast.UAdd
842
+ )
843
+
844
+ for child in ast.walk(node):
845
+ if not isinstance(child, allowed_nodes):
846
+ return False
847
+ return True
848
+
849
+ def _write_xml_to_zip(self, zip_file: zipfile.ZipFile, path: str, root: ET.Element):
850
+ """Write XML element to ZIP file with proper formatting."""
851
+ self._indent_xml(root)
852
+ xml_str = ET.tostring(root, encoding='utf-8', xml_declaration=True).decode('utf-8')
853
+ zip_file.writestr(path, xml_str)
854
+
855
+ def _indent_xml(self, elem, level=0):
856
+ """Add proper indentation to XML for readability."""
857
+ i = "\n" + level * " "
858
+ if len(elem):
859
+ if not elem.text or not elem.text.strip():
860
+ elem.text = i + " "
861
+ if not elem.tail or not elem.tail.strip():
862
+ elem.tail = i
863
+ for child in elem:
864
+ self._indent_xml(child, level + 1)
865
+ if not child.tail or not child.tail.strip():
866
+ child.tail = i
867
+ else:
868
+ if level and (not elem.tail or not elem.tail.strip()):
869
+ elem.tail = i
870
+
871
+ def _has_images(self, workbook: 'Workbook') -> bool:
872
+ """Check if workbook contains any images."""
873
+ for worksheet in workbook._worksheets.values():
874
+ if hasattr(worksheet, 'images') and len(worksheet.images) > 0:
875
+ return True
876
+ return False
877
+
878
+ def _write_images(self, zip_file: zipfile.ZipFile):
879
+ """Write all image files to the archive."""
880
+ # Write image files
881
+ for path, data in self.image_writer.get_image_files().items():
882
+ zip_file.writestr(path, data)
883
+
884
+ def _worksheet_has_images(self, worksheet: 'Worksheet') -> bool:
885
+ """Check if a specific worksheet has images."""
886
+ return hasattr(worksheet, 'images') and len(worksheet.images) > 0
887
+
888
+ def _write_drawing_for_worksheet(self, zip_file: zipfile.ZipFile, worksheet: 'Worksheet', sheet_id: int) -> str:
889
+ """Write drawing XML and relationships for worksheet images."""
890
+ if not self._worksheet_has_images(worksheet):
891
+ return None
892
+
893
+ # Create a temporary image writer for this drawing
894
+ drawing_writer = ImageWriter()
895
+
896
+ # Create drawing XML
897
+ images = list(worksheet.images)
898
+ drawing_xml = drawing_writer.create_drawing_xml(images)
899
+
900
+ # Write drawing XML
901
+ drawing_path = f"xl/drawings/drawing{sheet_id}.xml"
902
+ zip_file.writestr(drawing_path, drawing_xml)
903
+
904
+ # Write drawing relationships
905
+ drawing_rels_xml = drawing_writer.create_drawing_rels_xml()
906
+ if drawing_rels_xml:
907
+ drawing_rels_path = f"xl/drawings/_rels/drawing{sheet_id}.xml.rels"
908
+ zip_file.writestr(drawing_rels_path, drawing_rels_xml)
909
+
910
+ # Copy image files to main image writer for later writing
911
+ for path, data in drawing_writer.get_image_files().items():
912
+ self.image_writer.image_files[path] = data
913
+
914
+ # Copy content types entries to main image writer
915
+ for path, data in drawing_writer.get_image_files().items():
916
+ # Extract extension for content type tracking
917
+ filename = Path(path).name
918
+ ext = filename.split('.')[-1].lower()
919
+ if ext == 'jpg':
920
+ ext = 'jpeg'
921
+ # Store in main image writer for content type generation
922
+ self.image_writer.image_files[path] = data
923
+
924
+ # Return the drawing relationship ID (should be after hyperlinks)
925
+ # We need to account for any existing relationships
926
+ rel_id = 1
927
+ if hasattr(worksheet, '_cells'):
928
+ for cell in worksheet._cells.values():
929
+ if cell.has_hyperlink():
930
+ rel_id += 1
931
+ return f"rId{rel_id}"