#!/usr/bin/env fontforge
# -*- mode: python; coding: utf-8 -*-

import fontforge
import os
import shutil
import sys
import argparse
import re
import collections

# thanks https://stackoverflow.com/questions/5226958/which-equivalent-function-in-python
def which(file_name):
    for path in os.environ["PATH"].split(os.pathsep):
        full_path = os.path.join(path, file_name)
        if os.path.exists(full_path) and os.access(full_path, os.X_OK):
            return full_path
    return None

def bdfParseLine(line):
    words = []
    line = line.strip()
    while True:
        match = re.match(r'^\s*"((?:[^"]+|"")*)"($|\s+)', line)
        if match:
            word = match.group(1) # $1
            word = re.sub(r'""', '"', word)
            words.append(word)
            line = line[len(match.group(0)):] # $'
            continue
        match = re.match(r'^\s*(\S+)($|\s+)', line)
        if match:
            word = match.group(1) # $1
            words.append(word)
            line = line[len(match.group(0)):] # $'
            continue
        break
    return words

class MyBDFChar:
    def __init__(self, name = None):
        self.name = name
        self.encoding = None
        self.nonStandardEncoding = None
        self.hasBoundingBox = False
        self.boundingBoxX = None
        self.boundingBoxY = None
        self.boundingBoxXOffset = None
        self.boundingBoxYOffset = None
    def __str__(self):
        result = "<MyBDFChar"
        if self.name != None:
            result += (" %s" % self.name)
        if self.encoding != None:
            result += (" @%d" % self.encoding)
        if self.nonStandardEncoding != None:
            result += (" @[%d]" % self.nonStandardEncoding)
        if self.hasBoundingBox:
            result += (" [%g, %g offset %g, %g]" % (
                self.boundingBoxX, self.boundingBoxY,
                self.boundingBoxXOffset, self.boundingBoxYOffset
            ))
        result += ">"
        return result

class MyBDF:
    def __init__(self, filename = None):
        self.pointSize = None
        self.pointSize2 = None
        self.xRes = None
        self.yRes = None
        self.boundingBoxX = None
        self.boundingBoxY = None
        self.boundingBoxXOffset = None
        self.boundingBoxYOffset = None
        self.pixelSize = None
        self.resolutionX = None
        self.resolutionY = None
        self.spacing = None
        self.capHeight = None
        self.xHeight = None
        self.ascent = None
        self.descent = None
        self.filename = None
        self.hasBoundingBox = False
        self.chars = []
        self.charsByEncoding = {}
        self.charsByNonStandardEncoding = {}
        self.charsByName = {}
        if filename != None:
            self.read(filename)
    def read(self, filename):
        with open(filename) as fp:
            self.filename = filename
            self.readFp(fp)
    def readFp(self, fp):
        for line in fp:
            args = bdfParseLine(line)
            if len(args) < 1:
                continue
            (cmd, args) = (args[0].upper(), args[1:])
            if cmd == 'CHARS':
                self.readCharsFp(fp)
            if cmd == 'SIZE' and len(args) >= 3:
                self.pointSize = float(args[0])
                self.xRes      = float(args[1])
                self.yRes      = float(args[2])
                continue
            if cmd == 'FONTBOUNDINGBOX' and len(args) >= 4:
                self.hasBoundingBox = True
                self.boundingBoxX       = int(args[0])
                self.boundingBoxY       = int(args[1])
                self.boundingBoxXOffset = int(args[2])
                self.boundingBoxYOffset = int(args[3])
                continue
            if cmd == 'STARTPROPERTIES':
                self.readPropertiesFp(fp)
    def readPropertiesFp(self, fp):
        for line in fp:
            args = bdfParseLine(line)
            if len(args) < 1:
                continue
            (cmd, args) = (args[0].upper(), args[1:])
            if cmd == 'ENDPROPERTIES':
                return
            if cmd == 'PIXEL_SIZE' and len(args) >= 1:
                self.pixelSize = float(args[0])
            if cmd == 'POINT_SIZE' and len(args) >= 1:
                self.pointSize = float(args[0])
            if cmd == 'RESOLUTION_X' and len(args) >= 1:
                self.resolutionX = float(args[0])
            if cmd == 'RESOLUTION_Y' and len(args) >= 1:
                self.resolutionY = float(args[0])
            if cmd == 'SPACING' and len(args) >= 1:
                self.spacing = args[0].upper()
            if cmd == 'CAP_HEIGHT' and len(args) >= 1:
                self.capHeight = float(args[0])
            if cmd == 'X_HEIGHT' and len(args) >= 1:
                self.xHeight = float(args[0])
            if cmd == 'FONT_ASCENT' and len(args) >= 1:
                self.ascent = float(args[0])
            if cmd == 'FONT_DESCENT' and len(args) >= 1:
                self.descent = float(args[0])
    def readCharsFp(self, fp):
        for line in fp:
            args = bdfParseLine(line)
            if len(args) < 1:
                continue
            (cmd, args) = (args[0].upper(), args[1:])
            if cmd == 'STARTCHAR':
                char = self.readCharFp(fp, args[0])
                self.chars.append(char)
                if char.encoding != None:
                    self.charsByEncoding[char.encoding] = char
                if char.nonStandardEncoding != None:
                    self.charsByEncoding[char.nonStandardEncoding] = char
                if char.name != None:
                    self.charsByName[char.name] = char
    def readCharFp(self, fp, name):
        char = MyBDFChar(name)
        for line in fp:
            args = bdfParseLine(line)
            if len(args) < 1:
                continue
            (cmd, args) = (args[0].upper(), args[1:])
            if cmd == 'BITMAP':
                return char
            elif cmd == 'ENDCHAR':
                return char
            elif cmd == 'ENCODING':
                char.encoding = int(args[0])
                if len(args) > 1:
                    char.nonStandardEncoding = int(args[1])
                if char.encoding == -1:
                    char.encoding = None
            elif cmd == 'BBX':
                char.hasBoundingBox = True
                char.boundingBoxX       = int(args[0])
                char.boundingBoxY       = int(args[1])
                char.boundingBoxXOffset = int(args[2])
                char.boundingBoxYOffset = int(args[3])

    def __str__(self):
        result = "<MyBDF"
        if self.filename != None:
            result += (" %s" % self.filename)
        if self.pointSize != None:
            result += (" %gpt" % self.pointSize)
        if self.xRes != None:
            result += (" %gxdpi" % self.xRes)
        if self.yRes != None:
            result += (" %gydpi" % self.yRes)
        if self.hasBoundingBox:
            result += (" [%g, %g offset %g, %g]" % (
                self.boundingBoxX, self.boundingBoxY,
                self.boundingBoxXOffset, self.boundingBoxYOffset
            ))
        result += ">"
        return result

class BitmapFont2TTF:

    saveSFD = False
    nearestMultipleOfFour = False
    nextMultipleOfFour = False
    asciiOnly = False
    sfdDir = None
    filename = None
    destfilename = None
    verbose = 0
    filename = None
    destfilename = None
    args = None

    def setAutoTraceEnvironment(self):
        if self.verbose == 0:
            if 'AUTOTRACE_VERBOSE' in os.environ:
                del os.environ['AUTOTRACE_VERBOSE']
        else:
            os.environ['AUTOTRACE_VERBOSE'] = str(self.verbose)
        self.AUTOTRACE_NAME = 'exact-autotrace-c'
        self.autotrace = which(self.AUTOTRACE_NAME)
        if self.autotrace == None:
            sys.stderr.write('no ' + self.AUTOTRACE_NAME + ' program found\n')
            exit(1)
        os.environ['AUTOTRACE'] = self.autotrace
        fontforge.setPrefs('PreferPotrace', False)
        fontforge.setPrefs('AutotraceArgs', '')

    def setArgs(self, args):
        self.args = args
        self.filename = args.filename
        self.destfilename = args.destfilename
        self.nearestMultipleOfFour = False
        self.nextMultipleOfFour = False
        self.asciiOnly = False
        self.sfdDir = None
        self.verbose = 0
        if args.nearest_multiple_of_four != None:
            self.nearestMultipleOfFour = args.nearest_multiple_of_four
        if args.next_multiple_of_four != None:
            self.nextMultipleOfFour = args.next_multiple_of_four
        if args.ascii_only != None:
            self.asciiOnly = args.ascii_only
        if args.sfd_dir != None:
            self.sfdDir = args.sfd_dir
        if args.verbose != None:
            self.verbose = args.verbose

    def fixFilenames(self):
        if self.filename == os.path.basename(self.filename):
            # Work around an issue where importBitmaps segfaults if you only
            # specify a filename 'foo.pcf'.  Yes, './foo.pcf' works pefectly
            # fine whereas 'foo.pcf' does not.
            self.filename = os.path.join('.', self.filename)
        if self.destfilename == None:
            (rootdestfilename, junk) = os.path.splitext(self.filename)
            self.destfilename = rootdestfilename + '.ttf'
        else:
            (rootdestfilename, junk) = os.path.splitext(self.destfilename)
        self.sfdfilename = rootdestfilename + '.sfd'
        if self.sfdDir != None:
            self.sfdfilename = self.sfdDir + '/' + os.path.basename(self.sfdfilename)

    def fixMissingGlyphs(self):
        self.fixMissingGlyph(39, 8217) # U+0027 APOSTROPHE;   U+2019 RIGHT SINGLE QUOTATION MARK
        self.fixMissingGlyph(45, 8722) # U+002D HYPHEN-MINUS; U+2212 MINUS SIGN
        self.fixMissingGlyph(96, 8216) # U+0060 GRAVE;        U+2018 LEFT SINGLE QUOTATION MARK

    def fixMissingGlyph(self, destUni, sourceUni):
        hasSource = False
        hasDest = False
        sourceGlyph = None
        destGlyph = None
        self.font.selection.select(('unicode', None), sourceUni)
        for glyph in self.font.selection.byGlyphs:
            sourceGlyph = glyph
            hasSource = True
            break
        self.font.selection.select(('unicode', None), destUni)
        for glyph in self.font.selection.byGlyphs:
            hasDest = True
            break
        if hasSource and not hasDest:
            destGlyph = self.font.createChar(destUni)
            self.font.selection.select(sourceGlyph)
            self.font.copy()
            self.font.selection.select(destGlyph)
            self.font.paste()

    def autoTrace(self):
        glyphCount = 0
        for glyph in self.font.glyphs():
            if self.asciiOnly and (glyph.encoding < 32 or glyph.encoding > 126):
                continue
            glyphCount += 1

        sys.stderr.write("*** autotracing %d glyphs...\n" % glyphCount)

        glyphIndex = 0
        for glyph in self.font.glyphs():
            if self.asciiOnly and (glyph.encoding < 32 or glyph.encoding > 126):
                continue
            glyphIndex += 1
            if self.verbose >= 2:
                sys.stderr.write("*** [%d/%d] %s\n" % (glyphIndex, glyphCount, glyph))
            elif self.verbose >= 1:
                sys.stderr.write("*** [%d/%d]\r" % (glyphIndex, glyphCount))
            glyph.autoTrace()
            glyph.addExtrema()
            glyph.simplify()
            if self.swidth != None:
                glyph.width = self.swidth
            if self.finalPixelSize != self.pixelSize:
                glyph.transform(psMat.scale(1.0 * self.pixelSize / self.finalPixelSize))

    def loadBDF(self):
        # Since FontForge doesn't give us info or properties from the
        # BDF, we get them ourselves.  And we assume we're pulling a
        # BDF instead of, say, a PCF file.
        if not re.search(r'\.bdf$', self.filename):
            raise Exception("BDF required")
        self.bdf = MyBDF(self.filename)

    def setPropertiesFromBDF(self):
        self.isMonospace = self.bdf.spacing == 'M' or self.bdf.spacing == 'C'
        self.pixelSize = self.bdf.pixelSize
        self.finalPixelSize = self.pixelSize
        if self.nextMultipleOfFour:
            self.finalPixelSize = 4 * int((self.pixelSize + 3) / 4)
        elif self.nearestMultipleOfFour:
            self.finalPixelSize = 4 * int((self.pixelSize + 2) / 4)

    def setFontMetas(self):
        if self.args != None:
            if self.args.copyright != None:
                self.font.copyright = self.args.copyright
            if self.args.comment != None:
                self.font.comment = self.args.comment
            if self.args.font_name != None:
                self.font.fontname = self.args.font_name
            if self.args.family_name != None:
                self.font.familyname = self.args.family_name
            if self.args.full_name != None:
                self.font.fullname = self.args.full_name
            if self.args.version != None:
                self.font.version = self.args.version
            if self.args.weight != None:
                self.font.weight = self.args.weight
            if self.args.italic_angle != None:
                self.font.italicangle = self.args.italic_angle

    def save(self):
        self.font.generate(self.destfilename)
        sys.stderr.write("*** Wrote %s\n" % self.destfilename)
        if args.save_sfd:
            self.font.save(self.sfdfilename)
            sys.stderr.write("*** Wrote %s\n" % self.sfdfilename)

    def setSwidth(self):
        if self.isMonospace:
            self.swidth = int(0.5 + 1.0 * self.font.em * self.bdf.boundingBoxX / self.pixelSize)
        else:
            self.swidth = None

    def setAscentDescent(self):
        self.descentPx = self.pixelSize - self.bdf.ascent
        self.ascentPx = self.bdf.ascent

        # must be specified before bitmap import for baseline alignment
        em = self.font.em
        self.font.ascent  = int(0.5 + 1.0 * em * self.ascentPx  / self.pixelSize)
        self.font.descent = int(0.5 + 1.0 * em * self.descentPx / self.pixelSize)

    def setItalic(self):
        self.isItalic = (re.search(r'\b(italic|oblique)\b',
                                   self.font.fontname, flags = re.IGNORECASE) or
                         re.search(r'\b(italic|oblique)\b',
                                   self.font.fullname, flags = re.IGNORECASE))
        if self.isItalic:
            self.font.italicangle = 15
        else:
            self.font.italicangle = 0

    def setWeight(self):
        if self.font.weight == 'Regular' or self.font.weight == 'Medium' or self.font.weight == 'Book':
            self.font.weight = 'Book'
            self.font.os2_weight = 400
            if self.isItalic:
                self.font.os2_stylemap |= 0x0201
                self.font.macstyle     |= 0x0002
            else:
                self.font.os2_stylemap |= 0x0040
                self.font.macstyle     |= 0x0000
        elif self.font.weight == 'Bold':
            self.font.weight = 'Bold'
            self.font.os2_weight = 700
            if self.isItalic:
                self.font.os2_stylemap |= 0x0221
                self.font.macstyle     |= 0x0003
            else:
                self.font.os2_stylemap |= 0x0020
                self.font.macstyle     |= 0x0001

    def useFinalPixelSize(self):
        if self.finalPixelSize != self.pixelSize:
            if self.finalPixelSize == self.pixelSize + 3:
                self.ascentPx += 2
                self.descentPx += 1
            elif self.finalPixelSize == self.pixelSize + 2:
                self.ascentPx += 1
                self.descentPx += 1
            elif self.finalPixelSize == self.pixelSize + 1:
                self.ascentPx += 1
            elif self.finalPixelSize == self.pixelSize - 1:
                self.descentPx -= 1
            em = self.font.em
            self.font.ascent  = int(0.5 + 1.0 * em * self.ascentPx  / self.finalPixelSize)
            self.font.descent = int(0.5 + 1.0 * em * self.descentPx / self.finalPixelSize)

    def bitmapfont2ttf(self, args):
        self.setArgs(args)
        self.setAutoTraceEnvironment()
        self.fixFilenames()
        self.loadBDF()
        self.setPropertiesFromBDF()
        self.font = fontforge.font()
        self.setSwidth()
        self.setAscentDescent()
        self.font.importBitmaps(self.filename, True)
        self.font.os2_vendor = 'PfEd'
        self.font.encoding = 'iso10646-1'
        self.setItalic()
        self.setWeight()
        self.useFinalPixelSize()
        self.setFontMetas()
        self.autoTrace()
        self.fixMissingGlyphs()
        self.save()

bf2ttf = BitmapFont2TTF()

parser = argparse.ArgumentParser(description = "Generate TTF files from bitmap fonts, e.g., BDF and PCF")
parser.add_argument("--copyright",                help = "assign copyright holder and date, e.g., 'Copyright (c) 2020 Darren Embry'")
parser.add_argument("--comment",                  help = "assign comment string, e.g., '2020-01-01'")
parser.add_argument("--family-name",              help = "assign family name, e.g., 'Comic Sans'")
parser.add_argument("--font-name",                help = "assign font name, e.g., 'ComicSansBoldItalic'")
parser.add_argument("--full-name",                help = "assign full name, e.g., 'Comic Sans Bold Italic'")
parser.add_argument("--version",                  help = "assign version, e.g., '001.000'")
parser.add_argument("--weight",                   help = "assign font weight, e.g., 'Regular', 'Bold'")
parser.add_argument("--italic-angle",             type = float, help = "assign font italic angle, e.g., -22.5")
parser.add_argument("--save-sfd",                 help = "keep .sfd file (for FontForge)", action = "store_true")
parser.add_argument("--verbose", "-v",            action = 'count', help = "increase output verbosity")
parser.add_argument("--nearest-multiple-of-four", action = 'store_true')
parser.add_argument("--next-multiple-of-four",    action = 'store_true')
parser.add_argument("--ascii-only",               action = 'store_true')
parser.add_argument("--sfd-dir",                  type = str)
parser.add_argument("filename")
parser.add_argument("destfilename",               nargs = '?')

args = parser.parse_args()

bf2ttf.bitmapfont2ttf(args)
