1###############################################################################
2#
3# Image - A class for representing image objects in Excel.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
9
10import hashlib
11import os
12from io import BytesIO
13from pathlib import Path
14from struct import unpack
15from typing import Tuple, Union
16
17from xlsxwriter.url import Url
18
19from .exceptions import UndefinedImageSize, UnsupportedImageFormat
20
21DEFAULT_DPI = 96.0
22
23
24class Image:
25 """
26 A class to represent an image in an Excel worksheet.
27
28 """
29
30 def __init__(self, source: Union[str, Path, BytesIO]):
31 """
32 Initialize an Image instance.
33
34 Args:
35 source (Union[str, Path, BytesIO]): The filename, Path or BytesIO
36 object of the image.
37 """
38 if isinstance(source, (str, Path)):
39 self.filename = source
40 self.image_data = None
41 self.image_name = os.path.basename(source)
42 elif isinstance(source, BytesIO):
43 self.filename = ""
44 self.image_data = source
45 self.image_name = ""
46 else:
47 raise ValueError("Source must be a filename (str) or a BytesIO object.")
48
49 self._row: int = 0
50 self._col: int = 0
51 self._x_offset: int = 0
52 self._y_offset: int = 0
53 self._x_scale: float = 1.0
54 self._y_scale: float = 1.0
55 self._url: Union[Url, None] = None
56 self._anchor: int = 2
57 self._description: Union[str, None] = None
58 self._decorative: bool = False
59 self._header_position: Union[str, None] = None
60 self._ref_id: Union[str, None] = None
61
62 # Derived properties.
63 self._image_extension: str = ""
64 self._width: float = 0.0
65 self._height: float = 0.0
66 self._x_dpi: float = DEFAULT_DPI
67 self._y_dpi: float = DEFAULT_DPI
68 self._digest: Union[str, None] = None
69
70 self._get_image_properties()
71
72 def __repr__(self):
73 """
74 Return a string representation of the main properties of the Image
75 instance.
76 """
77 return (
78 f"Image:\n"
79 f" filename = {self.filename!r}\n"
80 f" image_name = {self.image_name!r}\n"
81 f" image_type = {self.image_type!r}\n"
82 f" width = {self._width}\n"
83 f" height = {self._height}\n"
84 f" x_dpi = {self._x_dpi}\n"
85 f" y_dpi = {self._y_dpi}\n"
86 )
87
88 @property
89 def image_type(self) -> str:
90 """Get the image type (e.g., 'PNG', 'JPEG')."""
91 return self._image_extension.upper()
92
93 @property
94 def width(self) -> float:
95 """Get the width of the image."""
96 return self._width
97
98 @property
99 def height(self) -> float:
100 """Get the height of the image."""
101 return self._height
102
103 @property
104 def x_dpi(self) -> float:
105 """Get the horizontal DPI of the image."""
106 return self._x_dpi
107
108 @property
109 def y_dpi(self) -> float:
110 """Get the vertical DPI of the image."""
111 return self._y_dpi
112
113 @property
114 def description(self) -> Union[str, None]:
115 """Get the description/alt-text of the image."""
116 return self._description
117
118 @description.setter
119 def description(self, value: str):
120 """Set the description/alt-text of the image."""
121 if value:
122 self._description = value
123
124 @property
125 def decorative(self) -> bool:
126 """Get whether the image is decorative."""
127 return self._decorative
128
129 @decorative.setter
130 def decorative(self, value: bool):
131 """Set whether the image is decorative."""
132 self._decorative = value
133
134 @property
135 def url(self) -> Union[Url, None]:
136 """Get the image url."""
137 return self._url
138
139 @url.setter
140 def url(self, value: Url):
141 """Set the image url."""
142 if value:
143 self._url = value
144
145 def _set_user_options(self, options=None):
146 """
147 This handles the additional optional parameters to ``insert_button()``.
148 """
149 if options is None:
150 return
151
152 if not self._url:
153 self._url = Url.from_options(options)
154 if self._url:
155 self._url._set_object_link()
156
157 self._anchor = options.get("object_position", self._anchor)
158 self._x_scale = options.get("x_scale", self._x_scale)
159 self._y_scale = options.get("y_scale", self._y_scale)
160 self._x_offset = options.get("x_offset", self._x_offset)
161 self._y_offset = options.get("y_offset", self._y_offset)
162 self._decorative = options.get("decorative", self._decorative)
163 self.image_data = options.get("image_data", self.image_data)
164 self._description = options.get("description", self._description)
165
166 # For backward compatibility with older parameter name.
167 self._anchor = options.get("positioning", self._anchor)
168
169 def _get_image_properties(self):
170 # Extract dimension information from the image file.
171 height = 0.0
172 width = 0.0
173 x_dpi = DEFAULT_DPI
174 y_dpi = DEFAULT_DPI
175
176 if self.image_data:
177 # Read the image data from the user supplied byte stream.
178 data = self.image_data.getvalue()
179 else:
180 # Open the image file and read in the data.
181 with open(self.filename, "rb") as fh:
182 data = fh.read()
183
184 # Get the image digest to check for duplicates.
185 digest = hashlib.sha256(data).hexdigest()
186
187 # Look for some common image file markers.
188 png_marker = unpack("3s", data[1:4])[0]
189 jpg_marker = unpack(">H", data[:2])[0]
190 bmp_marker = unpack("2s", data[:2])[0]
191 gif_marker = unpack("4s", data[:4])[0]
192 emf_marker = (unpack("4s", data[40:44]))[0]
193 emf_marker1 = unpack("<L", data[:4])[0]
194
195 if png_marker == b"PNG":
196 (image_type, width, height, x_dpi, y_dpi) = self._process_png(data)
197
198 elif jpg_marker == 0xFFD8:
199 (image_type, width, height, x_dpi, y_dpi) = self._process_jpg(data)
200
201 elif bmp_marker == b"BM":
202 (image_type, width, height) = self._process_bmp(data)
203
204 elif emf_marker1 == 0x9AC6CDD7:
205 (image_type, width, height, x_dpi, y_dpi) = self._process_wmf(data)
206
207 elif emf_marker1 == 1 and emf_marker == b" EMF":
208 (image_type, width, height, x_dpi, y_dpi) = self._process_emf(data)
209
210 elif gif_marker == b"GIF8":
211 (image_type, width, height, x_dpi, y_dpi) = self._process_gif(data)
212
213 else:
214 raise UnsupportedImageFormat(
215 f"{self.filename}: Unknown or unsupported image file format."
216 )
217
218 # Check that we found the required data.
219 if not height or not width:
220 raise UndefinedImageSize(
221 f"{self.filename}: no size data found in image file."
222 )
223
224 # Set a default dpi for images with 0 dpi.
225 if x_dpi == 0:
226 x_dpi = DEFAULT_DPI
227 if y_dpi == 0:
228 y_dpi = DEFAULT_DPI
229
230 self._image_extension = image_type
231 self._width = width
232 self._height = height
233 self._x_dpi = x_dpi
234 self._y_dpi = y_dpi
235 self._digest = digest
236
237 def _process_png(
238 self,
239 data: bytes,
240 ) -> Tuple[str, float, float, float, float]:
241 # Extract width and height information from a PNG file.
242 offset = 8
243 data_length = len(data)
244 end_marker = False
245 width = 0.0
246 height = 0.0
247 x_dpi = DEFAULT_DPI
248 y_dpi = DEFAULT_DPI
249
250 # Search through the image data to read the height and width in the
251 # IHDR element. Also read the DPI in the pHYs element.
252 while not end_marker and offset < data_length:
253 length = unpack(">I", data[offset + 0 : offset + 4])[0]
254 marker = unpack("4s", data[offset + 4 : offset + 8])[0]
255
256 # Read the image dimensions.
257 if marker == b"IHDR":
258 width = unpack(">I", data[offset + 8 : offset + 12])[0]
259 height = unpack(">I", data[offset + 12 : offset + 16])[0]
260
261 # Read the image DPI.
262 if marker == b"pHYs":
263 x_density = unpack(">I", data[offset + 8 : offset + 12])[0]
264 y_density = unpack(">I", data[offset + 12 : offset + 16])[0]
265 units = unpack("b", data[offset + 16 : offset + 17])[0]
266
267 if units == 1 and x_density > 0 and y_density > 0:
268 x_dpi = x_density * 0.0254
269 y_dpi = y_density * 0.0254
270
271 if marker == b"IEND":
272 end_marker = True
273 continue
274
275 offset = offset + length + 12
276
277 return "png", width, height, x_dpi, y_dpi
278
279 def _process_jpg(self, data: bytes) -> Tuple[str, float, float, float, float]:
280 # Extract width and height information from a JPEG file.
281 offset = 2
282 data_length = len(data)
283 end_marker = False
284 width = 0.0
285 height = 0.0
286 x_dpi = DEFAULT_DPI
287 y_dpi = DEFAULT_DPI
288
289 # Search through the image data to read the JPEG markers.
290 while not end_marker and offset < data_length:
291 marker = unpack(">H", data[offset + 0 : offset + 2])[0]
292 length = unpack(">H", data[offset + 2 : offset + 4])[0]
293
294 # Read the height and width in the 0xFFCn elements (except C4, C8
295 # and CC which aren't SOF markers).
296 if (
297 (marker & 0xFFF0) == 0xFFC0
298 and marker != 0xFFC4
299 and marker != 0xFFC8
300 and marker != 0xFFCC
301 ):
302 height = unpack(">H", data[offset + 5 : offset + 7])[0]
303 width = unpack(">H", data[offset + 7 : offset + 9])[0]
304
305 # Read the DPI in the 0xFFE0 element.
306 if marker == 0xFFE0:
307 units = unpack("b", data[offset + 11 : offset + 12])[0]
308 x_density = unpack(">H", data[offset + 12 : offset + 14])[0]
309 y_density = unpack(">H", data[offset + 14 : offset + 16])[0]
310
311 if units == 1:
312 x_dpi = x_density
313 y_dpi = y_density
314
315 if units == 2:
316 x_dpi = x_density * 2.54
317 y_dpi = y_density * 2.54
318
319 # Workaround for incorrect dpi.
320 if x_dpi == 1:
321 x_dpi = DEFAULT_DPI
322 if y_dpi == 1:
323 y_dpi = DEFAULT_DPI
324
325 if marker == 0xFFDA:
326 end_marker = True
327 continue
328
329 offset = offset + length + 2
330
331 return "jpeg", width, height, x_dpi, y_dpi
332
333 def _process_gif(self, data: bytes) -> Tuple[str, float, float, float, float]:
334 # Extract width and height information from a GIF file.
335 x_dpi = DEFAULT_DPI
336 y_dpi = DEFAULT_DPI
337
338 width = unpack("<h", data[6:8])[0]
339 height = unpack("<h", data[8:10])[0]
340
341 return "gif", width, height, x_dpi, y_dpi
342
343 def _process_bmp(self, data: bytes) -> Tuple[str, float, float]:
344 # Extract width and height information from a BMP file.
345 width = unpack("<L", data[18:22])[0]
346 height = unpack("<L", data[22:26])[0]
347 return "bmp", width, height
348
349 def _process_wmf(self, data: bytes) -> Tuple[str, float, float, float, float]:
350 # Extract width and height information from a WMF file.
351 x_dpi = DEFAULT_DPI
352 y_dpi = DEFAULT_DPI
353
354 # Read the bounding box, measured in logical units.
355 x1 = unpack("<h", data[6:8])[0]
356 y1 = unpack("<h", data[8:10])[0]
357 x2 = unpack("<h", data[10:12])[0]
358 y2 = unpack("<h", data[12:14])[0]
359
360 # Read the number of logical units per inch. Used to scale the image.
361 inch = unpack("<H", data[14:16])[0]
362
363 # Convert to rendered height and width.
364 width = float((x2 - x1) * x_dpi) / inch
365 height = float((y2 - y1) * y_dpi) / inch
366
367 return "wmf", width, height, x_dpi, y_dpi
368
369 def _process_emf(self, data: bytes) -> Tuple[str, float, float, float, float]:
370 # Extract width and height information from a EMF file.
371
372 # Read the bounding box, measured in logical units.
373 bound_x1 = unpack("<l", data[8:12])[0]
374 bound_y1 = unpack("<l", data[12:16])[0]
375 bound_x2 = unpack("<l", data[16:20])[0]
376 bound_y2 = unpack("<l", data[20:24])[0]
377
378 # Convert the bounds to width and height.
379 width = bound_x2 - bound_x1
380 height = bound_y2 - bound_y1
381
382 # Read the rectangular frame in units of 0.01mm.
383 frame_x1 = unpack("<l", data[24:28])[0]
384 frame_y1 = unpack("<l", data[28:32])[0]
385 frame_x2 = unpack("<l", data[32:36])[0]
386 frame_y2 = unpack("<l", data[36:40])[0]
387
388 # Convert the frame bounds to mm width and height.
389 width_mm = 0.01 * (frame_x2 - frame_x1)
390 height_mm = 0.01 * (frame_y2 - frame_y1)
391
392 # Get the dpi based on the logical size.
393 x_dpi = width * 25.4 / width_mm
394 y_dpi = height * 25.4 / height_mm
395
396 # This is to match Excel's calculation. It is probably to account for
397 # the fact that the bounding box is inclusive-inclusive. Or a bug.
398 width += 1
399 height += 1
400
401 return "emf", width, height, x_dpi, y_dpi