1#
2# The Python Imaging Library.
3# $Id$
4#
5# Windows Icon support for PIL
6#
7# History:
8# 96-05-27 fl Created
9#
10# Copyright (c) Secret Labs AB 1997.
11# Copyright (c) Fredrik Lundh 1996.
12#
13# See the README file for information on usage and redistribution.
14#
15
16# This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
17# <casadebender@gmail.com>.
18# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
19#
20# Icon format references:
21# * https://en.wikipedia.org/wiki/ICO_(file_format)
22# * https://msdn.microsoft.com/en-us/library/ms997538.aspx
23from __future__ import annotations
24
25import warnings
26from io import BytesIO
27from math import ceil, log
28from typing import IO, NamedTuple
29
30from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
31from ._binary import i16le as i16
32from ._binary import i32le as i32
33from ._binary import o8
34from ._binary import o16le as o16
35from ._binary import o32le as o32
36
37#
38# --------------------------------------------------------------------
39
40_MAGIC = b"\0\0\1\0"
41
42
43def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
44 fp.write(_MAGIC) # (2+2)
45 bmp = im.encoderinfo.get("bitmap_format") == "bmp"
46 sizes = im.encoderinfo.get(
47 "sizes",
48 [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
49 )
50 frames = []
51 provided_ims = [im] + im.encoderinfo.get("append_images", [])
52 width, height = im.size
53 for size in sorted(set(sizes)):
54 if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
55 continue
56
57 for provided_im in provided_ims:
58 if provided_im.size != size:
59 continue
60 frames.append(provided_im)
61 if bmp:
62 bits = BmpImagePlugin.SAVE[provided_im.mode][1]
63 bits_used = [bits]
64 for other_im in provided_ims:
65 if other_im.size != size:
66 continue
67 bits = BmpImagePlugin.SAVE[other_im.mode][1]
68 if bits not in bits_used:
69 # Another image has been supplied for this size
70 # with a different bit depth
71 frames.append(other_im)
72 bits_used.append(bits)
73 break
74 else:
75 # TODO: invent a more convenient method for proportional scalings
76 frame = provided_im.copy()
77 frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
78 frames.append(frame)
79 fp.write(o16(len(frames))) # idCount(2)
80 offset = fp.tell() + len(frames) * 16
81 for frame in frames:
82 width, height = frame.size
83 # 0 means 256
84 fp.write(o8(width if width < 256 else 0)) # bWidth(1)
85 fp.write(o8(height if height < 256 else 0)) # bHeight(1)
86
87 bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
88 fp.write(o8(colors)) # bColorCount(1)
89 fp.write(b"\0") # bReserved(1)
90 fp.write(b"\0\0") # wPlanes(2)
91 fp.write(o16(bits)) # wBitCount(2)
92
93 image_io = BytesIO()
94 if bmp:
95 frame.save(image_io, "dib")
96
97 if bits != 32:
98 and_mask = Image.new("1", size)
99 ImageFile._save(
100 and_mask,
101 image_io,
102 [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
103 )
104 else:
105 frame.save(image_io, "png")
106 image_io.seek(0)
107 image_bytes = image_io.read()
108 if bmp:
109 image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
110 bytes_len = len(image_bytes)
111 fp.write(o32(bytes_len)) # dwBytesInRes(4)
112 fp.write(o32(offset)) # dwImageOffset(4)
113 current = fp.tell()
114 fp.seek(offset)
115 fp.write(image_bytes)
116 offset = offset + bytes_len
117 fp.seek(current)
118
119
120def _accept(prefix: bytes) -> bool:
121 return prefix.startswith(_MAGIC)
122
123
124class IconHeader(NamedTuple):
125 width: int
126 height: int
127 nb_color: int
128 reserved: int
129 planes: int
130 bpp: int
131 size: int
132 offset: int
133 dim: tuple[int, int]
134 square: int
135 color_depth: int
136
137
138class IcoFile:
139 def __init__(self, buf: IO[bytes]) -> None:
140 """
141 Parse image from file-like object containing ico file data
142 """
143
144 # check magic
145 s = buf.read(6)
146 if not _accept(s):
147 msg = "not an ICO file"
148 raise SyntaxError(msg)
149
150 self.buf = buf
151 self.entry = []
152
153 # Number of items in file
154 self.nb_items = i16(s, 4)
155
156 # Get headers for each item
157 for i in range(self.nb_items):
158 s = buf.read(16)
159
160 # See Wikipedia
161 width = s[0] or 256
162 height = s[1] or 256
163
164 # No. of colors in image (0 if >=8bpp)
165 nb_color = s[2]
166 bpp = i16(s, 6)
167 icon_header = IconHeader(
168 width=width,
169 height=height,
170 nb_color=nb_color,
171 reserved=s[3],
172 planes=i16(s, 4),
173 bpp=i16(s, 6),
174 size=i32(s, 8),
175 offset=i32(s, 12),
176 dim=(width, height),
177 square=width * height,
178 # See Wikipedia notes about color depth.
179 # We need this just to differ images with equal sizes
180 color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
181 )
182
183 self.entry.append(icon_header)
184
185 self.entry = sorted(self.entry, key=lambda x: x.color_depth)
186 # ICO images are usually squares
187 self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
188
189 def sizes(self) -> set[tuple[int, int]]:
190 """
191 Get a set of all available icon sizes and color depths.
192 """
193 return {(h.width, h.height) for h in self.entry}
194
195 def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
196 for i, h in enumerate(self.entry):
197 if size == h.dim and (bpp is False or bpp == h.color_depth):
198 return i
199 return 0
200
201 def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image:
202 """
203 Get an image from the icon
204 """
205 return self.frame(self.getentryindex(size, bpp))
206
207 def frame(self, idx: int) -> Image.Image:
208 """
209 Get an image from frame idx
210 """
211
212 header = self.entry[idx]
213
214 self.buf.seek(header.offset)
215 data = self.buf.read(8)
216 self.buf.seek(header.offset)
217
218 im: Image.Image
219 if data[:8] == PngImagePlugin._MAGIC:
220 # png frame
221 im = PngImagePlugin.PngImageFile(self.buf)
222 Image._decompression_bomb_check(im.size)
223 else:
224 # XOR + AND mask bmp frame
225 im = BmpImagePlugin.DibImageFile(self.buf)
226 Image._decompression_bomb_check(im.size)
227
228 # change tile dimension to only encompass XOR image
229 im._size = (im.size[0], int(im.size[1] / 2))
230 d, e, o, a = im.tile[0]
231 im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a)
232
233 # figure out where AND mask image starts
234 if header.bpp == 32:
235 # 32-bit color depth icon image allows semitransparent areas
236 # PIL's DIB format ignores transparency bits, recover them.
237 # The DIB is packed in BGRX byte order where X is the alpha
238 # channel.
239
240 # Back up to start of bmp data
241 self.buf.seek(o)
242 # extract every 4th byte (eg. 3,7,11,15,...)
243 alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
244
245 # convert to an 8bpp grayscale image
246 try:
247 mask = Image.frombuffer(
248 "L", # 8bpp
249 im.size, # (w, h)
250 alpha_bytes, # source chars
251 "raw", # raw decoder
252 ("L", 0, -1), # 8bpp inverted, unpadded, reversed
253 )
254 except ValueError:
255 if ImageFile.LOAD_TRUNCATED_IMAGES:
256 mask = None
257 else:
258 raise
259 else:
260 # get AND image from end of bitmap
261 w = im.size[0]
262 if (w % 32) > 0:
263 # bitmap row data is aligned to word boundaries
264 w += 32 - (im.size[0] % 32)
265
266 # the total mask data is
267 # padded row size * height / bits per char
268
269 total_bytes = int((w * im.size[1]) / 8)
270 and_mask_offset = header.offset + header.size - total_bytes
271
272 self.buf.seek(and_mask_offset)
273 mask_data = self.buf.read(total_bytes)
274
275 # convert raw data to image
276 try:
277 mask = Image.frombuffer(
278 "1", # 1 bpp
279 im.size, # (w, h)
280 mask_data, # source chars
281 "raw", # raw decoder
282 ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
283 )
284 except ValueError:
285 if ImageFile.LOAD_TRUNCATED_IMAGES:
286 mask = None
287 else:
288 raise
289
290 # now we have two images, im is XOR image and mask is AND image
291
292 # apply mask image as alpha channel
293 if mask:
294 im = im.convert("RGBA")
295 im.putalpha(mask)
296
297 return im
298
299
300##
301# Image plugin for Windows Icon files.
302
303
304class IcoImageFile(ImageFile.ImageFile):
305 """
306 PIL read-only image support for Microsoft Windows .ico files.
307
308 By default the largest resolution image in the file will be loaded. This
309 can be changed by altering the 'size' attribute before calling 'load'.
310
311 The info dictionary has a key 'sizes' that is a list of the sizes available
312 in the icon file.
313
314 Handles classic, XP and Vista icon formats.
315
316 When saving, PNG compression is used. Support for this was only added in
317 Windows Vista. If you are unable to view the icon in Windows, convert the
318 image to "RGBA" mode before saving.
319
320 This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
321 <casadebender@gmail.com>.
322 https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
323 """
324
325 format = "ICO"
326 format_description = "Windows Icon"
327
328 def _open(self) -> None:
329 self.ico = IcoFile(self.fp)
330 self.info["sizes"] = self.ico.sizes()
331 self.size = self.ico.entry[0].dim
332 self.load()
333
334 @property
335 def size(self) -> tuple[int, int]:
336 return self._size
337
338 @size.setter
339 def size(self, value: tuple[int, int]) -> None:
340 if value not in self.info["sizes"]:
341 msg = "This is not one of the allowed sizes of this image"
342 raise ValueError(msg)
343 self._size = value
344
345 def load(self) -> Image.core.PixelAccess | None:
346 if self._im is not None and self.im.size == self.size:
347 # Already loaded
348 return Image.Image.load(self)
349 im = self.ico.getimage(self.size)
350 # if tile is PNG, it won't really be loaded yet
351 im.load()
352 self.im = im.im
353 self._mode = im.mode
354 if im.palette:
355 self.palette = im.palette
356 if im.size != self.size:
357 warnings.warn("Image was not the expected size")
358
359 index = self.ico.getentryindex(self.size)
360 sizes = list(self.info["sizes"])
361 sizes[index] = im.size
362 self.info["sizes"] = set(sizes)
363
364 self.size = im.size
365 return None
366
367 def load_seek(self, pos: int) -> None:
368 # Flag the ImageFile.Parser so that it
369 # just does all the decode at the end.
370 pass
371
372
373#
374# --------------------------------------------------------------------
375
376
377Image.register_open(IcoImageFile.format, IcoImageFile, _accept)
378Image.register_save(IcoImageFile.format, _save)
379Image.register_extension(IcoImageFile.format, ".ico")
380
381Image.register_mime(IcoImageFile.format, "image/x-icon")