IcnsImagePlugin.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. #
  10. # Copyright (c) 2004 by Bob Ippolito.
  11. # Copyright (c) 2004 by Secret Labs.
  12. # Copyright (c) 2004 by Fredrik Lundh.
  13. # Copyright (c) 2014 by Alastair Houghton.
  14. #
  15. # See the README file for information on usage and redistribution.
  16. #
  17. import io
  18. import os
  19. import shutil
  20. import struct
  21. import subprocess
  22. import sys
  23. import tempfile
  24. from PIL import Image, ImageFile, PngImagePlugin, features
  25. from PIL._binary import i8
  26. enable_jpeg2k = features.check_codec("jpg_2000")
  27. if enable_jpeg2k:
  28. from PIL import Jpeg2KImagePlugin
  29. HEADERSIZE = 8
  30. def nextheader(fobj):
  31. return struct.unpack(">4sI", fobj.read(HEADERSIZE))
  32. def read_32t(fobj, start_length, size):
  33. # The 128x128 icon seems to have an extra header for some reason.
  34. (start, length) = start_length
  35. fobj.seek(start)
  36. sig = fobj.read(4)
  37. if sig != b"\x00\x00\x00\x00":
  38. raise SyntaxError("Unknown signature, expecting 0x00000000")
  39. return read_32(fobj, (start + 4, length - 4), size)
  40. def read_32(fobj, start_length, size):
  41. """
  42. Read a 32bit RGB icon resource. Seems to be either uncompressed or
  43. an RLE packbits-like scheme.
  44. """
  45. (start, length) = start_length
  46. fobj.seek(start)
  47. pixel_size = (size[0] * size[2], size[1] * size[2])
  48. sizesq = pixel_size[0] * pixel_size[1]
  49. if length == sizesq * 3:
  50. # uncompressed ("RGBRGBGB")
  51. indata = fobj.read(length)
  52. im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1)
  53. else:
  54. # decode image
  55. im = Image.new("RGB", pixel_size, None)
  56. for band_ix in range(3):
  57. data = []
  58. bytesleft = sizesq
  59. while bytesleft > 0:
  60. byte = fobj.read(1)
  61. if not byte:
  62. break
  63. byte = i8(byte)
  64. if byte & 0x80:
  65. blocksize = byte - 125
  66. byte = fobj.read(1)
  67. for i in range(blocksize):
  68. data.append(byte)
  69. else:
  70. blocksize = byte + 1
  71. data.append(fobj.read(blocksize))
  72. bytesleft -= blocksize
  73. if bytesleft <= 0:
  74. break
  75. if bytesleft != 0:
  76. raise SyntaxError(f"Error reading channel [{repr(bytesleft)} left]")
  77. band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1)
  78. im.im.putband(band.im, band_ix)
  79. return {"RGB": im}
  80. def read_mk(fobj, start_length, size):
  81. # Alpha masks seem to be uncompressed
  82. start = start_length[0]
  83. fobj.seek(start)
  84. pixel_size = (size[0] * size[2], size[1] * size[2])
  85. sizesq = pixel_size[0] * pixel_size[1]
  86. band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1)
  87. return {"A": band}
  88. def read_png_or_jpeg2000(fobj, start_length, size):
  89. (start, length) = start_length
  90. fobj.seek(start)
  91. sig = fobj.read(12)
  92. if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a":
  93. fobj.seek(start)
  94. im = PngImagePlugin.PngImageFile(fobj)
  95. return {"RGBA": im}
  96. elif (
  97. sig[:4] == b"\xff\x4f\xff\x51"
  98. or sig[:4] == b"\x0d\x0a\x87\x0a"
  99. or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
  100. ):
  101. if not enable_jpeg2k:
  102. raise ValueError(
  103. "Unsupported icon subimage format (rebuild PIL "
  104. "with JPEG 2000 support to fix this)"
  105. )
  106. # j2k, jpc or j2c
  107. fobj.seek(start)
  108. jp2kstream = fobj.read(length)
  109. f = io.BytesIO(jp2kstream)
  110. im = Jpeg2KImagePlugin.Jpeg2KImageFile(f)
  111. if im.mode != "RGBA":
  112. im = im.convert("RGBA")
  113. return {"RGBA": im}
  114. else:
  115. raise ValueError("Unsupported icon subimage format")
  116. class IcnsFile:
  117. SIZES = {
  118. (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)],
  119. (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)],
  120. (256, 256, 2): [(b"ic14", read_png_or_jpeg2000)],
  121. (256, 256, 1): [(b"ic08", read_png_or_jpeg2000)],
  122. (128, 128, 2): [(b"ic13", read_png_or_jpeg2000)],
  123. (128, 128, 1): [
  124. (b"ic07", read_png_or_jpeg2000),
  125. (b"it32", read_32t),
  126. (b"t8mk", read_mk),
  127. ],
  128. (64, 64, 1): [(b"icp6", read_png_or_jpeg2000)],
  129. (32, 32, 2): [(b"ic12", read_png_or_jpeg2000)],
  130. (48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)],
  131. (32, 32, 1): [
  132. (b"icp5", read_png_or_jpeg2000),
  133. (b"il32", read_32),
  134. (b"l8mk", read_mk),
  135. ],
  136. (16, 16, 2): [(b"ic11", read_png_or_jpeg2000)],
  137. (16, 16, 1): [
  138. (b"icp4", read_png_or_jpeg2000),
  139. (b"is32", read_32),
  140. (b"s8mk", read_mk),
  141. ],
  142. }
  143. def __init__(self, fobj):
  144. """
  145. fobj is a file-like object as an icns resource
  146. """
  147. # signature : (start, length)
  148. self.dct = dct = {}
  149. self.fobj = fobj
  150. sig, filesize = nextheader(fobj)
  151. if sig != b"icns":
  152. raise SyntaxError("not an icns file")
  153. i = HEADERSIZE
  154. while i < filesize:
  155. sig, blocksize = nextheader(fobj)
  156. if blocksize <= 0:
  157. raise SyntaxError("invalid block header")
  158. i += HEADERSIZE
  159. blocksize -= HEADERSIZE
  160. dct[sig] = (i, blocksize)
  161. fobj.seek(blocksize, io.SEEK_CUR)
  162. i += blocksize
  163. def itersizes(self):
  164. sizes = []
  165. for size, fmts in self.SIZES.items():
  166. for (fmt, reader) in fmts:
  167. if fmt in self.dct:
  168. sizes.append(size)
  169. break
  170. return sizes
  171. def bestsize(self):
  172. sizes = self.itersizes()
  173. if not sizes:
  174. raise SyntaxError("No 32bit icon resources found")
  175. return max(sizes)
  176. def dataforsize(self, size):
  177. """
  178. Get an icon resource as {channel: array}. Note that
  179. the arrays are bottom-up like windows bitmaps and will likely
  180. need to be flipped or transposed in some way.
  181. """
  182. dct = {}
  183. for code, reader in self.SIZES[size]:
  184. desc = self.dct.get(code)
  185. if desc is not None:
  186. dct.update(reader(self.fobj, desc, size))
  187. return dct
  188. def getimage(self, size=None):
  189. if size is None:
  190. size = self.bestsize()
  191. if len(size) == 2:
  192. size = (size[0], size[1], 1)
  193. channels = self.dataforsize(size)
  194. im = channels.get("RGBA", None)
  195. if im:
  196. return im
  197. im = channels.get("RGB").copy()
  198. try:
  199. im.putalpha(channels["A"])
  200. except KeyError:
  201. pass
  202. return im
  203. ##
  204. # Image plugin for Mac OS icons.
  205. class IcnsImageFile(ImageFile.ImageFile):
  206. """
  207. PIL image support for Mac OS .icns files.
  208. Chooses the best resolution, but will possibly load
  209. a different size image if you mutate the size attribute
  210. before calling 'load'.
  211. The info dictionary has a key 'sizes' that is a list
  212. of sizes that the icns file has.
  213. """
  214. format = "ICNS"
  215. format_description = "Mac OS icns resource"
  216. def _open(self):
  217. self.icns = IcnsFile(self.fp)
  218. self.mode = "RGBA"
  219. self.info["sizes"] = self.icns.itersizes()
  220. self.best_size = self.icns.bestsize()
  221. self.size = (
  222. self.best_size[0] * self.best_size[2],
  223. self.best_size[1] * self.best_size[2],
  224. )
  225. @property
  226. def size(self):
  227. return self._size
  228. @size.setter
  229. def size(self, value):
  230. info_size = value
  231. if info_size not in self.info["sizes"] and len(info_size) == 2:
  232. info_size = (info_size[0], info_size[1], 1)
  233. if (
  234. info_size not in self.info["sizes"]
  235. and len(info_size) == 3
  236. and info_size[2] == 1
  237. ):
  238. simple_sizes = [
  239. (size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"]
  240. ]
  241. if value in simple_sizes:
  242. info_size = self.info["sizes"][simple_sizes.index(value)]
  243. if info_size not in self.info["sizes"]:
  244. raise ValueError("This is not one of the allowed sizes of this image")
  245. self._size = value
  246. def load(self):
  247. if len(self.size) == 3:
  248. self.best_size = self.size
  249. self.size = (
  250. self.best_size[0] * self.best_size[2],
  251. self.best_size[1] * self.best_size[2],
  252. )
  253. Image.Image.load(self)
  254. if self.im and self.im.size == self.size:
  255. # Already loaded
  256. return
  257. self.load_prepare()
  258. # This is likely NOT the best way to do it, but whatever.
  259. im = self.icns.getimage(self.best_size)
  260. # If this is a PNG or JPEG 2000, it won't be loaded yet
  261. im.load()
  262. self.im = im.im
  263. self.mode = im.mode
  264. self.size = im.size
  265. self.load_end()
  266. def _save(im, fp, filename):
  267. """
  268. Saves the image as a series of PNG files,
  269. that are then converted to a .icns file
  270. using the macOS command line utility 'iconutil'.
  271. macOS only.
  272. """
  273. if hasattr(fp, "flush"):
  274. fp.flush()
  275. # create the temporary set of pngs
  276. with tempfile.TemporaryDirectory(".iconset") as iconset:
  277. provided_images = {
  278. im.width: im for im in im.encoderinfo.get("append_images", [])
  279. }
  280. last_w = None
  281. second_path = None
  282. for w in [16, 32, 128, 256, 512]:
  283. prefix = f"icon_{w}x{w}"
  284. first_path = os.path.join(iconset, prefix + ".png")
  285. if last_w == w:
  286. shutil.copyfile(second_path, first_path)
  287. else:
  288. im_w = provided_images.get(w, im.resize((w, w), Image.LANCZOS))
  289. im_w.save(first_path)
  290. second_path = os.path.join(iconset, prefix + "@2x.png")
  291. im_w2 = provided_images.get(w * 2, im.resize((w * 2, w * 2), Image.LANCZOS))
  292. im_w2.save(second_path)
  293. last_w = w * 2
  294. # iconutil -c icns -o {} {}
  295. fp_only = not filename
  296. if fp_only:
  297. f, filename = tempfile.mkstemp(".icns")
  298. os.close(f)
  299. convert_cmd = ["iconutil", "-c", "icns", "-o", filename, iconset]
  300. convert_proc = subprocess.Popen(
  301. convert_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
  302. )
  303. convert_proc.stdout.close()
  304. retcode = convert_proc.wait()
  305. if retcode:
  306. raise subprocess.CalledProcessError(retcode, convert_cmd)
  307. if fp_only:
  308. with open(filename, "rb") as f:
  309. fp.write(f.read())
  310. Image.register_open(IcnsImageFile.format, IcnsImageFile, lambda x: x[:4] == b"icns")
  311. Image.register_extension(IcnsImageFile.format, ".icns")
  312. if sys.platform == "darwin":
  313. Image.register_save(IcnsImageFile.format, _save)
  314. Image.register_mime(IcnsImageFile.format, "image/icns")
  315. if __name__ == "__main__":
  316. if len(sys.argv) < 2:
  317. print("Syntax: python IcnsImagePlugin.py [file]")
  318. sys.exit()
  319. with open(sys.argv[1], "rb") as fp:
  320. imf = IcnsImageFile(fp)
  321. for size in imf.info["sizes"]:
  322. imf.size = size
  323. imf.save("out-%s-%s-%s.png" % size)
  324. with Image.open(sys.argv[1]) as im:
  325. im.save("out.png")
  326. if sys.platform == "windows":
  327. os.startfile("out.png")