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