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
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, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
101 )
102 else:
103 frame.save(image_io, "png")
104 image_io.seek(0)
105 image_bytes = image_io.read()
106 if bmp:
107 image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
108 bytes_len = len(image_bytes)
109 fp.write(o32(bytes_len)) # dwBytesInRes(4)
110 fp.write(o32(offset)) # dwImageOffset(4)
111 current = fp.tell()
112 fp.seek(offset)
113 fp.write(image_bytes)
114 offset = offset + bytes_len
115 fp.seek(current)
116
117
118def _accept(prefix: bytes) -> bool:
119 return prefix[:4] == _MAGIC
120
121
122class IcoFile:
123 def __init__(self, buf):
124 """
125 Parse image from file-like object containing ico file data
126 """
127
128 # check magic
129 s = buf.read(6)
130 if not _accept(s):
131 msg = "not an ICO file"
132 raise SyntaxError(msg)
133
134 self.buf = buf
135 self.entry = []
136
137 # Number of items in file
138 self.nb_items = i16(s, 4)
139
140 # Get headers for each item
141 for i in range(self.nb_items):
142 s = buf.read(16)
143
144 icon_header = {
145 "width": s[0],
146 "height": s[1],
147 "nb_color": s[2], # No. of colors in image (0 if >=8bpp)
148 "reserved": s[3],
149 "planes": i16(s, 4),
150 "bpp": i16(s, 6),
151 "size": i32(s, 8),
152 "offset": i32(s, 12),
153 }
154
155 # See Wikipedia
156 for j in ("width", "height"):
157 if not icon_header[j]:
158 icon_header[j] = 256
159
160 # See Wikipedia notes about color depth.
161 # We need this just to differ images with equal sizes
162 icon_header["color_depth"] = (
163 icon_header["bpp"]
164 or (
165 icon_header["nb_color"] != 0
166 and ceil(log(icon_header["nb_color"], 2))
167 )
168 or 256
169 )
170
171 icon_header["dim"] = (icon_header["width"], icon_header["height"])
172 icon_header["square"] = icon_header["width"] * icon_header["height"]
173
174 self.entry.append(icon_header)
175
176 self.entry = sorted(self.entry, key=lambda x: x["color_depth"])
177 # ICO images are usually squares
178 self.entry = sorted(self.entry, key=lambda x: x["square"], reverse=True)
179
180 def sizes(self):
181 """
182 Get a list of all available icon sizes and color depths.
183 """
184 return {(h["width"], h["height"]) for h in self.entry}
185
186 def getentryindex(self, size, bpp=False):
187 for i, h in enumerate(self.entry):
188 if size == h["dim"] and (bpp is False or bpp == h["color_depth"]):
189 return i
190 return 0
191
192 def getimage(self, size, bpp=False):
193 """
194 Get an image from the icon
195 """
196 return self.frame(self.getentryindex(size, bpp))
197
198 def frame(self, idx: int) -> Image.Image:
199 """
200 Get an image from frame idx
201 """
202
203 header = self.entry[idx]
204
205 self.buf.seek(header["offset"])
206 data = self.buf.read(8)
207 self.buf.seek(header["offset"])
208
209 im: Image.Image
210 if data[:8] == PngImagePlugin._MAGIC:
211 # png frame
212 im = PngImagePlugin.PngImageFile(self.buf)
213 Image._decompression_bomb_check(im.size)
214 else:
215 # XOR + AND mask bmp frame
216 im = BmpImagePlugin.DibImageFile(self.buf)
217 Image._decompression_bomb_check(im.size)
218
219 # change tile dimension to only encompass XOR image
220 im._size = (im.size[0], int(im.size[1] / 2))
221 d, e, o, a = im.tile[0]
222 im.tile[0] = d, (0, 0) + im.size, o, a
223
224 # figure out where AND mask image starts
225 bpp = header["bpp"]
226 if 32 == bpp:
227 # 32-bit color depth icon image allows semitransparent areas
228 # PIL's DIB format ignores transparency bits, recover them.
229 # The DIB is packed in BGRX byte order where X is the alpha
230 # channel.
231
232 # Back up to start of bmp data
233 self.buf.seek(o)
234 # extract every 4th byte (eg. 3,7,11,15,...)
235 alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
236
237 # convert to an 8bpp grayscale image
238 mask = Image.frombuffer(
239 "L", # 8bpp
240 im.size, # (w, h)
241 alpha_bytes, # source chars
242 "raw", # raw decoder
243 ("L", 0, -1), # 8bpp inverted, unpadded, reversed
244 )
245 else:
246 # get AND image from end of bitmap
247 w = im.size[0]
248 if (w % 32) > 0:
249 # bitmap row data is aligned to word boundaries
250 w += 32 - (im.size[0] % 32)
251
252 # the total mask data is
253 # padded row size * height / bits per char
254
255 total_bytes = int((w * im.size[1]) / 8)
256 and_mask_offset = header["offset"] + header["size"] - total_bytes
257
258 self.buf.seek(and_mask_offset)
259 mask_data = self.buf.read(total_bytes)
260
261 # convert raw data to image
262 mask = Image.frombuffer(
263 "1", # 1 bpp
264 im.size, # (w, h)
265 mask_data, # source chars
266 "raw", # raw decoder
267 ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
268 )
269
270 # now we have two images, im is XOR image and mask is AND image
271
272 # apply mask image as alpha channel
273 im = im.convert("RGBA")
274 im.putalpha(mask)
275
276 return im
277
278
279##
280# Image plugin for Windows Icon files.
281
282
283class IcoImageFile(ImageFile.ImageFile):
284 """
285 PIL read-only image support for Microsoft Windows .ico files.
286
287 By default the largest resolution image in the file will be loaded. This
288 can be changed by altering the 'size' attribute before calling 'load'.
289
290 The info dictionary has a key 'sizes' that is a list of the sizes available
291 in the icon file.
292
293 Handles classic, XP and Vista icon formats.
294
295 When saving, PNG compression is used. Support for this was only added in
296 Windows Vista. If you are unable to view the icon in Windows, convert the
297 image to "RGBA" mode before saving.
298
299 This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
300 <casadebender@gmail.com>.
301 https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
302 """
303
304 format = "ICO"
305 format_description = "Windows Icon"
306
307 def _open(self) -> None:
308 self.ico = IcoFile(self.fp)
309 self.info["sizes"] = self.ico.sizes()
310 self.size = self.ico.entry[0]["dim"]
311 self.load()
312
313 @property
314 def size(self):
315 return self._size
316
317 @size.setter
318 def size(self, value):
319 if value not in self.info["sizes"]:
320 msg = "This is not one of the allowed sizes of this image"
321 raise ValueError(msg)
322 self._size = value
323
324 def load(self):
325 if self.im is not None and self.im.size == self.size:
326 # Already loaded
327 return Image.Image.load(self)
328 im = self.ico.getimage(self.size)
329 # if tile is PNG, it won't really be loaded yet
330 im.load()
331 self.im = im.im
332 self.pyaccess = None
333 self._mode = im.mode
334 if im.palette:
335 self.palette = im.palette
336 if im.size != self.size:
337 warnings.warn("Image was not the expected size")
338
339 index = self.ico.getentryindex(self.size)
340 sizes = list(self.info["sizes"])
341 sizes[index] = im.size
342 self.info["sizes"] = set(sizes)
343
344 self.size = im.size
345
346 def load_seek(self, pos: int) -> None:
347 # Flag the ImageFile.Parser so that it
348 # just does all the decode at the end.
349 pass
350
351
352#
353# --------------------------------------------------------------------
354
355
356Image.register_open(IcoImageFile.format, IcoImageFile, _accept)
357Image.register_save(IcoImageFile.format, _save)
358Image.register_extension(IcoImageFile.format, ".ico")
359
360Image.register_mime(IcoImageFile.format, "image/x-icon")