Shiny new script which constructs the various icons for the PuTTY
[u/mdw/putty] / icons / mkicon.py
diff --git a/icons/mkicon.py b/icons/mkicon.py
new file mode 100755 (executable)
index 0000000..5bb6a1c
--- /dev/null
@@ -0,0 +1,994 @@
+#!/usr/bin/env python
+
+import math
+
+# Python code which draws the PuTTY icon components at a range of
+# sizes.
+
+# TODO
+# ----
+#
+#  - use of alpha blending
+#     + try for variable-transparency borders
+#
+#  - can we integrate the Mac icons into all this? Do we want to?
+
+def pixel(x, y, colour, canvas):
+    canvas[(int(x),int(y))] = colour
+
+def overlay(src, x, y, dst):
+    x = int(x)
+    y = int(y)
+    for (sx, sy), colour in src.items():
+       dst[sx+x, sy+y] = blend(colour, dst.get((sx+x, sy+y), cT))
+
+def finalise(canvas):
+    for k in canvas.keys():
+       canvas[k] = finalisepix(canvas[k])
+
+def bbox(canvas):
+    minx, miny, maxx, maxy = None, None, None, None
+    for (x, y) in canvas.keys():
+       if minx == None:
+           minx, miny, maxx, maxy = x, y, x+1, y+1
+       else:
+           minx = min(minx, x)
+           miny = min(miny, y)
+           maxx = max(maxx, x+1)
+           maxy = max(maxy, y+1)
+    return (minx, miny, maxx, maxy)
+
+def topy(canvas):
+    miny = {}
+    for (x, y) in canvas.keys():
+       miny[x] = min(miny.get(x, y), y)
+    return miny
+
+def render(canvas, minx, miny, maxx, maxy):
+    w = maxx - minx
+    h = maxy - miny
+    ret = []
+    for y in range(h):
+       ret.append([outpix(cT)] * w)
+    for (x, y), colour in canvas.items():
+       if x >= minx and x < maxx and y >= miny and y < maxy:
+           ret[y-miny][x-minx] = outpix(colour)
+    return ret
+
+# Code to actually draw pieces of icon. These don't generally worry
+# about positioning within a canvas; they just draw at a standard
+# location, return some useful coordinates, and leave composition
+# to other pieces of code.
+
+sqrthash = {}
+def memoisedsqrt(x):
+    if not sqrthash.has_key(x):
+       sqrthash[x] = math.sqrt(x)
+    return sqrthash[x]
+
+BR, TR, BL, TL = range(4) # enumeration of quadrants for border()
+
+def border(canvas, thickness, squarecorners):
+    # I haven't yet worked out exactly how to do borders in a
+    # properly alpha-blended fashion.
+    #
+    # When you have two shades of dark available (half-dark H and
+    # full-dark F), the right sequence of circular border sections
+    # around a pixel x starts off with these two layouts:
+    #
+    #   H    F
+    #  HxH  FxF
+    #   H    F
+    #
+    # Where it goes after that I'm not entirely sure, but I'm
+    # absolutely sure those are the right places to start. However,
+    # every automated algorithm I've tried has always started off
+    # with the two layouts
+    #
+    #   H   HHH
+    #  HxH  HxH
+    #   H   HHH
+    #
+    # which looks much worse. This is true whether you do
+    # pixel-centre sampling (define an inner circle and an outer
+    # circle with radii differing by 1, set any pixel whose centre
+    # is inside the inner circle to F, any pixel whose centre is
+    # outside the outer one to nothing, interpolate between the two
+    # and round sensibly), _or_ whether you plot a notional circle
+    # of a given radius and measure the actual _proportion_ of each
+    # pixel square taken up by it.
+    #
+    # It's not clear what I should be doing to prevent this. One
+    # option is to attempt error-diffusion: Ian Jackson proved on
+    # paper that if you round each pixel's ideal value to the
+    # nearest of the available output values, then measure the
+    # error at each pixel, propagate that error outwards into the
+    # original values of the surrounding pixels, and re-round
+    # everything, you do get the correct second stage. However, I
+    # haven't tried it at a proper range of radii.
+    #
+    # Another option is that the automated mechanisms described
+    # above would be entirely adequate if it weren't for the fact
+    # that the human visual centres are adapted to detect
+    # horizontal and vertical lines in particular, so the only
+    # place you have to behave a bit differently is at the ends of
+    # the top and bottom row of pixels in the circle, and the top
+    # and bottom of the extreme columns.
+    #
+    # For the moment, what I have below is a very simple mechanism
+    # which always uses only one alpha level for any given border
+    # thickness, and which seems to work well enough for Windows
+    # 16-colour icons. Everything else will have to wait.
+
+    thickness = memoisedsqrt(thickness)
+
+    if thickness < 0.9:
+       darkness = 0.5
+    else:
+       darkness = 1
+    if thickness < 1: thickness = 1
+    thickness = round(thickness - 0.5) + 0.3
+
+    dmax = int(round(thickness))
+    if dmax < thickness: dmax = dmax + 1
+
+    cquadrant = [[0] * (dmax+1) for x in range(dmax+1)]
+    squadrant = [[0] * (dmax+1) for x in range(dmax+1)]
+
+    for x in range(dmax+1):
+       for y in range(dmax+1):
+           if max(x, y) < thickness:
+               squadrant[x][y] = darkness
+           if memoisedsqrt(x*x+y*y) < thickness:
+               cquadrant[x][y] = darkness
+
+    bvalues = {}
+    for (x, y), colour in canvas.items():
+       for dx in range(-dmax, dmax+1):
+           for dy in range(-dmax, dmax+1):
+               quadrant = 2 * (dx < 0) + (dy < 0)
+               if (x, y, quadrant) in squarecorners:
+                   bval = squadrant[abs(dx)][abs(dy)]
+               else:
+                   bval = cquadrant[abs(dx)][abs(dy)]
+               if bvalues.get((x+dx,y+dy),0) < bval:
+                   bvalues[(x+dx,y+dy)] = bval
+
+    for (x, y), value in bvalues.items():
+       if not canvas.has_key((x,y)):
+           canvas[(x,y)] = dark(value)
+
+def sysbox(size):
+    canvas = {}
+
+    # The system box of the computer.
+
+    height = int(round(3*size))
+    width = int(round(17*size))
+    depth = int(round(2*size))
+    highlight = int(round(1*size))
+    bothighlight = int(round(0.49*size))
+
+    floppystart = int(round(19*size)) # measured in half-pixels
+    floppyend = int(round(29*size)) # measured in half-pixels
+    floppybottom = height - bothighlight
+    floppyrheight = 0.7 * size
+    floppyheight = int(round(floppyrheight))
+    if floppyheight < 1:
+       floppyheight = 1
+    floppytop = floppybottom - floppyheight
+
+    # The front panel is rectangular.
+    for x in range(width):
+       for y in range(height):
+           grey = 3
+           if x < highlight or y < highlight:
+               grey = grey + 1
+           if x >= width-highlight or y >= height-bothighlight:
+               grey = grey - 1
+           if y < highlight and x >= width-highlight:
+               v = (highlight-1-y) - (x-(width-highlight))
+               if v < 0:
+                   grey = grey - 1
+               elif v > 0:
+                   grey = grey + 1
+           if y >= floppytop and y < floppybottom and \
+           2*x+2 > floppystart and 2*x < floppyend:
+               if 2*x >= floppystart and 2*x+2 <= floppyend and \
+               floppyrheight >= 0.7:
+                   grey = 0
+               else:
+                   grey = 2
+           pixel(x, y, greypix(grey/4.0), canvas)
+
+    # The side panel is a parallelogram.
+    for x in range(depth):
+       for y in range(height+1):
+           pixel(x+width, y-(x+1), greypix(0.5), canvas)
+
+    # The top panel is another parallelogram.
+    for x in range(width-1):
+       for y in range(depth):
+           grey = 3
+           if x >= width-1 - highlight:
+               grey = grey + 1         
+           pixel(x+(y+1), -(y+1), greypix(grey/4.0), canvas)
+
+    # And draw a border.
+    border(canvas, size, [])
+
+    return canvas
+
+def monitor(size):
+    canvas = {}
+
+    # The computer's monitor.
+
+    height = int(round(9.55*size))
+    width = int(round(11*size))
+    surround = int(round(1*size))
+    botsurround = int(round(2*size))
+    sheight = height - surround - botsurround
+    swidth = width - 2*surround
+    depth = int(round(2*size))
+    highlight = int(round(math.sqrt(size)))
+    shadow = int(round(0.55*size))
+
+    # The front panel is rectangular.
+    for x in range(width):
+       for y in range(height):
+           if x >= surround and y >= surround and \
+           x < surround+swidth and y < surround+sheight:
+               # Screen.
+               sx = (float(x-surround) - swidth/3) / swidth
+               sy = (float(y-surround) - sheight/3) / sheight
+               shighlight = 1.0 - (sx*sx+sy*sy)*0.27
+               pix = bluepix(shighlight)
+               if x < surround+shadow or y < surround+shadow:
+                   pix = blend(cD, pix) # sharp-edged shadow on top and left
+           else:
+               # Complicated double bevel on the screen surround.
+
+               # First, the outer bevel. We compute the distance
+               # from this pixel to each edge of the front
+               # rectangle.
+               list = [
+               (x, +1),
+               (y, +1),
+               (width-1-x, -1),
+               (height-1-y, -1)
+               ]
+               # Now sort the list to find the distance to the
+               # _nearest_ edge, or the two joint nearest.
+               list.sort()
+               # If there's one nearest edge, that determines our
+               # bevel colour. If there are two joint nearest, our
+               # bevel colour is their shared one if they agree,
+               # and neutral otherwise.
+               outerbevel = 0
+               if list[0][0] < list[1][0] or list[0][1] == list[1][1]:
+                   if list[0][0] < highlight:
+                       outerbevel = list[0][1]
+
+               # Now, the inner bevel. We compute the distance
+               # from this pixel to each edge of the screen
+               # itself.
+               list = [
+               (surround-1-x, -1),
+               (surround-1-y, -1),
+               (x-(surround+swidth), +1),
+               (y-(surround+sheight), +1)
+               ]
+               # Now we sort to find the _maximum_ distance, which
+               # conveniently ignores any less than zero.
+               list.sort()
+               # And now the strategy is pretty much the same as
+               # above, only we're working from the opposite end
+               # of the list.
+               innerbevel = 0
+               if list[-1][0] > list[-2][0] or list[-1][1] == list[-2][1]:
+                   if list[-1][0] >= 0 and list[-1][0] < highlight:
+                       innerbevel = list[-1][1]
+
+               # Now we know the adjustment we want to make to the
+               # pixel's overall grey shade due to the outer
+               # bevel, and due to the inner one. We break a tie
+               # in favour of a light outer bevel, but otherwise
+               # add.
+               grey = 3
+               if outerbevel > 0 or outerbevel == innerbevel:
+                   innerbevel = 0
+               grey = grey + outerbevel + innerbevel
+
+               pix = greypix(grey / 4.0)
+
+           pixel(x, y, pix, canvas)
+
+    # The side panel is a parallelogram.
+    for x in range(depth):
+       for y in range(height):
+           pixel(x+width, y-x, greypix(0.5), canvas)
+
+    # The top panel is another parallelogram.
+    for x in range(width):
+       for y in range(depth-1):
+           pixel(x+(y+1), -(y+1), greypix(0.75), canvas)
+
+    # And draw a border.
+    border(canvas, size, [(0,int(height-1),BL)])
+
+    return canvas
+
+def computer(size):
+    # Monitor plus sysbox.
+    m = monitor(size)
+    s = sysbox(size)
+    x = int(round((2+size/(size+1))*size))
+    y = int(round(4*size))
+    mb = bbox(m)
+    sb = bbox(s)
+    xoff = sb[0] - mb[0] + x
+    yoff = sb[3] - mb[3] - y
+    overlay(m, xoff, yoff, s)
+    return s
+
+def lightning(size):
+    canvas = {}
+
+    # The lightning bolt motif.
+
+    # We always want this to be an even number of pixels in span.
+    width = round(7*size) * 2
+    height = round(8*size) * 2
+
+    # The outer edge of each side of the bolt goes to this point.
+    outery = round(8.4*size)
+    outerx = round(11*size)
+
+    # And the inner edge goes to this point.
+    innery = height - 1 - outery
+    innerx = round(7*size)
+
+    for y in range(int(height)):
+       list = []
+       if y <= outery:
+           list.append(width-1-int(outerx * float(y) / outery + 0.3))
+       if y <= innery:
+           list.append(width-1-int(innerx * float(y) / innery + 0.3))
+       y0 = height-1-y
+       if y0 <= outery:
+           list.append(int(outerx * float(y0) / outery + 0.3))
+       if y0 <= innery:
+           list.append(int(innerx * float(y0) / innery + 0.3))
+       list.sort()
+       for x in range(int(list[0]), int(list[-1]+1)):
+           pixel(x, y, cY, canvas)
+
+    # And draw a border.
+    border(canvas, size, [(int(width-1),0,TR), (0,int(height-1),BL)])
+
+    return canvas
+
+def document(size):
+    canvas = {}
+
+    # The document used in the PSCP/PSFTP icon.
+
+    width = round(13*size)
+    height = round(16*size)
+
+    lineht = round(1*size)
+    if lineht < 1: lineht = 1
+    linespc = round(0.7*size)
+    if linespc < 1: linespc = 1
+    nlines = int((height-linespc)/(lineht+linespc))
+    height = nlines*(lineht+linespc)+linespc # round this so it fits better
+
+    # Start by drawing a big white rectangle.
+    for y in range(int(height)):
+       for x in range(int(width)):
+           pixel(x, y, cW, canvas)
+
+    # Now draw lines of text.
+    for line in range(nlines):
+       # Decide where this line of text begins.
+       if line == 0:
+           start = round(4*size)
+       elif line < 5*nlines/7:
+           start = round((line - (nlines/7)) * size)
+       else:
+           start = round(1*size)
+       if start < round(1*size):
+           start = round(1*size)
+       # Decide where it ends.
+       endpoints = [10, 8, 11, 6, 5, 7, 5]
+       ey = line * 6.0 / (nlines-1)
+       eyf = math.floor(ey)
+       eyc = math.ceil(ey)
+       exf = endpoints[int(eyf)]
+       exc = endpoints[int(eyc)]
+       if eyf == eyc:
+           end = exf
+       else:
+           end = exf * (eyc-ey) + exc * (ey-eyf)
+       end = round(end * size)
+
+       liney = height - (lineht+linespc) * (line+1)
+       for x in range(int(start), int(end)):
+           for y in range(int(lineht)):
+               pixel(x, y+liney, cK, canvas)
+
+    # And draw a border.
+    border(canvas, size, \
+    [(0,0,TL),(int(width-1),0,TR),(0,int(height-1),BL), \
+    (int(width-1),int(height-1),BR)])
+
+    return canvas
+
+def hat(size):
+    canvas = {}
+
+    # The secret-agent hat in the Pageant icon.
+
+    topa = [6]*9+[5,3,1,0,0,1,2,2,1,1,1,9,9,10,10,11,11,12,12]
+    topa = [round(x*size) for x in topa]
+    botl = round(topa[0]+2.4*math.sqrt(size))
+    botr = round(topa[-1]+2.4*math.sqrt(size))
+    width = round(len(topa)*size)
+
+    # Line equations for the top and bottom of the hat brim, in the
+    # form y=mx+c. c, of course, needs scaling by size, but m is
+    # independent of size.
+    brimm = 1.0 / 3.75
+    brimtopc = round(4*size/3)
+    brimbotc = round(10*size/3)
+
+    for x in range(int(width)):
+       xs = float(x) * (len(topa)-1) / (width-1)
+       xf = math.floor(xs)
+       xc = math.ceil(xs)
+       topf = topa[int(xf)]
+       topc = topa[int(xc)]
+       if xf == xc:
+           top = topf
+       else:
+           top = topf * (xc-xs) + topc * (xs-xf)
+       top = math.floor(top)
+       bot = round(botl + (botr-botl) * x/(width-1))
+
+       for y in range(int(top), int(bot)):
+           pixel(x, y, cK, canvas)
+
+    # Now draw the brim.
+    for x in range(int(width)):
+       brimtop = brimtopc + brimm * x
+       brimbot = brimbotc + brimm * x
+       for y in range(int(math.floor(brimtop)), int(math.ceil(brimbot))):
+           tophere = max(min(brimtop - y, 1), 0)
+           bothere = max(min(brimbot - y, 1), 0)
+           grey = bothere - tophere
+           # Only draw brim pixels over pixels which are (a) part
+           # of the main hat, and (b) not right on its edge.
+           if canvas.has_key((x,y)) and \
+           canvas.has_key((x,y-1)) and \
+           canvas.has_key((x,y+1)) and \
+           canvas.has_key((x-1,y)) and \
+           canvas.has_key((x+1,y)):
+               pixel(x, y, greypix(grey), canvas)
+
+    return canvas
+
+def key(size):
+    canvas = {}
+
+    # The key in the PuTTYgen icon.
+
+    keyheadw = round(9.5*size)
+    keyheadh = round(12*size)
+    keyholed = round(4*size)
+    keyholeoff = round(2*size)
+    # Ensure keyheadh and keyshafth have the same parity.
+    keyshafth = round((2*size - (int(keyheadh)&1)) / 2) * 2 + (int(keyheadh)&1)
+    keyshaftw = round(18.5*size)
+    keyhead = [round(x*size) for x in [12,11,8,10,9,8,11,12]]
+
+    squarepix = []
+
+    # Ellipse for the key head, minus an off-centre circular hole.
+    for y in range(int(keyheadh)):
+       dy = (y-(keyheadh-1)/2.0) / (keyheadh/2.0)
+       dyh = (y-(keyheadh-1)/2.0) / (keyholed/2.0)
+       for x in range(int(keyheadw)):
+           dx = (x-(keyheadw-1)/2.0) / (keyheadw/2.0)
+           dxh = (x-(keyheadw-1)/2.0-keyholeoff) / (keyholed/2.0)
+           if dy*dy+dx*dx <= 1 and dyh*dyh+dxh*dxh > 1:
+               pixel(x + keyshaftw, y, cy, canvas)
+
+    # Rectangle for the key shaft, extended at the bottom for the
+    # key head detail.
+    for x in range(int(keyshaftw)):
+       top = round((keyheadh - keyshafth) / 2)
+       bot = round((keyheadh + keyshafth) / 2)
+       xs = float(x) * (len(keyhead)-1) / round((len(keyhead)-1)*size)
+       xf = math.floor(xs)
+       xc = math.ceil(xs)
+       in_head = 0
+       if xc < len(keyhead):
+           in_head = 1
+           yf = keyhead[int(xf)]
+           yc = keyhead[int(xc)]
+           if xf == xc:
+               bot = yf
+           else:
+               bot = yf * (xc-xs) + yc * (xs-xf)
+       for y in range(int(top),int(bot)):
+           pixel(x, y, cy, canvas)
+           if in_head:
+               last = (x, y)
+       if x == 0:
+           squarepix.append((x, int(top), TL))
+       if x == 0:
+           squarepix.append(last + (BL,))
+       if last != None and not in_head:
+           squarepix.append(last + (BR,))
+           last = None
+
+    # And draw a border.
+    border(canvas, size, squarepix)
+
+    return canvas
+
+def linedist(x1,y1, x2,y2, x,y):
+    # Compute the distance from the point x,y to the line segment
+    # joining x1,y1 to x2,y2. Returns the distance vector, measured
+    # with x,y at the origin.
+
+    vectors = []
+
+    # Special case: if x1,y1 and x2,y2 are the same point, we
+    # don't attempt to extrapolate it into a line at all.
+    if x1 != x2 or y1 != y2:
+       # First, find the nearest point to x,y on the infinite
+       # projection of the line segment. So we construct a vector
+       # n perpendicular to that segment...
+       nx = y2-y1
+       ny = x1-x2
+       # ... compute the dot product of (x1,y1)-(x,y) with that
+       # vector...
+       nd = (x1-x)*nx + (y1-y)*ny
+       # ... multiply by the vector we first thought of...
+       ndx = nd * nx
+       ndy = nd * ny
+       # ... and divide twice by the length of n.
+       ndx = ndx / (nx*nx+ny*ny)
+       ndy = ndy / (nx*nx+ny*ny)
+       # That gives us a displacement vector from x,y to the
+       # nearest point. See if it's within the range of the line
+       # segment.
+       cx = x + ndx
+       cy = y + ndy
+       if cx >= min(x1,x2) and cx <= max(x1,x2) and \
+       cy >= min(y1,y2) and cy <= max(y1,y2):
+           vectors.append((ndx,ndy))
+
+    # Now we have up to three candidate result vectors: (ndx,ndy)
+    # as computed just above, and the two vectors to the ends of
+    # the line segment, (x1-x,y1-y) and (x2-x,y2-y). Pick the
+    # shortest.
+    vectors = vectors + [(x1-x,y1-y), (x2-x,y2-y)]
+    bestlen, best = None, None
+    for v in vectors:
+       vlen = v[0]*v[0]+v[1]*v[1]
+       if bestlen == None or bestlen > vlen:
+           bestlen = vlen
+           best = v
+    return best
+
+def spanner(size):
+    canvas = {}
+
+    # The spanner in the config box icon.
+
+    headcentre = 0.5 + round(4*size)
+    headradius = headcentre + 0.1
+    headhighlight = round(1.5*size)
+    holecentre = 0.5 + round(3*size)
+    holeradius = round(2*size)
+    holehighlight = round(1.5*size)
+    shaftend = 0.5 + round(25*size)
+    shaftwidth = round(2*size)
+    shafthighlight = round(1.5*size)
+    cmax = shaftend + shaftwidth
+
+    # Define three line segments, such that the shortest distance
+    # vectors from any point to each of these segments determines
+    # everything we need to know about where it is on the spanner
+    # shape.
+    segments = [
+    ((0,0), (holecentre, holecentre)),
+    ((headcentre, headcentre), (headcentre, headcentre)),
+    ((headcentre+headradius/math.sqrt(2), headcentre+headradius/math.sqrt(2)),
+    (cmax, cmax))
+    ]
+
+    for y in range(int(cmax)):
+       for x in range(int(cmax)):
+           vectors = [linedist(a,b,c,d,x,y) for ((a,b),(c,d)) in segments]
+           dists = [memoisedsqrt(vx*vx+vy*vy) for (vx,vy) in vectors]
+
+           # If the distance to the hole line is less than
+           # holeradius, we're not part of the spanner.
+           if dists[0] < holeradius:
+               continue
+           # If the distance to the head `line' is less than
+           # headradius, we are part of the spanner; likewise if
+           # the distance to the shaft line is less than
+           # shaftwidth _and_ the resulting shaft point isn't
+           # beyond the shaft end.
+           if dists[1] > headradius and \
+           (dists[2] > shaftwidth or x+vectors[2][0] >= shaftend):
+               continue
+
+           # We're part of the spanner. Now compute the highlight
+           # on this pixel. We do this by computing a `slope
+           # vector', which points from this pixel in the
+           # direction of its nearest edge. We store an array of
+           # slope vectors, in polar coordinates.
+           angles = [math.atan2(vy,vx) for (vx,vy) in vectors]
+           slopes = []
+           if dists[0] < holeradius + holehighlight:
+               slopes.append(((dists[0]-holeradius)/holehighlight,angles[0]))
+           if dists[1]/headradius < dists[2]/shaftwidth:
+               if dists[1] > headradius - headhighlight and dists[1] < headradius:
+                   slopes.append(((headradius-dists[1])/headhighlight,math.pi+angles[1]))
+           else:
+               if dists[2] > shaftwidth - shafthighlight and dists[2] < shaftwidth:
+                   slopes.append(((shaftwidth-dists[2])/shafthighlight,math.pi+angles[2]))
+           # Now we find the smallest distance in that array, if
+           # any, and that gives us a notional position on a
+           # sphere which we can use to compute the final
+           # highlight level.
+           bestdist = None
+           bestangle = 0
+           for dist, angle in slopes:
+               if bestdist == None or bestdist > dist:
+                   bestdist = dist
+                   bestangle = angle
+           if bestdist == None:
+               bestdist = 1.0
+           sx = (1.0-bestdist) * math.cos(bestangle)
+           sy = (1.0-bestdist) * math.sin(bestangle)
+           sz = math.sqrt(1.0 - sx*sx - sy*sy)
+           shade = sx-sy+sz / math.sqrt(3) # can range from -1 to +1
+           shade = 1.0 - (1-shade)/3
+
+           pixel(x, y, yellowpix(shade), canvas)
+
+    # And draw a border.
+    border(canvas, size, [])
+
+    return canvas
+
+# Functions to draw entire icons by composing the above components.
+
+def xybolt(c1, c2, size, boltoffx=0, boltoffy=0):
+    # Two unspecified objects and a lightning bolt.
+
+    canvas = {}
+    w = h = round(32 * size)
+
+    bolt = lightning(size)
+
+    # Position c2 against the top right of the icon.
+    bb = bbox(c2)
+    assert bb[2]-bb[0] <= w and bb[3]-bb[1] <= h
+    overlay(c2, w-bb[2], 0-bb[1], canvas)
+    # Position c1 against the bottom left of the icon.
+    bb = bbox(c1)
+    assert bb[2]-bb[0] <= w and bb[3]-bb[1] <= h
+    overlay(c1, 0-bb[0], h-bb[3], canvas)
+    # Place the lightning bolt artistically off-centre. (The
+    # rationale for this positioning is that it's centred on the
+    # midpoint between the centres of the two monitors in the PuTTY
+    # icon proper, but it's not really feasible to _base_ the
+    # calculation here on that.)
+    bb = bbox(bolt)
+    assert bb[2]-bb[0] <= w and bb[3]-bb[1] <= h
+    overlay(bolt, (w-bb[0]-bb[2])/2 - round((1-boltoffx)*size), \
+    (h-bb[1]-bb[3])/2 - round((2-boltoffy)*size), canvas)
+
+    return canvas
+
+def putty_icon(size):
+    return xybolt(computer(size), computer(size), size)
+
+def puttycfg_icon(size):
+    w = h = round(32 * size)
+    s = spanner(size)
+    canvas = putty_icon(size)
+    # Centre the spanner.
+    bb = bbox(s)
+    overlay(s, (w-bb[0]-bb[2])/2, (h-bb[1]-bb[3])/2, canvas)
+    return canvas
+
+def puttygen_icon(size):
+    return xybolt(computer(size), key(size), size, boltoffx=2)
+
+def pscp_icon(size):
+    return xybolt(document(size), computer(size), size, boltoffx=1)
+
+def pterm_icon(size):
+    # Just a really big computer.
+
+    canvas = {}
+    w = h = round(32 * size)
+
+    c = computer(size * 1.4)
+
+    # Centre c in the return canvas.
+    bb = bbox(c)
+    assert bb[2]-bb[0] <= w and bb[3]-bb[1] <= h
+    overlay(c, (w-bb[0]-bb[2])/2, (h-bb[1]-bb[3])/2, canvas)
+
+    return canvas
+
+def ptermcfg_icon(size):
+    w = h = round(32 * size)
+    s = spanner(size)
+    canvas = pterm_icon(size)
+    # Centre the spanner.
+    bb = bbox(s)
+    overlay(s, (w-bb[0]-bb[2])/2, (h-bb[1]-bb[3])/2, canvas)
+    return canvas
+
+def pageant_icon(size):
+    # A biggish computer, in a hat.
+
+    canvas = {}
+    w = h = round(32 * size)
+
+    c = computer(size * 1.3)
+    ht = hat(size)
+
+    cbb = bbox(c)
+    hbb = bbox(ht)
+
+    # Determine the relative y-coordinates of the computer and hat.
+    # We just centre the one on the other.
+    xrel = (cbb[0]+cbb[2]-hbb[0]-hbb[2])/2
+
+    # Determine the relative y-coordinates of the computer and hat.
+    # We do this by sitting the hat as low down on the computer as
+    # possible without any computer showing over the top. To do
+    # this we first have to find the minimum x coordinate at each
+    # y-coordinate of both components.
+    cty = topy(c)
+    hty = topy(ht)
+    yrelmin = None
+    for cx in cty.keys():
+       hx = cx - xrel
+       assert hty.has_key(hx)
+       yrel = cty[cx] - hty[hx]
+       if yrelmin == None:
+           yrelmin = yrel
+       else:
+           yrelmin = min(yrelmin, yrel)
+
+    # Overlay the hat on the computer.
+    overlay(ht, xrel, yrelmin, c)
+
+    # And centre the result in the main icon canvas.
+    bb = bbox(c)
+    assert bb[2]-bb[0] <= w and bb[3]-bb[1] <= h
+    overlay(c, (w-bb[0]-bb[2])/2, (h-bb[1]-bb[3])/2, canvas)
+
+    return canvas
+
+# Test and output functions.
+
+import os
+import sys
+
+def testrun(func, fname):
+    canvases = []
+    for size in [0.5, 0.6, 1.0, 1.2, 1.5, 4.0]:
+       canvases.append(func(size))
+    wid = 0
+    ht = 0
+    for canvas in canvases:
+       minx, miny, maxx, maxy = bbox(canvas)
+       wid = max(wid, maxx-minx+4)
+       ht = ht + maxy-miny+4
+    block = []
+    for canvas in canvases:
+       minx, miny, maxx, maxy = bbox(canvas)
+       block.extend(render(canvas, minx-2, miny-2, minx-2+wid, maxy+2))
+    p = os.popen("convert -depth 8 -size %dx%d rgb:- %s" % (wid,ht,fname), "w")
+    assert len(block) == ht
+    for line in block:
+       assert len(line) == wid
+       for r, g, b, a in line:
+           # Composite on to orange.
+           r = int(round((r * a + 255 * (255-a)) / 255.0))
+           g = int(round((g * a + 128 * (255-a)) / 255.0))
+           b = int(round((b * a +   0 * (255-a)) / 255.0))
+           p.write("%c%c%c" % (r,g,b))
+    p.close()
+
+def drawicon(func, width, fname, orangebackground = 0):
+    canvas = func(width / 32.0)
+    finalise(canvas)
+    minx, miny, maxx, maxy = bbox(canvas)
+    assert minx >= 0 and miny >= 0 and maxx <= width and maxy <= width
+
+    block = render(canvas, 0, 0, width, width)
+    p = os.popen("convert -depth 8 -size %dx%d rgba:- %s" % (width,width,fname), "w")
+    assert len(block) == width
+    for line in block:
+       assert len(line) == width
+       for r, g, b, a in line:
+           if orangebackground:
+               # Composite on to orange.
+               r = int(round((r * a + 255 * (255-a)) / 255.0))
+               g = int(round((g * a + 128 * (255-a)) / 255.0))
+               b = int(round((b * a +   0 * (255-a)) / 255.0))
+               a = 255
+           p.write("%c%c%c%c" % (r,g,b,a))
+    p.close()
+
+args = sys.argv[1:]
+
+orangebackground = test = 0
+colours = 1 # 0=mono, 1=16col, 2=truecol
+doingargs = 1
+
+realargs = []
+for arg in args:
+    if doingargs and arg[0] == "-":
+       if arg == "-t":
+           test = 1
+       elif arg == "-it":
+           orangebackground = 1
+       elif arg == "-2":
+           colours = 0
+       elif arg == "-T":
+           colours = 2
+       elif arg == "--":
+           doingargs = 0
+       else:
+           sys.stderr.write("unrecognised option '%s'\n" % arg)
+           sys.exit(1)
+    else:
+       realargs.append(arg)
+
+if colours == 0:
+    # Monochrome.
+    cK=cr=cg=cb=cm=cc=cP=cw=cR=cG=cB=cM=cC=cD = 0
+    cY=cy=cW = 1
+    cT = -1
+    def greypix(value):
+       return [cK,cW][int(round(value))]
+    def yellowpix(value):
+       return [cK,cW][int(round(value))]
+    def bluepix(value):
+       return cK
+    def dark(value):
+       return [cT,cK][int(round(value))]
+    def blend(col1, col2):
+       if col1 == cT:
+           return col2
+       else:
+           return col1
+    pixvals = [
+    (0x00, 0x00, 0x00, 0xFF), # cK
+    (0xFF, 0xFF, 0xFF, 0xFF), # cW
+    (0x00, 0x00, 0x00, 0x00), # cT
+    ]
+    def outpix(colour):
+       return pixvals[colour]
+    def finalisepix(colour):
+       return colour
+elif colours == 1:
+    # Windows 16-colour palette.
+    cK,cr,cg,cy,cb,cm,cc,cP,cw,cR,cG,cY,cB,cM,cC,cW = range(16)
+    cT = -1
+    cD = -2 # special translucent half-darkening value used internally
+    def greypix(value):
+       return [cK,cw,cw,cP,cW][int(round(4*value))]
+    def yellowpix(value):
+       return [cK,cy,cY][int(round(2*value))]
+    def bluepix(value):
+       return [cK,cb,cB][int(round(2*value))]
+    def dark(value):
+       return [cT,cD,cK][int(round(2*value))]
+    def blend(col1, col2):
+       if col1 == cT:
+           return col2
+       elif col1 == cD:
+           return [cK,cK,cK,cK,cK,cK,cK,cw,cK,cr,cg,cy,cb,cm,cc,cw,cD,cD][col2]
+       else:
+           return col1
+    pixvals = [
+    (0x00, 0x00, 0x00, 0xFF), # cK
+    (0x80, 0x00, 0x00, 0xFF), # cr
+    (0x00, 0x80, 0x00, 0xFF), # cg
+    (0x80, 0x80, 0x00, 0xFF), # cy
+    (0x00, 0x00, 0x80, 0xFF), # cb
+    (0x80, 0x00, 0x80, 0xFF), # cm
+    (0x00, 0x80, 0x80, 0xFF), # cc
+    (0xC0, 0xC0, 0xC0, 0xFF), # cP
+    (0x80, 0x80, 0x80, 0xFF), # cw
+    (0xFF, 0x00, 0x00, 0xFF), # cR
+    (0x00, 0xFF, 0x00, 0xFF), # cG
+    (0xFF, 0xFF, 0x00, 0xFF), # cY
+    (0x00, 0x00, 0xFF, 0xFF), # cB
+    (0xFF, 0x00, 0xFF, 0xFF), # cM
+    (0x00, 0xFF, 0xFF, 0xFF), # cC
+    (0xFF, 0xFF, 0xFF, 0xFF), # cW
+    (0x00, 0x00, 0x00, 0x80), # cD
+    (0x00, 0x00, 0x00, 0x00), # cT
+    ]
+    def outpix(colour):
+       return pixvals[colour]
+    def finalisepix(colour):
+       # cD is used internally, but can't be output. Convert to cK.
+       if colour == cD:
+           return cK
+       return colour
+else:
+    # True colour.
+    cK = (0x00, 0x00, 0x00, 0xFF)
+    cr = (0x80, 0x00, 0x00, 0xFF)
+    cg = (0x00, 0x80, 0x00, 0xFF)
+    cy = (0x80, 0x80, 0x00, 0xFF)
+    cb = (0x00, 0x00, 0x80, 0xFF)
+    cm = (0x80, 0x00, 0x80, 0xFF)
+    cc = (0x00, 0x80, 0x80, 0xFF)
+    cP = (0xC0, 0xC0, 0xC0, 0xFF)
+    cw = (0x80, 0x80, 0x80, 0xFF)
+    cR = (0xFF, 0x00, 0x00, 0xFF)
+    cG = (0x00, 0xFF, 0x00, 0xFF)
+    cY = (0xFF, 0xFF, 0x00, 0xFF)
+    cB = (0x00, 0x00, 0xFF, 0xFF)
+    cM = (0xFF, 0x00, 0xFF, 0xFF)
+    cC = (0x00, 0xFF, 0xFF, 0xFF)
+    cW = (0xFF, 0xFF, 0xFF, 0xFF)
+    cD = (0x00, 0x00, 0x00, 0x80)
+    cT = (0x00, 0x00, 0x00, 0x00)
+    def greypix(value):
+       value = max(min(value, 1), 0)
+       return (int(round(0xFF*value)),) * 3 + (0xFF,)
+    def yellowpix(value):
+       value = max(min(value, 1), 0)
+       return (int(round(0xFF*value)),) * 2 + (0, 0xFF)
+    def bluepix(value):
+       value = max(min(value, 1), 0)
+       return (0, 0, int(round(0xFF*value)), 0xFF)
+    def dark(value):
+       value = max(min(value, 1), 0)
+       return (0, 0, 0, int(round(0xFF*value)))
+    def blend(col1, col2):
+       r1,g1,b1,a1 = col1
+       r2,g2,b2,a2 = col2
+       r = int(round((r1*a1 + r2*(0xFF-a1)) / 255.0))
+       g = int(round((g1*a1 + g2*(0xFF-a1)) / 255.0))
+       b = int(round((b1*a1 + b2*(0xFF-a1)) / 255.0))
+       a = int(round((255*a1 + a2*(0xFF-a1)) / 255.0))
+       return r, g, b, a
+    def outpix(colour):
+       return colour
+    if colours == 2:
+       # True colour with no alpha blending: we still have to
+       # finalise half-dark pixels to black.
+       def finalisepix(colour):
+           if colour[3] > 0:
+               return colour[:3] + (0xFF,)
+           return colour
+    else:
+       def finalisepix(colour):
+           return colour
+
+if test:
+    testrun(eval(realargs[0]), realargs[1])
+else:
+    drawicon(eval(realargs[0]), int(realargs[1]), realargs[2], orangebackground)