############################################################################### # # Image - A class for representing image objects in Excel. # # SPDX-License-Identifier: BSD-2-Clause # # Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org # import hashlib import os from io import BytesIO from pathlib import Path from struct import unpack from typing import Tuple, Union from xlsxwriter.url import Url from .exceptions import UndefinedImageSize, UnsupportedImageFormat DEFAULT_DPI = 96.0 class Image: """ A class to represent an image in an Excel worksheet. """ def __init__(self, source: Union[str, Path, BytesIO]) -> None: """ Initialize an Image instance. Args: source (Union[str, Path, BytesIO]): The filename, Path or BytesIO object of the image. """ if isinstance(source, (str, Path)): self.filename = source self.image_data = None self.image_name = os.path.basename(source) elif isinstance(source, BytesIO): self.filename = "" self.image_data = source self.image_name = "" else: raise ValueError("Source must be a filename (str) or a BytesIO object.") self._row: int = 0 self._col: int = 0 self._x_offset: int = 0 self._y_offset: int = 0 self._x_scale: float = 1.0 self._y_scale: float = 1.0 self._url: Union[Url, None] = None self._anchor: int = 2 self._description: Union[str, None] = None self._decorative: bool = False self._header_position: Union[str, None] = None self._ref_id: Union[str, None] = None # Derived properties. self._image_extension: str = "" self._width: float = 0.0 self._height: float = 0.0 self._x_dpi: float = DEFAULT_DPI self._y_dpi: float = DEFAULT_DPI self._digest: Union[str, None] = None self._get_image_properties() def __repr__(self) -> str: """ Return a string representation of the main properties of the Image instance. """ return ( f"Image:\n" f" filename = {self.filename!r}\n" f" image_name = {self.image_name!r}\n" f" image_type = {self.image_type!r}\n" f" width = {self._width}\n" f" height = {self._height}\n" f" x_dpi = {self._x_dpi}\n" f" y_dpi = {self._y_dpi}\n" ) @property def image_type(self) -> str: """Get the image type (e.g., 'PNG', 'JPEG').""" return self._image_extension.upper() @property def width(self) -> float: """Get the width of the image.""" return self._width @property def height(self) -> float: """Get the height of the image.""" return self._height @property def x_dpi(self) -> float: """Get the horizontal DPI of the image.""" return self._x_dpi @property def y_dpi(self) -> float: """Get the vertical DPI of the image.""" return self._y_dpi @property def description(self) -> Union[str, None]: """Get the description/alt-text of the image.""" return self._description @description.setter def description(self, value: str) -> None: """Set the description/alt-text of the image.""" if value: self._description = value @property def decorative(self) -> bool: """Get whether the image is decorative.""" return self._decorative @decorative.setter def decorative(self, value: bool) -> None: """Set whether the image is decorative.""" self._decorative = value @property def url(self) -> Union[Url, None]: """Get the image url.""" return self._url @url.setter def url(self, value: Url) -> None: """Set the image url.""" if value: self._url = value def _set_user_options(self, options=None) -> None: """ This handles the additional optional parameters to ``insert_button()``. """ if options is None: return if not self._url: self._url = Url.from_options(options) if self._url: self._url._set_object_link() self._anchor = options.get("object_position", self._anchor) self._x_scale = options.get("x_scale", self._x_scale) self._y_scale = options.get("y_scale", self._y_scale) self._x_offset = options.get("x_offset", self._x_offset) self._y_offset = options.get("y_offset", self._y_offset) self._decorative = options.get("decorative", self._decorative) self.image_data = options.get("image_data", self.image_data) self._description = options.get("description", self._description) # For backward compatibility with older parameter name. self._anchor = options.get("positioning", self._anchor) def _get_image_properties(self) -> None: # Extract dimension information from the image file. height = 0.0 width = 0.0 x_dpi = DEFAULT_DPI y_dpi = DEFAULT_DPI if self.image_data: # Read the image data from the user supplied byte stream. data = self.image_data.getvalue() else: # Open the image file and read in the data. with open(self.filename, "rb") as fh: data = fh.read() # Get the image digest to check for duplicates. digest = hashlib.sha256(data).hexdigest() # Look for some common image file markers. png_marker = unpack("3s", data[1:4])[0] jpg_marker = unpack(">H", data[:2])[0] bmp_marker = unpack("2s", data[:2])[0] gif_marker = unpack("4s", data[:4])[0] emf_marker = (unpack("4s", data[40:44]))[0] emf_marker1 = unpack(" Tuple[str, float, float, float, float]: # Extract width and height information from a PNG file. offset = 8 data_length = len(data) end_marker = False width = 0.0 height = 0.0 x_dpi = DEFAULT_DPI y_dpi = DEFAULT_DPI # Search through the image data to read the height and width in the # IHDR element. Also read the DPI in the pHYs element. while not end_marker and offset < data_length: length = unpack(">I", data[offset + 0 : offset + 4])[0] marker = unpack("4s", data[offset + 4 : offset + 8])[0] # Read the image dimensions. if marker == b"IHDR": width = unpack(">I", data[offset + 8 : offset + 12])[0] height = unpack(">I", data[offset + 12 : offset + 16])[0] # Read the image DPI. if marker == b"pHYs": x_density = unpack(">I", data[offset + 8 : offset + 12])[0] y_density = unpack(">I", data[offset + 12 : offset + 16])[0] units = unpack("b", data[offset + 16 : offset + 17])[0] if units == 1 and x_density > 0 and y_density > 0: x_dpi = x_density * 0.0254 y_dpi = y_density * 0.0254 if marker == b"IEND": end_marker = True continue offset = offset + length + 12 return "png", width, height, x_dpi, y_dpi def _process_jpg(self, data: bytes) -> Tuple[str, float, float, float, float]: # Extract width and height information from a JPEG file. offset = 2 data_length = len(data) end_marker = False width = 0.0 height = 0.0 x_dpi = DEFAULT_DPI y_dpi = DEFAULT_DPI # Search through the image data to read the JPEG markers. while not end_marker and offset < data_length: marker = unpack(">H", data[offset + 0 : offset + 2])[0] length = unpack(">H", data[offset + 2 : offset + 4])[0] # Read the height and width in the 0xFFCn elements (except C4, C8 # and CC which aren't SOF markers). if ( (marker & 0xFFF0) == 0xFFC0 and marker != 0xFFC4 and marker != 0xFFC8 and marker != 0xFFCC ): height = unpack(">H", data[offset + 5 : offset + 7])[0] width = unpack(">H", data[offset + 7 : offset + 9])[0] # Read the DPI in the 0xFFE0 element. if marker == 0xFFE0: units = unpack("b", data[offset + 11 : offset + 12])[0] x_density = unpack(">H", data[offset + 12 : offset + 14])[0] y_density = unpack(">H", data[offset + 14 : offset + 16])[0] if units == 1: x_dpi = x_density y_dpi = y_density if units == 2: x_dpi = x_density * 2.54 y_dpi = y_density * 2.54 # Workaround for incorrect dpi. if x_dpi == 1: x_dpi = DEFAULT_DPI if y_dpi == 1: y_dpi = DEFAULT_DPI if marker == 0xFFDA: end_marker = True continue offset = offset + length + 2 return "jpeg", width, height, x_dpi, y_dpi def _process_gif(self, data: bytes) -> Tuple[str, float, float, float, float]: # Extract width and height information from a GIF file. x_dpi = DEFAULT_DPI y_dpi = DEFAULT_DPI width = unpack(" Tuple[str, float, float]: # Extract width and height information from a BMP file. width = unpack(" Tuple[str, float, float, float, float]: # Extract width and height information from a WMF file. x_dpi = DEFAULT_DPI y_dpi = DEFAULT_DPI # Read the bounding box, measured in logical units. x1 = unpack(" Tuple[str, float, float, float, float]: # Extract width and height information from a EMF file. # Read the bounding box, measured in logical units. bound_x1 = unpack("