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