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

232 statements  

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.exceptions import UndefinedImageSize, UnsupportedImageFormat 

18from xlsxwriter.url import Url 

19 

20DEFAULT_DPI = 96.0 

21 

22 

23class Image: 

24 """ 

25 A class to represent an image in an Excel worksheet. 

26 

27 """ 

28 

29 def __init__(self, source: Union[str, Path, BytesIO]) -> None: 

30 """ 

31 Initialize an Image instance. 

32 

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.") 

47 

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 

60 

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 

68 

69 self._get_image_properties() 

70 

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 ) 

86 

87 @property 

88 def image_type(self) -> str: 

89 """Get the image type (e.g., 'PNG', 'JPEG').""" 

90 return self._image_extension.upper() 

91 

92 @property 

93 def width(self) -> float: 

94 """Get the width of the image.""" 

95 return self._width 

96 

97 @property 

98 def height(self) -> float: 

99 """Get the height of the image.""" 

100 return self._height 

101 

102 @property 

103 def x_dpi(self) -> float: 

104 """Get the horizontal DPI of the image.""" 

105 return self._x_dpi 

106 

107 @property 

108 def y_dpi(self) -> float: 

109 """Get the vertical DPI of the image.""" 

110 return self._y_dpi 

111 

112 @property 

113 def description(self) -> Union[str, None]: 

114 """Get the description/alt-text of the image.""" 

115 return self._description 

116 

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 

122 

123 @property 

124 def decorative(self) -> bool: 

125 """Get whether the image is decorative.""" 

126 return self._decorative 

127 

128 @decorative.setter 

129 def decorative(self, value: bool) -> None: 

130 """Set whether the image is decorative.""" 

131 self._decorative = value 

132 

133 @property 

134 def url(self) -> Union[Url, None]: 

135 """Get the image url.""" 

136 return self._url 

137 

138 @url.setter 

139 def url(self, value: Url) -> None: 

140 """Set the image url.""" 

141 if value: 

142 self._url = value 

143 

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 

150 

151 if not self._url: 

152 self._url = Url.from_options(options) 

153 if self._url: 

154 self._url._set_object_link() 

155 

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) 

164 

165 # For backward compatibility with older parameter name. 

166 self._anchor = options.get("positioning", self._anchor) 

167 

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 

174 

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() 

182 

183 # Get the image digest to check for duplicates. 

184 digest = hashlib.sha256(data).hexdigest() 

185 

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] 

193 

194 if png_marker == b"PNG": 

195 (image_type, width, height, x_dpi, y_dpi) = self._process_png(data) 

196 

197 elif jpg_marker == 0xFFD8: 

198 (image_type, width, height, x_dpi, y_dpi) = self._process_jpg(data) 

199 

200 elif bmp_marker == b"BM": 

201 (image_type, width, height) = self._process_bmp(data) 

202 

203 elif emf_marker1 == 0x9AC6CDD7: 

204 (image_type, width, height, x_dpi, y_dpi) = self._process_wmf(data) 

205 

206 elif emf_marker1 == 1 and emf_marker == b" EMF": 

207 (image_type, width, height, x_dpi, y_dpi) = self._process_emf(data) 

208 

209 elif gif_marker == b"GIF8": 

210 (image_type, width, height, x_dpi, y_dpi) = self._process_gif(data) 

211 

212 else: 

213 raise UnsupportedImageFormat( 

214 f"{self.filename}: Unknown or unsupported image file format." 

215 ) 

216 

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 ) 

222 

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 

228 

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 

235 

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 

248 

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] 

254 

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] 

259 

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] 

265 

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 

269 

270 if marker == b"IEND": 

271 end_marker = True 

272 continue 

273 

274 offset = offset + length + 12 

275 

276 return "png", width, height, x_dpi, y_dpi 

277 

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 

287 

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] 

292 

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] 

303 

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] 

309 

310 if units == 1: 

311 x_dpi = x_density 

312 y_dpi = y_density 

313 

314 if units == 2: 

315 x_dpi = x_density * 2.54 

316 y_dpi = y_density * 2.54 

317 

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 

323 

324 if marker == 0xFFDA: 

325 end_marker = True 

326 continue 

327 

328 offset = offset + length + 2 

329 

330 return "jpeg", width, height, x_dpi, y_dpi 

331 

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 

336 

337 width = unpack("<h", data[6:8])[0] 

338 height = unpack("<h", data[8:10])[0] 

339 

340 return "gif", width, height, x_dpi, y_dpi 

341 

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 

347 

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 

352 

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] 

358 

359 # Read the number of logical units per inch. Used to scale the image. 

360 inch = unpack("<H", data[14:16])[0] 

361 

362 # Convert to rendered height and width. 

363 width = float((x2 - x1) * x_dpi) / inch 

364 height = float((y2 - y1) * y_dpi) / inch 

365 

366 return "wmf", width, height, x_dpi, y_dpi 

367 

368 def _process_emf(self, data: bytes) -> Tuple[str, float, float, float, float]: 

369 # Extract width and height information from a EMF file. 

370 

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] 

376 

377 # Convert the bounds to width and height. 

378 width = bound_x2 - bound_x1 

379 height = bound_y2 - bound_y1 

380 

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] 

386 

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) 

390 

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 

394 

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 

399 

400 return "emf", width, height, x_dpi, y_dpi