macicon.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. #!/usr/bin/env python3
  2. # Generate Mac OS X .icns files, or at least the simple subformats
  3. # that don't involve JPEG encoding and the like.
  4. #
  5. # Sources: https://en.wikipedia.org/wiki/Apple_Icon_Image_format and
  6. # some details implicitly documented by the source code of 'libicns'.
  7. import sys
  8. import struct
  9. import subprocess
  10. assert sys.version_info[:2] >= (3,0), "This is Python 3 code"
  11. # The file format has a typical IFF-style (type, length, data) chunk
  12. # structure, with one outer chunk containing subchunks for various
  13. # different icon sizes and formats.
  14. def make_chunk(chunkid, data):
  15. assert len(chunkid) == 4
  16. return chunkid + struct.pack(">I", len(data) + 8) + data
  17. # Monochrome icons: a single chunk containing a 1 bpp image followed
  18. # by a 1 bpp transparency mask. Both uncompressed, unless you count
  19. # packing the bits into bytes.
  20. def make_mono_icon(size, rgba):
  21. assert len(rgba) == size * size
  22. # We assume our input image was monochrome, so that the R,G,B
  23. # channels are all the same; we want the image and then the mask,
  24. # so we take the R channel followed by the alpha channel. However,
  25. # we have to flip the former, because in the output format the
  26. # image has 0=white and 1=black, while the mask has 0=transparent
  27. # and 1=opaque.
  28. pixels = [rgba[index][chan] ^ flip for (chan, flip) in [(0,0xFF),(3,0)]
  29. for index in range(len(rgba))]
  30. # Encode in 1-bit big-endian format.
  31. data = b''
  32. for i in range(0, len(pixels), 8):
  33. byte = 0
  34. for j in range(8):
  35. if pixels[i+j] >= 0x80:
  36. byte |= 0x80 >> j
  37. data += bytes(byte)
  38. # This size-32 chunk id is an anomaly in what would otherwise be a
  39. # consistent system of using {s,l,h,t} for {16,32,48,128}-pixel
  40. # icon sizes.
  41. chunkid = { 16: b"ics#", 32: b"ICN#", 48: b"ich#" }[size]
  42. return make_chunk(chunkid, data)
  43. # Mask for full-colour icons: a chunk containing an 8 bpp alpha
  44. # bitmap, uncompressed. The RGB data appears in a separate chunk.
  45. def make_colour_mask(size, rgba):
  46. assert len(rgba) == size * size
  47. data = bytes(map(lambda pix: pix[3], rgba))
  48. chunkid = { 16: b"s8mk", 32: b"l8mk", 48: b"h8mk", 128: b"t8mk" }[size]
  49. return make_chunk(chunkid, data)
  50. # Helper routine for deciding when to start and stop run-length
  51. # encoding.
  52. def runof3(string, position):
  53. return (position < len(string) and
  54. string[position:position+3] == string[position] * 3)
  55. # RGB data for full-colour icons: a chunk containing 8 bpp red, green
  56. # and blue images, each run-length encoded (see comment inside the
  57. # function), and then concatenated.
  58. def make_colour_icon(size, rgba):
  59. assert len(rgba) == size * size
  60. data = b""
  61. # Mysterious extra zero header word appearing only in the size-128
  62. # icon chunk. libicns doesn't know what it's for, and neither do
  63. # I.
  64. if size == 128:
  65. data += b"\0\0\0\0"
  66. # Handle R,G,B channels in sequence. (Ignore the alpha channel; it
  67. # goes into the separate mask chunk constructed above.)
  68. for chan in range(3):
  69. pixels = bytes([rgba[index][chan] for index in range(len(rgba))])
  70. # Run-length encode each channel using the following format:
  71. # * byte 0x80-0xFF followed by one literal byte means repeat
  72. # that byte 3-130 times
  73. # * byte 0x00-0x7F followed by n+1 literal bytes means emit
  74. # those bytes once each.
  75. pos = 0
  76. while pos < len(pixels):
  77. start = pos
  78. if runof3(pixels, start):
  79. pos += 3
  80. pixval = pixels[start]
  81. while (pos - start < 130 and
  82. pos < len(pixels) and
  83. pixels[pos] == pixval):
  84. pos += 1
  85. data += bytes(0x80 + pos-start - 3) + pixval
  86. else:
  87. while (pos - start < 128 and
  88. pos < len(pixels) and
  89. not runof3(pixels, pos)):
  90. pos += 1
  91. data += bytes(0x00 + pos-start - 1) + pixels[start:pos]
  92. chunkid = { 16: b"is32", 32: b"il32", 48: b"ih32", 128: b"it32" }[size]
  93. return make_chunk(chunkid, data)
  94. # Load an image file from disk and turn it into a simple list of
  95. # 4-tuples giving 8-bit R,G,B,A values for each pixel.
  96. #
  97. # To avoid adding any build dependency on ImageMagick or Python
  98. # imaging libraries, none of which comes as standard on OS X, I insist
  99. # here that the file is in RGBA .pam format (as mkicon.py will have
  100. # generated it).
  101. def load_rgba(filename):
  102. with open(filename, "rb") as f:
  103. assert f.readline() == b"P7\n"
  104. for line in iter(f.readline, ''):
  105. words = line.decode("ASCII").rstrip("\n").split()
  106. if words[0] == "WIDTH":
  107. width = int(words[1])
  108. elif words[0] == "HEIGHT":
  109. height = int(words[1])
  110. elif words[0] == "DEPTH":
  111. assert int(words[1]) == 4
  112. elif words[0] == "TUPLTYPE":
  113. assert words[1] == "RGB_ALPHA"
  114. elif words[0] == "ENDHDR":
  115. break
  116. assert width == height
  117. data = f.read()
  118. assert len(data) == width*height*4
  119. rgba = [list(data[i:i+4]) for i in range(0, len(data), 4)]
  120. return width, rgba
  121. data = b""
  122. # Trivial argument format: each argument is a filename prefixed with
  123. # "mono:", "colour:" or "output:". The first two indicate image files
  124. # to use as part of the icon, and the last gives the output file name.
  125. # Icon subformat chunks are written out in the order of the arguments.
  126. for arg in sys.argv[1:]:
  127. kind, filename = arg.split(":", 2)
  128. if kind == "output":
  129. outfile = filename
  130. else:
  131. size, rgba = load_rgba(filename)
  132. if kind == "mono":
  133. data += make_mono_icon(size, rgba)
  134. elif kind == "colour":
  135. data += make_colour_icon(size, rgba) + make_colour_mask(size, rgba)
  136. else:
  137. assert False, "bad argument '%s'" % arg
  138. data = make_chunk(b"icns", data)
  139. with open(outfile, "wb") as f:
  140. f.write(data)