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