# pebbleFonts.py v1.0
# John McManigle, 17 Feb 2014
#
# I appreciate attribution or links to http://www.oxfordechoes.com/pebble-fonts/
# but you are free to use this for any purpose.  No warranty, express or implied.

import argparse
import pickle
import itertools
import numpy as np
from struct import pack,unpack
from bitstring import BitArray
import sys
from PIL import Image



class PebbleFontCharacter(object):
	def __init__(self, c):
		self.c = unichr(c)
		self.lmargin = 0
		self.rmargin = 0
		self.tmargin = 0
		self.u1 = 0
		self.u2 = 0
		self.u3 = 0
		self.bitarray = None
		
	def __str__(self):
		s = 'pebbleFonts.PebbleFontCharacter \''+self.c.encode('utf-8')+'\'\n'
		s += '  L-margin='+str(self.lmargin)+';'
		s += '  R-margin='+str(self.rmargin)+';'
		s += '  T-margin='+str(self.tmargin)+';'
		if self.bitarray != None:
			s += '  W='+str(self.bitarray.shape[1])+';'
			s += '  H='+str(self.bitarray.shape[0])+''

			ya = 0
			for line in self.bitarray:
				s += '\n   |'
				for char in line:
					if char: s += 'X'
					else:    s += ' '
				s += '|'
		else: s += ' (No bit array.)'
		return s
		
	def setMeta(self, data):
		(  width,  height, self.lmargin, self.tmargin,
		 self.u1, self.u2,      self.u3,      spacing ) = unpack("2B2b3Bb", data)
		self.rmargin = spacing - self.lmargin - width
		return (width, height)

	def setBits(self, data, width, height):
		bits = BitArray(bytes=data)
		array = np.array(bits)
		for i in range(0, len(array), 8):
			array[i:i+8] = array[i:i+8][::-1]
		self.bitarray = np.reshape(array[:width*height], (height,width))
	
	def width(self):
		if self.bitarray == None: return 0
		return self.bitarray.shape[1]
	
	def height(self):
		if self.bitarray == None: return 0
		return self.bitarray.shape[0]
		
	def packed(self):
		if self.bitarray != None: h,w = self.bitarray.shape
		else:                     h,w = 0,0
		
		spacing = self.lmargin + w + self.rmargin
		s = pack("2B2b3Bb", w, h, self.lmargin, self.tmargin, self.u1, self.u2, self.u3, spacing)
		
		if self.bitarray != None:
			array = self.bitarray.flatten().tolist()
			r = len(array) % 32
			if r: array.extend([False] * (32-r))
			array = np.asarray(array)
			for i in range(0, len(array), 8):
				array[i:i+8] = array[i:i+8][::-1]
			bytes = BitArray(bin=''.join('1' if b else '0' for b in array))
			s += bytes.bytes

		return s
		
	def bit_array_packed_size(self):
		if self.bitarray != None:
			return int(np.ceil(self.bitarray.size/32.0))
		else: return 0

	def trimArray(self):
		if self.bitarray == None: return
		while not np.any(self.bitarray[0,:]):
			self.bitarray = np.delete(self.bitarray, 0, 0)
			self.tmargin += 1
		while not np.any(self.bitarray[-1,:]):
			self.bitarray = np.delete(self.bitarray, -1, 0)
		while not np.any(self.bitarray[:,0]):
			self.bitarray = np.delete(self.bitarray, 0, 1)				
		while not np.any(self.bitarray[:,-1]):
			self.bitarray = np.delete(self.bitarray, -1, 1)
		


class PebbleFontFile(object):

	def __init__(self, filename=None):
		self.char_table = {}
		if filename != None:
			self.load(filename)
			
	def __len__(self):
		return len(self.char_table)
	
	def __getitem__(self, key):
		return self.char_table[ord(key)]
		
	def __setitem__(self, key, value):
		self.char_table[ord(key)] = value
	
	def __delitem__(self, key):
		del(self.char_table[ord(key)])
		
	def __iter__(self):
		return self.iterkeys()

	def iterkeys(self):
		return itertools.imap(unichr, self.char_table.iterkeys())
		
	def __str__(self):
		s = 'pebbleFonts.PebbleFontFile ('+self.orig_filename+')\n'
		s += '  '+str(len(self))+' Characters'
		return s

	def load(self, filename):
		self.orig_filename = filename
		
		with open(filename,'rb') as inf:
			data = inf.read()
		
		self.version, self.nom_height, num_entries, self.failsafe_char \
			= unpack("2B2H", data[:6])
	
		lookup_table = {}
		for i in range(num_entries):
			char, offset = unpack("2H", data[6+i*4:10+i*4])
			lookup_table[char] = offset
			
		data_offset = 6 + num_entries * 4

		for c in lookup_table:
			offset = lookup_table[c]*4 + data_offset
			char = PebbleFontCharacter(c)
			w, h = char.setMeta(data[offset:offset+8])
			
			if( w * h > 0 ):
				offset += 8
				length = int(np.ceil(w*h/8.0))
				char.setBits(data[offset:offset+length], w, h)
				
			self[unichr(c)] = char
	
	def save(self, filename = None):
		if filename is None:
			filename = self.orig_filename
			
		with open(filename, 'wb') as file:
			file.write(pack("2B2H", self.version, self.nom_height, len(self), self.failsafe_char))
		
			offset = 1
			for c in sorted(self.iterkeys()):
				file.write(pack("2H", ord(c), offset))
				offset += 2 + self[c].bit_array_packed_size()
				
			file.write(pack("2H", 0, 0))
			
			for c in sorted(self.iterkeys()):
				file.write( self[c].packed() )
			
			file.close()
		
	def saveImage(self, filename):
		tot_width = np.amax([self[x].width() for x in self]) + 4
		tot_height = np.sum([self[x].height() for x in self]) + len(self)
		img = Image.new('1',(tot_width,tot_height),0)
		pix = img.load()
		
		y = 0
		for c in sorted(self.iterkeys()):
			if self[c].bitarray != None:
				ya = 0
				for l in self[c].bitarray:
					x = 0
					if (ya + 1 == self.nom_height - self[c].tmargin):
						pix[x, y] = 1
					x += 2
					for p in l:
						pix[x, y] = p
						x += 1
					if (ya + 1 == self.nom_height - self[c].tmargin):
						pix[x+1, y] = 1
					y += 1
					ya += 1
				y += 1
		img.save(filename,'PNG')
		
	def loadImage(self, filename):
		img = Image.open(filename)
		pix = img.load()
		
		y = 0
		for c in sorted(self.iterkeys()):
			if self[c].bitarray != None:
				for l in range(self[c].bitarray.shape[0]):
					x = 2
					for p in range(self[c].bitarray.shape[1]):
						self[c].bitarray[l,p] = pix[x,y]
						x += 1
					y += 1
				y += 1
					
	def fixWidths(self):
		for c in self.iterkeys():			
			if self[c].bitarray != None:
				if self[c].lmargin > 1:
					self[c].lmargin = 1
				if self[c].rmargin > 1:
					self[c].rmargin = 1

	def padAllChars(self, p=3):
		for c in self.iterkeys():
			if self[c].bitarray != None:
				self[c].bitarray = np.pad(self[c].bitarray, p, 'constant', constant_values=False)
				self[c].tmargin -= p
		self.sanitizeHeight()
		
	def trimAllChars(self):
		for c in self.iterkeys():
			self[c].trimArray()
		self.sanitizeHeight()
	
	def sanitizeHeight(self):
		min_t = min(self[c].tmargin for c in self.iterkeys())
		self.nom_height -= min_t
		
		for c in self.iterkeys():
			self[c].tmargin -= min_t
	
	def printAllChars(self):
		for c in sorted(self.iterkeys()):
			print self[c],'\n'



if __name__ == '__main__':
	parser = argparse.ArgumentParser(
		description='Performs operations on a Pebble font file (.pfo).  Uses a scratch file (.pkl) to store intermediate steps, so this command can be used sequentially for multiple operations.  A typical use case is to --loadPFO to get the font into working memory.  Then, save as a PNG image with --savePNG and edit in an external editor before loading the modified image back into working memory with --loadPNG.  Finally, use --savePFO to update the Pebble font file and put it in your Pebble app.  This is an interface over a fairly versatile Python class, so if you don\'t see what you need in the options here, import it into your own Python script and go nuts.  Do back up your .pfo file before you start.',
		epilog='pebbleFonts.py v1.0, by John McManigle, 17 Feb 2014.  Use freely.'
	)
	parser.add_argument('-t', nargs=1, default='font.pkl', metavar='<fname>',
	                    help='Use specified scratch file. (default: font.pkl)')
	parser.add_argument('-i', nargs=1, default='font.png', metavar='<fname>',
						help='Use specified .png file for --savePNG or --loadPNG (default: font.png)')
	action_group = parser.add_argument_group('Action', 'Select one or more actions from the following options. Each action may only be taken once per run, and they will always be executed in the order in which they appear below.')
	action_group.add_argument('--loadPFO', metavar='<fname>',
	                          help='Load new working font from a .pfo file.')
	action_group.add_argument('--printChar', metavar='<char>',
							  help='Print a character from the working font to screen.')
	action_group.add_argument('--printAllChars', action='store_true',
							  help='Print all characters from the working font to screen.')
	action_group.add_argument('--savePNG', nargs='?', const=3, metavar='pad', type=int,
	                          help='Output font to .png image, padding each character by [pad] pixels. (default: 3) (see -i above)')
	action_group.add_argument('--loadPNG', action='store_true',
							  help='Load edited .png file and trim away empty padding.')
	action_group.add_argument('--sanitize', action='store_true',
							  help='Trim padding and sanitize margins.')
	action_group.add_argument('--savePFO', nargs='?', const=True, metavar='fname',
							  help='Save .pfo file. (default: overwrite loaded .pfo file)')
							  
	if len(sys.argv) > 1: args = parser.parse_args()
	else:                 args = parser.parse_args(['-h'])
	
	if args.loadPFO is None:
		print 'Loading font from scratch file',args.t
		try:
			pkl_file = open(args.t, 'rb')
		except IOError:
			print 'Error: Could not find scratch file.  Did you forget to --loadPFO?'
			sys.exit()
		else:
			ff = pickle.load(pkl_file)
			pkl_file.close()
	else:
		print 'Loading font from Pebble file',args.loadPFO
		ff = PebbleFontFile(args.loadPFO)
		
	if args.printChar is not None:
		print ff[args.printChar]
	
	if args.printAllChars is True:
		ff.printAllChars()
		
	if args.savePNG is not None:
		print 'Padding each character with',args.savePNG,'pixel margins'
		ff.padAllChars(args.savePNG)
		print 'Saving composite font image to',args.i
		ff.saveImage(args.i)
	
	if args.loadPNG is True:
		print 'Loading composite font image from',args.i
		ff.loadImage(args.i)
		print 'Trimming extra padding from each character'
		ff.trimAllChars()
		
	if args.sanitize is True:
		print 'Trimming extra padding from each character'
		ff.trimAllChars()
		print 'Sanitizing left and right margins'
		ff.fixWidths()
		
	if args.savePFO is not None:
		if args.savePFO is True:
			print 'Saving font to Pebble file',ff.orig_filename
			ff.save()
		else:
			print 'Saving font to Pebble file',args.savePFO
			ff.save(args.savePFO)
	
	print 'Saving font to scratch file',args.t
	with open(args.t, 'wb') as pkl_file:
		pickle.dump(ff, pkl_file)
	