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.url import Url 

18 

19from .exceptions import UndefinedImageSize, UnsupportedImageFormat 

20 

21DEFAULT_DPI = 96.0 

22 

23 

24class Image: 

25 """ 

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

27 

28 """ 

29 

30 def __init__(self, source: Union[str, Path, BytesIO]): 

31 """ 

32 Initialize an Image instance. 

33 

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

48 

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 

61 

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 

69 

70 self._get_image_properties() 

71 

72 def __repr__(self): 

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 ) 

87 

88 @property 

89 def image_type(self) -> str: 

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

91 return self._image_extension.upper() 

92 

93 @property 

94 def width(self) -> float: 

95 """Get the width of the image.""" 

96 return self._width 

97 

98 @property 

99 def height(self) -> float: 

100 """Get the height of the image.""" 

101 return self._height 

102 

103 @property 

104 def x_dpi(self) -> float: 

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

106 return self._x_dpi 

107 

108 @property 

109 def y_dpi(self) -> float: 

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

111 return self._y_dpi 

112 

113 @property 

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

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

116 return self._description 

117 

118 @description.setter 

119 def description(self, value: str): 

120 """Set the description/alt-text of the image.""" 

121 if value: 

122 self._description = value 

123 

124 @property 

125 def decorative(self) -> bool: 

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

127 return self._decorative 

128 

129 @decorative.setter 

130 def decorative(self, value: bool): 

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

132 self._decorative = value 

133 

134 @property 

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

136 """Get the image url.""" 

137 return self._url 

138 

139 @url.setter 

140 def url(self, value: Url): 

141 """Set the image url.""" 

142 if value: 

143 self._url = value 

144 

145 def _set_user_options(self, options=None): 

146 """ 

147 This handles the additional optional parameters to ``insert_button()``. 

148 """ 

149 if options is None: 

150 return 

151 

152 if not self._url: 

153 self._url = Url.from_options(options) 

154 if self._url: 

155 self._url._set_object_link() 

156 

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) 

165 

166 # For backward compatibility with older parameter name. 

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

168 

169 def _get_image_properties(self): 

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 

175 

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

183 

184 # Get the image digest to check for duplicates. 

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

186 

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] 

194 

195 if png_marker == b"PNG": 

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

197 

198 elif jpg_marker == 0xFFD8: 

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

200 

201 elif bmp_marker == b"BM": 

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

203 

204 elif emf_marker1 == 0x9AC6CDD7: 

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

206 

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

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

209 

210 elif gif_marker == b"GIF8": 

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

212 

213 else: 

214 raise UnsupportedImageFormat( 

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

216 ) 

217 

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 ) 

223 

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 

229 

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 

236 

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 

249 

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] 

255 

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] 

260 

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] 

266 

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 

270 

271 if marker == b"IEND": 

272 end_marker = True 

273 continue 

274 

275 offset = offset + length + 12 

276 

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

278 

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 

288 

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] 

293 

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] 

304 

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] 

310 

311 if units == 1: 

312 x_dpi = x_density 

313 y_dpi = y_density 

314 

315 if units == 2: 

316 x_dpi = x_density * 2.54 

317 y_dpi = y_density * 2.54 

318 

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 

324 

325 if marker == 0xFFDA: 

326 end_marker = True 

327 continue 

328 

329 offset = offset + length + 2 

330 

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

332 

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 

337 

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

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

340 

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

342 

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 

348 

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 

353 

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] 

359 

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

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

362 

363 # Convert to rendered height and width. 

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

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

366 

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

368 

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

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

371 

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] 

377 

378 # Convert the bounds to width and height. 

379 width = bound_x2 - bound_x1 

380 height = bound_y2 - bound_y1 

381 

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] 

387 

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) 

391 

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 

395 

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 

400 

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