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

240 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 # 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 ) 

191 

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] 

199 

200 if png_marker == b"PNG": 

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

202 

203 elif jpg_marker == 0xFFD8: 

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

205 

206 elif bmp_marker == b"BM": 

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

208 

209 elif emf_marker1 == 0x9AC6CDD7: 

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

211 

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

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

214 

215 elif gif_marker == b"GIF8": 

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

217 

218 else: 

219 raise UnsupportedImageFormat( 

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

221 ) 

222 

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 ) 

228 

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 

234 

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 

241 

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 

254 

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] 

260 

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] 

265 

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] 

271 

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 

275 

276 if marker == b"IEND": 

277 end_marker = True 

278 continue 

279 

280 offset = offset + length + 12 

281 

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

283 

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 

293 

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] 

298 

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] 

309 

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] 

315 

316 if units == 1: 

317 x_dpi = x_density 

318 y_dpi = y_density 

319 

320 if units == 2: 

321 x_dpi = x_density * 2.54 

322 y_dpi = y_density * 2.54 

323 

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 

329 

330 if marker == 0xFFDA: 

331 end_marker = True 

332 continue 

333 

334 offset = offset + length + 2 

335 

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

337 

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 

342 

343 width = unpack("<H", data[6:8])[0] 

344 height = unpack("<H", data[8:10])[0] 

345 

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

347 

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 

354 

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 

359 

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] 

365 

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

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

368 

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 ) 

374 

375 # Convert to rendered height and width. 

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

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

378 

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

380 

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 

385 

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] 

391 

392 # Convert the bounds to width and height. 

393 width = bound_x2 - bound_x1 

394 height = bound_y2 - bound_y1 

395 

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] 

401 

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) 

405 

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 

412 

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 

417 

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