#! /usr/bin/env python2 # licenced GPL v2 # copyright Kyle Keen 2010-2011 import tempfile, os, subprocess, shutil, optparse import Image, ImageChops from itertools import * from math import sqrt join = os.path.join basename = os.path.basename splitext = os.path.splitext # requires Python Image Library, Fontforge # Written because Elvang's is a closed source and slow (30 minutes per font) AutoHotKey script for an $80 windows app. # mapping contains unicode point for each tile blank_points = [0, 32, 160] mapping = [ 0, 9786, 9787, 9829, 9830, 9827, 9824, 8226, 9688, 9675, 9689, 9794, 9792, 9834, 9835, 9788, 9658, 9668, 8597, 8252, 182, 167, 9644, 8616, 8593, 8595, 8594, 8592, 8735, 8596, 9650, 9660, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 8962, 199, 252, 233, 226, 228, 224, 229, 231, 234, 235, 232, 239, 238, 236, 196, 197, 201, 230, 198, 244, 246, 242, 251, 249, 255, 214, 220, 162, 163, 165, 8359, 402, 225, 237, 243, 250, 241, 209, 170, 186, 191, 8976, 172, 189, 188, 161, 171, 187, 9617, 9618, 9619, 9474, 9508, 9569, 9570, 9558, 9557, 9571, 9553, 9559, 9565, 9564, 9563, 9488, 9492, 9524, 9516, 9500, 9472, 9532, 9566, 9567, 9562, 9556, 9577, 9574, 9568, 9552, 9580, 9575, 9576, 9572, 9573, 9561, 9560, 9554, 9555, 9579, 9578, 9496, 9484, 9608, 9604, 9612, 9616, 9600, 945, 223, 915, 960, 931, 963, 181, 964, 934, 920, 937, 948, 8734, 966, 949, 8745, 8801, 177, 8805, 8804, 8992, 8993, 247, 8776, 176, 8729, 183, 8730, 8319, 178, 9632]#, 0] def load(path): "returns img" img = Image.open(path) return img def cell_size(img): size = img.size w = size[0] / 16 h = size[1] / 16 assert size == (w*16, h*16) return w, h def img_apply(img, fn): "like Image.eval, but not dumb" pix = img.load() for x,y in product(*map(range, img.size)): pix[x,y] = fn(pix[x,y]) return img def chop16(img, gamma=False): "return 256 list of subimages, black on white" back = Image.new('RGB', img.size, 'black') if 'P' in img.getbands(): img = img.convert('RGB') if 'A' in img.getbands(): trans = img else: chromakey = lambda px: ((255,255,255),(0,0,0))[px==(255, 0, 255)] trans = img_apply(img.copy(), chromakey) trans = trans.convert('L') img2 = Image.composite(img, back, trans) img3 = img2.convert('L') if gamma: gammafn = lambda x: int(((x/255.0)**2.2)*255) img3 = Image.eval(img3, gammafn) img3 = ImageChops.invert(img3) size = img3.size w,h = cell_size(img3) for y,x in product(range(0, size[1], h), range(0, size[0], w)): yield img3.crop((x, y, x+w, y+h)) def px_box(x, y, off1=0, off2=1): "Box for 1 pixel square, by default" return set(((x+off1, y+off1), (x+off1, y+off2), (x+off2, y+off2), (x+off2, y+off1))) def gray_box(x, y, gray): "gray is 0-1, box is 1 px" #gamma = 2.2 #lum = gray**gamma #side = sqrt(lum) side = sqrt(gray) off1 = 0.5 - side/2 off2 = 0.5 + side/2 assert off1 >= 0 return px_box(x, y, off1, off2) def pixels_to_rects(img, bw=False, dither=False): "vectorizes, some compression" boxes = [] # set((x,y), (x,y), (x,y), (x,y)) corners if bw: dith = (Image.NONE, Image.FLOYDSTEINBERG)[dither] img = img.convert('1', dither=dith) pix = img.load() for y,x in product(*map(range, img.size[::-1])): if pix[x,y] == 0: boxes.append(px_box(x,y)) if (0 < pix[x,y] < 255) and not bw: boxes.append(gray_box(x, y, 1.0 - pix[x,y]/255.0)) while True: prev_len = len(boxes) for i,j in combinations(range(prev_len), 2): new_box = boxes[i] ^ boxes[j] if len(new_box) != 4: # sanity continue boxes.pop(max(i,j)) boxes.pop(min(i,j)) boxes.append(new_box) break if len(boxes) == prev_len: break return boxes def marching_squares(img, dither=False): "not finished" n,s,e,w = (0.5,0), (0.5,1), (1,0.5), (0,0.5) sw,se,ne,nw = (0,1), (1,1), (1,0), (0,0) # might want to include center points on edges? poly = [[], [sw,w,s], [ne,s,e], [sw,w,e,se], [ne,e,n], [sw,w,n,ne,e,s], [s,n,ne,se], [sw,w,n,ne,se], [w,nw,n], [sw,sw,n,s], [nw,n,e,se,s,w], [sw,nw,n,e,se], [w,nw,ne,e], [sw,nw,ne,e,s], [w,nw,ne,sw,s], [sw,nw,ne,se]] dith = (Image.NONE, Image.FLOYDSTEINBERG)[dither] img = img.convert('1', dither=dith) pix = img.load() xs,ys = img.size() polies = [] for x,y in product(range(xs-1), range(ys-1)): p = pix[x,y+1] + 2*pix[x+1,y+1] + 4*pix[x+1,y] + 8*pix[x,y] polies.append(poly[p]) # and collapse overlapping points somehow for y,x in product(*map(range, img.size[::-1])): if pix[x,y] == 0: boxes.append(px_box(x,y)) if (0 < pix[x,y] < 255) and not bw: boxes.append(gray_box(x, y, 1.0 - pix[x,y]/255.0)) def xml_wrap(tag, inner, **kwargs): kw = ' '.join('%s="%s"' % (k, str(v)) for k,v in kwargs.items()) if inner is None: return '<%s %s/>' % (tag, kw) return '<%s %s>%s\n' % (tag, kw, inner, tag) def rect(box, xdim, ydim): xs,ys = map(set, zip(*box)) x,y = min(xs), min(ys) h = max(ys) - y w = max(xs) - x x = int(x * 1000.0/ydim) y = int(y * 1000.0/ydim) w = int(w * 1000.0/ydim) h = int(h * 1000.0/ydim) return xml_wrap('rect', None, x=x, y=y, width=w, height=h, fill='black') def rects_to_svg(boxes, xdim, ydim): xml_rects = ''.join(rect(b, xdim, ydim) for b in boxes) svg = '' svg = svg + xml_rects svg = xml_wrap('svg', svg, width=xdim*ydim/1000, height=1000) svg = '\n' + svg svg = '\n' + svg return svg def call_status(cmd): "returns exit status" spp = subprocess.PIPE return subprocess.Popen(cmd, shell=True, stdout=spp, stderr=spp).wait() def parse(): parser = optparse.OptionParser(usage='Usage: %prog [options] tileset.png/bmp', description='Convert a Dwarf Fortress PNG/BMP tileset to a TrueType Font. See http://kmkeen.com/df2ttf/ for more.') parser.add_option('-g', '--gamma', dest='gamma', action='store_true', default=False, help='Enable gamma correction.') parser.add_option('-d', '--dither', dest='dither', action='store_true', default=False, help='Enable dithering, implies -b.') parser.add_option('-b', '--blackwhite', dest='bw', action='store_true', default=False, help='Create 1 bit font.') options, args = parser.parse_args() return options, args def main(): options, args = parse() if len(args) != 1: print 'Missing required PNG argument.' return if options.dither: options.bw = True png = args[0] name = splitext(basename(png))[0] ttf = name+'.ttf' path = tempfile.mkdtemp() xdim, ydim = cell_size(load(png)) for i,img in enumerate(chop16(load(png), options.gamma)): boxes = pixels_to_rects(img, options.bw, options.dither) svg = rects_to_svg(boxes, xdim, ydim) open(join(path, '%04d.svg' % i), 'w').write(svg) pe = open(join(path, 'svg2ttf.pe'), 'w') pe.write('New()\n') pe.write('SetFontNames("%s", "%s", "%s")\n' % (name, name, name)) pe.write('SetTTFName(0x409, 1, "%s")\n' % name) pe.write('SetTTFName(0x409, 2, "Medium")\n') pe.write('SetTTFName(0x409, 4, "%s")\n' % name) pe.write('SetTTFName(0x409, 5, "1.0")\n') pe.write('SetTTFName(0x409, 6, "%s")\n' % name) pe.write('Reencode("unicode")\n') for i,m in enumerate(mapping): if i in blank_points: continue pe.write('SelectSingletons(UCodePoint(%d))\n' % m) pe.write('Import("%s/%04d.svg", 0)\n' % (path, i)) pe.write('SetWidth(%d)\n' % int(xdim*1000/ydim)) pe.write('SetVWidth(1000)\n') for m in blank_points: pe.write('SelectSingletons(UCodePoint(%d))\n' % m) pe.write('SetWidth(%d)\n' % int(xdim*1000/ydim)) pe.write('SetVWidth(1000)\n') #pe.write('Save("%s")\n' % ttf) pe.write('Generate("%s")\n' % ttf) pe.close() call_status('fontforge -script %s' % join(path, 'svg2ttf.pe')) shutil.rmtree(path) if __name__ == '__main__': main()