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.
- aspose/__init__.py +14 -0
- aspose/cells/__init__.py +31 -0
- aspose/cells/cell.py +350 -0
- aspose/cells/constants.py +44 -0
- aspose/cells/converters/__init__.py +13 -0
- aspose/cells/converters/csv_converter.py +55 -0
- aspose/cells/converters/json_converter.py +46 -0
- aspose/cells/converters/markdown_converter.py +453 -0
- aspose/cells/drawing/__init__.py +17 -0
- aspose/cells/drawing/anchor.py +172 -0
- aspose/cells/drawing/collection.py +233 -0
- aspose/cells/drawing/image.py +338 -0
- aspose/cells/formats.py +80 -0
- aspose/cells/formula/__init__.py +10 -0
- aspose/cells/formula/evaluator.py +360 -0
- aspose/cells/formula/functions.py +433 -0
- aspose/cells/formula/tokenizer.py +340 -0
- aspose/cells/io/__init__.py +27 -0
- aspose/cells/io/csv/__init__.py +8 -0
- aspose/cells/io/csv/reader.py +88 -0
- aspose/cells/io/csv/writer.py +98 -0
- aspose/cells/io/factory.py +138 -0
- aspose/cells/io/interfaces.py +48 -0
- aspose/cells/io/json/__init__.py +8 -0
- aspose/cells/io/json/reader.py +126 -0
- aspose/cells/io/json/writer.py +119 -0
- aspose/cells/io/md/__init__.py +8 -0
- aspose/cells/io/md/reader.py +161 -0
- aspose/cells/io/md/writer.py +334 -0
- aspose/cells/io/models.py +64 -0
- aspose/cells/io/xlsx/__init__.py +9 -0
- aspose/cells/io/xlsx/constants.py +312 -0
- aspose/cells/io/xlsx/image_writer.py +311 -0
- aspose/cells/io/xlsx/reader.py +284 -0
- aspose/cells/io/xlsx/writer.py +931 -0
- aspose/cells/plugins/__init__.py +6 -0
- aspose/cells/plugins/docling_backend/__init__.py +7 -0
- aspose/cells/plugins/docling_backend/backend.py +535 -0
- aspose/cells/plugins/markitdown_plugin/__init__.py +15 -0
- aspose/cells/plugins/markitdown_plugin/plugin.py +128 -0
- aspose/cells/range.py +210 -0
- aspose/cells/style.py +287 -0
- aspose/cells/utils/__init__.py +54 -0
- aspose/cells/utils/coordinates.py +68 -0
- aspose/cells/utils/exceptions.py +43 -0
- aspose/cells/utils/validation.py +102 -0
- aspose/cells/workbook.py +352 -0
- aspose/cells/worksheet.py +670 -0
- aspose_cells_foss-25.12.1.dist-info/METADATA +189 -0
- aspose_cells_foss-25.12.1.dist-info/RECORD +53 -0
- aspose_cells_foss-25.12.1.dist-info/WHEEL +5 -0
- aspose_cells_foss-25.12.1.dist-info/entry_points.txt +2 -0
- 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}"
|