1#
2# The Python Imaging Library.
3# $Id$
4#
5# macOS icns file decoder, based on icns.py by Bob Ippolito.
6#
7# history:
8# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies.
9# 2020-04-04 Allow saving on all operating systems.
10#
11# Copyright (c) 2004 by Bob Ippolito.
12# Copyright (c) 2004 by Secret Labs.
13# Copyright (c) 2004 by Fredrik Lundh.
14# Copyright (c) 2014 by Alastair Houghton.
15# Copyright (c) 2020 by Pan Jing.
16#
17# See the README file for information on usage and redistribution.
18#
19from __future__ import annotations
20
21import io
22import os
23import struct
24import sys
25from typing import IO
26
27from . import Image, ImageFile, PngImagePlugin, features
28
29enable_jpeg2k = features.check_codec("jpg_2000")
30if enable_jpeg2k:
31 from . import Jpeg2KImagePlugin
32
33MAGIC = b"icns"
34HEADERSIZE = 8
35
36
37def nextheader(fobj):
38 return struct.unpack(">4sI", fobj.read(HEADERSIZE))
39
40
41def read_32t(fobj, start_length, size):
42 # The 128x128 icon seems to have an extra header for some reason.
43 (start, length) = start_length
44 fobj.seek(start)
45 sig = fobj.read(4)
46 if sig != b"\x00\x00\x00\x00":
47 msg = "Unknown signature, expecting 0x00000000"
48 raise SyntaxError(msg)
49 return read_32(fobj, (start + 4, length - 4), size)
50
51
52def read_32(fobj, start_length, size):
53 """
54 Read a 32bit RGB icon resource. Seems to be either uncompressed or
55 an RLE packbits-like scheme.
56 """
57 (start, length) = start_length
58 fobj.seek(start)
59 pixel_size = (size[0] * size[2], size[1] * size[2])
60 sizesq = pixel_size[0] * pixel_size[1]
61 if length == sizesq * 3:
62 # uncompressed ("RGBRGBGB")
63 indata = fobj.read(length)
64 im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1)
65 else:
66 # decode image
67 im = Image.new("RGB", pixel_size, None)
68 for band_ix in range(3):
69 data = []
70 bytesleft = sizesq
71 while bytesleft > 0:
72 byte = fobj.read(1)
73 if not byte:
74 break
75 byte = byte[0]
76 if byte & 0x80:
77 blocksize = byte - 125
78 byte = fobj.read(1)
79 for i in range(blocksize):
80 data.append(byte)
81 else:
82 blocksize = byte + 1
83 data.append(fobj.read(blocksize))
84 bytesleft -= blocksize
85 if bytesleft <= 0:
86 break
87 if bytesleft != 0:
88 msg = f"Error reading channel [{repr(bytesleft)} left]"
89 raise SyntaxError(msg)
90 band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1)
91 im.im.putband(band.im, band_ix)
92 return {"RGB": im}
93
94
95def read_mk(fobj, start_length, size):
96 # Alpha masks seem to be uncompressed
97 start = start_length[0]
98 fobj.seek(start)
99 pixel_size = (size[0] * size[2], size[1] * size[2])
100 sizesq = pixel_size[0] * pixel_size[1]
101 band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1)
102 return {"A": band}
103
104
105def read_png_or_jpeg2000(fobj, start_length, size):
106 (start, length) = start_length
107 fobj.seek(start)
108 sig = fobj.read(12)
109 if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a":
110 fobj.seek(start)
111 im = PngImagePlugin.PngImageFile(fobj)
112 Image._decompression_bomb_check(im.size)
113 return {"RGBA": im}
114 elif (
115 sig[:4] == b"\xff\x4f\xff\x51"
116 or sig[:4] == b"\x0d\x0a\x87\x0a"
117 or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
118 ):
119 if not enable_jpeg2k:
120 msg = (
121 "Unsupported icon subimage format (rebuild PIL "
122 "with JPEG 2000 support to fix this)"
123 )
124 raise ValueError(msg)
125 # j2k, jpc or j2c
126 fobj.seek(start)
127 jp2kstream = fobj.read(length)
128 f = io.BytesIO(jp2kstream)
129 im = Jpeg2KImagePlugin.Jpeg2KImageFile(f)
130 Image._decompression_bomb_check(im.size)
131 if im.mode != "RGBA":
132 im = im.convert("RGBA")
133 return {"RGBA": im}
134 else:
135 msg = "Unsupported icon subimage format"
136 raise ValueError(msg)
137
138
139class IcnsFile:
140 SIZES = {
141 (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)],
142 (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)],
143 (256, 256, 2): [(b"ic14", read_png_or_jpeg2000)],
144 (256, 256, 1): [(b"ic08", read_png_or_jpeg2000)],
145 (128, 128, 2): [(b"ic13", read_png_or_jpeg2000)],
146 (128, 128, 1): [
147 (b"ic07", read_png_or_jpeg2000),
148 (b"it32", read_32t),
149 (b"t8mk", read_mk),
150 ],
151 (64, 64, 1): [(b"icp6", read_png_or_jpeg2000)],
152 (32, 32, 2): [(b"ic12", read_png_or_jpeg2000)],
153 (48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)],
154 (32, 32, 1): [
155 (b"icp5", read_png_or_jpeg2000),
156 (b"il32", read_32),
157 (b"l8mk", read_mk),
158 ],
159 (16, 16, 2): [(b"ic11", read_png_or_jpeg2000)],
160 (16, 16, 1): [
161 (b"icp4", read_png_or_jpeg2000),
162 (b"is32", read_32),
163 (b"s8mk", read_mk),
164 ],
165 }
166
167 def __init__(self, fobj):
168 """
169 fobj is a file-like object as an icns resource
170 """
171 # signature : (start, length)
172 self.dct = dct = {}
173 self.fobj = fobj
174 sig, filesize = nextheader(fobj)
175 if not _accept(sig):
176 msg = "not an icns file"
177 raise SyntaxError(msg)
178 i = HEADERSIZE
179 while i < filesize:
180 sig, blocksize = nextheader(fobj)
181 if blocksize <= 0:
182 msg = "invalid block header"
183 raise SyntaxError(msg)
184 i += HEADERSIZE
185 blocksize -= HEADERSIZE
186 dct[sig] = (i, blocksize)
187 fobj.seek(blocksize, io.SEEK_CUR)
188 i += blocksize
189
190 def itersizes(self):
191 sizes = []
192 for size, fmts in self.SIZES.items():
193 for fmt, reader in fmts:
194 if fmt in self.dct:
195 sizes.append(size)
196 break
197 return sizes
198
199 def bestsize(self):
200 sizes = self.itersizes()
201 if not sizes:
202 msg = "No 32bit icon resources found"
203 raise SyntaxError(msg)
204 return max(sizes)
205
206 def dataforsize(self, size):
207 """
208 Get an icon resource as {channel: array}. Note that
209 the arrays are bottom-up like windows bitmaps and will likely
210 need to be flipped or transposed in some way.
211 """
212 dct = {}
213 for code, reader in self.SIZES[size]:
214 desc = self.dct.get(code)
215 if desc is not None:
216 dct.update(reader(self.fobj, desc, size))
217 return dct
218
219 def getimage(self, size=None):
220 if size is None:
221 size = self.bestsize()
222 if len(size) == 2:
223 size = (size[0], size[1], 1)
224 channels = self.dataforsize(size)
225
226 im = channels.get("RGBA", None)
227 if im:
228 return im
229
230 im = channels.get("RGB").copy()
231 try:
232 im.putalpha(channels["A"])
233 except KeyError:
234 pass
235 return im
236
237
238##
239# Image plugin for Mac OS icons.
240
241
242class IcnsImageFile(ImageFile.ImageFile):
243 """
244 PIL image support for Mac OS .icns files.
245 Chooses the best resolution, but will possibly load
246 a different size image if you mutate the size attribute
247 before calling 'load'.
248
249 The info dictionary has a key 'sizes' that is a list
250 of sizes that the icns file has.
251 """
252
253 format = "ICNS"
254 format_description = "Mac OS icns resource"
255
256 def _open(self) -> None:
257 self.icns = IcnsFile(self.fp)
258 self._mode = "RGBA"
259 self.info["sizes"] = self.icns.itersizes()
260 self.best_size = self.icns.bestsize()
261 self.size = (
262 self.best_size[0] * self.best_size[2],
263 self.best_size[1] * self.best_size[2],
264 )
265
266 @property
267 def size(self):
268 return self._size
269
270 @size.setter
271 def size(self, value):
272 info_size = value
273 if info_size not in self.info["sizes"] and len(info_size) == 2:
274 info_size = (info_size[0], info_size[1], 1)
275 if (
276 info_size not in self.info["sizes"]
277 and len(info_size) == 3
278 and info_size[2] == 1
279 ):
280 simple_sizes = [
281 (size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"]
282 ]
283 if value in simple_sizes:
284 info_size = self.info["sizes"][simple_sizes.index(value)]
285 if info_size not in self.info["sizes"]:
286 msg = "This is not one of the allowed sizes of this image"
287 raise ValueError(msg)
288 self._size = value
289
290 def load(self):
291 if len(self.size) == 3:
292 self.best_size = self.size
293 self.size = (
294 self.best_size[0] * self.best_size[2],
295 self.best_size[1] * self.best_size[2],
296 )
297
298 px = Image.Image.load(self)
299 if self.im is not None and self.im.size == self.size:
300 # Already loaded
301 return px
302 self.load_prepare()
303 # This is likely NOT the best way to do it, but whatever.
304 im = self.icns.getimage(self.best_size)
305
306 # If this is a PNG or JPEG 2000, it won't be loaded yet
307 px = im.load()
308
309 self.im = im.im
310 self._mode = im.mode
311 self.size = im.size
312
313 return px
314
315
316def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
317 """
318 Saves the image as a series of PNG files,
319 that are then combined into a .icns file.
320 """
321 if hasattr(fp, "flush"):
322 fp.flush()
323
324 sizes = {
325 b"ic07": 128,
326 b"ic08": 256,
327 b"ic09": 512,
328 b"ic10": 1024,
329 b"ic11": 32,
330 b"ic12": 64,
331 b"ic13": 256,
332 b"ic14": 512,
333 }
334 provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])}
335 size_streams = {}
336 for size in set(sizes.values()):
337 image = (
338 provided_images[size]
339 if size in provided_images
340 else im.resize((size, size))
341 )
342
343 temp = io.BytesIO()
344 image.save(temp, "png")
345 size_streams[size] = temp.getvalue()
346
347 entries = []
348 for type, size in sizes.items():
349 stream = size_streams[size]
350 entries.append((type, HEADERSIZE + len(stream), stream))
351
352 # Header
353 fp.write(MAGIC)
354 file_length = HEADERSIZE # Header
355 file_length += HEADERSIZE + 8 * len(entries) # TOC
356 file_length += sum(entry[1] for entry in entries)
357 fp.write(struct.pack(">i", file_length))
358
359 # TOC
360 fp.write(b"TOC ")
361 fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
362 for entry in entries:
363 fp.write(entry[0])
364 fp.write(struct.pack(">i", entry[1]))
365
366 # Data
367 for entry in entries:
368 fp.write(entry[0])
369 fp.write(struct.pack(">i", entry[1]))
370 fp.write(entry[2])
371
372 if hasattr(fp, "flush"):
373 fp.flush()
374
375
376def _accept(prefix: bytes) -> bool:
377 return prefix[:4] == MAGIC
378
379
380Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
381Image.register_extension(IcnsImageFile.format, ".icns")
382
383Image.register_save(IcnsImageFile.format, _save)
384Image.register_mime(IcnsImageFile.format, "image/icns")
385
386if __name__ == "__main__":
387 if len(sys.argv) < 2:
388 print("Syntax: python3 IcnsImagePlugin.py [file]")
389 sys.exit()
390
391 with open(sys.argv[1], "rb") as fp:
392 imf = IcnsImageFile(fp)
393 for size in imf.info["sizes"]:
394 width, height, scale = imf.size = size
395 imf.save(f"out-{width}-{height}-{scale}.png")
396 with Image.open(sys.argv[1]) as im:
397 im.save("out.png")
398 if sys.platform == "windows":
399 os.startfile("out.png")