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.url import Url
19from .exceptions import UndefinedImageSize, UnsupportedImageFormat
21DEFAULT_DPI = 96.0
24class Image:
25 """
26 A class to represent an image in an Excel worksheet.
28 """
30 def __init__(self, source: Union[str, Path, BytesIO]) -> None:
31 """
32 Initialize an Image instance.
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.")
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
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
70 self._get_image_properties()
72 def __repr__(self) -> str:
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 )
88 @property
89 def image_type(self) -> str:
90 """Get the image type (e.g., 'PNG', 'JPEG')."""
91 return self._image_extension.upper()
93 @property
94 def width(self) -> float:
95 """Get the width of the image."""
96 return self._width
98 @property
99 def height(self) -> float:
100 """Get the height of the image."""
101 return self._height
103 @property
104 def x_dpi(self) -> float:
105 """Get the horizontal DPI of the image."""
106 return self._x_dpi
108 @property
109 def y_dpi(self) -> float:
110 """Get the vertical DPI of the image."""
111 return self._y_dpi
113 @property
114 def description(self) -> Union[str, None]:
115 """Get the description/alt-text of the image."""
116 return self._description
118 @description.setter
119 def description(self, value: str) -> None:
120 """Set the description/alt-text of the image."""
121 if value:
122 self._description = value
124 @property
125 def decorative(self) -> bool:
126 """Get whether the image is decorative."""
127 return self._decorative
129 @decorative.setter
130 def decorative(self, value: bool) -> None:
131 """Set whether the image is decorative."""
132 self._decorative = value
134 @property
135 def url(self) -> Union[Url, None]:
136 """Get the image url."""
137 return self._url
139 @url.setter
140 def url(self, value: Url) -> None:
141 """Set the image url."""
142 if value:
143 self._url = value
145 def _set_user_options(self, options=None) -> None:
146 """
147 This handles the additional optional parameters to ``insert_button()``.
148 """
149 if options is None:
150 return
152 if not self._url:
153 self._url = Url.from_options(options)
154 if self._url:
155 self._url._set_object_link()
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)
166 # For backward compatibility with older parameter name.
167 self._anchor = options.get("positioning", self._anchor)
169 def _get_image_properties(self) -> None:
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
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()
184 # Get the image digest to check for duplicates.
185 digest = hashlib.sha256(data).hexdigest()
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]
195 if png_marker == b"PNG":
196 (image_type, width, height, x_dpi, y_dpi) = self._process_png(data)
198 elif jpg_marker == 0xFFD8:
199 (image_type, width, height, x_dpi, y_dpi) = self._process_jpg(data)
201 elif bmp_marker == b"BM":
202 (image_type, width, height) = self._process_bmp(data)
204 elif emf_marker1 == 0x9AC6CDD7:
205 (image_type, width, height, x_dpi, y_dpi) = self._process_wmf(data)
207 elif emf_marker1 == 1 and emf_marker == b" EMF":
208 (image_type, width, height, x_dpi, y_dpi) = self._process_emf(data)
210 elif gif_marker == b"GIF8":
211 (image_type, width, height, x_dpi, y_dpi) = self._process_gif(data)
213 else:
214 raise UnsupportedImageFormat(
215 f"{self.filename}: Unknown or unsupported image file format."
216 )
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 )
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
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
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
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]
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]
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]
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
271 if marker == b"IEND":
272 end_marker = True
273 continue
275 offset = offset + length + 12
277 return "png", width, height, x_dpi, y_dpi
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
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]
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]
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]
311 if units == 1:
312 x_dpi = x_density
313 y_dpi = y_density
315 if units == 2:
316 x_dpi = x_density * 2.54
317 y_dpi = y_density * 2.54
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
325 if marker == 0xFFDA:
326 end_marker = True
327 continue
329 offset = offset + length + 2
331 return "jpeg", width, height, x_dpi, y_dpi
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
338 width = unpack("<h", data[6:8])[0]
339 height = unpack("<h", data[8:10])[0]
341 return "gif", width, height, x_dpi, y_dpi
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
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
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]
360 # Read the number of logical units per inch. Used to scale the image.
361 inch = unpack("<H", data[14:16])[0]
363 # Convert to rendered height and width.
364 width = float((x2 - x1) * x_dpi) / inch
365 height = float((y2 - y1) * y_dpi) / inch
367 return "wmf", width, height, x_dpi, y_dpi
369 def _process_emf(self, data: bytes) -> Tuple[str, float, float, float, float]:
370 # Extract width and height information from a EMF file.
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]
378 # Convert the bounds to width and height.
379 width = bound_x2 - bound_x1
380 height = bound_y2 - bound_y1
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]
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)
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
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
401 return "emf", width, height, x_dpi, y_dpi