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 # Look for some common image file markers.
187 png_marker = unpack("3s", data[1:4])[0]
188 jpg_marker = unpack(">H", data[:2])[0]
189 bmp_marker = unpack("2s", data[:2])[0]
190 gif_marker = unpack("4s", data[:4])[0]
191 emf_marker = (unpack("4s", data[40:44]))[0]
192 emf_marker1 = unpack("<L", data[:4])[0]
194 if png_marker == b"PNG":
195 (image_type, width, height, x_dpi, y_dpi) = self._process_png(data)
197 elif jpg_marker == 0xFFD8:
198 (image_type, width, height, x_dpi, y_dpi) = self._process_jpg(data)
200 elif bmp_marker == b"BM":
201 (image_type, width, height) = self._process_bmp(data)
203 elif emf_marker1 == 0x9AC6CDD7:
204 (image_type, width, height, x_dpi, y_dpi) = self._process_wmf(data)
206 elif emf_marker1 == 1 and emf_marker == b" EMF":
207 (image_type, width, height, x_dpi, y_dpi) = self._process_emf(data)
209 elif gif_marker == b"GIF8":
210 (image_type, width, height, x_dpi, y_dpi) = self._process_gif(data)
212 else:
213 raise UnsupportedImageFormat(
214 f"{self.filename}: Unknown or unsupported image file format."
215 )
217 # Check that we found the required data.
218 if not height or not width:
219 raise UndefinedImageSize(
220 f"{self.filename}: no size data found in image file."
221 )
223 # Set a default dpi for images with 0 dpi.
224 if x_dpi == 0:
225 x_dpi = DEFAULT_DPI
226 if y_dpi == 0:
227 y_dpi = DEFAULT_DPI
229 self._image_extension = image_type
230 self._width = width
231 self._height = height
232 self._x_dpi = x_dpi
233 self._y_dpi = y_dpi
234 self._digest = digest
236 def _process_png(
237 self,
238 data: bytes,
239 ) -> Tuple[str, float, float, float, float]:
240 # Extract width and height information from a PNG file.
241 offset = 8
242 data_length = len(data)
243 end_marker = False
244 width = 0.0
245 height = 0.0
246 x_dpi = DEFAULT_DPI
247 y_dpi = DEFAULT_DPI
249 # Search through the image data to read the height and width in the
250 # IHDR element. Also read the DPI in the pHYs element.
251 while not end_marker and offset < data_length:
252 length = unpack(">I", data[offset + 0 : offset + 4])[0]
253 marker = unpack("4s", data[offset + 4 : offset + 8])[0]
255 # Read the image dimensions.
256 if marker == b"IHDR":
257 width = unpack(">I", data[offset + 8 : offset + 12])[0]
258 height = unpack(">I", data[offset + 12 : offset + 16])[0]
260 # Read the image DPI.
261 if marker == b"pHYs":
262 x_density = unpack(">I", data[offset + 8 : offset + 12])[0]
263 y_density = unpack(">I", data[offset + 12 : offset + 16])[0]
264 units = unpack("b", data[offset + 16 : offset + 17])[0]
266 if units == 1 and x_density > 0 and y_density > 0:
267 x_dpi = x_density * 0.0254
268 y_dpi = y_density * 0.0254
270 if marker == b"IEND":
271 end_marker = True
272 continue
274 offset = offset + length + 12
276 return "png", width, height, x_dpi, y_dpi
278 def _process_jpg(self, data: bytes) -> Tuple[str, float, float, float, float]:
279 # Extract width and height information from a JPEG file.
280 offset = 2
281 data_length = len(data)
282 end_marker = False
283 width = 0.0
284 height = 0.0
285 x_dpi = DEFAULT_DPI
286 y_dpi = DEFAULT_DPI
288 # Search through the image data to read the JPEG markers.
289 while not end_marker and offset < data_length:
290 marker = unpack(">H", data[offset + 0 : offset + 2])[0]
291 length = unpack(">H", data[offset + 2 : offset + 4])[0]
293 # Read the height and width in the 0xFFCn elements (except C4, C8
294 # and CC which aren't SOF markers).
295 if (
296 (marker & 0xFFF0) == 0xFFC0
297 and marker != 0xFFC4
298 and marker != 0xFFC8
299 and marker != 0xFFCC
300 ):
301 height = unpack(">H", data[offset + 5 : offset + 7])[0]
302 width = unpack(">H", data[offset + 7 : offset + 9])[0]
304 # Read the DPI in the 0xFFE0 element.
305 if marker == 0xFFE0:
306 units = unpack("b", data[offset + 11 : offset + 12])[0]
307 x_density = unpack(">H", data[offset + 12 : offset + 14])[0]
308 y_density = unpack(">H", data[offset + 14 : offset + 16])[0]
310 if units == 1:
311 x_dpi = x_density
312 y_dpi = y_density
314 if units == 2:
315 x_dpi = x_density * 2.54
316 y_dpi = y_density * 2.54
318 # Workaround for incorrect dpi.
319 if x_dpi == 1:
320 x_dpi = DEFAULT_DPI
321 if y_dpi == 1:
322 y_dpi = DEFAULT_DPI
324 if marker == 0xFFDA:
325 end_marker = True
326 continue
328 offset = offset + length + 2
330 return "jpeg", width, height, x_dpi, y_dpi
332 def _process_gif(self, data: bytes) -> Tuple[str, float, float, float, float]:
333 # Extract width and height information from a GIF file.
334 x_dpi = DEFAULT_DPI
335 y_dpi = DEFAULT_DPI
337 width = unpack("<h", data[6:8])[0]
338 height = unpack("<h", data[8:10])[0]
340 return "gif", width, height, x_dpi, y_dpi
342 def _process_bmp(self, data: bytes) -> Tuple[str, float, float]:
343 # Extract width and height information from a BMP file.
344 width = unpack("<L", data[18:22])[0]
345 height = unpack("<L", data[22:26])[0]
346 return "bmp", width, height
348 def _process_wmf(self, data: bytes) -> Tuple[str, float, float, float, float]:
349 # Extract width and height information from a WMF file.
350 x_dpi = DEFAULT_DPI
351 y_dpi = DEFAULT_DPI
353 # Read the bounding box, measured in logical units.
354 x1 = unpack("<h", data[6:8])[0]
355 y1 = unpack("<h", data[8:10])[0]
356 x2 = unpack("<h", data[10:12])[0]
357 y2 = unpack("<h", data[12:14])[0]
359 # Read the number of logical units per inch. Used to scale the image.
360 inch = unpack("<H", data[14:16])[0]
362 # Convert to rendered height and width.
363 width = float((x2 - x1) * x_dpi) / inch
364 height = float((y2 - y1) * y_dpi) / inch
366 return "wmf", width, height, x_dpi, y_dpi
368 def _process_emf(self, data: bytes) -> Tuple[str, float, float, float, float]:
369 # Extract width and height information from a EMF file.
371 # Read the bounding box, measured in logical units.
372 bound_x1 = unpack("<l", data[8:12])[0]
373 bound_y1 = unpack("<l", data[12:16])[0]
374 bound_x2 = unpack("<l", data[16:20])[0]
375 bound_y2 = unpack("<l", data[20:24])[0]
377 # Convert the bounds to width and height.
378 width = bound_x2 - bound_x1
379 height = bound_y2 - bound_y1
381 # Read the rectangular frame in units of 0.01mm.
382 frame_x1 = unpack("<l", data[24:28])[0]
383 frame_y1 = unpack("<l", data[28:32])[0]
384 frame_x2 = unpack("<l", data[32:36])[0]
385 frame_y2 = unpack("<l", data[36:40])[0]
387 # Convert the frame bounds to mm width and height.
388 width_mm = 0.01 * (frame_x2 - frame_x1)
389 height_mm = 0.01 * (frame_y2 - frame_y1)
391 # Get the dpi based on the logical size.
392 x_dpi = width * 25.4 / width_mm
393 y_dpi = height * 25.4 / height_mm
395 # This is to match Excel's calculation. It is probably to account for
396 # the fact that the bounding box is inclusive-inclusive. Or a bug.
397 width += 1
398 height += 1
400 return "emf", width, height, x_dpi, y_dpi